Compare commits

...

105 Commits

Author SHA1 Message Date
bherbruck fea32f8aac Release 0.18.3
The tree is main at 034aa60d4 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 08:46:37 -04:00
bherbruck 034aa60d47 Release 0.18.3 (#4270)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:46:14 -04:00
bherbruck 971b223246 Thread: turn a shaft thicker than a modelled thread down to its major diameter (#4256)
* Thread: turn a shaft thicker than a modelled thread down to its major diameter

A modelled external thread on a shaft thicker than the thread no longer refuses: the shaft
is turned down to the major diameter over the threaded length (run past a free end of the
face), then the thread is cut as before. The turned face is named F<n>:turned and the step
it leaves F<n>:shoulder:start/end. A shaft the part goes on around is refused rather than
cut into, shafts under the minor diameter and cosmetic threads on thicker shafts are still
refused.

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

* Thread preview test: an 8 mm shaft is turned down for M6; M10 still refused

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:27:18 -04:00
bherbruck 8c861f9f46 Face names: pieces of a split face keep their numbers across evaluations (#3902) (#3991)
* Face names: pieces of a split face keep their numbers across evaluations (#3902)

Pieces sharing a name were numbered in the kernel's order, or by the index of the operand face
they came from. A boolean can list its result's faces in another order on the next evaluation,
and an operand made by one (a closed sweep tool) carries that order into the indices, so the
#1/#2 of a split face could swap between two evaluations of the same design. Pieces that
nothing else tells apart are now numbered along the spread of their centres (as edge pieces
already were), and pieces from different faces of one operand by kind, then by place.

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

* Face names: older designs keep the piece numbers they were saved with

Numbering pieces by place renumbers split faces in many existing designs, and a stored name
matched exactly would then land on the other piece. Features of a design saved before this
change are marked `kernel_piece_order` by a pending format step and number their pieces as
before; features added afterwards number by place.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:08:08 -04:00
bherbruck e04a016e7c Align takes planes and the origin (#4248)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 08:06:32 -04:00
Irakli Kourtis 93a2922c5e Display plans and the model cache for large STEP assemblies (#2814) (#4021)
* STEP import: mesh band faces that wrap around a closed surface correctly

Fixes two related bugs that caused SolidCraft to fail to mesh a face wrapping
all the way around a closed, non-periodic surface (a revolved ridge band, or a
tube-shaped B-spline surface): a missing face, a folded/self-overlapping
triangulation, or a hole in the rendered body.

1. Ring edges that run slightly off their surface. STEP files write a surface
   and the curves bounding it to the file's own declared uncertainty, so a
   ring edge can legitimately sit a few micrometres off the surface it
   bounds. The code finding where a ring edge crosses the planned split line
   compared against the edge's raw (off-surface) point using a hardcoded
   tolerance far stricter than the file's own precision, rejecting a valid
   cut. Now matches against the point on the surface directly below the ring
   crossing, respecting the file's declared tolerance.

2. Closed B-spline surfaces, their slits and their slivers. truck only treats
   the rotation of a revolved surface as periodic; a face on a B-spline
   surface whose control net closes on itself (a tube) wasn't recognized as
   wrapping around at all. This produced a folded face, a hole (a boundary
   loop landing a full period away from where it should cut), and one
   near-zero-width sliver face costing the entire body its tessellation
   plan. Adds periodicity detection for closed B-spline surfaces, period-
   aware hole-loop alignment, dropping of zero-width slit boundaries, and
   isolating a too-thin-to-triangulate face so it no longer takes the rest
   of the body down with it. The GPU shader's periodic wrap mirrors the CPU
   math so the two don't drift apart.

Regression tests: band_faces_split_when_ring_edges_run_off_their_surface,
band_face_with_a_zero_width_slit_is_still_meshed,
a_sliver_face_too_thin_to_triangulate_does_not_cost_the_body_its_plan.
Ablation-tested: disabling either mechanism in (2) reproduces its target
test's original failure message while leaving the other test unaffected.

* STEP import: mesh the band's split faces, not just split them

jyviko asked whether a band wall missing from the interactive app's render
was a shading issue or a genuinely missing face. It was missing: the ring-
edge split fix made the band's topology correct, but plan_face still
produced zero triangles for the two new sub-faces.

Two causes, both in plan_face (crates/kernel/src/tess/mod.rs):

1. The tolerance for how far an imported boundary may lie off its surface
   was tied to the requested mesh's chord tolerance instead of to the
   body's own size. A finer display tolerance could then reject a boundary
   a coarser one had just accepted, so the face would drop out exactly when
   zooming in on it.

2. Near a sharp corner of this flat-panelled band, several boundary points
   that are legitimately a few micrometres apart (within the STEP file's
   own declared precision) all projected onto the same surface point. The
   boundary loop then repeated a point in (u, v), which a triangulator
   can't follow, so the face's plan failed outright. One face without a
   plan costs the whole body its plan, and the fallback mesher can't
   handle a closed B-spline surface either, leaving both halves of the
   split band with no triangles at all: present in the topology, invisible
   in every renderer, and absent from the body's volume.

Fix: scale the off-surface tolerance to the body's own extent (new
Rules::extent), and collapse a run of boundary nodes that land on the same
surface point to one (drop_collapsed) before triangulating.

Regression test additions to band_faces_split_when_ring_edges_run_off_their_surface:
the body's volume now matches the analytic pentagon prism (was a third of
it - only the top cap - before this fix); display_plan leaves no face
without triangles from coarse to fine tolerance; and the actual tolerance
and facet angle the viewport draws with gives the split band its full
lateral area on both halves.

Confirmed on review_band_big.step, the fixture behind the review screenshots:
volume's relative error against the analytic pentagon prism drops from
1.4e-4 to 4.1e-6, and the band wall now occludes the floor grid behind it
in a snapshot the same way the top and bottom caps always did.

Full kernel suite: 739 tests, 0 failed.

* STEP import: hold an imported boundary to the file's own tolerance, not the part's size

jyviko: the off-surface acceptance added in the last commit
(rules.extent * OFF_SURFACE) scaled with the part, so a 2 m part
silently accepted a boundary 2 mm off its surface while a 20 mm copy
of the same part accepted only 0.02 mm. A STEP file's own declared
uncertainty does not grow with the part, so the bound shouldn't either.

Body gains boundary_tol: Option<f64>, Some(file's declared tolerance)
for a body read from STEP (crate::step_in::Units::tol, already used
by the ring-split fix in split.rs), None for anything this kernel
built. It's set before the import's own unmeshed-face check runs, and
carried over by the two face-split operations that re-cut an imported
surface (splitface.rs, outline.rs), since the file's uncertainty still
describes those boundaries afterward.

plan_face's accept now reads: the file's own tolerance when the body
declares one, or the old chord-tolerance/extent bound otherwise. The
old bound's tol * 10.0 term had to go too, not just the extent term:
at a 2 m part's typical display tolerance that term alone is ~20 mm,
nearly ten times the extent bound jyviko objected to, so keeping it
would have left the exact 2 m/2 mm example still silently accepted.

Two new tests in step_in/tests.rs:
- band_faces_split_when_a_scaled_copy_runs_off_its_surface: the same
  band fixture at a realistic 2 m scale with the same absolute slop
  and declared uncertainty as the small one, meshes the same way at
  every chord tolerance from coarse to fine.
- off_surface_budget_follows_the_file_not_the_part_size: the actual
  regression. The same off-surface error is accepted or refused the
  same way whether the part is 20 mm or 2 m; a 2 mm error against a
  1 µm declared uncertainty is refused at both sizes now (accepted at
  2 m under the old bound, refused at 20 mm - that asymmetry was the
  bug). Checked at both a coarse and a fine display tolerance.

Known gap, not fixed here: a STEP-imported body saved into a
.solidcraft design and reopened loses boundary_tol (native.rs's
decoder has no field to read it from) and falls back to the old
extent-based bound on reload. Today's design format has no concept of
this value at all; persisting it is a real format change, out of
scope for this fix.

Full kernel suite: 776 tests, 0 failed. clippy -D warnings clean.

* Display plans for STEP threads and pointed ends (#3847)

Three faces cost a threaded or pointed STEP part its whole display plan, so it
was drawn from the reference mesh: cracked, faceted, and never refined up close.

- A loop through a pole (a cone's apex on the boundary) seemed to wind around
  the surface: the jump at the apex, unwrapped to the nearest copy, was
  arbitrary. The loop is turned to end where it leaves the pole, so the step
  that closes it is an ordinary one (`seam::close_through_pole`).
- Projection onto a spline with hundreds of spans (a helical flank) restarted
  from a 16 x 16 grid and settled on another turn; it now starts from two
  points per span, capped.
- Sag refinement no longer inserts a point exactly on a boundary side, which
  split the side and failed the face.

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

* tess: a refused face's edges are divided finer and it is planned again (#2814)

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

* tess: sliver faces plan empty; a face no method plans is filled flat from its nodes (#2814)

A face whose boundary runs out and back along itself (two edges between the
same vertices, a rounding apart, as STEP files write slivers) encloses nothing
and is planned with no triangles. A face still refused after its edges are
divided finer (a cusp, a sliver whose sides sag across each other, a small cap
at a pole) is filled flat from its shared boundary nodes, so the rest of the
body keeps its display plan instead of falling back to the reference mesh.

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

* display mesh tests: a rim the chord tolerance leaves a line is divided again (#2814)

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

* portable B-rep: up to 8192 control points along a spline direction (#2814)

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

* solidcraft-cli: --debug logs what the engine reports to stderr

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

* STEP import: a spline written over a tiny parameter range is read over one (#2814)

Some writers parameterize splines over a few millionths (a humanoid robot's
edges span 5.4e-6). The kernel tells parameters apart only to 1e-6, so over
such a range it evaluated the curve's end wrongly, and trimming an edge to its
vertices dropped a control point: the bodies could not be stored exactly and
the whole model cache was left out of the design. The knots are now stretched
to span one; the curve is the same and edges are trimmed by their vertices, so
nothing read from the file refers to the old parameters.

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

* model cache: a body keeps its file's declared accuracy (#2814)

A body restored from the cache dropped the accuracy the file it was read from
declares, so the planner held its boundaries to the kernel's own bound and a
reopened design drew differently from the import. It is stored with the body's
runtime values, and only when there is one.

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

* tess: a boundary within a hundredth of its body lies on its surface, at every quality (#2814)

#1721 held a body read from a file to the accuracy the file declares. A writer
declares what it aims for, not a bound its edges keep: a 251-body robot
assembly declaring 0.01 µm has boundaries 10 to 30 µm off their surfaces, and
95 of its bodies lost their display plans. The bound is a hundredth of the
body again, what Normal quality always allowed, and now the same at every
quality, so zooming in still never drops a face (#1721's point). A file
declaring more raises it.

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

* STEP import: keep faces whose edge vertices sit slightly off their curves

Some exporters write an edge's vertex beside the end of its curve, further off
than the accuracy the file declares (a surface export declares
0.01 mm and puts a vertex 0.19 mm off two spline ends). The reader refused
such edges and left the face, and with single-face bodies the body, out.

A vertex may now sit up to the file's tolerance or 1/1000 of the model's size
(the diagonal around its VERTEX_POINTs), whichever is larger, never more than
the existing 1 mm ceiling. Spline and open-conic edges have the end control
point moved onto the vertex, so every such edge meets its vertices exactly.
The file's declared accuracy still governs closed-curve, seam and circle
checks.

* tess, io: review clean-ups (#2814)

- A sliver face is one whose loops are all slit: drop_slits finds it in the
  retry after a failed plan, so the pre-check on every face (folded) is gone.
- The flat fill uses cdt::triangulate (a lenient fill that leaves out sides
  crossing one already kept) and its flood-fill inside test, and the plane
  from geom::Plane.
- Refinement only builds its indexes when a face was refused, takes the
  faces to plan again from edge_faces, and re-samples edges on the pool;
  Rules::finer for the finer rules.
- One PackageWriter::fits for the model cache, the preview and the thumbnail.
- The band split's ring tolerance lives in split.rs; comments say what the
  off-surface bound is now.
- The CLI logger prints Level::as_str().

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

---------

Co-authored-by: David Lackovic <lackovicdavid1234@gmail.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Ray <raykholo@users.noreply.github.com>
2026-10-21 10:37:12 +00:00
Irakli Kourtis ff8b09c225 Kernel paused: label new kernel issues, PRs and discussions for a maintainer to answer (#3994)
While a new geometry kernel is evaluated, a workflow labels new issues, pull
requests and discussions about kernel work kernel-paused, so a maintainer finds
them and answers in person. A pull request counts when it changes
crates/kernel/ or vendor/truck*, anything else when its title names a kernel
operation. Crash and data-loss reports and anyone who can push are left alone.
CONTRIBUTING.md says so.


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

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:03:47 +00:00
raykholo 975a08c992 ui-egui tests: hold OK's evaluation where a test checks it is computing (#4040)
Two OK-off-the-window-thread tests checked that OK was computing right
after Enter without holding the evaluation. The preview's evaluation is in
the cache, so the worker was usually in before OK's zero wait was over and
OK applied at once: cancel_while_computing_applies_nothing failed in most
runs (5 of 5 in a debug build, 4 of 5 with the ci profile). Both now hold
the evaluation with the test gate, as their neighbour does, and let it go
once the computing state is checked.
2026-10-21 09:38:14 +00:00
bherbruck 55587f4ac4 View keys: a held arrow turns the view at a steady rate, one tween at most (#4090)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 04:22:26 -04:00
bherbruck e94900933d IGES import: close small gaps between trim pieces (#3845) (#3846)
Consecutive pieces of a trim loop can miss each other by micrometres to a tenth of a
millimetre, past the file's resolution, so the loop did not close and the face was
dropped. Faces are now read first and sewn after: each small gap's ends, and the other
piece ends at them, move to one vertex. Gaps over 0.1 mm or not small next to their
pieces stay open; the sewing tolerance stays the file's. The stated accuracy covers how
far a vertex moved, so the STEP reader keeps those edges.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 02:20:02 +00:00
bherbruck 4927214976 Release 0.18.2
The tree is main at 57bcc4083 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 22:09:18 -04:00
bherbruck 57bcc40838 Release 0.18.2 (#3970)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:08:36 -04:00
bherbruck 9974413a5b Ribbon: Finish Sketch follows the last sketch panel (#3958)
* Ribbon: Finish Sketch follows the last sketch panel

The Sketch tab's exit group sat alone at the far right of the ribbon,
about 600 px from the sketch tools on a 1600 px window. It now comes right
after the last panel, as before the redesign. Its room is still kept at
every width, and when panels go into the » menu it moves to the right end
after it.

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

* ui_layout test: Finish follows the last sketch panel, right end only with the » menu

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:08:14 -04:00
bherbruck 818af2b856 Icon audit: regenerate the requested-icons manifest (#3969)
The menus no longer ask for offset_one_side, offset_symmetric,
offset_two_sides, pattern_center or pattern_flip, so the manifest check
failed on main and on every UI pull request.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:08:10 -04:00
bherbruck 80238a7cfb Sketch: a quick double Esc finishes the sketch (#3950)
* Sketch: a quick double Esc finishes the sketch

One Esc still puts the tool down, then clears the selection, and with
nothing left to cancel keeps the sketch open (#1804). A second press within
0.4 s finishes the sketch, keeping it, the same as Finish Sketch, also when
the first press cancelled a tool.

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

* Sketch: a double Esc takes away a blank sketch; only Esc twice in a row counts

A quick double Esc on a sketch the user added nothing to removes it in one
undo step, the view and tab going back as on Finish, so no empty sketch is
left behind. A sketch with anything in it is still finished and kept.

The double press counts only for two Esc presses in a row with no tool or
dialog left after the first: any other key in between resets it, and while
a tool is still out (its first Esc only ended a chain) the press puts it
down as before. Tests that press Esc on its own now wait past the window.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:57:32 -04:00
bherbruck bd65a561c8 Ribbon: New Sketch on the Surface tab (#3964)
Sketches drive surface extrudes, revolves, sweeps and lofts, so the Surface
tab gets the same Sketch panel as Model and Sheet Metal. A sketch started
there keeps the tab until it opens.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:57:10 -04:00
SS-EN 776541b081 Agent commands compute off the window thread, so the window stays live (opt-in) (#1989) (#3340)
An agent's design changes over the control channel (engine.execute, engine.batch) are evaluated
ahead on a copy of the session on an `agent` worker, then run on the window thread reusing every
feature computed there (doc::KeptResults, whatever the bounded evaluation cache dropped); the
worker also makes the display meshes of the bodies they end with. Exports run on a copy. While a
change computes, or the agent cursor acts one out, ui.*, inspection and screenshots answer; later
design changes wait their turn. A chip names the command with Stop. A person's edit meanwhile goes
first; a replaced design refuses the request. Preference: Preferences > AI, agent_off_thread, off
by default.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:58:56 +00:00
raykholo 79a843c963 Transform: refuse a matrix that is not an affine placement (#3900)
transform_matrix took any finite 4x4 matrix. A perspective entry in the
last row, or a singular linear part, was built into faces that mesh to
nonsense: a revolved ring placed with such a matrix measured hundreds of
millions of mm3, and a boolean on it ran for over half an hour before
failing with "no result". Matrices come from design files and command
parameters, so they are refused with KernelError::Invalid instead.
2026-10-21 00:58:49 +00:00
AlF 1bcc3602b4 STEP export: a merged cylinder or cone face's loop starts on the side its angle grows from (#3394) (#3557)
* claim: #3394

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* STEP export: a merged cylinder or cone face's loop starts on the side its angle grows from (#3394)

A whole-turn cylinder or cone face is written as one loop: ring, seam, ring, seam reversed. Readers lay the ring that follows the seam's first use out from the surface's angle 0, so that ring must turn the way the angle grows (anticlockwise about the written axis). Three of the four tapered legs in #3394 came out the other way and other readers dropped their sides. When the second ring turns against the angle, the loop now starts with it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-21 00:58:47 +00:00
raykholo 93684a4728 Expressions: a leading decimal comma such as ,5 is accepted (#2966) (#3797)
* claim: #2966

* Expressions: a leading decimal comma such as ,5 is accepted (#2966)

In a comma-decimal locale `,5` means 0,5, but the lexer only started a
number on a digit or a point. A comma now starts a number outside
parentheses when a digit follows and an operand is expected (at the
start or after + - * / ^). Commas after an operand keep their meaning,
so function arguments and the existing error cases are unchanged.
2026-10-21 00:58:44 +00:00
raykholo 9db39f3d7d Holes from a sketch on a body's face drill that body (#2882) (#3770)
* claim: #2882

* Holes from a sketch on a body's face drill that body (#2882)

A multi-point hole from a sketch attached to a body's face chose its
side and moved its points onto whichever body lay along the direction,
then cut every body, so points on a front body's face could drill the
body behind it. `targets` was accepted and ignored.

- solid.hole: holes at the points of a sketch on a body's face store
  that body as their participant (the lookup a face-sketch Join uses);
  `targets` sets the bodies to drill, `["*"]` every body; explicit
  participants still override.
- Evaluation: for sketch-point holes with participants, the drilling
  side, the surface each point moves onto and pattern copies are found
  on those bodies only.

Sketches on planes, position and Single holes, and saved holes without
participants recompute as before; no design-format change.
2026-10-21 00:58:43 +00:00
Colin Faulkingham 29369a0bb5 Extrude to an edge or corner ends at the requested plane (#3729) (#3748)
* claim: #3729

* Fix extrusion targets at planar face boundaries (#3729)
2026-10-21 00:58:41 +00:00
kezmdor 5a9e771cc9 io: the 3MF and DXF budget tests do not check wall-clock time (#2998) (#2999)
* claim: #2998

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

* io: the 3MF and DXF budget tests do not check wall-clock time

nested_fan_out_of_empty_objects_is_bounded spends about 20 million
visits reaching the work budget: 1.5 s alone, up to 40 s and more when
every test binary runs at once, past its 30 s limit. The time checks
run only after the reader returns, so a missing budget hangs these tests
with or without them; the error, the empty geometry and the three
vertices per placement are what show the budgets work. CONTRIBUTING.md:
a test must not depend on wall-clock timing.

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

---------

Co-authored-by: kezmdor <15539254+kezmdor@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:58:39 +00:00
NOWN-DEV 63cb48a4ee Measure: a body whose meshes leave a gap keeps its volume wherever it is (#2069)
* Measure: a body whose meshes leave a gap keeps its volume wherever it is

A part ring cut by solid.combine (two prisms through its ends, a notch
through its chamfer) measured 992.03 mm3 at the origin, 999.17 moved
100 mm and 1063.48 moved 1000 mm; it encloses 993.92. The reference
mesher leaves a crack where the notch meets the chamfer's cone: the
volume beside it is left out, and the divergence-theorem sums, taken
about the world origin, close the crack by a cone to the origin, so the
error grows with the body's distance from it.

A closed surface's area vectors sum to zero. When the picked meshes'
sum could move the volume by more than 1e-5 within the body's size, and
the display plan (which shares every edge's nodes between its faces) is
closed, the body is measured on the plan, about its box centre. Every
other body measures exactly as before.

Tests: the cut ring at the origin and moved by up to 10000 mm, against a
grid quadrature of the cut; a revolved ring of cones and cylinders moved
100/1000/10000 mm along each axis (1e-6); a cylinder and a frustum far
from the origin against hand values. Robustness seed 64 moves by 1.4e-6
and is recorded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Measure: a gap the display plan can't close is measured about the box's centre

When the meshes leave a gap and the display plan isn't closed either, the
sums used to fall back to the origin-based volume, which drifts with the
body's distance from the origin (7 % at 1000 mm for the part ring). The
per-face sums are now taken about the fine mesh's box centre, which closes
the gap by a cone to a point inside the body; over a closed mesh nothing
changes. The gap decision moves into gap_closed, and a unit test with a
cube left open by hand covers closed vs open meshes, the closed plan, and
the fallback with an open plan or none.

Seeds 14, 20, 40, 55 and 61 end with such a body and are recorded with
ROBUSTNESS_GOLDENS_BLESS (volumes move by -0.51 %, +0.16 %, +1.9e-4,
+9.0e-5 and -1.5e-5; steps, outcomes and counts unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Record robustness seed 86 after measuring bodies whose meshes leave a gap

Seed 86 was added on main by #2387 after this branch's goldens were recorded. Its last
body leaves a gap in its meshes, so the measure change here moves its volume by -0.27 %
and its area by +0.13 %; steps, outcome and counts are unchanged. Seed 86 passes
unchanged on main 57d21013. Every other seed matches on the merged branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* measure: take the display plan only where it agrees with the sums to within what the gap could move

A closed display plan meshed less finely than the sums differs from them by its own chord error,
which can be larger than anything the gap explains: an imported part with small blends measured
0.03 % low on its plan. The plan is now taken only where the two differ by at most what the
gap's area could move the volume within the body's size; otherwise the sums about the box's
centre stand.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* measure: sum a mesh left open about its box's centre only, without the display plan

The closed display plan meshes some faces of an imported B-spline part
more coarsely than the sums do, so a body and its boolean pieces no
longer add up: clearance's union cross-check then refused a 17.3 mm3
overlap between two such parts (the pieces implied 20.5). Summing about
the box's centre alone keeps the gap closed by a cone to a point inside
the body and stays consistent with the pieces. Seeds 64 and 86 are
re-recorded with the reason.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* measure: placement tests in the area's test files

The ring and frustum tests join measure.rs's tests, and the cut ring
joins measure_centre_tests.rs (AGENTS.md: a regression test goes into
the existing test file for its area). The goldens note for seeds 14, 20,
40, 55 and 61 describes the centre-only sums.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* measure: keep the origin for a body left open on purpose

A surface, or a cup whose lid is still a separate sheet, has no volume of
its own. Measured about its box's centre, two such bodies stopped
comparing once the centre moved (a crowned lid rises), which broke the
patch crown test. Only a mesh with a small crack is measured about the
centre now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aac7mP9rthK3WsPpPRvCsF

* Robustness goldens: keep main's notes, one note for this change's seeds

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aac7mP9rthK3WsPpPRvCsF

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 00:57:03 +00:00
Colin Faulkingham 36c732ddb7 Shell: show the tangent-chain face selection and explain full-body removal (#209) (#3809)
* claim: #209

* Shell: show tangent-chain faces and explain full-body removal (#209)
2026-10-21 00:54:34 +00:00
Mateusz Sych fdec948abc Control channel: control.running lists what the app is running, across connections (#3654) (#3689)
* claim: #3654

* Control channel: control.running lists what the app is running, across connections (#3654)

control.pending answers only for the connection that asks, so a second client,
the MCP bridge after a reconnect or a script saw {"pending": null} while the app
was busy. control.running answers for the whole app, the same on every
connection and at once (the connection's own thread answers it, also while it
is busy with a timed-out request): every request the app has been sent and not
answered yet, oldest first, with its connection, id, method, command id (or a
batch's step count), seconds since it arrived and whether it still waits its
turn. A timed-out request stays listed until the app answers it, also after its
connection closed. Parameters are never shown.

The window marks a request started when it takes it (ControlRequest.started),
which tells queued from running. The MCP server gets a read-only `running` tool.

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-21 00:54:33 +00:00
bherbruck 7803b3968f Fix main: a test built Applying without the new seen field (#4001)
#3609 added Applying::seen; dialogs_sketch_tests' apply_keeps_the_panel_open_after_asynchronous_evaluation
still built the struct without it, so ui-egui's tests stopped compiling on main. The test stands
for a job that is already being shown, so seen is true (the behaviour it had before).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:41:24 -04:00
JEREMY cbac4eab3f OK off the window thread: a job that has just started waits a frame (#2929) (#3609)
* claim: #2929

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

* OK off the window thread: a job that has just started waits a frame (#2929)

The off-thread evaluation of a feature dialog was taken up in the same frame
it started, so a job whose worker had already finished was applied at once
and the dialog never said it is computing. Under load the window thread is
held up between starting the job and the check, and the feature appears
where the computing state belongs - which is what made
`apply_off_thread_tests::cancel_while_computing_applies_nothing` fail with
`assertion failed: h.app.applying.is_some()`.

A job that has just started now waits one frame: its result is taken up from
the next one, so the dialog says it is computing before the feature appears.

The app waits APPLY_AT_ONCE (150 ms) for the evaluation before it lets the
window go on, so a job only exists when the evaluation took longer than
that: the change shows the computing state for a frame in the 150-151 ms
window, and nothing else moves.

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

---------

Co-authored-by: jeremymiribung-blip <jeremymiribung-blip@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-20 23:02:34 -04:00
bherbruck 08eff4f9dd Sketch on a face: project from the model's own body, not a copy that meshes it again (#3981)
A sketch attached to a face is evaluated in the face's frame. When that frame is the body's
own, the body was still rebuilt as a new ModelBody with empty mesh cells, so projecting the
face's edges display-meshed a throwaway copy and the next cut or join meshed the same body a
second time. On a body whose cut edges are slow to mesh this added about a second to each
feature after a sketch on a face. In place, the anchored body is now the model's own clone and
shares its mesh.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:02:22 -04:00
raykholo 9b80163421 Extrudes from a nested STEP part's face join and cut it with the imported root active (#3223, #2872) (#3852)
* claim: #3223

* Extrudes from a nested STEP part's face join and cut it with the imported root active (#3223, #2872)

Opening a STEP assembly leaves its root component active, and the root
owns no body: its parts are components inside it. A profile sketched on
such a part's face was therefore treated as outside the active scope.
Join made a separate body instead of joining the part, and the automatic
operation saw no material and offered New Body in either direction; an
automatic extrude that came out as a cut found nothing to cut.

A component with no body of its own now works on the bodies of the
components inside it whose geometry lies in its own frame (as an import
places them): Join targets the body whose face the sketch is on, the
automatic operation looks at those bodies, and an extrude whose own
operation is Cut or Intersect takes part in every body as an explicit
cut already did.
2026-10-21 00:54:16 +00:00
Tyler Chiriboga 164554edba 3MF import: production extension review follow-ups (#2089) (#3628)
* claim: #2089 (3MF production extension: review follow-ups)

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

* 3MF import: production extension review follow-ups (#2089)

Follow-ups from the review of #2127:
- Only the root model's build is read; other parts' build sections are
  ignored as the spec asks (a slicer's child build pointing at a thumbnail
  no longer matters).
- A model part that can't be read fails only the placements that need it;
  the package-wide caps (part names, 1 GB of model XML) still refuse the
  package. The root XML is dropped once parsed and each part is read
  against the remaining budget.
- Loops are reported apart from depth, keyed by (part, object).
- Messages name parts as written; objects are kept in id order so parts are
  read in the same order every run; %XX escapes in part names are decoded;
  the model unit is a local of the parser.
- Tests: a component without p:path stays in its part (T1), the same id in
  two parts is no loop (T2), a slicer-shaped project (T3), the placement
  budget across parts (T4), a loop through three parts and a chain past the
  depth cap (T5), broken parts and child builds (T6), escaped names; the
  hostile-parts test asserts its messages; the mutation test also mutates
  the parts' XML and zips it again.
- Guide: what slicer-only data is not read.

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

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:54:14 +00:00
Matthew Knutson 453c6bcb14 Split schema contracts per command file and add shared field types (#2829) (#3558)
* Split the schema contracts into one module per command file

`schema/commands.rs` becomes `schema/features.rs` (solid.box, solid.extrude,
FeatureResult) and `schema/inspect.rs` (engine.describe), each with its own
tests file holding its `schemas_and_the_key_check_agree` key lists and its
`strict_keeps_todays_errors` cases, so conversions of different command files
touch different files. The shared loops become helpers in `schema/tests.rs`;
the assertions are unchanged. `engine.describe` gains a key-list test, and
`schema_defaults_are_valid` now covers every command with a contract.

No behaviour change.

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

* Add shared schema field types for the next conversions

For the feature commands to be converted next, each deciding acceptance with
the reader the handlers already use:

- `FaceRef` and `FaceRefs` (`face::face_ref`, `features::face_points`)
- `EdgeRef` and `EdgeRefs` (`features::edge_points`)
- `SketchRef` (`features::sketch_id`)
- `FeatureCommon`: `name`, `body_name`, `body_names` and `participants`,
  which `add_feature` reads for every feature, flattened into a contract
  with `deserialize_with = "feature_common"`, which keeps the key in the
  message and the issue's path.

The shape checks of `face_ref`, `edge_points` and `sketch_id` (and
`feature_sketch`) move into pure helpers (`face_ref_form`, `edge_ref_form`,
`sketch_key`) that the handlers and the types share; the handlers behave as
before. Tests check that each type and its schema accept what the reader
accepts (apart from a face name's byte cap and an index written `1.0`),
that the contract's messages match the handlers' errors, and that the
handlers still look picks up one at a time.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:54:13 +00:00
phanoob f44ff18ca4 Split Body: each piece can be renamed on its own (#3510) (#3527)
* claim: #3510

* Split Body: each piece can be renamed on its own (#3510)

A split named its new pieces after the split body every time (`Top (1)`),
so renaming a piece did nothing, and renaming the split body renamed the
other piece too (and broke the features that used it). The pieces now
take the names body.rename stores, and a rename keeps the names later
features derive from the renamed body.

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:54:11 +00:00
raykholo 98cd67f029 Joins, cuts and intersects meet their tools where they are shown, across placed components (#3383) (#3384)
* claim: #3383

* Combine: tools from another component meet the target where they are shown (#3383)

Bodies are evaluated in their own component's frame and an occurrence's
placement is applied only for display. Combine passed tool bodies to the
boolean untouched, so a tool in a component placed differently from the
target's was read as if its coordinates were in the target's frame: a
join that overlapped on screen gave two separate shells, and cuts and
intersects missed the same way. Any placement difference did it, a turn
or a plain move, in either direction.

Combine now moves each tool by the inverse of the target's placement
times the tool's own, the same placement used to show the body, keeping
face lineage. A tool placed exactly like the target is left untouched,
so combines within one component give the same result as before.
Moving an occurrence now re-evaluates a combine that depends on it.

* Joins, cuts and intersects draw their tools where they are shown (#3383)

A primitive, extrude, revolve, loft, sweep, pipe or coil that joins,
cuts or intersects bodies draws its tool in its own component's frame,
but each body is kept in its own component's. A target in a component
placed differently (an occurrence moved or turned, or a body moved into
it) met the tool as if the tool's coordinates were in the target's
frame: a join that overlapped on screen gave a separate lump 100 mm
away, and cuts missed the same way.

While such a feature evaluates, the tool now goes into each body's frame
(the inverse of the body's placement times the feature's component's)
wherever the join, cut or intersect meets it, and a body joined into one
placed differently is moved into its frame. Bodies placed as the feature
is are left untouched, so results within one component are unchanged.
Features that name the bodies they change re-evaluate when an occurrence
moves.

This also settles the moved-fastener cut of #439 (Combine, earlier commit).

* Through all and an extrude's contact tests see targets where they are shown (#3383)

An extrude or revolve measured its through-all length, and tested where
its tools meet the targets (a profile on a target's face, coplanar
growth, holes over empty space, taper run-on), on the targets as stored
in their own component's frame. With the tool now moved into a target
placed differently, a through-all cut drawn from the root under a cube
in a component moved 100 mm up was cut only as far as the cube is tall
in its own frame, and missed it.

These features now see the bodies they change that are placed
differently moved into their own component's frame, the same moves the
tools are taken back through, so the length reaches the target where it
is shown. Bodies placed as the feature is are read as before.
2026-10-21 00:54:09 +00:00
raykholo d9d6af51c6 Sheet metal: mirrored and copied sheet bodies stay sheet metal (#3353) (#3354)
* claim: #3353

* Sheet metal: mirrored and copied sheet bodies stay sheet metal

Mirroring a sheet metal body into a new body gave a plain solid: the
opposite-hand bracket had no flat pattern, DXF or bend table
("`Body1 (2)` is not a sheet metal body"). Copies from a body pattern or
Move with copy lost their sheet model the same way.

A copy now takes the sheet model with it (SheetBody::copied). A rigid
copy moves the flat frame. A mirror moves the frame with its x axis
turned round, so the thickness side stays, and mirrors everything in
flat coordinates (x -> -x): base, flange bend lines (run from their far
end, with their end extensions, slopes and tears swapped), panel shapes,
cut-outs, pockets, bores, letters and base sketch lines. Arcs stay exact
and loops keep their sense. A perforation field of a mirrored copy runs
its grid from the right, so its holes are the mirror image too. The flat
length, bends and K-factor are unchanged, and the copy takes further
sheet metal features of its own.

Mirror with combine is unchanged: the joined body has no single sheet
model yet. Mirroring or patterning flange features is still refused.
Sheet models are derived from the timeline, so no design format change.

* Format: note the saved from_right of a mirrored sheet's perforation field

The new `from_right` flag is saved in the design, so it gets a pending
format note. No migration: a field without it runs from the left as
before.
2026-10-21 00:54:07 +00:00
Christian Svensson 38c710e72e Save designs with large STEP imports: compress the STEP part (#3221) (#3285)
* claim: #3221

* Save designs with large STEP imports: compress the STEP part (#3221)

A STEP between 256 and 512 MiB imported fine but could not be saved: a
design stored the STEP text uncompressed, and a design part is capped at
256 MiB. STEP sources are now always Deflated (level 2), may be as large
as an import accepts inside the file, and an import that could not be
saved even compressed is refused with a clear message. Reading stops at a
part's declared size, so a decompression bomb is refused early.
2026-10-21 00:54:05 +00:00
raykholo 5b5b051e02 Delete in Edit Joint deletes that joint, and a refused joint delete says why (#3749) (#3776)
* claim: #3749

* Edit Joint: deleting the joint being edited closes the dialog and deletes it (#3749)

A joint delete was dropped without a word while any dialog was open, and
Edit Joint is a dialog. Deleting only the joint Edit Joint edits now
cancels the dialog first (as its Cancel does) and goes through the usual
selection.delete dry run; Delete with nothing else selected in Edit Joint
targets that joint. Every other refusal (another dialog, a tool, a
computing history, a loading file) now says why in the status bar.
2026-10-21 00:49:50 +00:00
kaltenbrunnermarkus 686576e368 Section Analysis: Edit reopens the dialog where the section was left (#3617)
Edit Section Analysis (its menu item, or a double-click on its browser row)
opened the dialog at distance 0 with no plane, so moving the cut on by a
millimetre meant picking the plane and typing the whole distance again.

OK now keeps the dialog's plane pick, distance and flip with the cut they
made, and Edit starts from them while the section shown (or hidden) is still
that cut. The toolbar still starts a new section afresh, and a section made
some other way (a command, an agent) opens the dialog as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:49:48 +00:00
rlartot 68e4186080 fix(ai-chat): replay with reasoning_effort none when OpenAI refuses tools (#3798) (#3817)
* claim: #3798

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

* fix(ai-chat): replay once with reasoning_effort none when tools are refused (#3798)

Newer OpenAI models answer HTTP 400 to function tools on /v1/chat/completions
unless reasoning_effort is "none". On that exact refusal the request is sent
again once with it, and later requests of the chat carry it directly.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:44:52 +00:00
Colin Faulkingham b66ad0a9c9 Move shortcut respects the selected component (#3580) (#3588)
* claim: #3580

* fix(ui): move the selected component from its shortcut (#3580)
2026-10-21 00:44:48 +00:00
amolinari-autotex 2eacbc37f4 Keep sketches out of the Browser Features folder (#3584) 2026-10-21 00:44:43 +00:00
phanoob f2f9c6fe27 Settings › AI: Add agent opens from the Home tab too (#3483, #3513) (#3522)
* claim: #3483

* Settings > AI: Add agent opens from the Home tab too (#3483, #3513)

The Add agent dialog was drawn by the chat panel, which Home doesn't
show, so Add agent did nothing while Home was open. It is now drawn
from the frame itself, before Home and the workspace part ways.

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:44:41 +00:00
SS-EN d21e2b1afd Keep picked points when a type-first dimension turns horizontal or vertical (#3168)
Type-first (pick, then type), the dimension's text follows the pointer, and where it goes
picks the kind: across from two points or a slanted line the distance between them, above or
below the horizontal span, beside them the vertical one. The draft kept the value of the kind it
started as, so moving from diagonal to horizontal previewed (and Enter applied) the aligned
length as the horizontal span: the solver moved the picked points (the first dimension of a
sketch even scaled it), so the dimension's extension lines no longer met them.

While nothing has been typed, moving the text now re-derives the draft for the new place, so
its value is what that kind measures and stays selected for typing. A typed value is kept.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:44:40 +00:00
phanoob bdd2fb1a5c Fillet and chamfer previews keep moving while a radius or distance changes (#3150)
Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:44:38 +00:00
JZiegener e64f1e0427 Rejoin the split Files table in ATTRIBUTION.md (#3143)
A blank line left after the oracle rows ended the table, so GitHub
rendered the 339 rows after it as one paragraph of pipe-separated text.
2026-10-21 00:44:21 +00:00
CamWight ac362945cc English (UK) as a language: a plain catalogue of the 25 messages UK English spells differently (#2501) (#2502)
* claim: #2501

* English (UK) as a language: a plain catalogue of the 25 messages UK English spells differently (#2501)

assets/locales/en-GB.json holds only the English messages whose spelling differs in UK English (Centre, Customise, Organisation, Licence, colours, towards, ...), in UK spelling; every other message falls back to English. One registration in i18n.rs (English first, then by id), a test that shows UK spelling and English for the rest, a row in ATTRIBUTION.md, and the docs/localization.md note. The translation-coverage checks skip a regional English catalogue, which has no gaps to report.

Fixes #2501

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:44:19 +00:00
JEREMY 382680d8e8 fix(skills): refresh Boolean checking facts that fail on current release (#1835) (#2093)
* fix(skills): refresh Boolean checking facts that fail on current release (#1835)

* fix(skills): restore warning coverage in test_run and use present tense in Boolean facts

* fix(skills): the slot keeps its side at zero since the solver takes the shortest step

The upstream solver now takes the shortest step that fits (#2060), so the slot
that other constraints also held slides to the origin when the distance reaches
0 and keeps the side it was drawn on when the value comes back. It no longer
comes back mirrored, so the trap and its test state the current behaviour.

---------

Co-authored-by: jeremymiribung-blip <jeremymiribung-blip@users.noreply.github.com>
2026-10-21 00:41:45 +00:00
Yeonghun Lee 316b49cd84 Extrude from a whole face rounded by fillets: join, and cuts whose walls only touch (#2040) (#2041)
* Extrude: a join from a whole face rounded by fillets sweeps the face itself (#2040)

A face's sketch fits an edge that is no line or arc (the quarter-ellipses
where fillets meet a slanted face) as splines a hair off the body's edges,
so a join from the face touched the body along a face it did not match and
failed with "boolean Union: no result" after minutes. When the profile is
a whole planar face of the one target, the join now sweeps the body's own
face, which shares its edges and is sewn on along it.

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

* Extrude: a cut into a whole face keeps the walls that cut and moves the others off (#2040)

A cut from a whole planar face sweeps walls from each of its edges. Where
the face beside an edge turns away from the sweep (n_N . -n_P >= 0) the wall
only touches the body along that edge, or lies in the face beside it, and
the boolean failed on it with "no result". The cut now keeps the walls that
go into the body, exactly (the body's own edges), runs the outline round
the others well outside the body, and starts a little out from the face.
A cut whose walls all only touch takes a slab beyond its depth plane.

Not covered: a cutting wall tangent to the face beside a touching one (a
round's wall meeting an end face, as in the issue's part); the boolean
still fails there.

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

* Extrude: leave a whole face outlined by lines and arcs to the profile's own tool

Only a face whose outline the projection had to fit needs the body's own face as the tool. A square drawn over a box's top already meets the body exactly, and the swept face lost the names a sketch on the new top attaches to (stacked_face_tests, #489).

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

* Run the whole cut tool from a face under the kernel guard (#2040)

Review: `builder::vertex`, `builder::line`, `Face::try_new` and
`builder::translated` ran outside `crate::guard`; only the sweep was
guarded. `cut_tool` now runs all of it under one guard.

Adds an end-to-end test of a cut into a face the body stands out past
below it (the block's top strip between its rounds): it takes only
what lies over the face.

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

* test: the slab cut of whole_face_tool, on a spline prism (#2040)

A prism on a closed spline standing on a wider plate, cut 3 into its whole
top face: the slab takes the face's area times the depth and leaves the
plate alone. A test-only counter asserts the slab was the tool, so the test
cannot pass through the profile's own tool. Shortens the name of the
rounded block's top-face cut test.

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

* Kernel: the face's outline as a Vec now that polyline returns a closed chain (#2040)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:41:41 +00:00
phanoob b0932a487e Document tabs: a right-click menu to close a tab, the others or all (#3055) (#3152)
* claim: #3055

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

* Document tabs: a right-click menu closes the tab, the others or all of them (#3055)

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:39:45 +00:00
rlartot edad8078ba Ribbon tooltips: a short written help for the main tools (#2758) (#3089)
* claim: #2758

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

* Ribbon tooltips: a short written help for six tools (#2758)

Extrude, Revolve, Fillet, Chamfer, Shell and Hole get two sentences under the
tool name in the ribbon tooltip (en, ja, zh). Text only.

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

* fr: aide des infobulles (6 outils) (#2758)

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

* Ribbon help: also in the panel drop-downs (#2758)

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:39:43 +00:00
Luke Jones 26c0c19ef7 Preserve imported part names and IGES assembly structure (#1609)
Build on #1426's import components and nested STEP assembly instances.
Replace placeholder solid labels with meaningful part or instance names.
Translate IGES groups and subfigure instances through the existing STEP
component path, preserving nesting and placements and sewing surfaces
within each group. Bound assembly walks and reuse work buffers.

Related to:
- tangency-labs/solidcraft#1386
- tangency-labs/solidcraft#1426

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:39:08 +00:00
raykholo 2f43ad5c5b Loft: a circle lofted to a non-star-shaped profile is split once round (#2570) (#3670)
* claim: #2570

* Loft: a circle lofted to a non-star-shaped profile is split once round (#2570)

A circle section was split at the lead section's vertex directions, kept in
the lead's order and forced to increase by whole turns. A non-star-shaped
lead (a C, a comb) turns back on itself, so the circle wound round many
times and closed with a negative arc: self-crossing walls and a huge mesh
(which froze the web build while meshing it).

The circle is now split exactly once round: at the lead's directions when
they go round once in order (clockwise on a plane facing the other way),
otherwise at the lead's perimeter fractions, evenly as the fallback. Every
arc is finite and positive and they total one turn; the unbounded loop is
gone. Section preparation is split out as loft_section_loops so the
resampled sections can be checked.
2026-10-21 00:30:58 +00:00
rlartot 6a8604c55d Fix lofts through sections turning past 90 degrees (#2847) (#3452)
* claim: #2847

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

* loft: keep sections turned past 90 degrees facing the same way (#2847)

The loft oriented each section by the sign of its normal against the first section's, so on planes turning about an axis every section beyond 90 degrees was reversed and the side faces folded. Follow the facing neighbour to neighbour instead.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:15:41 +00:00
bherbruck 4098ff9dc8 Ribbon: New Sketch on the Sheet Metal tab again; tests for entering and leaving sketches (#3946)
* Ribbon: New Sketch on the Sheet Metal tab again; tests pin sketch enter/exit

Before the ribbon redesign the Sheet Metal tab started with New Sketch. It
gets its own Sketch panel again, like the Model tab's, and a sketch started
there keeps the tab until it opens.

Workspace tests pin the ways into and out of a sketch: New Sketch then a
plane, a double-click in the timeline or the browser, and the browser's Edit
Sketch all open the SKETCH tab looking at the sketch. Finish Sketch from the
palette or the ribbon lands on MODEL with the earlier view back. Escape and
Enter keep the sketch open (#1804), and Undo after Finish reopens it.

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

* docs: ribbon layout lists Sheet Metal's Sketch panel

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

* Workspace test: press the keys well apart

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:32:48 -04:00
bherbruck 97383454c6 Browser rows truncate long names with an ellipsis and a tooltip; Solve Sketch is no longer offered in the UI (#3940)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 21:32:42 -04:00
bherbruck 10d7bd045e CI: build and run the tests in one job, sccache on a Blacksmith disk (#3941)
* CI: build and run the tests in one job, sccache on a Blacksmith sticky disk

The tests job compiles and runs the tests itself on the 32 vCPU runner, so a pull request or merge
group uploads and downloads no 3 GB archive (the download took 45 to 385 s per shard, and once
14 minutes) and starts beside the repository checks instead of after them. The archive is built
only for the nightly and manual jobs that need it. sccache moves from GitHub's 10 GB cache, where
about 1100 per-ref entries were evicting each other, to a sticky disk that only main commits.

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

* TEMP: let this branch save caches so the warm run can be measured

* Revert "TEMP: let this branch save caches so the warm run can be measured"

This reverts commit 1bbbfcf5ff.

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 21:25:35 -04:00
bherbruck 6e87fb1a25 Ribbon: Parameters and Configurations back on Model, Sheet Metal and Sketch; Sheet Metal tab always reachable; Finish Sketch button back in the sketch palette (#3937)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 21:08:04 -04:00
denelson1-dot 30e0db1e05 Pattern of a join that merged its targets keeps working (#3378) (#3387)
* claim: #3378

* Pattern and mirror copies of a join that merged its targets join the merged body (#3378)

Since #2755 a pattern or mirror copy of a primitive reuses the seed's targets. When the
seed was a join that merged two targets, the second one no longer exists, so the copies
failed with `unknown body`. The copies now keep the seed targets that are still there;
when none is, the names stay and the error says which body is missing.

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

* Rerun CI: the previous run was cancelled before any check ran

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:15:39 +00:00
Dahak Builds 2b1cb2c4e1 MCP batch: rollback restores the original document (#3242) (#3288)
* claim: #3242

* MCP batch: rollback restores the design the batch began in (#3242)

A failed batch with rollback: true reported rolled_back: true even when the
design never went back. Two defects, both fixed:

- file.new (and any document-replacing command) replaced the session with it,
  taking its checkpoints down too, so a batch that failed after file.new had
  nothing to restore. Session::replace_design now carries the checkpoints over
  to the fresh session: the checkpoint belongs to the batch, not to the
  session that happened to be holding the document.
- rolled_back echoed the requested flag. It now reports whether the design is
  actually back: the app answers engine.batch with a new restored field, and
  the MCP tool reports restored && rollback. An app that predates the field
  falls back to undone/completed.

* Batch: answer with main's rolled_back field after the merge (#3242)

Upstream #2900 put the real restore check on engine.batch as
`rolled_back` while this branch named it `restored`; the merge kept
main's control test and this branch's field, and CI caught it.
Standardize on main's wire name: the app answers `rolled_back`, the
MCP tool reads it (fallback for older apps unchanged), docs and tests
follow. The checkpoint carry-over across file.new is unchanged.

---------

Co-authored-by: DahakBuilds <dahakbuilds@users.noreply.github.com>
2026-10-21 00:15:36 +00:00
phanoob f815491936 2-tangent circle: start from the touching circle, not the click (#3141) (#3210)
* claim: #3141

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

* 2-tangent circle: start from the touching circle, not the click (#3141)

The tool passed its second click as the place to start solving, and a
click lies on a curve: the circle began centred on that curve, where its
tangency has no side yet. The solve then moved the user's curves to fit
(a rectangle came out skewed), or failed outright when they were fixed.
The command now starts from the centre, nearest the requested point, of
a circle of that radius touching both curves (where the curves moved by
the radius meet), and the tool asks for the circle between its two clicks.

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:15:34 +00:00
rlartot a595805fc8 Sketch: symmetry about a line pairs line ends by mirror image (#2961, #2940) (#3029)
* claim: #2961

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

* Tests: symmetry about a line keeps rectangle sides' height (#2961, #2940)

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

* Sketch: symmetry about a line pairs line ends by mirror image (#2961, #2940)

c_symmetry paired the ends of two lines by index. Opposite sides of a
rectangle run in opposite directions, so that forced both heights to 0
(or was refused when a side had a length). Pair each end with the end
of the other line nearest its mirror image, as symmetry about a point does.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:15:32 +00:00
bherbruck 0379ee76c6 CI: spread the slowest tests across the test shards (#3646)
* CI: give the slowest tests a shard each

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

* CI: spread the slow tests on pull requests too, and keep both junit files

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

* CI: pull requests skip the goldens and the action matrix sample unless the kernel changes

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

* Docs: which pull requests run the goldens

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

* CI: run the tests in one job on a big runner, slowest first

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

* CI: allow the 32 vCPU runner label in actionlint, read junit.xml in the golden hint

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:41:58 -04:00
plexi32 4f7ff8c4d8 Move: Point to Point takes the origin point (#3696) (#3718)
The origin point was drawn in the view and listed in the browser, but
nothing could pick it: the viewport never offered it as a hit and the
browser's O row had no selection. A new ORIGIN accept bit lets an input
take it as a vertex of no body; Move's point inputs (From, To, Point,
Pivot) accept it, from the view or the browser's Origin folder.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:13:59 +00:00
bherbruck d2188fb7ab IGES import: a B-spline curve over a tiny knot span keeps its faces (#3885) (#3886)
A curve written over a knot span of 1e-7 or less was refused on the STEP side
("knot vector consists single value"), and both faces it bounds were lost. Its
knots are moved and scaled onto (0, 1), which leaves its shape as it is.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:13:28 +00:00
bherbruck 438abbcf82 STEP conformance sweep: the cones to a point read right now (#258) (#3901)
cone_point and cone_point_vertex join the sweep now that #3871 reads a cone
bounded only by its base circle up to its point.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:03:46 +00:00
Christian Svensson 079a600481 Recovery: autosave every open design tab (#2884) (#2952)
* Fix recovery for unsaved background tabs (#2884)

* Recovery: scheduled autosaves of every tab write on the worker thread (#2884)

After #3139, Autosaver::save waits for the write and Autosaver::tick runs
it on a worker. The scheduled path now goes through tick (which skips a
tab whose previous write is still running), so several dirty tabs no
longer write on the window thread each interval (#3044). save stays for
Save, close and shutdown. Each tab's autosaver has no clock of its own:
the shared cadence decides when it is due. The tests wait for the
writes a tick started.

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

* Recovery: closing a tab waits for its running autosave before removing the entry (#2884)

With the autosave on a worker thread, discard could remove the files and
the lock while a write was still running; the write then recreated the
entry without a lock, and the next launch offered a design closed with
Don't Save. discard now ends the running write first, as Drop does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:58:05 +00:00
bherbruck 94bd8719aa STEP import: a void reads as a cavity whichever way its shell flag points (#3730) (#3872)
A BREP_WITH_VOIDS void's faces face into the cavity, so it encloses volume of
the opposite sign to the outer shell. Its ORIENTED_CLOSED_SHELL flag alone
doesn't tell: SOLIDWORKS writes .F. over a shell already facing into the
cavity, SolidCraft .T., and flipping by the flag added the cavity's volume. A
void enclosing volume of the outer shell's sign is now turned inside out.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:54:54 +00:00
bherbruck fc3dedfd5c STEP import: regression sweep over the step-conformance parts of known shape (#3880)
* STEP import: regression sweep over the step-conformance parts of known shape

Fifteen generated parts from step-conformance (CC0): plain, NURBS, seamed,
placed, inch and far-away cylinders, domes, a grooved shaft, a plate with 100
holes, a 3.6 m disc, a 0.5 mm pin, a B-spline half-torus and a washer. Each
reads back with its exact volume, its box and its face count. The half-torus
on a TOROIDAL_SURFACE and the cones to a point are left out until they read
right.

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

* STEP conformance sweep: the pin's volume as FRAC_PI_8 (clippy)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:07:47 +00:00
bherbruck 41a62d210c File menu: Export STL… and Export 3MF… open the mesh dialog with that format; command search finds mesh export by format (#3873) 2026-10-20 22:43:30 +00:00
bherbruck 5638259737 STEP import: a cone bounded only by its base circle reaches its point (#258) (#3871)
A cone face written with no vertex at its point has the base circle as its only
loop (or that circle and a VERTEX_LOOP at the point, which has no edges). Its
surface was spanned over the circle alone, a sliver, so a solid cone came in as
a flat disc. Such a face is now spanned to the apex, and its circle is joined to
the apex by a seam line out and back, as a cone with a seam is written.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:35:12 +00:00
NOWN-DEV 090119a5c5 Boolean: a small hole in a large body's face stays a hole when the face is divided (#2579) (#2580)
* Boolean: a small hole in a large body's face stays a hole when the face is divided

A boolean divides faces at a tolerance that follows the larger operand (a few
millimetres on a part a few metres long). A small hole's rim, two half circles
when read from STEP, then became two chords: its polygon in the face's
parameters had no area, and the sign that tells a hole from a face piece came
out at random. The hole became a face of its own, inside out, and the face it
was cut in stayed whole. A box cut round such a hole gave a piece without it,
and the shared part of that piece and a pin in the hole came out negative
("malformed") or the boolean failed.

divide_face now divides a loop whose polygon is flat again, finer (down to a
hundredth of the loop's own size), before reading its sign. Only the polygon
that places the loop changes; loops that have an area are untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Kernel: the long-plate hole test in tests.rs; a flat loop's size from its box

Review: the test moves into the kernel's test file (no file of its own),
and finer_if_flat takes the loop's size from the diagonal of the box round
its sample points instead of the largest distance between any two.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aac7mP9rthK3WsPpPRvCsF

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 21:56:33 +00:00
bherbruck b621819c0c Release 0.18.1
The tree is main at 522ae7755 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 17:39:11 -04:00
bherbruck 522ae77556 Release 0.18.1 (#3851)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:33:29 -04:00
raykholo 5ba8c815a6 IGES import: trim curves with reversed pieces no longer drop their faces (#3358) (#3359)
* claim: #3358

* IGES import: trim curves with reversed pieces no longer drop their faces

A curve on a surface (142) gives each trim loop twice: B in the surface's
parameter space and C in model space, running the same way. Some exporters
write C as a composite curve (102) whose pieces are in order but some of
them end to start. The reader passed those pieces on as they were, so the
loop did not close and the STEP reader left the face out ("face boundary is
not a closed loop").

The pieces of C are now turned to run head to tail, starting at S(B(start)),
the point where the parameter-space curve begins on the surface. That keeps
the loop's sense from the file, so holes still run against the outer loop.
A piece whose ends are one vertex at the sewing tolerance keeps its
direction.

On a 9,276-face trimmed-surface car model, 7,887 faces now import instead
of 3,882.
2026-10-20 17:32:48 -04:00
bherbruck cb1b230732 Add Dutch, Swedish, Traditional Chinese, Indonesian, Romanian and Hungarian (#3826)
The new catalogs are machine translated; native speakers, corrections are very welcome.
2026-10-20 17:32:44 -04:00
bherbruck 6e22e3709d Web: reopen designs saved on the computer from the recent list after a reload; forget stale remembered files (#104) (#3834) 2026-10-20 17:32:36 -04:00
Krzysztof Heim 1b84ce6330 fix: skip impossible drags of determined sketch geometry (#2006) (#2199)
Co-authored-by: Krzysztof Heim <krzysztof.heim@b4bridge.com>
2026-10-20 20:56:50 +00:00
bherbruck 687caad24c IGES import: a revolved arc is a cap, not a whole sphere (#3806) (#3813)
A surface of revolution (120) wrote its arc generatrix as a whole STEP circle, which
turns into a whole sphere twice over when the arc is centred on the axis; the trim could
not pick the cap out of it and the face meshed as the full ball. An arc in a plane through
the axis now comes in as a sphere or torus, any other arc as an exact rational B-spline of
just the arc.
2026-10-20 20:56:35 +00:00
bherbruck ec552a5300 Release 0.18.0
The tree is main at c3151fb7f exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 16:28:59 -04:00
bherbruck e0cc740cc3 Release 0.17.0
The tree is main at 511faedcf exactly; the 0.16.x release commits are kept as a parent so release only moves forward.
2026-10-20 11:41:48 -04:00
bherbruck 5de48e916d Release 0.16.2 2026-10-19 00:39:30 +00:00
bherbruck 80af7bbe3e Release 0.16.2
Version 0.16.2: 0.16.1 with the rigid orbit fix (#2753). Same design format (27).

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

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

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

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

This reverts commit cac4bccea8.

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

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

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

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

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

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

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

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

* CI: a test filter YAML parses

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:56:47 +00:00
227 changed files with 47597 additions and 1284 deletions
+7 -6
View File
@@ -1,6 +1,5 @@
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
# CI builds the tests once and runs them in shards: 2 for a pull request, 4 for main and the nightly
# (.github/workflows/ci.yml).
# CI builds the tests once and runs them in one job on a 32 vCPU runner (.github/workflows/ci.yml).
[profile.ci]
# Heavy tests (one to twelve minutes each on a CI runner) that pull requests skip. They run on
@@ -34,12 +33,14 @@ slow-timeout = { period = "60s" }
[profile.ci.junit]
path = "junit.xml"
# The action matrix sample runs its designs in parallel worker processes, one per CPU. Run with
# other tests beside it (the heavy ones on main), a pi-case hole that takes 8 s alone (#952 makes
# it a real hole) went past the matrix's 90 s action limit. It runs on its own.
# The action matrix sample runs its designs in parallel workers (ACTION_MATRIX_JOBS, set to match in
# ci.yml). Its workers reserve that many of nextest's threads, so the cores are not oversubscribed
# (a pi-case hole that takes 8 s alone went past the matrix's 90 s action limit when they were),
# and it starts first, beside the other slow tests.
[[profile.ci.overrides]]
filter = "binary(action_matrix) & test(=action_matrix_sample)"
threads-required = "num-cpus"
threads-required = 12
priority = 100
# The slowest tests start first, so a shard does not end with one of them running alone on a
+1
View File
@@ -7,4 +7,5 @@ self-hosted-runner:
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-8vcpu-ubuntu-2404
- blacksmith-16vcpu-ubuntu-2404
- blacksmith-32vcpu-ubuntu-2404
- blacksmith-8vcpu-windows-2025
+117 -48
View File
@@ -8,12 +8,13 @@
# whole workspace, as do pushes to main that change code, the nightly run and Run workflow.
# Documentation, packaging, the other workflows and the GitHub bots' scripts select nothing:
# no native job runs for them, on pull requests or on main.
# Tests are built once and run in shards. A few heavy tests (see .config/nextest.toml) are
# Tests are built and run in one job on a big runner, slowest first, starting beside the repository
# checks (no archive to upload and download). A few heavy tests (see .config/nextest.toml) are
# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders
# and app startup), the reference tests and the doc tests run on main and nightly only, not on
# pull requests (the fast lane, in Select checks); a break there is fixed forward on main. Only
# main saves the build caches; pull requests restore them. On pull requests the test build and the
# shards start only after the format and repository checks pass.
# main saves the build caches; pull requests restore them. sccache is on a Blacksmith sticky disk that
# only main commits.
# A push to main runs only the repository checks when a passing merge-queue run tested that very
# commit, or when nothing a build reads changed since the last push to main whose run passed.
# Otherwise it runs everything.
@@ -29,14 +30,13 @@
# reads `ci` from the newest run of a commit, so a run without it would block a PR that passed.
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
# Every Linux job runs on Blacksmith's Ubuntu 24.04 runners (blacksmith-<N>vcpu-ubuntu-2404), sized
# per job: 16 vCPU for Build tests, Clippy and the merge queue's test shards, 8 for compiles, the
# other test shards, doc and reference tests, 4 for the light checks, 2 for Select checks and ci.
# per job: 32 vCPU for the tests, 16 for Clippy, the archive build (nightly) and the merge queue's, 8 for compiles, doc and reference tests, 4 for the light checks, 2 for Select checks and ci.
# The Windows shader job runs on Blacksmith's Windows Server 2025 runners (blacksmith-8vcpu-windows-2025);
# the Windows app startup smoke stays on windows-latest, which has a desktop session that presents frames.
# Pull requests, forks included, the merge queue, pushes to main and the nightly run all use them:
# the runners are ephemeral, fork runs get no secrets, and main saves the build caches that every
# other run restores read-only. The tests are built once (Build tests) into an archive the shards
# run; Blacksmith's image matches ubuntu-24.04, so the archive and the recorded goldens behave the
# other run restores read-only. Nightly and by hand, Build tests also makes an archive the matrix
# jobs run; Blacksmith's image matches ubuntu-24.04, so the archive and the recorded goldens behave the
# same.
name: CI
@@ -178,9 +178,12 @@ jobs:
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/ci-paths"
while IFS= read -r -d '' path; do
case "$path" in crates/kernel/*|vendor/truck-*) kernel=true ;; esac
# What can change a robustness golden: the kernel and the crates a design is built with.
# What a pull request re-checks the goldens for: the kernel, truck and the goldens
# themselves (a re-record is checked before it is queued). The goldens are frozen, so
# on any other change they would only re-check unchanged results; the merge queue,
# main and the nightly run every seed before anything lands.
case "$path" in
crates/kernel/*|crates/engine/*|crates/doc/*|crates/sketch/*|crates/geom/*|vendor/truck-*|tests/robustness/*) goldens=true ;;
crates/kernel/*|vendor/truck-*|tests/robustness/*) goldens=true ;;
esac
# What the Windows D3D12 jobs exercise: the GPU crate and its shaders, the app and
# its startup, the viewport's wgpu glue, the vendored egui-wgpu, their scripts.
@@ -287,8 +290,8 @@ jobs:
run: python3 -I scripts/ci/test_affected.py
- name: Merge-of-main detection self-test
run: python3 -I scripts/ci/test_main_merge.py
- name: Claim and triage bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
- name: Claim, triage and kernel-paused bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js
# Fast feedback for a pull request that has not been approved for the full CI yet.
quick-compile:
@@ -337,25 +340,33 @@ jobs:
build-tests:
name: Build tests
# Fail fast on pull requests: the test archive (and so the shards) waits for the repository
# checks, the most common cheap failure; heavy jobs used to run on to the end beside a failed
# format check. Main, nightly and Run workflow build regardless, so the action matrix and the
# property tests that hang off this job keep their signal.
needs: [changes, checks]
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' && (github.event_name != 'pull_request' || needs.checks.result == 'success') }}
# The archive is for the action matrix and the property tests, which run only nightly and by
# hand. Pull requests, the merge queue and main build and run their tests in the `tests` job.
needs: changes
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }}
runs-on: blacksmith-16vcpu-ubuntu-2404
# Fork builds start from main's cache, read-only; give them room to finish and upload.
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 60 || 45 }}
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
SCCACHE_DIR: /home/runner/.cache/sccache
SCCACHE_CACHE_SIZE: 20G
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
- name: Mount the compiler cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-sccache-archive
path: /home/runner/.cache/sccache
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-tests
@@ -387,63 +398,94 @@ jobs:
run: sccache --show-stats
tests:
name: Native Linux tests (${{ matrix.shard }}/4)
needs: [changes, checks, build-tests]
if: ${{ !cancelled() && needs.changes.outputs.native == 'true' && (github.event_name != 'pull_request' || needs.checks.result == 'success') && needs.build-tests.result == 'success' }}
runs-on: ${{ github.event_name == 'merge_group' && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-8vcpu-ubuntu-2404' }}
name: Native Linux tests
needs: changes
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' }}
# Builds the tests and runs them in one job on a big runner: there is no archive to upload
# and download (3 GB on a full run), and the job starts beside the repository checks, not
# after them. nextest hands tests to its threads as they free up, the slowest first (priority
# in .config/nextest.toml), so they start at once and no core idles at the end.
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
# Pull requests test their affected crates in 2 shards; main and the nightly use 4.
shard: ${{ fromJSON(github.event_name == 'pull_request' && '[1, 2]' || '[1, 2, 3, 4]') }}
env:
# Pictures (snapshots, screenshots, ui.render, the viewport's offscreen tests) are drawn on
# the GPU. The runner has none: Mesa's lavapipe (software Vulkan) draws them, and a test that
# would skip for want of an adapter fails instead.
SOLIDCRAFT_GPU: software
SOLIDCRAFT_REQUIRE_GPU: "1"
ACTION_MATRIX_JOBS: "12"
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_DIR: /home/runner/.cache/sccache
SCCACHE_CACHE_SIZE: 20G
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install a software Vulkan driver (lavapipe)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
- name: Mount the compiler cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-sccache-tests
path: /home/runner/.cache/sccache
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-tests
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
# Each shard runs the archive the Build tests job made.
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Run tests
env:
EVENT_NAME: ${{ github.event_name }}
GOLDENS: ${{ needs.changes.outputs.goldens }}
SHARD: ${{ matrix.shard }}
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
FULL: ${{ needs.changes.outputs.full }}
PACKAGES: ${{ needs.changes.outputs.packages }}
# The robustness golden seeds a pull request skips: each takes one to twenty minutes under
# CI load, together about four fifths of the goldens' time. Pushes to main, the merge
# queue and the nightly run every seed.
SLOW_SEEDS: 81|45|20|24|95|02|57|40|18|48|32|46|85|61|77|34|14
run: |
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
filter=()
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml); `default()`
# below is that filter, so a pull request still runs only what it ran before.
base='default()'
if [ "$EVENT_NAME" = pull_request ]; then
# The action matrix sample (about 6 minutes on all cores) runs in the merge queue, on
# main and nightly, not on every pull request push.
base='default() & not (binary(action_matrix) & test(=action_matrix_sample))'
fi
if [ "$EVENT_NAME" != pull_request ]; then
# The boolean property tests draw new random inputs every run; they run only in the
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
filter=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
base='not (package(solidcraft-kernel) & binary(boolean_props))'
elif [ "$GOLDENS" = true ]; then
# A pull request that can change a robustness golden (tests/robustness/goldens.json)
# also checks every seed but the slow ones, in the same run so they share its cores.
filter=(--ignore-default-filter -E "default() | (package(solidcraft-engine) & test(/^tests::robustness_goldens::/) & not test(/^tests::robustness_goldens::seed_($SLOW_SEEDS)\$/))")
base="(default() & not (binary(action_matrix) & test(=action_matrix_sample))) | (package(solidcraft-engine) & test(/^tests::robustness_goldens::/) & not test(/^tests::robustness_goldens::seed_($SLOW_SEEDS)\$/))"
fi
junit=${CARGO_TARGET_DIR:-target}/nextest/ci/junit.xml
rm -f "$junit"
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
# the build job's path, which is the same checkout path here.
run=(cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite)
if ! "${run[@]}" --profile ci --partition "slice:$SHARD/$SHARDS" --no-tests=pass "${filter[@]}"; then
# Pull requests test the affected packages (scripts/ci/affected.py).
scope=(--workspace)
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
status=0
cargo nextest run "${scope[@]}" --cargo-profile ci --locked --profile ci --no-tests=pass \
--ignore-default-filter -E "$base" || status=1
if [ "$status" != 0 ]; then
# A failed golden gets the hint to re-record it (junit.xml lists the failures).
if [ -f "$junit" ] && tr '\n' ' ' < "$junit" | grep -q '<testcase name="tests::robustness_goldens::[^"]*"[^>]*>[[:space:]]*<failure'; then
echo "::error::A robustness golden changed. If the new result is intended, re-record it in this pull request and say why:"
@@ -451,11 +493,14 @@ jobs:
fi
exit 1
fi
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
- name: Upload test timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: junit-${{ matrix.shard }}
name: junit
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
retention-days: 7
@@ -560,13 +605,23 @@ jobs:
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
SCCACHE_DIR: /home/runner/.cache/sccache
SCCACHE_CACHE_SIZE: 20G
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
- name: Mount the compiler cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-sccache-doc
path: /home/runner/.cache/sccache
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
@@ -608,13 +663,23 @@ jobs:
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
SCCACHE_DIR: /home/runner/.cache/sccache
SCCACHE_CACHE_SIZE: 20G
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
- name: Mount the compiler cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-sccache-oracle
path: /home/runner/.cache/sccache
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
@@ -746,6 +811,7 @@ jobs:
DOCTESTS: ${{ needs.changes.outputs.doctests }}
CLIPPY: ${{ needs.clippy.result }}
BUILD: ${{ needs.build-tests.result }}
EVENT_NAME: ${{ github.event_name }}
TESTS: ${{ needs.tests.result }}
DOCS: ${{ needs.doc-tests.result }}
ORACLE: ${{ needs.oracle.result }}
@@ -760,7 +826,10 @@ jobs:
fi
if [ "$NATIVE" = true ]; then
test "$CLIPPY" = success
test "$BUILD" = success
# The archive for the action matrix and property tests exists nightly and by hand only.
if [ "$EVENT_NAME" = schedule ] || [ "$EVENT_NAME" = workflow_dispatch ]; then
test "$BUILD" = success
fi
test "$TESTS" = success
fi
if [ "$DOCTESTS" = true ]; then
+42
View File
@@ -0,0 +1,42 @@
# While a new geometry kernel is evaluated, new issues, pull requests and discussions about kernel
# work are labelled `kernel-paused` for a maintainer to answer (CONTRIBUTING.md, "Kernel work is
# paused"). The logic is in scripts/kernel-paused/ (tested in ci.yml).
name: Kernel paused
on:
issues:
types: [opened]
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
# scripts/kernel-paused and only lists the PR's changed paths; the PR's code is never checked out.
pull_request_target:
types: [opened]
discussion:
types: [created]
permissions:
contents: read # to check out scripts/kernel-paused
issues: write
pull-requests: read # to list a PR's changed paths
discussions: write
concurrency:
group: kernel-paused-${{ github.event.issue.number || github.event.pull_request.number || github.event.discussion.number }}
cancel-in-progress: false
jobs:
paused:
name: Kernel paused bot
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/kernel-paused
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const paused = require('./scripts/kernel-paused/kernel-paused.js');
const done = context.eventName === 'discussion'
? await paused.handleDiscussion({ github, context })
: await paused.handleOpened({ github, context });
core.info(done);
+24 -1
View File
@@ -57,6 +57,23 @@ Editable drawing code, native projects and shipped exports are collected under `
| docs/book/src/generated/tool-preview.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/tool-preview.json`; gitignored) | MIT OR Apache-2.0 |
| assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
| assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in assets/fonts/biz-udp-gothic/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
| tests/step-conformance/cone_point.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cone_point_vertex.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder_inch.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder_placed.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder_seam.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder_twice.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/dome.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/dome_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/far_cylinder.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/grooved_shaft.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/plate_100_holes.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/sheet_disc.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/tiny_pin.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/torus_ring_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/washer.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/23-bracket-multifeature/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/23-bracket-multifeature/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/28-blind-pocket-fillets/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/28-blind-pocket-fillets/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
@@ -77,7 +94,6 @@ Editable drawing code, native projects and shipped exports are collected under `
| tests/oracle/61-shaft-keyway/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/61-shaft-keyway/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/ibm-plex-mono/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
@@ -102,21 +118,28 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/locales/cs.json | potuzai | Czech translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/da.json | SolidCraft contributors | Original Danish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/en-GB.json | SolidCraft contributors | English (UK): the English catalog's messages whose spelling differs, in UK spelling | MIT OR Apache-2.0 |
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/gl.json | Pablo López (pablopeza) | Galician translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/hu.json | SolidCraft contributors | Original Hungarian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/id.json | SolidCraft contributors | Original Indonesian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/it.json | SolidCraft contributors | Original Italian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ms.json | A.Hakim (a-hakim) | Malay translation of the English message catalog | MIT OR Apache-2.0 |
| assets/locales/nl.json | SolidCraft contributors | Original Dutch translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/pl.json | cyryllo | Polish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/sv.json | SolidCraft contributors | Original Swedish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/pt-BR.json | DuBearte | Brazilian Portuguese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/ro.json | SolidCraft contributors | Original Romanian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/tr.json | macrulz54 | Turkish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/vi.json | vandang890615 | Vietnamese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/zh-TW.json | SolidCraft contributors | Original Traditional Chinese (Taiwan) translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
+10 -1
View File
@@ -15,6 +15,15 @@ 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
We're evaluating a new geometry kernel. Until that's decided, please don't open issues, pull
requests or discussions about kernel work: booleans, fillets, chamfers, shell, offsets, sweeps and
lofts, threads and holes, STEP/IGES import and export, tessellation, or anything else that changes
`crates/kernel/`. 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, and everything outside the kernel carries on as normal.
## Get it running (about 10 minutes on a fast machine)
You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web
@@ -199,7 +208,7 @@ Check that validation after merging. They are listed in
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
- `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests matching the workflow's geometry paths run all but the slowest, `SLOW_SEEDS` in `ci.yml`),
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests that change the kernel, truck or the goldens run all but the slowest, `SLOW_SEEDS` in `ci.yml`),
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests
Generated
+18 -17
View File
@@ -4162,7 +4162,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4193,13 +4193,14 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.18.0"
version = "0.18.3"
[[package]]
name = "solidcraft-cli"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"image",
"log",
"serde_json",
"solidcraft-engine",
"solidcraft-gpu",
@@ -4208,7 +4209,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"proc-macro2",
"quote",
@@ -4217,7 +4218,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"log",
"serde",
@@ -4234,7 +4235,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"log",
"schemars",
@@ -4253,7 +4254,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"serde",
"serde_json",
@@ -4262,7 +4263,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"log",
"naga",
@@ -4279,7 +4280,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4302,7 +4303,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"log",
"proptest",
@@ -4322,7 +4323,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4331,7 +4332,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"image",
"moxcms",
@@ -4345,7 +4346,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"serde",
"serde_json",
@@ -4356,14 +4357,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"arboard",
"egui",
@@ -4390,7 +4391,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"eframe",
"egui",
@@ -5920,7 +5921,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.18.0"
version = "0.18.3"
dependencies = [
"serde_json",
"sha2 0.10.9",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.18.0"
version = "0.18.3"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+1
View File
@@ -23,6 +23,7 @@ path = "src/alias.rs"
solidcraft-engine = { workspace = true }
solidcraft-gpu = { workspace = true }
solidcraft-mcp = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
+49
View File
@@ -0,0 +1,49 @@
//! What the engine logs (`log::info!`, `log::debug!`: why a body has no display plan, why a save
//! left its model cache out), written to stderr. Off unless asked for, so the CLI's output and
//! the scripts reading it stay as they are: `--debug` shows SolidCraft's own debug messages (and
//! other crates' warnings, whose debug output is large), `RUST_LOG=error|warn|info|debug|trace`
//! sets one level for every crate.
use log::{LevelFilter, Log, Metadata, Record};
struct Stderr {
/// The level for SolidCraft's crates.
ours: LevelFilter,
/// The level for every other crate.
others: LevelFilter,
}
impl Log for Stderr {
fn enabled(&self, m: &Metadata) -> bool {
m.level() <= if m.target().starts_with("solidcraft") { self.ours } else { self.others }
}
fn log(&self, r: &Record) {
if self.enabled(r.metadata()) {
use std::io::Write as _;
// A closed stderr is no reason to stop.
let _ = writeln!(std::io::stderr().lock(), "[{} {}] {}", r.level().as_str().to_ascii_lowercase(), r.target(), r.args());
}
}
fn flush(&self) {}
}
/// Removes `--debug` from `args`, wherever it is (every subcommand takes it), and installs the
/// logger when it or `RUST_LOG` asks for one.
pub fn init(args: &mut Vec<String>) {
let before = args.len();
args.retain(|a| a != "--debug");
let debug = args.len() != before;
let env = std::env::var("RUST_LOG").ok().and_then(|v| v.trim().parse::<LevelFilter>().ok());
let (ours, others) = match (debug, env) {
(false, Some(all)) => (all, all),
(true, Some(all)) => (all.max(LevelFilter::Debug), all),
(true, None) => (LevelFilter::Debug, LevelFilter::Warn),
(false, None) => return,
};
// Installed once, for the life of the process.
if log::set_logger(Box::leak(Box::new(Stderr { ours, others }))).is_ok() {
log::set_max_level(ours.max(others));
}
}
+7 -1
View File
@@ -17,6 +17,8 @@
//! MCP server on stdio (docs/mcp.md)
//! solidcraft-cli licences SolidCraft's licence and the third-party licences
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
//!
//! --debug (with any command) SolidCraft's debug log on stderr (or RUST_LOG=LEVEL)
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -24,6 +26,7 @@
mod bench_agent;
mod crashes;
mod fuzz;
mod logger;
mod oracle;
mod preview;
mod recipe;
@@ -82,10 +85,13 @@ const USAGE: &str = "usage:
lands on: gpu, gpu-software, or the CPU rasterizer (auto with no adapter, or cpu); the policy defaults
to SOLIDCRAFT_GPU, else auto; exits with an error when hardware or software finds no such adapter)
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
--debug, with any command: what SolidCraft logs (why a body has no display plan, why a save left its
model cache out), on stderr; RUST_LOG=error|warn|info|debug|trace sets one level for every crate
";
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let mut args: Vec<String> = std::env::args().skip(1).collect();
logger::init(&mut args);
let r = match args.first().map(String::as_str) {
Some("run") => cmd_run(&args[1..]),
Some("eval" | "inspect") => cmd_eval(&args[1..]),
+143 -3
View File
@@ -59,6 +59,34 @@ pub fn make_id(ms: u64, random: u64) -> String {
format!("{ms:x}-{random:x}")
}
/// How long a remembered file may go unused, and unreferenced by the recent designs and the
/// autosave, before the browser's copy of its handle is dropped.
pub const HANDLE_MAX_AGE_MS: f64 = 90.0 * 24.0 * 3600.0 * 1000.0;
/// What to do with the remembered handles.
#[derive(Debug, Default, PartialEq)]
pub struct PrunePlan {
/// Handles to forget.
pub delete: Vec<String>,
/// Handles seen unused for the first time: start their clock.
pub stamp: Vec<String>,
}
/// Decide which of the remembered handles `ids` (id, when it was last stamped) to forget: those
/// that no recent design or autosave (`keep`) names and that have gone unused for `max_age`. One
/// never stamped starts its clock now, so a file another tab has just saved is never dropped.
pub fn plan_prune(ids: &[(String, Option<f64>)], keep: &std::collections::HashSet<String>, now: f64, max_age: f64) -> PrunePlan {
let mut plan = PrunePlan::default();
for (id, stamped) in ids.iter().filter(|(id, _)| !keep.contains(id)) {
match stamped {
None => plan.stamp.push(id.clone()),
Some(at) if now - at > max_age => plan.delete.push(id.clone()),
Some(_) => {}
}
}
plan
}
/// Where a design saved with Save As goes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SaveTarget {
@@ -112,7 +140,7 @@ mod web {
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{FileSystemFileHandle, IdbDatabase, IdbObjectStore, IdbOpenDbRequest, IdbRequest, IdbTransactionMode};
use super::{is_abort, make_id, opens_design, parse, path, save_type};
use super::{HANDLE_MAX_AGE_MS, is_abort, make_id, opens_design, parse, path, plan_prune, save_type};
use crate::files::{Files, err, read_handle};
const DB: &str = "solidcraft-disk-handles";
@@ -397,10 +425,106 @@ mod web {
db.transaction_with_str_and_mode(STORE, mode).map_err(err)?.object_store(STORE).map_err(err)
}
/// Key of the time a handle was last used (the handle's own key is its id).
fn stamp_key(id: &str) -> String {
format!("at:{id}")
}
async fn remember(id: &str, handle: &FileSystemFileHandle) -> Result<(), String> {
let db = open_db().await?;
let req = store(&db, IdbTransactionMode::Readwrite)?.put_with_key(handle, &id.into()).map_err(err)?;
result(&req).await.map(|_| ())
let store = store(&db, IdbTransactionMode::Readwrite)?;
result(&store.put_with_key(handle, &id.into()).map_err(err)?).await?;
result(&store.put_with_key(&js_sys::Date::now().into(), &stamp_key(id).into()).map_err(err)?).await.map(|_| ())
}
/// Note that the file was used now.
async fn touch(id: &str) {
let Ok(db) = open_db().await else { return };
if let Ok(store) = store(&db, IdbTransactionMode::Readwrite)
&& let Ok(req) = store.put_with_key(&js_sys::Date::now().into(), &stamp_key(id).into())
{
let _ = result(&req).await;
}
}
/// The ids of every remembered file.
pub async fn known_ids() -> Result<Vec<String>, String> {
let db = open_db().await?;
let req = store(&db, IdbTransactionMode::Readonly)?.get_all_keys().map_err(err)?;
let keys: Array = result(&req).await?.dyn_into().map_err(|_| "no keys".to_string())?;
Ok(keys.iter().filter_map(|k| k.as_string()).filter(|k| !k.starts_with("at:")).collect())
}
async fn forget(ids: &[String]) -> Result<(), String> {
let db = open_db().await?;
let store = store(&db, IdbTransactionMode::Readwrite)?;
for id in ids {
result(&store.delete(&id.as_str().into()).map_err(err)?).await?;
result(&store.delete(&stamp_key(id).into()).map_err(err)?).await?;
HANDLES.with(|h| h.borrow_mut().remove(id));
}
Ok(())
}
/// Forget the remembered files nothing refers to any more (`keep`: the ids the recent designs
/// and the autosave name), once they have gone unused for a long while.
pub fn prune(keep: std::collections::HashSet<String>) {
spawn_local(async move {
let r = async {
// A transaction ends when the page awaits anything but its own requests, so each
// step opens its own.
let known = known_ids().await?;
let db = open_db().await?;
let reading = store(&db, IdbTransactionMode::Readonly)?;
let mut ids = Vec::new();
for id in known {
let at = result(&reading.get(&stamp_key(&id).into()).map_err(err)?).await?.as_f64();
ids.push((id, at));
}
let plan = plan_prune(&ids, &keep, js_sys::Date::now(), HANDLE_MAX_AGE_MS);
for id in &plan.stamp {
touch(id).await;
}
forget(&plan.delete).await
};
if let Err(e) = r.await {
log::warn!("remembered files weren't tidied: {e}");
}
});
}
/// Open a design remembered from an earlier session (a recent one, after a reload): ask once
/// for access to the file (call in the click), read it into the engine's files and queue it
/// to open. A file that is gone is forgotten and dropped from the recent designs.
pub fn restore(files: &Rc<Files>, path: &str) {
let Some((id, name)) = parse(path) else { return };
let (files, path, id, name) = (files.clone(), path.to_string(), id.to_string(), name.to_string());
spawn_local(async move {
let r = async {
let handle = handle_for(&id).await?;
ensure_readwrite(&handle).await?;
read_handle(&handle).await
};
match r.await {
Ok(bytes) => {
vfs::insert(&path, bytes);
files.opened.borrow_mut().push(path);
files.wake();
touch(&id).await;
}
Err(e) => {
let gone = e.contains("NotFoundError") || e.contains("could not be found") || e.contains("no longer remembers");
if gone {
let _ = forget(std::slice::from_ref(&id)).await;
files.disk_known.borrow_mut().remove(&id);
files.gone.borrow_mut().push(path);
files.say(format!("{name} isn't where it was saved any more"), true);
} else {
files.say(format!("{name}: {e}"), true);
}
}
}
});
}
async fn recall(id: &str) -> Result<Option<JsValue>, String> {
@@ -455,6 +579,22 @@ mod tests {
assert_eq!(route("/upload/part.step"), WriteTo::Memory);
}
#[test]
fn only_unreferenced_handles_unused_for_long_are_forgotten() {
let keep: std::collections::HashSet<String> = ["recent".to_string()].into();
let day = 24.0 * 3600.0 * 1000.0;
let now = 1000.0 * day;
let ids = vec![
("recent".to_string(), Some(now - 200.0 * day)),
("old".to_string(), Some(now - 100.0 * day)),
("fresh".to_string(), Some(now - 5.0 * day)),
("unstamped".to_string(), None),
];
let plan = plan_prune(&ids, &keep, now, HANDLE_MAX_AGE_MS);
assert_eq!(plan, PrunePlan { delete: vec!["old".to_string()], stamp: vec!["unstamped".to_string()] });
assert_eq!(plan_prune(&[], &keep, now, HANDLE_MAX_AGE_MS), PrunePlan::default());
}
#[test]
fn handle_ids_differ_by_time_and_by_chance() {
assert_ne!(make_id(1, 5), make_id(2, 5));
+9 -1
View File
@@ -57,6 +57,10 @@ pub struct Files {
pub incoming: RefCell<Vec<(String, Vec<u8>, Intent)>>,
/// Designs opened from the computer's disk: their paths in the engine's files.
pub opened: RefCell<Vec<String>>,
/// The ids of the files remembered from earlier sessions (see `disk::restore`).
pub disk_known: RefCell<std::collections::HashSet<String>>,
/// Recent designs whose file is gone: to drop from the recent list.
pub gone: RefCell<Vec<String>>,
/// Disk designs whose last write failed: they are not saved.
pub unsaved: RefCell<Vec<String>>,
/// Messages for the status line (errors and confirmations).
@@ -90,7 +94,7 @@ impl Files {
}
/// Run a frame (the app sleeps until something happens).
fn wake(&self) {
pub fn wake(&self) {
if let Some(c) = self.ctx.borrow().as_ref() {
c.request_repaint();
}
@@ -267,6 +271,10 @@ pub fn start(files: &Rc<Files>) {
}
let files = files.clone();
spawn_local(async move {
match disk::known_ids().await {
Ok(ids) => files.disk_known.borrow_mut().extend(ids),
Err(e) => log::info!("no remembered files: {e}"),
}
match read_all().await {
Ok(all) => {
if local_only() {
+19 -1
View File
@@ -136,6 +136,11 @@ mod web {
self.app.open_path(&path);
self.files.panel.set(false);
}
// Recent designs whose file is gone are dropped from the list.
for path in self.files.gone.borrow_mut().drain(..) {
self.app.home.recent.retain(|r| *r != path);
self.app.home.opened_at.remove(&path);
}
for (text, error) in self.files.messages.borrow_mut().drain(..) {
self.app.set_status(text, error);
}
@@ -203,7 +208,9 @@ mod web {
let mut entries = self.files.entries.borrow().clone();
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
let recent = &mut self.app.home.recent;
recent.retain(|p| vfs::exists(p));
let known = self.files.disk_known.borrow().clone();
// A design on the computer's disk stays listed while its file is remembered.
recent.retain(|p| vfs::exists(p) || disk::parse(p).is_some_and(|(id, _)| known.contains(id)));
for e in entries.iter().filter(|e| solidcraft_engine::io::is_design_path(&e.name)) {
let p = format!("{}{}", files::OPFS, e.name);
if !recent.contains(&p) {
@@ -211,6 +218,10 @@ mod web {
}
}
recent.truncate(solidcraft_ui_egui::home::MAX_RECENT);
// Remembered files that no recent design or autosave names are forgotten in time.
let recovered = self.files.recovery.borrow().as_ref().and_then(|(_, p)| p.clone());
let keep = recent.iter().chain(recovered.as_ref()).filter_map(|p| disk::parse(p)).map(|(id, _)| id.to_string()).collect();
disk::prune(keep);
}
/// The stored designs: open, insert, rename, delete, download; open from the computer.
@@ -563,6 +574,7 @@ mod web {
let files = Rc::new(Files::default());
files::start(&files);
let (panel, opener, saver, shown) = (files.clone(), files.clone(), files.clone(), files.clone());
let (restorable, restorer) = (files.clone(), files.clone());
let services = 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); otherwise,
@@ -587,6 +599,12 @@ mod web {
SaveTarget::Download => Box::pin(std::future::ready(Some(format!("{}{name}", files::DOWNLOADS)))),
}
})),
// A recent design on the disk, from an earlier session, opens by asking
// the browser for its remembered file.
can_restore: Some(Box::new(move |path: &str| {
disk::parse(path).is_some_and(|(id, _)| restorable.disk_known.borrow().contains(id))
})),
restore_file: Some(Box::new(move |path: &str| disk::restore(&restorer, path))),
download_design: Some(Box::new(files::download)),
browser_files: Some(Box::new(move || shown.panel.set(true))),
install_app: Some(crate::install::service(&cc.egui_ctx)),
+238 -10
View File
@@ -3,9 +3,10 @@
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use std::time::Duration;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use solidcraft_engine::MAX_LINE;
@@ -178,11 +179,137 @@ struct Pending {
reply: Receiver<Value>,
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
serve_with(stream, token, tx, ctx, patient, WAITS);
/// Every request the app has been sent and not answered yet, from every connection, for
/// `control.running` (#3654). Each connection's own thread answers that, so it never waits behind
/// a running request.
struct Running {
list: Mutex<Vec<InFlight>>,
connections: AtomicU64,
keys: AtomicU64,
}
fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool, waits: Waits) {
/// What `control.running` says about a request: never its parameters beyond the command id.
struct InFlight {
key: u64,
connection: u64,
id: Value,
method: String,
command: Option<&'static str>,
steps: Option<usize>,
since: Instant,
started: Arc<AtomicBool>,
}
/// The app's requests, from both of its listeners.
static RUNNING: Running = Running::new();
impl Running {
const fn new() -> Self {
Running { list: Mutex::new(Vec::new()), connections: AtomicU64::new(0), keys: AtomicU64::new(0) }
}
fn lock(&self) -> MutexGuard<'_, Vec<InFlight>> {
self.list.lock().unwrap_or_else(PoisonError::into_inner)
}
/// List `req`, sent on `connection`, until the returned ticket is dropped.
fn add(&'static self, connection: u64, id: &Value, req: &ControlRequest) -> Ticket {
let key = self.keys.fetch_add(1, Ordering::Relaxed);
// Ids and methods come from the client: only short ones are repeated to the others.
let id = match id {
Value::Number(_) => id.clone(),
Value::String(s) if s.len() <= 64 => id.clone(),
_ => Value::Null,
};
let p = &req.params;
let command = match req.method.as_str() {
"engine.execute" | "command" => p.get("command").and_then(Value::as_str).and_then(solidcraft_engine::find_command).map(|c| c.id),
_ => None,
};
let steps = match req.method.as_str() {
"engine.batch" | "engine.script" => p.get("commands").and_then(Value::as_array).map(Vec::len),
_ => None,
};
let method = req.method.chars().take(64).collect();
let started = req.started.clone();
self.lock().push(InFlight { key, connection, id, method, command, steps, since: Instant::now(), started });
Ticket { running: self, key }
}
/// The `control.running` result, as `asker`'s connection sees it.
fn result(&self, asker: u64) -> Value {
let rows: Vec<Value> = self
.lock()
.iter()
.map(|e| {
let mut r = json!({
"connection": e.connection,
"id": e.id,
"method": e.method,
"running_s": (e.since.elapsed().as_secs_f64() * 10.0).round() / 10.0,
"queued": !e.started.load(Ordering::Relaxed),
});
if let Some(c) = e.command {
r["command"] = json!(c);
}
if let Some(n) = e.steps {
r["steps"] = json!(n);
}
if e.connection == asker {
r["self"] = json!(true);
}
r
})
.collect();
json!({"running": rows})
}
}
/// A listed request; dropped when its reply arrives.
struct Ticket {
running: &'static Running,
key: u64,
}
impl Drop for Ticket {
fn drop(&mut self) {
let key = self.key;
self.running.lock().retain(|e| e.key != key);
}
}
/// The reply to a timed-out request, received on a thread of its own so the request stays listed
/// until the app answers, also once its connection has closed (#3654).
fn watch(reply: Receiver<Value>, ticket: Ticket) -> Receiver<Value> {
let (late_tx, late_rx) = channel();
// A small thread per timed-out request (each connection has at most one). If none can be
// made, the reply is lost and `control.pending` says the app ended the request without one.
// The reply is passed on before the request leaves the list: once `control.running` no longer
// shows it, its connection's `control.pending` has it.
let _ = std::thread::Builder::new().stack_size(64 * 1024).spawn(move || {
if let Ok(v) = reply.recv() {
let _ = late_tx.send(v);
}
drop(late_tx);
drop(ticket);
});
late_rx
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
serve_with(stream, token, tx, ctx, patient, WAITS, &RUNNING);
}
fn serve_with(
stream: TcpStream,
token: &str,
tx: Sender<ControlRequest>,
ctx: egui::Context,
patient: bool,
waits: Waits,
running: &'static Running,
) {
let connection = running.connections.fetch_add(1, Ordering::Relaxed) + 1;
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
@@ -216,13 +343,16 @@ fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: e
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
// A request that timed out may have finished since (#1618).
let late = pending.as_ref().and_then(|p| match p.reply.try_recv() {
// A request that timed out may have finished since (#1618). `control.running` only
// looks, so it leaves that reply where it is.
let late = pending.as_ref().filter(|_| method != "control.running").and_then(|p| match p.reply.try_recv() {
Ok(reply) => Some(reply),
Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app ended the request without a reply"})),
Err(TryRecvError::Empty) => None,
});
let mut r = if method == "control.pending" {
let mut r = if method == "control.running" {
json!({"ok": true, "result": running.result(connection)})
} else if method == "control.pending" {
match (pending.take(), late) {
(None, _) => json!({"ok": true, "result": {"pending": null}}),
(Some(p), Some(reply)) => json!({"ok": true, "result": {"pending": p.id, "method": p.method, "done": true, "reply": reply}}),
@@ -243,6 +373,7 @@ fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: e
// A batch replaces many short requests: the whole of it may take long (#743).
let wait = if patient || method == "engine.batch" { waits.long } else { waits.short };
let (req, rrx) = ControlRequest::new(method.clone(), params);
let ticket = running.add(connection, &id, &req);
if tx.send(req).is_err() {
break;
}
@@ -251,7 +382,7 @@ fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: e
Ok(r) => r,
Err(_) => {
let r = json!({"ok": false, "running": true, "error": format!("timeout: `{method}` is still running in the app and will still apply; ask `control.pending` for its result")});
pending = Some(Pending { id: id.clone(), method, reply: rrx });
pending = Some(Pending { id: id.clone(), method, reply: watch(rrx, ticket) });
r
}
}
@@ -307,9 +438,10 @@ mod tests {
}
});
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100) };
static RUNNING: Running = Running::new();
std::thread::spawn(move || {
if let Some(stream) = listener.incoming().flatten().next() {
serve_with(stream, token, tx, egui::Context::default(), false, waits);
serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING);
}
});
let s = TcpStream::connect(addr).unwrap();
@@ -349,6 +481,102 @@ mod tests {
assert_eq!(*ran.lock().unwrap(), ["slow", "ui.view"], "the busy request never reached the app");
}
/// #3654: `control.running` answers on every connection, at once, with what any connection
/// has the app running or queued (command ids, never parameters); a timed-out request stays
/// listed until the app answers it, and an idle app lists nothing.
#[test]
fn every_connection_sees_what_the_app_is_running() {
static RUNNING: Running = Running::new();
let token = "0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
// The window: one request at a time, as `drain_control` takes them; the export is held
// until the test lets it go.
let (gate_tx, gate_rx) = channel::<()>();
std::thread::spawn(move || {
for req in rx {
req.started.store(true, Ordering::Relaxed);
if req.method == "engine.execute" {
let _ = gate_rx.recv();
}
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
});
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_secs(60) };
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING));
}
});
let open = || {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let mut out = s.try_clone().unwrap();
let mut lines = BufReader::new(s);
move |v: Value| -> Value {
writeln!(out, "{v}").unwrap();
let mut l = String::new();
lines.read_line(&mut l).unwrap();
serde_json::from_str(&l).unwrap()
}
};
let running = |ask: &mut dyn FnMut(Value) -> Value, until: &dyn Fn(&[Value]) -> bool| {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let r = ask(json!({"id": 0, "method": "control.running", "token": token}));
let rows = r["result"]["running"].as_array().cloned().unwrap_or_default();
if until(&rows) || Instant::now() > deadline {
return (r, rows);
}
std::thread::sleep(Duration::from_millis(10));
}
};
let mut watcher = open();
let (r, rows) = running(&mut watcher, &|_| true);
assert!(r["ok"] == true && rows.is_empty(), "idle: {r}");
let mut export = open();
let path = "/private/part.step";
let params = json!({"command": "file.export", "params": {"path": path}});
let r = export(json!({"id": 7, "method": "engine.execute", "params": params, "token": token}));
assert_eq!(r["running"], true, "{r}");
// A batch from another connection waits behind the export.
let mut batch = open();
let queued = std::thread::spawn(move || {
let steps = json!([{"command": "solid.box"}, {"command": "solid.box"}]);
batch(json!({"id": "b", "method": "engine.batch", "params": {"commands": steps}, "token": token}))
});
// The window may not have taken the export yet, even though it timed out.
let (r, rows) = running(&mut watcher, &|rows| rows.len() == 2 && rows[0]["queued"] == false);
assert_eq!(rows.len(), 2, "{r}");
let (e, b) = (&rows[0], &rows[1]);
assert_eq!(
(&e["id"], &e["method"], &e["command"], &e["queued"]),
(&json!(7), &json!("engine.execute"), &json!("file.export"), &json!(false)),
"{r}"
);
assert!(e["running_s"].as_f64().is_some_and(|s| s >= 0.1), "{r}");
assert_eq!((&b["id"], &b["method"], &b["steps"], &b["queued"]), (&json!("b"), &json!("engine.batch"), &json!(2), &json!(true)), "{r}");
assert_ne!(e["connection"], b["connection"], "{r}");
assert!(rows.iter().all(|row| row.get("self").is_none()), "{r}");
assert!(!r.to_string().contains(path), "parameters stay with their connection: {r}");
// The connection whose request timed out may ask too, and sees its own marked.
let r = export(json!({"id": 8, "method": "control.running"}));
let own: Vec<&Value> = r["result"]["running"].as_array().unwrap().iter().filter(|row| row["self"] == true).collect();
assert_eq!(own.len(), 1, "{r}");
assert_eq!(own[0]["command"], "file.export", "{r}");
gate_tx.send(()).unwrap();
assert_eq!(queued.join().unwrap()["result"], "engine.batch");
let (r, rows) = running(&mut watcher, &|rows| rows.is_empty());
assert!(rows.is_empty(), "done: {r}");
// Asking `control.running` did not take the export's reply from its connection.
let r = export(json!({"id": 9, "method": "control.pending"}));
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(7), &json!(true)), "{r}");
}
/// #205: a connection runs nothing until a request carries the launch's token; a wrong or
/// missing token, or garbage, is refused and the connection closed. Once shown, the token
/// need not be repeated on that connection.
+2
View File
@@ -210,6 +210,8 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
http_download: Some(std::sync::Arc::new(http_download)),
// Already an installed app: only the browser build offers to install.
install_app: None,
can_restore: None,
restore_file: None,
download_design: None,
browser_files: None,
agent_http: Some(std::sync::Arc::new(agent_http)),
@@ -141,18 +141,13 @@ new false
normal false
offset false
offset_face false
offset_one_side false
offset_symmetric false
offset_two_sides false
open false
orbit false
origin false
pan false
params false
paste false
pattern_center false
pattern_circ false
pattern_flip false
pattern_path false
pattern_rect false
perforate false
+27
View File
@@ -0,0 +1,27 @@
{
"Center X": "Centre X",
"Center Y": "Centre Y",
"Center of mass": "Centre of mass",
"Customize Toolbar": "Customise Toolbar",
"Mass Center": "Mass Centre",
"Modeled": "Modelled",
"cmd.construct_point_center": "Point At Centre Of Circle/Sphere/Torus",
"cmd.inspect_center_of_mass": "Centre of Mass",
"cmd.sketch_arc_center_point": "Centre Point Arc",
"cmd.sketch_circle_center": "Centre Diameter Circle",
"cmd.sketch_rectangle_center": "Centre Rectangle",
"cmd.sketch_slot_arc_center": "Centre Point Arc Slot",
"cmd.sketch_slot_center_point": "Centre Point Slot",
"cmd.sketch_slot_center_to_center": "Centre to Centre Slot",
"pattern.alignment_hint": "Counts include the original. Centre balances copies; Flip chooses the extra side for even totals.",
"render.opaque_preview_help": "Opaque PBR and simple colours; blended transparency. Refraction, HDRI and final rendering are not available yet.",
"sketch_text.align_center": "Centre",
"ui.center": "Centre",
"ui.centerline": "Centreline",
"ui.customizeToolbar": "Customise Toolbar…",
"ui.design_properties.license": "Licence",
"ui.design_properties.organization": "Organisation",
"ui.space_mouse_step_pull": "Pull the cap towards you, hold it, then let go.",
"ui.zoom_to_cursor_tip": "The wheel zooms towards the point under the cursor (off: towards the view's centre)",
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software centre. SolidCraft doesn't check."
}
+8
View File
@@ -855,6 +855,12 @@
"help.licences": "Licences",
"help.mit": "MIT licence",
"help.notice": "NOTICE",
"help.solid.chamfer": "Cuts the selected edges with a flat bevel of the distance you set. Use it to break sharp edges or to ease the entry of a pin or screw.",
"help.solid.extrude": "Pushes a sketch profile into 3D along a distance, adding or removing material. Use it to turn a flat outline into a block, a boss or a pocket.",
"help.solid.fillet": "Rounds the selected edges with a smooth circular blend of the radius you set. Use it to soften sharp edges for strength, comfort or looks.",
"help.solid.hole": "Drills a round hole of the diameter you set at a chosen point on a face. Use it for screw clearances, pins and other simple round openings.",
"help.solid.revolve": "Spins a sketch profile around an axis to make a round solid. Use it for shafts, knobs, bowls and any part that is round about a centre line.",
"help.solid.shell": "Hollows out a solid, leaving walls of the thickness you set, and can open the faces you pick. Use it for boxes, covers and other thin-walled parts.",
"help.third_party": "Third-party licences",
"home.documentation": "Documentation",
"home.in_this_browser": "in this browser",
@@ -1203,6 +1209,8 @@
"ui.agent_chat_view_attached": "View attached",
"ui.agent_cursor": "Agent cursor",
"ui.agent_cursor_speed": "Agent cursor speed",
"ui.agent_off_thread": "Keep the window responsive while agents run commands",
"ui.agent_off_thread_note": "An agent's slow commands (from the AI panel or an MCP client) compute beside the window: you can keep turning and inspecting the view, and stop one from the chip in the view. Off: each runs on the window's thread and the window waits for it.",
"ui.ai_add": "Add",
"ui.ai_add_agent": "Add agent…",
"ui.ai_add_agent_title": "Add agent",
+6
View File
@@ -855,6 +855,12 @@
"help.licences": "Licences",
"help.mit": "Licence MIT",
"help.notice": "NOTICE",
"help.solid.chamfer": "Coupe les arêtes sélectionnées par un biseau plat de la distance indiquée. À utiliser pour casser des arêtes vives ou faciliter l'entrée d'une goupille ou d'une vis.",
"help.solid.extrude": "Pousse le profil d'une esquisse en 3D sur une distance, en ajoutant ou en retirant de la matière. À utiliser pour transformer un contour plat en bloc, en bossage ou en poche.",
"help.solid.fillet": "Arrondit les arêtes sélectionnées par un raccord circulaire lisse du rayon indiqué. À utiliser pour adoucir des arêtes vives par souci de solidité, de confort ou d'aspect.",
"help.solid.hole": "Perce un trou rond du diamètre indiqué en un point choisi d'une face. À utiliser pour les passages de vis, les goupilles et autres ouvertures rondes simples.",
"help.solid.revolve": "Fait tourner le profil d'une esquisse autour d'un axe pour obtenir un solide de révolution. À utiliser pour les arbres, boutons, bols et toute pièce ronde autour d'une ligne centrale.",
"help.solid.shell": "Évide un solide en laissant des parois de l'épaisseur indiquée, et peut ouvrir les faces choisies. À utiliser pour les boîtes, couvercles et autres pièces à parois minces.",
"help.third_party": "Licences tierces",
"home.documentation": "Documentation",
"home.in_this_browser": "dans ce navigateur",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8
View File
@@ -855,6 +855,12 @@
"help.licences": "ライセンス",
"help.mit": "MITライセンス",
"help.notice": "NOTICE",
"help.solid.chamfer": "選択した辺を、指定した距離で平らに斜めに削ります。鋭い角を落とすときや、ピンやねじを入れやすくするときに使います。",
"help.solid.extrude": "スケッチの輪郭を指定した距離だけ立体方向に押し出し、材料を足したり削ったりします。平面の図形をブロック、突起、ポケットにするときに使います。",
"help.solid.fillet": "選択した辺を、指定した半径のなめらかな円弧で丸めます。強度や持ちやすさ、見た目のために鋭い角を和らげるときに使います。",
"help.solid.hole": "面上の指定した位置に、指定した直径の丸い穴をあけます。ねじのクリアランスやピン穴など、単純な丸い開口に使います。",
"help.solid.revolve": "スケッチの輪郭を軸のまわりに回転させ、丸い立体を作ります。軸、つまみ、ボウルなど、中心線まわりに丸い部品に使います。",
"help.solid.shell": "立体の中をくり抜き、指定した厚さの壁を残します。選んだ面を開口にすることもできます。箱、カバーなどの薄肉の部品に使います。",
"help.third_party": "サードパーティライセンス",
"home.documentation": "ドキュメント",
"home.in_this_browser": "このブラウザ内",
@@ -1203,6 +1209,8 @@
"ui.agent_chat_view_attached": "ビューを添付済み",
"ui.agent_cursor": "エージェントカーソル",
"ui.agent_cursor_speed": "エージェントカーソル速度",
"ui.agent_off_thread": "エージェントのコマンド実行中もウィンドウを操作可能にする",
"ui.agent_off_thread_note": "エージェント(AI パネルまたは MCP クライアント)の時間のかかるコマンドをウィンドウとは別に計算します。実行中もビューの回転や確認ができ、ビュー内の表示から停止できます。オフ:各コマンドはウィンドウのスレッドで実行され、終わるまでウィンドウは待ちます。",
"ui.ai_add": "追加",
"ui.ai_add_agent": "エージェントを追加…",
"ui.ai_add_agent_title": "エージェントを追加",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8
View File
@@ -855,6 +855,12 @@
"help.licences": "许可证",
"help.mit": "MIT 许可证",
"help.notice": "NOTICE",
"help.solid.chamfer": "用指定的距离在所选边上切出一个平直的斜面。用于去除锐边,或使销钉、螺钉更容易插入。",
"help.solid.extrude": "将草图轮廓按指定距离拉伸为三维形体,可以增加或去除材料。用于把平面轮廓变成方块、凸台或凹槽。",
"help.solid.fillet": "用指定半径的平滑圆弧将所选的边倒圆。用于出于强度、手感或外观的考虑而柔化锐边。",
"help.solid.hole": "在面上的指定位置钻出指定直径的圆孔。用于螺钉过孔、销孔等简单的圆形开口。",
"help.solid.revolve": "将草图轮廓绕一条轴旋转,生成圆形的实体。用于轴、旋钮、碗等围绕中心线呈圆形的零件。",
"help.solid.shell": "将实体掏空,留下指定厚度的壁,还可以打开所选的面。用于盒子、盖子等薄壁零件。",
"help.third_party": "第三方许可证",
"home.documentation": "文档",
"home.in_this_browser": "在此浏览器中",
@@ -1203,6 +1209,8 @@
"ui.agent_chat_view_attached": "已附加视图",
"ui.agent_cursor": "代理光标",
"ui.agent_cursor_speed": "代理光标速度",
"ui.agent_off_thread": "代理运行命令时保持窗口响应",
"ui.agent_off_thread_note": "代理(来自 AI 面板或 MCP 客户端)的耗时命令在窗口之外计算:运行时仍可旋转和查看视图,并可在视图中的提示上停止它。关闭:每条命令在窗口线程上运行,窗口会等待其完成。",
"ui.ai_add": "添加",
"ui.ai_add_agent": "添加智能体…",
"ui.ai_add_agent_title": "添加智能体",
+7 -2
View File
@@ -7,8 +7,13 @@ pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anc
let raw = state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("anchored body `{}`", anchor.target.body)))?;
let relative = anchor.source_to_author(doc, author, raw)?;
let mut output = crate::state_in_frame_at(doc, state, author, frame).into_owned();
let shape = if crate::is_identity(&relative) { raw.body.clone() } else { solidcraft_kernel::transform_matrix(&raw.body, relative)? };
let mut body = ModelBody::new(raw.name.clone(), shape, raw.feature);
// In place, the body is the model's own, display mesh included: a sketch projecting the
// face's edges meshes it once for the features after it too, rather than a copy.
let mut body = if crate::is_identity(&relative) {
raw.clone()
} else {
ModelBody::new(raw.name.clone(), solidcraft_kernel::transform_matrix(&raw.body, relative)?, raw.feature)
};
body.component = raw.component;
body.transferred = raw.transferred;
body.source_name = raw.source_name.clone();
+3
View File
@@ -180,6 +180,9 @@ fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
assert_eq!(current["format"], crate::format::format_string(crate::format::FORMAT));
current["format"] = json!("solidcraft/12");
crate::format::pieces_in_shell_order(&mut legacy["features"]);
for f in legacy["features"].as_array_mut().into_iter().flatten() {
f["kernel_piece_order"] = json!(true);
}
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()).is_err());
+16
View File
@@ -950,6 +950,15 @@ pub enum FeatureKind {
/// Normals the same way instead of facing each other.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
/// A plane the bodies move with (an origin or construction plane): its origin and normal
/// are the `from` point and normal, worked out again whenever the plane moves.
#[serde(default, skip_serializing_if = "Option::is_none")]
from_plane: Option<PlaneRef>,
/// A plane the bodies move onto: after turning to meet it (when `from` has a normal) the
/// `from` point is brought onto the plane along its normal, so the plane may move and the
/// bodies follow. Replaces `to`.
#[serde(default, skip_serializing_if = "Option::is_none")]
to_plane: Option<PlaneRef>,
},
/// A helical coil: a circular or square section swept `turns` times about `axis`, rising
/// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside).
@@ -2320,6 +2329,12 @@ pub struct Feature {
/// where newer ones number them after the first name (`Ribs (4)`, #373).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub shell_piece_order: bool,
/// The pieces of a face this feature splits are numbered in the kernel's order and by the
/// index of the face they came from, as builds before #3902 numbered them, instead of by
/// place. Set on the features of designs saved before then, whose stored `#k` face names
/// mean that numbering.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub kernel_piece_order: bool,
#[serde(flatten)]
pub kind: FeatureKind,
}
@@ -3021,6 +3036,7 @@ impl Document {
face_names: Vec::new(),
participants: Vec::new(),
shell_piece_order: false,
kernel_piece_order: false,
kind,
};
self.place_feature(f);
+583 -65
View File
File diff suppressed because it is too large Load Diff
+21 -5
View File
@@ -116,19 +116,35 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip } => {
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip, from_plane, to_plane } => {
// A plane on either side is worked out now, so the bodies follow it when it moves.
let (mut from, mut from_normal) = (from.0, from_normal.and_then(|n| n.normalized()));
if let Some(p) = from_plane {
let pl = doc.resolve_plane_in(vals, p, 0, Some(&*st))?;
(from, from_normal) = (pl.origin, Some(pl.normal()));
}
let target = to_plane.as_ref().map(|p| doc.resolve_plane_in(vals, p, 0, Some(&*st))).transpose()?;
let (to, to_normal) = match &target {
Some(pl) => (pl.origin, Some(pl.normal())),
None => (to.0, to_normal.and_then(|n| n.normalized())),
};
let mut m = translation(Vec3::ZERO);
if let (Some(a), Some(b)) = (from_normal.and_then(|n| n.normalized()), to_normal.and_then(|n| n.normalized())) {
if let (Some(a), Some(b)) = (from_normal, to_normal) {
// Turn `a` onto −b (faces meet) or b (flipped), about the `from` point.
let want = if *flip { b } else { b * -1.0 };
let axis = a.cross(want);
let ang = axis.len().atan2(a.dot(want));
let axis = axis.normalized().unwrap_or_else(|| a.any_perp());
if ang.abs() > 1e-12 {
m = rotation(from.0, axis, ang);
m = rotation(from, axis, ang);
}
}
let d = to.0 - from.0;
// Onto a plane the point goes straight along the plane's normal (nothing slides
// across it); onto a point it lands on it.
let d = match (&target, to_normal) {
(Some(_), Some(n)) => n * (to - from).dot(n),
_ => to - from,
};
m[3][0] += d.x;
m[3][1] += d.y;
m[3][2] += d.z;
@@ -611,7 +627,7 @@ pub(super) fn sheet_eval(
)));
}
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square });
sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square, from_right: false });
Ok(())
}
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
+85
View File
@@ -0,0 +1,85 @@
//! Extrudes from a whole face whose outline is no lines and arcs (#2040), checked through the
//! tool `whole_face_tool` picks.
use std::cell::Cell;
use serde_json::json;
use solidcraft_geom::{Plane, Vec2, Vec3};
use solidcraft_kernel as kernel;
use solidcraft_sketch::{CurveKind, Sketch};
use crate::*;
thread_local! {
static SLABS: Cell<usize> = const { Cell::new(0) };
}
/// A cut took a slab over the whole body as its tool, on this thread.
pub(super) fn slab_taken() {
SLABS.with(|n| n.set(n.get() + 1));
}
/// A closed spline through six points round the origin, on the plane at height `z`.
fn spline_sketch(z: f64) -> FeatureKind {
let mut sk = Sketch::new();
let fit = [(8.0, 0.0), (4.0, 6.0), (-3.0, 7.0), (-7.0, 1.0), (-4.0, -6.0), (3.0, -5.0)];
let mut pts: Vec<usize> = fit.iter().map(|&(x, y)| sk.add_point(Vec2::new(x, y), None).unwrap()).collect();
pts.push(pts[0]);
sk.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true }, None).unwrap();
let plane = Plane { origin: Vec3::new(0.0, 0.0, z), ..Plane::XY };
FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch: sk }
}
fn extrude(doc: &mut Document, sketch: u64, extent: serde_json::Value, operation: Operation) {
let kind = FeatureKind::Extrude {
thin: None,
sketch: SketchId(sketch),
profiles: ProfileSel::All,
extent: serde_json::from_value(extent).unwrap(),
operation,
targets: vec![],
};
doc.add_feature(kind, None).unwrap();
}
fn volume(model: &Model) -> f64 {
let state = model.state();
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some()));
assert_eq!(state.bodies.len(), 1);
assert!(state.bodies[0].body.validity().is_empty(), "{:?}", state.bodies[0].body.validity());
kernel::measure(&state.bodies[0].body).unwrap().volume
}
/// A prism 10 tall on a closed spline, standing on a 40 x 40 x 5 plate, cut 3 into its whole
/// top face. The walls only touch the prism, so the cut takes a slab over the whole body from 3
/// below the face up: it must take the face's area times 3 and leave the plate alone.
#[test]
fn slab_cut_into_a_spline_prism() {
let mut doc = Document::new("spline prism");
let plate = FeatureKind::Box {
corner: point_expr(Vec3::new(-20.0, -20.0, 0.0)),
length: "40 mm".into(),
width: "40 mm".into(),
height: "5 mm".into(),
operation: Operation::NewBody,
};
doc.add_feature(plate, None).unwrap();
let base = doc.add_feature(spline_sketch(5.0), None).unwrap();
extrude(&mut doc, base, json!({"distance": "10 mm"}), Operation::Join);
let mut model = Model::new();
model.evaluate(&doc);
let joined = volume(&model);
let area = (joined - 40.0 * 40.0 * 5.0) / 10.0;
assert!(area > 100.0, "the prism's area {area}");
let top = doc.add_feature(spline_sketch(15.0), None).unwrap();
extrude(&mut doc, top, json!({"distance": "3 mm", "direction": "negative"}), Operation::Cut);
SLABS.with(|n| n.set(0));
model.evaluate(&doc);
let cut = volume(&model);
assert_eq!(SLABS.with(Cell::get), 1, "the cut took the slab");
assert!((joined - cut - area * 3.0).abs() < 1e-3 * area, "took {}, expected {}", joined - cut, area * 3.0);
let bounds = model.state().bodies[0].mesh().bounds();
assert!((bounds.max.z - 12.0).abs() < 1e-6, "top at {}", bounds.max.z);
assert!(bounds.min.dist(Vec3::new(-20.0, -20.0, 0.0)) < 1e-6, "plate from {:?}", bounds.min);
}
+37 -3
View File
@@ -330,8 +330,13 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
while let Some(&(_, c)) = cs.get(i) {
if c.is_whitespace() {
i += 1;
} else if c.is_ascii_digit() || c == '.' {
} else if c.is_ascii_digit() || c == '.' || leading_decimal_comma(&cs, i, depth, out.last()) {
let st = i;
// A leading decimal comma (`,5` for 0,5, #2966) starts the number as `.5` would.
let leading_comma = c == ',';
if leading_comma {
i += 1;
}
while cs.get(i).is_some_and(|(_, c)| c.is_ascii_digit() || *c == '.') {
i += 1;
}
@@ -340,7 +345,7 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
// them a comma separates a function's arguments: `max(1,2)`).
let digit = |k: usize| cs.get(k).is_some_and(|(_, c)| c.is_ascii_digit());
let has_point = cs.get(st..i).is_some_and(|ds| ds.iter().any(|(_, c)| *c == '.'));
if depth == 0 && !has_point && digit(i.wrapping_sub(1)) && cs.get(i).is_some_and(|(_, c)| *c == ',') && digit(i + 1) {
if depth == 0 && !leading_comma && !has_point && digit(i.wrapping_sub(1)) && cs.get(i).is_some_and(|(_, c)| *c == ',') && digit(i + 1) {
i += 1;
while digit(i) {
i += 1;
@@ -413,6 +418,22 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
Ok(out)
}
/// Whether the comma at `i` starts a number, as a leading decimal comma (`,5` for 0,5, #2966).
/// It must be outside parentheses (inside them a comma separates arguments, as for any decimal
/// comma), be followed by a digit, and stand where an operand is expected: at the start or right
/// after `+ - * / ^`. A comma after a number, name, `)` or `%` keeps its meaning (`2 ,5` and
/// `1,5,3` stay errors).
fn leading_decimal_comma(cs: &[(usize, char)], i: usize, depth: usize, prev: Option<&(Tok, usize, usize)>) -> bool {
depth == 0
&& cs.get(i).is_some_and(|(_, c)| *c == ',')
&& cs.get(i + 1).is_some_and(|(_, c)| c.is_ascii_digit())
&& match prev {
None => true,
Some((Tok::Op(op), _, _)) => "+-*/^".contains(*op),
Some(_) => false,
}
}
/// The proper fraction of a mixed number whose whole part ends at `i`: spaces, digits, `/`,
/// digits, with nothing after them that would continue the number (`2 1/8.5`, `2 1/8e3`, a
/// decimal comma outside parentheses: `2 1/8,5`). Its value and where it ends. `depth`: the
@@ -991,11 +1012,24 @@ mod tests {
assert_eq!(ev("max(1 mm, 3, 2)", Kind::Length).unwrap(), 3.0);
assert_eq!(ev("(2,5)", Kind::Unitless).ok(), None, "a comma inside parentheses is not a decimal comma");
// Not between two digits, or a second comma: still an error.
for bad in ["2,", ",5", "2 ,5", "2, 5", "1,5,3", "1.5,3"] {
for bad in ["2,", "2 ,5", "2, 5", "1,5,3", "1.5,3", ",5,3", ",5.3", "width ,5", "(1) ,5"] {
assert!(ev(bad, Kind::Unitless).is_err(), "{bad}");
}
}
#[test]
fn a_leading_decimal_comma_is_a_number() {
// #2966: `,5` is 0,5 where `.5` would be a number: at the start or after an operator.
assert_eq!(ev(",5", Kind::Unitless).unwrap(), 0.5);
assert_eq!(ev(",5 mm", Kind::Length).unwrap(), 0.5);
assert_eq!(ev("-,5", Kind::Unitless).unwrap(), -0.5);
assert_eq!(ev("2*,5", Kind::Unitless).unwrap(), 1.0);
assert_eq!(ev("width - ,25", Kind::Length).unwrap(), 39.75);
// Function arguments are still separated by commas.
assert_eq!(ev("max(1,5)", Kind::Unitless).unwrap(), 5.0);
assert_eq!(ev("max(,5; 2)", Kind::Unitless).ok(), None, "inside parentheses a comma is a separator");
}
#[test]
fn inch_and_foot_marks() {
// #1502: `1"` in a millimetre design is an inch, `2'` two feet, straight or as primes.
+8 -3
View File
@@ -1250,7 +1250,9 @@ mod tests {
file["format"] = Value::from("solidcraft/23");
let opened = read(&file.to_string()).unwrap();
assert_eq!(opened.format, format_string(FORMAT));
assert_eq!(opened.features, plain.features);
let mut kept = plain.features.clone();
kept.iter_mut().for_each(|f| f.kernel_piece_order = true);
assert_eq!(opened.features, kept);
// One that chooses its relief says so in the file, which is stamped 24 and keeps it.
let mut own = Document::new("A");
own.add_feature(flange(Some("round"), Some("6 mm")), None).unwrap();
@@ -1742,7 +1744,7 @@ mod tests {
legacy["format"] = Value::from("solidcraft/6");
let original_table = legacy["configs"].clone();
let upgraded = read(&legacy.to_string()).unwrap();
d.features.iter_mut().for_each(|f| f.shell_piece_order = true);
d.features.iter_mut().for_each(|f| (f.shell_piece_order, f.kernel_piece_order) = (true, true));
assert_eq!(upgraded, d, "the identity migration preserves the complete design");
assert_eq!(serde_json::to_value(&upgraded.configs).unwrap(), original_table);
assert_eq!(upgraded.format, format_string(FORMAT));
@@ -1796,9 +1798,12 @@ mod tests {
legacy["format"] = Value::from("solidcraft/7");
let mut features = legacy["features"].clone();
pieces_in_shell_order(&mut features);
for f in features.as_array_mut().into_iter().flatten() {
f["kernel_piece_order"] = Value::Bool(true);
}
let params = legacy["params"].clone();
let upgraded = read(&legacy.to_string()).unwrap();
d.features.iter_mut().for_each(|f| f.shell_piece_order = true);
d.features.iter_mut().for_each(|f| (f.shell_piece_order, f.kernel_piece_order) = (true, true));
assert_eq!(upgraded, d);
let upgraded_json: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
assert_eq!(upgraded_json["features"], features);
@@ -0,0 +1,5 @@
//! An Align can name planes (`from_plane`, `to_plane`: an origin, construction or offset plane, or
//! an explicit one) instead of a point or a face's point and normal, so the bodies follow the plane
//! when it moves. Older designs need no transformation: an Align without them keeps the points and
//! normals it was saved with. The last release refuses a design saved with this (the newer format
//! number).
@@ -0,0 +1,34 @@
//! The pieces of a split face are numbered by place (#3902): `#1`, `#2`, … follow the spread of
//! their centres, not the kernel's face order or the index of the operand face they came from,
//! which can change from one evaluation to the next. Older designs' stored `#k` names mean the
//! old numbering, so this step marks each of their features `kernel_piece_order`, which keeps
//! it for them. Features added later number by place. The last release refuses a design saved
//! with this (the newer format number).
use serde_json::Value;
pub fn step(v: &mut Value) -> crate::Result<()> {
let Some(Value::Array(features)) = v.get_mut("features") else {
return Ok(());
};
for feature in features {
if let Value::Object(o) = feature {
o.insert("kernel_piece_order".into(), Value::Bool(true));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::json;
#[test]
fn older_features_keep_their_piece_numbering() {
let mut v = json!({"features": [{"type": "sketch"}, {"type": "extrude"}]});
super::step(&mut v).unwrap();
assert!(v["features"].as_array().unwrap().iter().all(|f| f["kernel_piece_order"] == true));
let mut empty = json!({});
super::step(&mut empty).unwrap();
assert_eq!(empty, json!({}));
}
}
@@ -0,0 +1,4 @@
//! A sheet's perforation field can run its grid from the flat's right edge (`from_right`): the
//! field of a mirrored sheet copy, whose holes are the mirror image of its source's. Older designs
//! need no transformation: a field without `from_right` runs from the left as before. The last
//! release refuses a design saved with this (the newer format number).
+68 -16
View File
@@ -17,6 +17,7 @@
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, OnceLock};
use solidcraft_geom::Vec3;
use solidcraft_kernel::history::{FaceOrigin, FaceProv, History};
use crate::ModelBody;
@@ -44,13 +45,14 @@ pub(crate) struct FaceInfo {
impl Names {
/// Names worked out from geometry: the same names, none of them verified, pieces told apart
/// by the faces around them (`adjacent`: pairs of faces sharing an edge).
pub(crate) fn unverified(names: Vec<String>, adjacent: &[(usize, usize)]) -> Names {
/// by the faces around them (`adjacent`: pairs of faces sharing an edge), then numbered by
/// place (`centres`, see [`qualify_at`]).
pub(crate) fn unverified(names: Vec<String>, adjacent: &[(usize, usize)], centres: Option<&dyn Fn() -> Vec<Vec3>>) -> Names {
let named = names
.into_iter()
.map(|name| Named { name: crate::naming::strip_piece(&name).to_string(), aliases: Vec::new(), verified: false, origins: None })
.collect();
qualify(named, adjacent)
qualify_at(named, adjacent, centres)
}
/// Names given as they are (a moved copy keeps its source's), taken as exact.
@@ -95,7 +97,8 @@ pub(crate) fn derive(b: &ModelBody, o: &NamingOrigin, lineage: &Lineage) -> Opti
}
}
let named: Vec<Named> = named.into_iter().collect::<Option<_>>()?;
Some(qualify(named, &adjacency(h)))
let centres = || crate::naming::face_centres(b);
Some(qualify_at(named, &adjacency(h), by_place(&o.feature, &centres)))
}
/// Pairs of faces that share an edge, from a history.
@@ -140,7 +143,8 @@ impl<'a> OperandNames<'a> {
.get_or_init(|| {
Arc::new(derive(body, o, &stage.lineage).unwrap_or_else(|| {
let names = crate::naming::geometric_names(body, o, body.body.face_count());
Names::unverified(names, &crate::naming::mesh_adjacency(body))
let centres = || crate::naming::face_centres(body);
Names::unverified(names, &crate::naming::mesh_adjacency(body), by_place(&o.feature, &centres))
}))
})
.clone();
@@ -266,7 +270,32 @@ pub(crate) fn descends(name: &str, stem: &str) -> bool {
/// Tell pieces apart: faces sharing a name are named by the faces around them that the others
/// lack (`adjacent`: pairs of faces sharing an edge).
#[cfg(test)]
fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
qualify_at(named, adjacent, None)
}
/// How the pieces `f` splits a face into are numbered: by place (`centres`), else, for a feature
/// saved before that (`Feature::kernel_piece_order`), as the build that saved it numbered them.
pub(crate) fn by_place<'a>(f: &crate::Feature, centres: &'a dyn Fn() -> Vec<Vec3>) -> Option<&'a dyn Fn() -> Vec<Vec3>> {
(!f.kernel_piece_order).then_some(centres)
}
/// [`qualify`], with `centres` giving each face's centre (worked out only when pieces tie): pieces
/// that nothing else tells apart are numbered along their spread, not in the kernel's order,
/// which can differ from one evaluation to the next (#3902). Without `centres`, in the kernel's
/// order and by the faces they came from, as builds before that numbered them.
fn qualify_at(named: Vec<Named>, adjacent: &[(usize, usize)], centres: Option<&dyn Fn() -> Vec<Vec3>>) -> Names {
let by_place = centres.is_some();
let worked_out = std::cell::OnceCell::new();
// Tied pieces in the order of their spread; as they are when there are no centres.
let in_order = |tied: Vec<usize>| -> Vec<usize> {
let at: &Vec<Vec3> = worked_out.get_or_init(|| centres.map(|c| c()).unwrap_or_default());
if at.len() != named.len() {
return tied;
}
crate::naming::along_spread(tied.into_iter().filter_map(|i| Some((i, *at.get(i)?))).collect())
};
let n = named.len();
let mut bounds: Vec<BTreeSet<String>> = vec![BTreeSet::new(); n];
for &(a, b) in adjacent {
@@ -320,8 +349,20 @@ fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
let mut tied = tied;
let origins: Vec<Option<&Vec<FaceOrigin>>> = tied.iter().map(|i| named.get(*i).and_then(|x| x.origins.as_ref())).collect();
let distinct = origins.iter().all(Option::is_some) && origins.iter().collect::<BTreeSet<_>>().len() == origins.len();
if distinct {
if distinct && !by_place {
tied.sort_by_key(|i| named.get(*i).and_then(|x| x.origins.clone()));
} else if distinct {
// By what they came from, but not by where it stood in its operand's face order:
// an operand that came out of a boolean can list its faces in another order on
// the next evaluation (#3902). Pieces of faces of one kind are numbered by place.
let mut by_kind: BTreeMap<Vec<FaceOrigin>, Vec<usize>> = BTreeMap::new();
for i in tied {
let kind = named.get(i).and_then(|x| x.origins.as_deref()).unwrap_or_default().iter().map(|o| without_index(*o)).collect();
by_kind.entry(kind).or_default().push(i);
}
tied = by_kind.into_values().flat_map(&in_order).collect();
} else {
tied = in_order(tied);
}
for (k, i) in tied.iter().enumerate() {
if let Some(slot) = names.get_mut(*i) {
@@ -333,17 +374,17 @@ fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
}
}
}
// A piece's new name can meet a face that already had it: numbered too, never shared.
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
for name in &names {
*seen.entry(name.clone()).or_default() += 1;
// A piece's new name can meet a face that already had it: numbered too (along their spread),
// never shared.
let mut seen: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (i, name) in names.iter().enumerate() {
seen.entry(name.clone()).or_default().push(i);
}
let mut counter: BTreeMap<String, usize> = BTreeMap::new();
for (i, name) in names.iter_mut().enumerate() {
if seen.get(name.as_str()).is_some_and(|c| *c > 1) {
let k = counter.entry(name.clone()).or_default();
*k += 1;
*name = format!("{name}#{k}");
for (name, same) in seen.into_iter().filter(|(_, same)| same.len() > 1) {
for (k, i) in in_order(same).into_iter().enumerate() {
if let Some(slot) = names.get_mut(i) {
*slot = format!("{name}#{}", k + 1);
}
if let Some(v) = verified.get_mut(i) {
*v = false;
}
@@ -353,6 +394,17 @@ fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
Names { faces: Arc::new(names), info }
}
/// `origin` with the face or edge it names left out (its kind and operand kept).
fn without_index(origin: FaceOrigin) -> FaceOrigin {
match origin {
FaceOrigin::Face { operand, .. } => FaceOrigin::Face { operand, face: 0 },
FaceOrigin::Edge { operand, .. } => FaceOrigin::Edge { operand, edge: 0 },
FaceOrigin::Offset { operand, .. } => FaceOrigin::Offset { operand, face: 0 },
// A profile's segments come in the sketch's order.
FaceOrigin::Profile { .. } | FaceOrigin::Cap { .. } => origin,
}
}
/// How a stored face name matches a current face.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Match {
+2 -1
View File
@@ -57,11 +57,12 @@ pub use assembly::{
rigid,
};
pub use document::*;
pub use eval::KeptResults;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SavedModel, SolvedSketch, ThreadInfo, curve_pick_error,
edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, metric_thread_sizes, object_height,
parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane,
state_in_frame, state_in_frame_at, suggested_thread, through_all_distance, world_state, world_state_reusing,
state_in_frame, state_in_frame_at, suggested_thread, tangent_face_chain, through_all_distance, world_state, world_state_reusing,
};
pub use expr::Expr;
pub use names::{BodyName, FeatureName, SketchId};
+46 -36
View File
@@ -241,13 +241,11 @@ pub(crate) fn names(b: &ModelBody) -> Arc<Names> {
.get_or_init(|| {
// Exact history needs no mesh; geometry does (its face count is the mesh's).
let names = match b.names.origin.as_ref() {
Some(o) if o.depth < MAX_DEPTH => b
.names
.lineage
.as_ref()
.and_then(|l| crate::history_names::derive(b, o, l))
.unwrap_or_else(|| Names::unverified(geometric_names(b, o, face_count(b)), &mesh_adjacency(b))),
_ => Names::unverified((0..face_count(b)).map(|k| format!("{}:face:{k}", b.name)).collect(), &[]),
Some(o) if o.depth < MAX_DEPTH => b.names.lineage.as_ref().and_then(|l| crate::history_names::derive(b, o, l)).unwrap_or_else(|| {
let centres = || face_centres(b);
Names::unverified(geometric_names(b, o, face_count(b)), &mesh_adjacency(b), crate::history_names::by_place(&o.feature, &centres))
}),
_ => Names::unverified((0..face_count(b)).map(|k| format!("{}:face:{k}", b.name)).collect(), &[], None),
};
Arc::new(names)
})
@@ -519,35 +517,8 @@ fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
let ps = pts.get(j).cloned().unwrap_or_default();
ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64)
};
let cs: Vec<(usize, Vec3)> = same.iter().map(|j| (*j, centre(*j))).collect();
let ext = cs.iter().map(|(_, c)| *c).collect::<Aabb3>().size();
let key = |c: Vec3| {
if ext.x >= ext.y && ext.x >= ext.z {
(c.x, c.y, c.z)
} else if ext.y >= ext.z {
(c.y, c.z, c.x)
} else {
(c.z, c.x, c.y)
}
};
let mut order = cs.clone();
// Pieces lying almost level along the sort axis differ only in the last bits of their centre:
// the centre is the mean of a tessellated boundary, and that depends on where the loop's
// polyline starts, which can differ between processes. Comparing the keys with a tolerance
// makes those pieces equal, so the stable sort keeps the order they arrived in and a name
// keeps its number across runs (solidcraft#569). total_cmp keeps a NaN deterministic.
let tol = ext.x.max(ext.y).max(ext.z).max(1.0) * 1e-9;
let bucket = |c: Vec3| {
let k = key(c);
// Rounding onto the grid, rather than comparing with a tolerance, is what keeps this a
// total order: `a` and `b` in one bucket and `b` and `c` in the next must not leave `a`
// and `c` in different ones, or the sort has no order to work with. Since Rust 1.81
// `sort_by` may panic on a comparator that is not one. Two pieces can still flip when
// they straddle a bucket edge; that is the price of a key that ignores their low bits.
((k.0 / tol).round() as i64, (k.1 / tol).round() as i64, (k.2 / tol).round() as i64)
};
order.sort_by_key(|a| bucket(a.1));
for (k, (j, _)) in order.iter().enumerate() {
let order = along_spread(same.iter().map(|j| (*j, centre(*j))).collect());
for (k, j) in order.iter().enumerate() {
if let Some(slot) = names.get_mut(*j) {
*slot = format!("{n}{mark}{}", k + 1);
}
@@ -555,6 +526,45 @@ fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
}
}
/// The items of `cs` (index, centre) ordered along the longest axis of their centres' spread,
/// then the next: how the pieces of one face or edge are numbered, the same on every run
/// whatever order the kernel gives them in.
pub(crate) fn along_spread(cs: Vec<(usize, Vec3)>) -> Vec<usize> {
let ext = cs.iter().map(|(_, c)| *c).collect::<Aabb3>().size();
let key = |c: Vec3| {
if ext.x >= ext.y && ext.x >= ext.z {
(c.x, c.y, c.z)
} else if ext.y >= ext.z {
(c.y, c.z, c.x)
} else {
(c.z, c.x, c.y)
}
};
let mut order = cs;
// Pieces lying almost level along the sort axis differ only in the last bits of their centre:
// the centre is the mean of a tessellated boundary, and that depends on where the loop's
// polyline starts, which can differ between processes. Comparing the keys with a tolerance
// makes those pieces equal, so the stable sort keeps the order they arrived in and a name
// keeps its number across runs (solidcraft#569).
let tol = ext.x.max(ext.y).max(ext.z).max(1.0) * 1e-9;
let bucket = |c: Vec3| {
let k = key(c);
// Rounding onto the grid, rather than comparing with a tolerance, is what keeps this a
// total order: `a` and `b` in one bucket and `b` and `c` in the next must not leave `a`
// and `c` in different ones, or the sort has no order to work with. Since Rust 1.81
// `sort_by` may panic on a comparator that is not one. Two pieces can still flip when
// they straddle a bucket edge; that is the price of a key that ignores their low bits.
((k.0 / tol).round() as i64, (k.1 / tol).round() as i64, (k.2 / tol).round() as i64)
};
order.sort_by_key(|a| bucket(a.1));
order.into_iter().map(|(j, _)| j).collect()
}
/// Each face's centre: the mean of the sampled centres of its triangles.
pub(crate) fn face_centres(b: &ModelBody) -> Vec<Vec3> {
samples(&b.mesh(), face_count(b)).into_iter().map(|ps| ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64)).collect()
}
/// The name of a face new to `f`.
fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usize, fid: u64) -> String {
let c = ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64);
+105 -3
View File
@@ -541,6 +541,10 @@ pub struct SheetPerforation {
pub pitch: f64,
pub margin: f64,
pub square: bool,
/// The grid runs from the flat's right edge instead of its left: the field of a mirrored
/// copy, whose holes are the mirror image of its source's.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub from_right: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -1992,8 +1996,11 @@ impl SheetBody {
if y > hi.y - clear + 1e-9 {
break;
}
let mut x = lo.x + clear + if !p.square && row % 2 == 1 { p.pitch / 2.0 } else { 0.0 };
while x <= hi.x - clear + 1e-9 {
let shift = if !p.square && row % 2 == 1 { p.pitch / 2.0 } else { 0.0 };
// Steps across the row from its first hole; a mirrored field counts from the right.
let mut step = 0.0;
while lo.x + clear + shift + step <= hi.x - clear + 1e-9 {
let x = if p.from_right { hi.x - clear - shift - step } else { lo.x + clear + shift + step };
let c = Vec2::new(x, y);
let (i, j) = idx(x, y);
let near = grid.get(j * nx + i).map(Vec::as_slice).unwrap_or(&[]);
@@ -2003,7 +2010,7 @@ impl SheetBody {
{
out.push(c);
}
x += p.pitch;
step += p.pitch;
}
row += 1;
}
@@ -4355,6 +4362,101 @@ impl Letters {
}
}
/// A flat point mirrored across the flat frame's y axis (x → −x).
fn flip_x(p: Vec2) -> Vec2 {
Vec2::new(-p.x, p.y)
}
/// A loop mirrored across the flat frame's y axis, exactly (arcs stay arcs, free-form curves
/// follow their control points), and run backwards so it keeps its sense: an outer loop stays
/// counter-clockwise, a hole clockwise.
fn flip_x_loop(l: &Loop2) -> Loop2 {
let segs = l
.segs
.iter()
.map(|s| match *s {
Seg2::Arc { center, radius, start, sweep } => {
Seg2::Arc { center: flip_x(center), radius, start: std::f64::consts::PI - start, sweep: -sweep }
}
other => map_controls(&other, flip_x),
})
.collect();
Loop2 { segs }.reversed()
}
fn flip_x_region(r: &Region2) -> Region2 {
Region2 { outer: flip_x_loop(&r.outer), holes: r.holes.iter().map(flip_x_loop).collect() }
}
impl SheetBody {
/// The sheet model of a copy of this body moved by `m` and named `body`: a rigid motion
/// moves its frame; a mirror moves its frame too and mirrors everything in the flat, so the
/// copy is the opposite hand, with the same thickness side, bends, K-factor and flat
/// length. `None` when `m` is not a rigid motion or a mirror (a scaled copy is no sheet).
pub fn copied(&self, body: &str, m: &Mat) -> Option<SheetBody> {
let axis = |i: usize| m.get(i).map(|c| Vec3::new(c[0], c[1], c[2]));
let (x, y, z) = (axis(0)?, axis(1)?, axis(2)?);
let unit = |v: Vec3| (v.dot(v) - 1.0).abs() < 1e-6;
let isometry = m.iter().flatten().all(|v| v.is_finite())
&& unit(x)
&& unit(y)
&& unit(z)
&& x.dot(y).abs() < 1e-6
&& x.dot(z).abs() < 1e-6
&& y.dot(z).abs() < 1e-6;
if !isometry {
return None;
}
let (origin, fx, fy) = (apply_point(m, self.frame.origin), apply_vector(m, self.frame.x), apply_vector(m, self.frame.y));
let mut out = self.clone();
out.body = body.to_string();
if x.cross(y).dot(z) > 0.0 {
out.frame = Plane::new(origin, fx, fy)?;
} else {
// The mirror of flat point (u, v, w) is the point (−u, v, w) of the frame with its x
// axis turned round: that frame's normal is the mirrored normal, so the material
// stays on the same side of the mirrored base face.
out.frame = Plane::new(origin, fx * -1.0, fy)?;
out.flip_flat();
}
Some(out)
}
/// Everything in flat coordinates mirrored across the flat's y axis (x → −x).
fn flip_flat(&mut self) {
self.base = flip_x_region(&self.base);
for f in &mut self.flanges {
// The bend line runs the other way, from its far end, so the flange still lies on
// the same side of it (`n` turned as `d` is): its ends swap.
f.p = flip_x(f.p + f.d * f.len);
f.d = Vec2::new(f.d.x, -f.d.y);
f.n = flip_x(f.n);
f.ext = (f.ext.1, f.ext.0);
f.slope = (f.slope.1, f.slope.0);
f.tear = (f.tear.1, f.tear.0);
if let Some(shape) = &mut f.shape {
*shape = shape.iter().rev().map(|p| flip_x(*p)).collect();
}
if let Some(exact) = &mut f.exact {
*exact = flip_x_loop(exact);
}
}
for r in self.holes.iter_mut().chain(self.labels.iter_mut()).chain(self.pockets.iter_mut().map(|p| &mut p.region)) {
*r = flip_x_region(r);
}
for b in &mut self.bores {
b.base.x = -b.base.x;
b.axis.x = -b.axis.x;
}
for p in &mut self.perforations {
p.from_right = !p.from_right;
}
for (_, a, b) in &mut self.base_curves {
(*a, *b) = (flip_x(*b), flip_x(*a));
}
}
}
/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel.
pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result<bool> {
let (a, b) = (planar_region(a)?, planar_region(b)?);
+22
View File
@@ -103,6 +103,28 @@ fn set_back_reports_restored_features() {
assert_eq!((m.last_recomputed, m.last_restored), (0, 0));
}
/// #1989: what an evaluation ahead on a copy of the model computed is kept for the run that
/// follows on the model itself, which finds it there even once the evaluation cache has dropped
/// it (the cache keeps only a few states of a design with large bodies). Copies keep nothing.
#[test]
fn results_kept_ahead_outlive_the_cache() {
let (mut doc, _) = plate_doc();
let mut m = Model::new();
m.evaluate(&doc);
doc.set_param("width", "50 mm", None, None).unwrap();
let kept = std::sync::Arc::new(KeptResults::default());
let mut ahead = m.clone();
ahead.keep(Some(kept.clone()));
assert!(ahead.clone().kept().is_none(), "a copy keeps nothing");
ahead.evaluate(&doc);
assert_eq!((ahead.last_recomputed, kept.len()), (2, 2));
*m.cache.lock().unwrap() = Default::default();
m.keep(Some(kept));
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (0, 2), "nothing computed again");
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 30000.0) < 1e-9);
}
#[test]
fn box_fillet_cut_timeline() {
let (mut doc, _) = plate_doc();
@@ -1150,3 +1150,76 @@ fn joined_loft_frames_reference_mesh_covers_each_face_once() {
}
}
}
/// The block of #2040 with `extra` run after it.
fn rounded_block_and(extra: Value) -> Session {
let mut script: Value = serde_json::from_str(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")).unwrap();
script["commands"].as_array_mut().unwrap().push(extra);
replay(&script.to_string())
}
/// #2040: the slanted face of a block whose ends fillets round, extruded as a join. Its outline's
/// quarter-ellipses come into the face's sketch as splines a hair off the body's edges, and the
/// join failed with "boolean Union: no result"; it now sweeps the body's own face. It adds the
/// face's area times its height: a 10 x 12 face less four corners of (1 - pi/4) r * r*sqrt(2).
#[test]
fn extrude_join_from_a_face_rounded_by_fillets() {
let at = json!([16.191269086108846, -6.702792508916929, 4.676550460347439]);
let before = volume(&replay(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")), "Body1");
let r = 3.8_f64;
let area = 10.0 * 12.0 - 4.0 * (1.0 - std::f64::consts::FRAC_PI_4) * r * r * 2f64.sqrt();
let joined = rounded_block_and(json!({"command": "solid.extrude", "params": {"face": at, "distance": 5, "operation": "join"}}));
let bodies = &joined.model.state().bodies;
assert_eq!(bodies.len(), 1, "the join makes no second body");
assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity());
let v = volume(&joined, "Body1");
assert!((v - (before + area * 5.0)).abs() < 0.05, "join {v} expected {}", before + area * 5.0);
}
/// #2040: a cut 3 mm into the slanted face of the block rounded along its top only (its bottom
/// edge left sharp). The walls from the bottom edge go into the body and the others only touch
/// it, along edges with curved faces beside them: the cut failed with "boolean Cut: no result".
/// In the face's plane the body's section at depth t is the block's section (10 wide, h high, its
/// top corners rounded) moved down the face by t: the cut takes
/// √2 ∫₀³ (10 (h − t/√2) − 2 (1 − π/4) r²) dt.
#[test]
fn extrude_cut_into_a_face_beside_rounds() {
let at = json!([16.191269086108846, -6.702792508916929, 4.676550460347439]);
let top_only = |extra: Option<Value>| {
let mut script: Value = serde_json::from_str(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")).unwrap();
let commands = script["commands"].as_array_mut().unwrap();
let fillet = commands.last_mut().unwrap();
fillet["params"]["edges"] = json!([[10, -10, 8.485281374238571], [10, 0, 8.485281374238571]]);
commands.extend(extra);
replay(&script.to_string())
};
let before = volume(&top_only(None), "Body1");
let cut = top_only(Some(json!({"command": "solid.extrude", "params": {"face": at, "distance": 3, "direction": "negative", "operation": "cut"}})));
let bodies = &cut.model.state().bodies;
assert_eq!(bodies.len(), 1);
assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity());
let (h, r, d, w) = (8.485281374238571_f64, 3.8_f64, 3.0_f64, 10.0_f64);
let corner = (1.0 - std::f64::consts::FRAC_PI_4) * r * r;
let taken = 2f64.sqrt() * w * h * d - w * d * d / 2.0 - 2.0 * 2f64.sqrt() * corner * d;
let v = before - volume(&cut, "Body1");
assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}");
}
/// #2040: a cut 2 mm into the top face of the block rounded at its ends, the strip 10 − 2r wide
/// between the rounds. Below the face the rounds stand out past its outline, so a slab over the
/// whole body would take them too: the cut takes only what lies over the face, at height z from
/// x = 0 to the slanted face at x = x0 + z.
#[test]
fn extrude_cut_into_a_rounded_block_top() {
let (h, r, x0, d) = (8.485281374238571_f64, 3.8_f64, 11.514718625761429_f64, 2.0_f64);
let before = volume(&replay(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")), "Body1");
let cut = rounded_block_and(
json!({"command": "solid.extrude", "params": {"face": [5, -5, h], "distance": d, "direction": "negative", "operation": "cut"}}),
);
let bodies = &cut.model.state().bodies;
assert_eq!(bodies.len(), 1);
assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity());
let taken = (10.0 - 2.0 * r) * (x0 * d + (h * h - (h - d) * (h - d)) / 2.0);
let v = before - volume(&cut, "Body1");
assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}");
}
+30 -4
View File
@@ -559,18 +559,24 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
let first = f.body_names.first().is_some_and(|n| !n.trim().is_empty());
f.body_names.get(i).is_none_or(|n| n.trim().is_empty()) && (f.shell_piece_order || i < f.body_names.len() || !first)
};
let later: Vec<(u64, Vec<String>)> = match s.doc.features.iter().position(|f| f.id == fid) {
Some(at) if s.doc.features.get(at).is_some_and(|f| counted(f, k)) => s
// Bodies later features name after this one (`Top (1)`, a split's other piece) keep their
// names too: they would otherwise follow it, and break what names them (#3510).
let derived = format!("{old} (");
let at = s.doc.features.iter().position(|f| f.id == fid);
let counts = at.and_then(|at| s.doc.features.get(at)).is_some_and(|f| counted(f, k));
let later: Vec<(u64, Vec<String>)> = match at {
Some(at) => s
.doc
.features
.iter()
.skip(at + 1)
.filter_map(|f| {
let made: Vec<String> = s.model.result(f.id)?.state.bodies.iter().filter(|x| x.feature == f.id).map(|x| x.name.clone()).collect();
(0..made.len()).any(|i| counted(f, i)).then_some((f.id, made))
let keep = (counts && (0..made.len()).any(|i| counted(f, i))) || made.iter().any(|n| n.starts_with(&derived));
keep.then_some((f.id, made))
})
.collect(),
_ => Vec::new(),
None => Vec::new(),
};
let d = s.doc_mut();
for (id, made) in &later {
@@ -771,6 +777,26 @@ mod tests {
assert_eq!(names(&s)[1], b);
}
/// #3510: each piece of a split is renamed on its own. Renaming the split body leaves the
/// other piece, and the features that use it, as they were.
#[test]
fn split_pieces_are_renamed_on_their_own() {
let ok = |s: &Session| s.model.results.iter().all(|r| r.error.is_none());
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 0, -5], "body_name": "Top"}));
run(&mut s, "solid.split_body", json!({"body": "Top", "plane": "XY"}));
assert_eq!(names(&s), vec!["Top", "Top (1)"]);
run(&mut s, "solid.move", json!({"bodies": ["Top (1)"], "translate": [0, 0, -5]}));
run(&mut s, "body.rename", json!({"body": "Top", "name": "Upper"}));
assert_eq!(names(&s), vec!["Upper", "Top (1)"]);
assert!(ok(&s), "the move still finds the other piece");
run(&mut s, "body.rename", json!({"body": "Top (1)", "name": "Lower"}));
assert_eq!(names(&s), vec!["Upper", "Lower"]);
assert!(ok(&s), "the move follows the renamed piece");
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&s), vec!["Upper", "Top (1)"]);
}
/// #1108: a primitive's `targets` are its participants, kept beside its kind; a rename
/// follows them, as it follows the names inside the kind (a combine's target and tools).
#[test]
@@ -111,8 +111,9 @@ fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renam
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
legacy["format"] = json!("solidcraft/9");
let reopened = Session::new(solidcraft_doc::Document::from_json(&legacy.to_string()).unwrap());
// Saved before format 14, it keeps naming the pieces it makes in the kernel's shell order.
let anchored = solidcraft_doc::Feature { shell_piece_order: true, ..anchored };
// Saved before format 14, it keeps naming the pieces it makes in the kernel's shell order,
// and numbering the pieces of its faces as that build did (#3902).
let anchored = solidcraft_doc::Feature { shell_piece_order: true, kernel_piece_order: true, ..anchored };
assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored);
// Names saved before format 14 keep naming the face they named, marked so they are found as
// the build that saved them found them.
+20 -4
View File
@@ -63,14 +63,30 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
/// The point is in the frame of the command's other picks: the component's own when
/// they were mapped into it, else the world's.
pub(super) fn face_ref(s: &Session, v: &Value, body: Option<&str>, cmd: &str) -> Result<Vec3> {
match face_ref_form(v).map_err(|why| bad(cmd, why))? {
FaceRefForm::Point(p) => Ok(p),
FaceRefForm::Name(name) => {
let st = if crate::frames::mapped() { s.model.state() } else { s.world_state() };
solidcraft_doc::naming::named_face_point(&st, body, name).map_err(|why| bad(cmd, why))
}
}
}
/// A face reference as given, before it is looked up: see [`face_ref`].
pub(crate) enum FaceRefForm<'a> {
Point(Vec3),
Name(&'a str),
}
/// What [`face_ref`] reads `v` as, without the design: a point, or a name of 1…512 bytes.
pub(crate) fn face_ref_form(v: &Value) -> std::result::Result<FaceRefForm<'_>, &'static str> {
if let Some(name) = v.as_str() {
if name.is_empty() || name.len() > 512 {
return Err(bad(cmd, "a face name is 1…512 characters"));
return Err("a face name is 1…512 characters");
}
let st = if crate::frames::mapped() { s.model.state() } else { s.world_state() };
return solidcraft_doc::naming::named_face_point(&st, body, name).map_err(|why| bad(cmd, why));
return Ok(FaceRefForm::Name(name));
}
vec3(v).or_else(|| v.get("point").and_then(vec3)).ok_or_else(|| bad(cmd, "a face is a point [x, y, z] on it, or its name from model.faces"))
vec3(v).or_else(|| v.get("point").and_then(vec3)).map(FaceRefForm::Point).ok_or("a face is a point [x, y, z] on it, or its name from model.faces")
}
/// The body face under a point: (mesh, face index, distance, body).
+125 -64
View File
@@ -11,7 +11,8 @@ use solidcraft_geom::Vec3;
use super::{CommandSpec, not_in_sketch};
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec2, vec3};
use crate::schema::{BoxParams, ExtrudeParams, FeatureResult, TypedContract, output_schema_for};
use crate::schema::features::{BoxParams, ExtrudeParams, FeatureResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const BOX_CONTRACT: TypedContract<BoxParams> = TypedContract::new();
@@ -67,7 +68,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
CommandSpec::new("solid.pipe", "Pipe", pipe)
.at("SOLID", "CREATE")
.icon("pipe")
@@ -91,7 +92,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.thread", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body"),
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body; a shaft thicker than the thread is first turned down to its major diameter over the threaded length"),
CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
@@ -204,13 +205,16 @@ fn auto_operation_in(s: &Session, p: &Value, world_faces: bool) -> Option<&'stat
// Match the evaluator's implicit participants: a grouped cut from an empty
// assembly component considers every body; otherwise keep the active scope.
// Explicit participants override either default.
// A component with no body of its own works on those of the components inside it (#3223).
let parts = string_list(p, "participants");
let implicit_all = grouped.is_some() && !st.bodies.iter().any(|body| s.doc.model_body_component(body) == s.active_component);
let bodies: Vec<_> = st
.bodies
.iter()
.filter(|b| if parts.is_empty() { implicit_all || s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
.collect();
let bodies: Vec<_> = if !parts.is_empty() {
st.bodies.iter().filter(|b| parts.contains(&b.name)).collect()
} else if implicit_all {
st.bodies.iter().collect()
} else {
active_scope(s, &st)
};
if bodies.is_empty() {
return Some("new");
}
@@ -241,11 +245,7 @@ pub(super) fn operation(p: &Value, cmd: &str) -> Result<Operation> {
/// The sketch a feature uses: `sketch` param, the active sketch, or the last sketch.
pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
if let Some(v) = p.get("sketch") {
let key = match v {
Value::Number(n) => n.to_string(),
Value::String(x) => x.clone(),
_ => return Err(bad(cmd, "`sketch` must be an id or name")),
};
let key = sketch_key(Some(v)).ok_or_else(|| bad(cmd, "`sketch` must be an id or name"))?;
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
return match f.kind {
FeatureKind::Sketch { .. } => Ok(f.id),
@@ -497,11 +497,12 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
}
// A cut or intersect made where the active component has no body of its own (a sketch in
// the assembly's root, cutting through the parts, #1234) would have nothing to work on: it
// takes part in every body instead, which it changes only where it meets them.
// takes part in every body instead, which it changes only where it meets them. An extrude's
// own operation counts too: an automatic one that came out as a cut (#3223).
let participants = if participants.is_empty()
&& !primitive
&& (matches!(str_(p, "operation").and_then(Operation::parse), Some(Operation::Cut | Operation::Intersect))
|| p.get("faces").is_some() && matches!(&kind, FeatureKind::Extrude { operation: Operation::Cut | Operation::Intersect, .. }))
|| matches!(&kind, FeatureKind::Extrude { operation: Operation::Cut | Operation::Intersect, .. }))
&& {
let st = s.model.state();
!st.bodies.is_empty() && !st.bodies.iter().any(|b| s.doc.model_body_component(b) == s.active_component)
@@ -655,14 +656,40 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The body whose face `sketch` is attached to (on the face itself, not offset from it), when
/// it is a body of the active component.
/// it is one the active component works on ([`active_scope`]).
fn sketch_face_body(s: &Session, sketch: u64) -> Option<String> {
let FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch)?.kind else {
return None;
};
let st = s.model.state();
let body = st.body(&anchor.target.body)?;
(s.doc.model_body_component(body) == s.active_component).then(|| body.name.clone())
active_scope(s, &st).into_iter().find(|b| b.name == anchor.target.body).map(|b| b.name.clone())
}
/// The bodies a feature of the active component works on when it names none: the component's
/// own. A component with no body of its own (the root of an opened STEP assembly, whose parts
/// are components inside it) works on the bodies of the components inside it whose geometry is
/// in its own frame, as an import places it: a profile sketched on such a part's face meets the
/// part where it is shown (#3223).
fn active_scope<'a>(s: &Session, st: &'a solidcraft_doc::ModelState) -> Vec<&'a solidcraft_doc::ModelBody> {
let active = s.active_component;
let own: Vec<_> = st.bodies.iter().filter(|b| s.doc.model_body_component(b) == active).collect();
if !own.is_empty() {
return own;
}
let Some(into_active) = solidcraft_doc::mat_inverse(&s.doc.component_transform(active)) else { return own };
st.bodies
.iter()
.filter(|b| {
let comp = s.doc.model_body_component(b);
if !s.doc.component_within(comp, active) {
return false;
}
let frame = s.doc.component_transform(comp);
let world = s.doc.model_body_offset(b).map_or(frame, |offset| solidcraft_doc::mat_mul(&frame, offset));
let rel = solidcraft_doc::mat_mul(&into_active, &world);
rel.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).all(|(a, b)| (a - b).abs() < 1e-9)
})
.collect()
}
/// A thin extrude's wall from its parameters: the sketch's curves it follows (every drawn
@@ -970,45 +997,64 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
let mut candidates: Option<Vec<(f64, Vec<solidcraft_kernel::EdgeInfo>)>> = None;
let mut out = Vec::new();
for e in a {
if let Some(pt) = vec3(e).or_else(|| e.get("point").and_then(vec3)) {
let bodies = candidates.get_or_insert_with(|| {
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
st.bodies
.iter()
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
.filter_map(|b| {
let size = b.body.size();
b.body.edges((size * 2e-3).max(1e-3)).ok().map(|edges| (size, edges))
})
.collect()
});
let mut nearest = f64::INFINITY;
let on_edge = bodies.iter().any(|(size, edges)| {
let d = edges
.iter()
.flat_map(|e| e.points.windows(2))
.filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?)))
.fold(f64::INFINITY, f64::min);
nearest = nearest.min(d);
d <= size * 0.05 + 1e-3
});
if !on_edge && !bodies.is_empty() {
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
let pt = match edge_ref_form(e).map_err(|why| bad(cmd, why))? {
EdgeRefForm::Point(pt) => pt,
EdgeRefForm::Index { body, index } => {
let idx = index as usize;
let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
let tol = (b.body.size() * 2e-3).max(1e-3);
let edges = b.body.edges(tol)?;
out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid);
continue;
}
out.push(pt);
continue;
};
let bodies = candidates.get_or_insert_with(|| {
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
st.bodies
.iter()
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
.filter_map(|b| {
let size = b.body.size();
b.body.edges((size * 2e-3).max(1e-3)).ok().map(|edges| (size, edges))
})
.collect()
});
let mut nearest = f64::INFINITY;
let on_edge = bodies.iter().any(|(size, edges)| {
let d = edges
.iter()
.flat_map(|e| e.points.windows(2))
.filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?)))
.fold(f64::INFINITY, f64::min);
nearest = nearest.min(d);
d <= size * 0.05 + 1e-3
});
if !on_edge && !bodies.is_empty() {
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
}
let body = e.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "an edge is a point [x,y,z], {point} or {body, index}"))?;
let idx = e.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "edge `index` must be a number"))? as usize;
let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
let tol = (b.body.size() * 2e-3).max(1e-3);
let edges = b.body.edges(tol)?;
out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid);
out.push(pt);
}
Ok(out)
}
/// An edge reference as given, before it is looked up: see [`edge_points`].
pub(crate) enum EdgeRefForm<'a> {
Point(Vec3),
Index { body: &'a str, index: u64 },
}
/// What [`edge_points`] reads one edge as, without the design: a point on it (`[x, y, z]` or
/// `{point}`), else `{body, index}`.
pub(crate) fn edge_ref_form(e: &Value) -> std::result::Result<EdgeRefForm<'_>, &'static str> {
if let Some(pt) = vec3(e).or_else(|| e.get("point").and_then(vec3)) {
return Ok(EdgeRefForm::Point(pt));
}
let body = e.get("body").and_then(Value::as_str).ok_or("an edge is a point [x,y,z], {point} or {body, index}")?;
let index = e.get("index").and_then(Value::as_u64).ok_or("edge `index` must be a number")?;
Ok(EdgeRefForm::Index { body, index })
}
/// Press Pull: a face moves along its normal (an extrude that joins outward or cuts inward); an
/// edge is rounded (a fillet).
fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1807,15 +1853,25 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] on the face to drill to"))?),
None => None,
};
// A hole picked on a body of another component (a moved one, shown where it is 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.
let mut p = p.clone();
if points.is_none()
&& p.get("participants").is_none()
&& let Some(body) = body_of_other_component_at(s, position)
{
p["participants"] = json!([body]);
let targets = string_list(&p, "targets");
if p.get("participants").is_none() {
// The bodies to drill, as `targets` (like an extrude's): its participants.
if !targets.is_empty() {
p["participants"] = json!(targets);
} else if let Some(body) = points.as_ref().filter(|(sp, _, _)| !sp.single).and_then(|(sp, _, _)| sketch_face_body(s, sp.sketch.0)) {
// Holes at the points of a sketch on a body's face drill that body (#2882): a body
// further along, past the face, doesn't take them. Stored, so recomputes and
// reopening keep the same scope.
p["participants"] = json!([body]);
} else if points.is_none()
&& let Some(body) = body_of_other_component_at(s, position)
{
// A hole picked on a body of another component (a moved one, shown where it is
// 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]);
}
}
add_feature(
s,
@@ -1920,17 +1976,22 @@ fn draft(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(super) fn sketch_id(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result<u64> {
let key = match v {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => return Err(bad(cmd, format!("`{what}` must be a sketch id or name"))),
};
let key = sketch_key(v).ok_or_else(|| bad(cmd, format!("`{what}` must be a sketch id or name")))?;
match s.doc.find_feature(&key) {
Some(f) if matches!(f.kind, FeatureKind::Sketch { .. }) => Ok(f.id),
_ => Err(bad(cmd, format!("no sketch `{key}`"))),
}
}
/// The key [`sketch_id`] and [`feature_sketch`] look a sketch up by: an id (a number) or a name.
pub(crate) fn sketch_key(v: Option<&Value>) -> Option<String> {
match v {
Some(Value::Number(n)) => Some(n.to_string()),
Some(Value::String(x)) => Some(x.clone()),
_ => None,
}
}
fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.sweep";
let sketch = feature_sketch(s, p, cmd)?;
+30 -5
View File
@@ -41,7 +41,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.align", "Align", align)
.at("SOLID", "MODIFY")
.icon("align")
.params("bodies: [names]; from: [x,y,z] | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z]; to: (the same) | to_face: [x,y,z] (faces also turn to meet); flip?: bool"),
.params("bodies: [names]; from: [x,y,z] | \"origin\" | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z] | from_plane: XY|XZ|YZ|construction or offset plane name or id|{origin, normal}; to: (the same) | to_face: [x,y,z] | to_plane: (a plane as from_plane; the point or face goes onto it along its normal, and the plane may move later: the bodies follow); faces and planes also turn to meet; flip?: bool"),
CommandSpec::new("solid.remove", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
];
@@ -385,23 +385,46 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("no body `{b}`")));
}
}
let pick = |s: &Session, pk: &str, fk: &str| -> Result<(Vec3, Option<Vec3>)> {
// A side is a plane (`from_plane`/`to_plane`: XY|XZ|YZ, a construction or offset plane, or
// {origin, normal}), kept as a plane so the bodies follow it when it moves; or a point and, for
// a face, its normal.
let plane = |s: &Session, key: &str| -> Result<Option<solidcraft_doc::PlaneRef>> {
p.get(key).map(|_| super::features::plane_param(s, p.get(key), cmd)).transpose()
};
let (from_plane, to_plane) = (plane(s, "from_plane")?, plane(s, "to_plane")?);
let pick = |s: &Session, pk: &str, fk: &str, plane: &Option<solidcraft_doc::PlaneRef>| -> Result<(Vec3, Option<Vec3>)> {
if plane.is_some() {
if p.get(pk).is_some() || p.get(fk).is_some() {
return Err(bad(cmd, format!("give `{pk}_plane` or `{pk}`/`{fk}`, not both")));
}
// Worked out from the plane when the feature is evaluated.
return Ok((Vec3::ZERO, None));
}
if let Some(fp) = p.get(fk).and_then(vec3) {
let n = super::face::face_normal(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
return Ok((fp, Some(n)));
}
// The origin point.
if p.get(pk).and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("origin")) {
return Ok((Vec3::ZERO, None));
}
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
if let Some(o) = p.get(pk).filter(|v| v.is_object()) {
let at = o.get("at").and_then(vec3).ok_or_else(|| bad(cmd, format!("`{pk}.at` must be [x, y, z]")))?;
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex");
return snap_point(s, kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")));
}
p.get(pk).and_then(vec3).map(|v| (v, None)).ok_or_else(|| bad(cmd, format!("give `{pk}` or `{fk}` as [x, y, z]")))
p.get(pk)
.and_then(vec3)
.map(|v| (v, None))
.ok_or_else(|| bad(cmd, format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)")))
};
let (from, from_normal) = pick(s, "from", "from_face")?;
let (to, to_normal) = pick(s, "to", "to_face")?;
let (from, from_normal) = pick(s, "from", "from_face", &from_plane)?;
let (to, to_normal) = pick(s, "to", "to_face", &to_plane)?;
// Faces turn only to meet another face or a plane; a plane's normal is read when evaluated.
let (from_normal, to_normal) = match (from_normal, to_normal) {
(Some(a), Some(b)) => (Some(a), Some(b)),
(a, b) if from_plane.is_some() || to_plane.is_some() => (a, b),
_ => (None, None),
};
let flip = bool_(p, "flip").unwrap_or(false);
@@ -415,6 +438,8 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
from_normal: from_normal.map(Dir),
to_normal: to_normal.map(Dir),
flip,
from_plane,
to_plane,
},
)
}
@@ -540,6 +540,57 @@ fn replace_face_align_remove() {
assert_eq!(measure(&mut s)["body_count"], 1);
}
/// The z range and the x minimum of body `name`.
fn z_x(s: &mut Session, name: &str) -> (f64, f64, f64) {
let m = measure(s);
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == name).unwrap().clone();
let g = |k: &str, i: usize| b["bbox"][k][i].as_f64().unwrap_or(f64::NAN);
(g("min", 2), g("max", 2), g("min", 0))
}
/// Align onto planes and the origin: a face goes flush onto an origin plane (turning to meet it),
/// onto an offset plane, a vertex onto the origin point, and a plane the bodies are aligned to
/// is followed when it moves.
#[test]
fn align_takes_planes_and_the_origin() {
let boxed = || {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 20], "body_name": "C"}));
s
};
// The bottom face onto XY: it already faces down, so it only drops; nothing slides across.
let mut s = boxed();
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_face": [55, 5, 20], "to_plane": "XY"}));
let (lo, hi, x) = z_x(&mut s, "C");
assert!(lo.abs() < 1e-6 && (hi - 10.0).abs() < 1e-6 && (x - 50.0).abs() < 1e-6, "{lo} {hi} {x}");
// The top face onto XY: the body turns over (the face now faces down) and sits on the plane.
let mut s = boxed();
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_face": [55, 5, 30], "to_plane": "XY"}));
let (lo, hi, _) = z_x(&mut s, "C");
assert!(lo.abs() < 1e-6 && (hi - 10.0).abs() < 1e-6, "{lo} {hi}");
assert!(measure(&mut s)["bodies"][0]["bbox"]["max"][2].as_f64().is_some());
// A vertex onto the origin point.
let mut s = boxed();
run(&mut s, "solid.align", json!({"bodies": ["C"], "from": {"snap": "vertex", "at": [50, 0, 20]}, "to": "origin"}));
let (lo, _, x) = z_x(&mut s, "C");
assert!(lo.abs() < 1e-6 && x.abs() < 1e-6, "{lo} {x}");
// Onto an offset plane, and the bodies follow it when its distance changes.
let mut s = boxed();
run(&mut s, "parameters.add", json!({"name": "lift", "expression": "30 mm"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": "lift", "name": "Shelf"}));
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_face": [55, 5, 20], "to_plane": "Shelf"}));
assert!((z_x(&mut s, "C").0 - 30.0).abs() < 1e-6);
run(&mut s, "parameters.change", json!({"name": "lift", "expression": "45 mm"}));
assert!((z_x(&mut s, "C").0 - 45.0).abs() < 1e-6, "the bodies follow the plane");
// A plane as the source: the bodies move with it onto a point.
let mut s = boxed();
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_plane": "XY", "to": [0, 0, 7]}));
assert!((z_x(&mut s, "C").0 - 27.0).abs() < 1e-6);
// One side is a plane or a point, not both; an unknown plane is refused.
assert!(s.execute("solid.align", &json!({"bodies": ["C"], "from_plane": "XY", "from": [0, 0, 0], "to": [0, 0, 7]})).is_err());
assert!(s.execute("solid.align", &json!({"bodies": ["C"], "from": [0, 0, 0], "to_plane": "Nowhere"})).is_err());
}
#[test]
fn coil_and_loft_to_a_point() {
use std::f64::consts::{PI, TAU};
+6 -5
View File
@@ -20,11 +20,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("open")
.key("Ctrl+O")
.noundo()
.params("path: .solidcraft design, 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); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step)"),
.params("path: .solidcraft design, 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); 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)"),
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
.at("SOLID", "INSERT")
.icon("import")
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: auto|solid|product|instance (default auto: keep meaningful solid names, replace placeholders with the nearest named product, else instance; solid: the solid's own name; product: the part's name; instance: the occurrence's name, else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
.at("SOLID", "INSERT")
.icon("import")
@@ -89,9 +89,10 @@ fn step_options(p: &Value, cmd: &str) -> Result<StepOptions> {
};
let names = match p.get("names") {
None | Some(Value::Null) => solidcraft_io::StepNames::default(),
Some(v) => {
v.as_str().and_then(solidcraft_io::StepNames::parse).ok_or_else(|| bad(cmd, "`names` must be \"solid\", \"product\" or \"instance\""))?
}
Some(v) => v
.as_str()
.and_then(solidcraft_io::StepNames::parse)
.ok_or_else(|| bad(cmd, "`names` must be \"auto\", \"solid\", \"product\" or \"instance\""))?,
};
Ok(StepOptions { y_up, names })
}
+3 -3
View File
@@ -49,7 +49,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.doc_only()
.contract(&DESCRIBE_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::DescribeResult>)
.output(crate::schema::output_schema_for::<crate::schema::inspect::DescribeResult>)
.params("command: a command id. Returns {id, label, params, input, output} (and param_warnings in the warn parameter-check mode): the command's parameter documentation and, once the command has a typed contract, the JSON Schemas (2020-12) of its parameters and of a successful result (null otherwise). Read-only."),
];
@@ -326,13 +326,13 @@ fn model_faces(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"body": b.name, "faces": faces}))
}
const DESCRIBE_CONTRACT: crate::schema::TypedContract<crate::schema::DescribeParams> = crate::schema::TypedContract::new();
const DESCRIBE_CONTRACT: crate::schema::TypedContract<crate::schema::inspect::DescribeParams> = crate::schema::TypedContract::new();
/// A command's prose documentation and, for converted commands, its schemas.
fn describe(_s: &mut Session, p: &Value) -> Result<Value> {
let id = p.get("command").and_then(Value::as_str).ok_or_else(|| crate::params::bad("engine.describe", "`command` must be a command id"))?;
let spec = super::find_command(id).ok_or_else(|| crate::EngineError::UnknownCommand(id.to_string()))?;
let reply = crate::schema::DescribeResult {
let reply = crate::schema::inspect::DescribeResult {
id: spec.id.to_string(),
label: spec.label.to_string(),
params: spec.params.to_string(),
+2
View File
@@ -35,7 +35,9 @@ mod edit;
mod face;
mod features;
mod features_more;
pub(crate) use face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, edge_ref_form, sketch_key};
pub use features::{auto_operation, extrude_reach};
pub use sketch_modify::trim_piece;
mod component_link;
+127
View File
@@ -616,6 +616,61 @@ fn matrix_move_turns_a_quarter_and_moves() {
}
}
/// #3223, #2872: a STEP assembly whose part sits two levels down, each level placed, opened and
/// stood up. With the imported root active, as Open leaves it, a circle sketched on the part's
/// top face cuts it through, reads as a Cut dragged in and a Join dragged out, and joins it in
/// one piece instead of starting a body of the root's own.
#[test]
fn a_stood_up_nested_step_part_is_cut_and_joined_from_its_face() {
let dir = std::env::temp_dir().join(format!("solidcraft-nested-stood-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("nested.step");
let mut src = Session::default();
let (_, carrier) = created(&mut src, "Carrier");
let (part, nested) = created(&mut src, "Nested Part");
run(&mut src, "solid.box", json!({"length": 40, "width": 24, "height": 10, "body_name": "NestedBlock"}));
run(&mut src, "occurrence.move", json!({"occurrence": carrier, "translate": [12, 7, 5], "axis": [0, 0, 1], "angle": "30 deg"}));
run(&mut src, "occurrence.move", json!({"occurrence": nested, "translate": [3, 11, 2], "axis": [0, 1, 0], "angle": "20 deg"}));
// The middle of the top face, in the file's frame: the imported root's.
let top = solidcraft_doc::apply_point(&src.doc.component_transform(part), solidcraft_geom::Vec3::new(20.0, 12.0, 10.0));
run(&mut src, "file.export", json!({"path": path, "format": "step"}));
let mut s = Session::default();
run(&mut s, "doc.open", json!({"path": path, "up": "y"}));
let _ = std::fs::remove_dir_all(&dir);
let at = solidcraft_doc::apply_point(&s.doc.component_transform(s.active_component), top);
run(&mut s, "sketch.create", json!({"plane": {"face": [at.x, at.y, at.z]}, "project_edges": false}));
let centre = s
.doc
.features
.iter()
.rev()
.find_map(|f| match &f.kind {
solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { plane, .. }, .. } => Some(plane.to_local(top)),
_ => None,
})
.unwrap();
run(&mut s, "sketch.circle.center", json!({"center": [centre.x, centre.y], "radius": 3}));
run(&mut s, "sketch.finish", json!({}));
assert_eq!(crate::auto_operation(&s, &json!({"distance": -5})), Some("cut"));
assert_eq!(crate::auto_operation(&s, &json!({"distance": 5})), Some("join"));
let volume = |s: &mut Session| run(s, "inspect.measure", json!({"bodies": ["NestedBlock"]}))["total"]["volume_mm3"].as_f64().unwrap();
let block = 40.0 * 24.0 * 10.0;
let hole = std::f64::consts::PI * 3.0 * 3.0;
// The circle is measured as faceted: within a thousandth of what it removes or adds.
for operation in ["cut", "auto"] {
run(&mut s, "solid.extrude", json!({"distance": -20, "operation": operation}));
assert!((block - volume(&mut s) - hole * 10.0).abs() < 1e-3 * hole * 10.0, "{operation}: {}", volume(&mut s));
run(&mut s, "edit.undo", json!({}));
}
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
let st = s.model.state();
assert_eq!(st.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["NestedBlock"]);
assert_eq!(st.body("NestedBlock").map(|b| b.body.shell_count()), Some(1));
assert!((volume(&mut s) - block - hole * 5.0).abs() < 1e-3 * hole * 5.0, "join: {}", volume(&mut s));
}
/// A rotation that is one only up to rounding is orthonormalised; a scale, a shear or a mirror
/// is refused with the reason, and the occurrence stays where it was.
#[test]
@@ -662,3 +717,75 @@ fn matrix_move_orthonormalises_rounding_and_refuses_what_is_not_rigid() {
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == occ).unwrap().transform, before);
run(&mut s, "occurrence.move", json!({"occurrence": occ, "matrix": rounded, "tolerance": 1e-3}));
}
/// #3383: a combine across components meets the tool where it is shown, not where its numbers
/// would be in the target's own frame. The plate in a component turned a quarter about X sits at
/// y -10..0, z -20..20; the boss in the root overlaps it by 10 x 5 x 5. The reverse (a peg in the
/// turned component cut from a root plate) removes 10 x 10 x 5 of it, not 10 x 10 x 10.
#[test]
fn combine_across_a_turned_occurrence_meets_the_tool_where_it_is_shown() {
let volume = |s: &Session, name: &str| s.model.state().body(name).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0);
let turned = |s: &mut Session, body: Value| {
created(s, "Part");
run(s, "solid.box", body);
run(s, "component.activate", json!({"component": "root"}));
run(s, "occurrence.move", json!({"occurrence": "Part:1", "axis": [1, 0, 0], "angle": "90 deg"}));
run(s, "component.capture_position", json!({}));
};
let mut s = Session::default();
turned(&mut s, json!({"length": 40, "width": 40, "height": 10, "corner": [-20, -20, 0], "body_name": "Plate"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [-5, -5, -25], "operation": "new", "body_name": "Boss"}));
run(&mut s, "solid.combine", json!({"target": "Plate", "tools": ["Boss"]}));
assert_eq!(s.model.state().body("Plate").map(|b| b.body.shell_count()), Some(1));
let want = 40.0 * 40.0 * 10.0 + 10.0 * 10.0 * 10.0 - 10.0 * 5.0 * 5.0;
assert!((volume(&s, "Plate") - want).abs() < 1e-6 * want, "{} vs {want}", volume(&s, "Plate"));
let mut s = Session::default();
turned(&mut s, json!({"length": 10, "width": 10, "height": 10, "corner": [-5, -5, 0], "body_name": "Peg"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 10, "corner": [-20, -20, 0], "operation": "new", "body_name": "Base"}));
run(&mut s, "solid.combine", json!({"target": "Base", "tools": ["Peg"], "operation": "cut"}));
let want = 40.0 * 40.0 * 10.0 - 10.0 * 10.0 * 5.0;
assert!((volume(&s, "Base") - want).abs() < 1e-6 * want, "{} vs {want}", volume(&s, "Base"));
}
/// #3383: a feature joining into a body of another, placed component draws its tool where the
/// tool is shown, as Combine does. Part is moved 100 mm along x, so its 10 mm cubes sit at x 100..110
/// and 120..130; a box and an extrude drawn from the root over each add 4 x 4 x 5 to it in one
/// piece, not a separate lump 100 mm further on. A through-all cut is as long as the target is
/// far where it is shown.
#[test]
fn joins_into_a_moved_occurrence_meet_the_tool_where_it_is_shown() {
let volume = |s: &Session, name: &str| s.model.state().body(name).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0);
let mut s = Session::default();
created(&mut s, "Part");
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new", "body_name": "B"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [100, 0, 0]}));
run(&mut s, "component.capture_position", json!({}));
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 15, "corner": [103, 3, 0], "operation": "join", "targets": ["A"]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [123, 3], "p1": [127, 7]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 15, "operation": "join", "targets": ["B"]}));
let want = 10.0 * 10.0 * 10.0 + 4.0 * 4.0 * 5.0;
for b in ["A", "B"] {
assert_eq!(s.model.state().body(b).map(|b| b.body.shell_count()), Some(1), "{b}");
assert!((volume(&s, b) - want).abs() < 1e-6 * want, "{b}: {} vs {want}", volume(&s, b));
}
// Through all reaches the target where it is shown: Part moved 100 mm along x and up, a cut
// drawn on the root's XY plane goes 100 mm up through its cube, not 10 mm.
let mut s = Session::default();
created(&mut s, "Part");
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "C"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [100, 0, 100]}));
run(&mut s, "component.capture_position", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [103, 3], "p1": [107, 7]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"through_all": true, "operation": "cut", "targets": ["C"]}));
let want = 10.0 * 10.0 * 10.0 - 4.0 * 4.0 * 10.0;
assert!((volume(&s, "C") - want).abs() < 1e-6 * want, "C: {} vs {want}", volume(&s, "C"));
}
+26
View File
@@ -549,3 +549,29 @@ fn copies_of_a_primitive_cut_only_its_targets() {
assert!(rel(removed, holes * PI * 9.0 * 10.0) < 1e-3, "{id}: {removed} mm³ cut from Plaque");
}
}
/// #3378: a pattern or mirror of a join whose targets the join merged into one body copies onto
/// that body, as before #2755, instead of looking for the target the join used up.
#[test]
fn copies_of_a_join_that_merged_its_targets_join_the_merged_body() {
for (copy, n) in [
(json!(["solid.pattern.circular", {"features": ["spoke"], "axis": "Y", "count": 5}]), 5.0),
(json!(["solid.mirror", {"features": ["spoke"], "plane": {"origin": [0, 0, 0], "normal": [1, 0, 0]}}]), 2.0),
] {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 66, "height": 3, "base": [0, 0, 0], "axis": "Y", "body_name": "rim"}));
run(&mut s, "solid.cylinder", json!({"radius": 63, "height": 3, "base": [0, 0, 0], "axis": "Y", "operation": "cut", "targets": ["rim"]}));
run(&mut s, "solid.cylinder", json!({"radius": 12, "height": 3, "base": [0, 0, 0], "axis": "Y", "body_name": "hub"}));
let v0 = volume(&mut s);
let spoke = json!({"length": 52, "width": 3, "height": 2.5, "corner": [12, 0, -1.25], "operation": "join", "targets": ["rim", "hub"], "name": "spoke"});
run(&mut s, "solid.box", spoke);
let v1 = volume(&mut s);
let (id, p) = (copy[0].as_str().unwrap_or_default(), copy[1].clone());
run(&mut s, id, p);
let names = run(&mut s, "inspect.measure", json!({}))["bodies"].as_array().map(|b| b.iter().map(|x| x["name"].clone()).collect::<Vec<_>>());
assert_eq!(names, Some(vec![json!("rim")]), "{id}: one body, the merged rim and hub");
// Every copy adds what the seed spoke added (curved bodies measure to about 0.02 %; a
// missing or doubled spoke is about 5 %).
assert!(rel(volume(&mut s), v0 + n * (v1 - v0)) < 1e-3, "{id}: {} vs {}", volume(&mut s), v0 + n * (v1 - v0));
}
}
+4 -1
View File
@@ -359,7 +359,10 @@ fn designs_from_before_bend_edges_open_unchanged() {
assert!(!older.to_string().contains("\"bends\""), "{older}");
older["format"] = json!("solidcraft/16");
let doc = solidcraft_doc::Document::from_json(&older.to_string()).unwrap();
assert_eq!(doc, *s.doc);
// An older design keeps the numbering of the pieces it was saved with (#3902).
let mut same = solidcraft_doc::Document::clone(&s.doc);
same.features.iter_mut().for_each(|f| f.kernel_piece_order = true);
assert_eq!(doc, same);
let mut reopened = Session::new(doc);
assert!(reopened.model.results.iter().all(|x| x.error.is_none()));
assert!(same_size(flat_size(&mut reopened), (100.0, 60.0), 1e-9), "{:?}", flat_size(&mut reopened));
+105
View File
@@ -4878,3 +4878,108 @@ fn a_sheet_made_of_sheet_features_and_flat_cuts_still_exports_its_flat_pattern()
}
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(2));
}
/// An L-bracket with a hole off its centre and two flanges, so that its mirror image is the
/// opposite hand: a 100 x 60 base on XY with a 10 mm hole at (25, 40), a 20 mm flange on the
/// front edge and a 15 mm one on the right edge.
fn asymmetric_bracket(s: &mut Session) {
run(s, "sheet.manage_rules", json!({"name": "S", "thickness": "2 mm", "bend_radius": "2 mm", "k_factor": 0.4, "active": true}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
run(s, "sketch.finish", json!({}));
run(s, "sheet.flange", json!({"type": "base"}));
run(s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20, "name": "F1"}));
run(s, "sheet.flange", json!({"edges": [[100, 30, 2]], "height": 15, "name": "F2"}));
circle_sketch(s, "Hole", json!("XY"), [25.0, 40.0], 10.0);
run(s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"}));
}
/// The points of a flat pattern's loops, `x` scaled by `sx`, sorted, rounded to 1e-6 mm.
fn flat_points(v: &Value, sx: f64) -> Vec<(i64, i64)> {
let mut out: Vec<(i64, i64)> = v
.as_array()
.into_iter()
.flatten()
.flat_map(|l| l.as_array().cloned().unwrap_or_default())
.map(|p| ((p[0].as_f64().unwrap_or(f64::NAN) * sx * 1e6).round() as i64, (p[1].as_f64().unwrap_or(f64::NAN) * 1e6).round() as i64))
.collect();
out.sort_unstable();
out
}
fn body_volume(s: &mut Session, name: &str) -> f64 {
let m = run(s, "inspect.measure", json!({}));
m["bodies"].as_array().into_iter().flatten().find(|b| b["name"] == name).and_then(|b| b["volume_mm3"].as_f64()).unwrap_or(f64::NAN)
}
#[test]
fn a_mirrored_sheet_copy_is_the_opposite_hand_with_the_mirror_image_flat_pattern() {
let mut s = Session::default();
asymmetric_bracket(&mut s);
let original = run(&mut s, "sheet.flat_pattern", json!({"body": "Body1"}));
run(&mut s, "solid.mirror", json!({"bodies": ["Body1"], "plane": "YZ"}));
let mirrored = run(&mut s, "sheet.flat_pattern", json!({"body": "Body1 (2)"}));
// The same blank, the other way round: K-factor flat length, bends and thickness are kept.
assert_eq!(mirrored["flat_size_mm"], original["flat_size_mm"], "{mirrored}");
assert_eq!(mirrored["thickness"], original["thickness"]);
assert_eq!(flat_points(&mirrored["outline"], 1.0), flat_points(&original["outline"], -1.0));
assert_eq!(original["cutouts"].as_array().map(Vec::len), Some(1), "{original}");
assert_eq!(flat_points(&mirrored["cutouts"], 1.0), flat_points(&original["cutouts"], -1.0));
let bends = |f: &Value| -> Vec<(String, i64, i64)> {
let mut b: Vec<_> = f["bends"]
.as_array()
.into_iter()
.flatten()
.map(|b| {
(
b["direction"].to_string(),
(b["allowance"].as_f64().unwrap_or(0.0) * 1e9).round() as i64,
(b["length"].as_f64().unwrap_or(0.0) * 1e9).round() as i64,
)
})
.collect();
b.sort();
b
};
assert_eq!(bends(&mirrored), bends(&original));
assert_eq!(bends(&mirrored).len(), 2);
// The hole is where the mirror put it, on the copy and in its flat pattern.
let in_copy = |p: [f64; 3]| {
s.model.state().bodies.iter().find(|b| b.name == "Body1 (2)").is_some_and(|b| b.body.contains(solidcraft_geom::Vec3::new(p[0], p[1], p[2])))
};
assert!(!in_copy([-25.0, 40.0, 1.0]) && in_copy([-35.0, 40.0, 1.0]));
let hole = flat_points(&mirrored["cutouts"], 1.0);
let cx = hole.iter().map(|p| p.0 as f64).sum::<f64>() / hole.len().max(1) as f64 * 1e-6;
assert!((cx + 25.0).abs() < 1e-3, "hole centre x {cx}");
// The sheet model is the mirrored body: a flange added to the copy grows it from its own
// back edge, and its DXF is that of a sheet.
let before = body_volume(&mut s, "Body1 (2)");
assert!(rel(before, body_volume(&mut s, "Body1")) < 1e-9);
run(&mut s, "sheet.flange", json!({"edges": [[-50, 60, 2]], "height": 10, "body": "Body1 (2)"}));
let after = body_volume(&mut s, "Body1 (2)");
assert!(after > before + 100.0, "{before} -> {after}");
let dxf = run(&mut s, "sheet.export_dxf", json!({"body": "Body1 (2)"}));
assert_eq!(dxf["bends"], 3);
}
#[test]
fn mirroring_or_copying_a_sheet_body_leaves_the_original_sheet_as_it_was() {
let mut s = Session::default();
asymmetric_bracket(&mut s);
let original = run(&mut s, "sheet.flat_pattern", json!({"body": "Body1"}));
let volume = body_volume(&mut s, "Body1");
run(&mut s, "solid.mirror", json!({"bodies": ["Body1"], "plane": "YZ"}));
run(&mut s, "solid.move", json!({"bodies": ["Body1"], "translate": [0, 200, 0], "copy": true}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["Body1"], "dir1": [0, 0, 1], "count1": 2, "spacing1": 100}));
assert_eq!(run(&mut s, "sheet.flat_pattern", json!({"body": "Body1"})), original);
assert!(rel(body_volume(&mut s, "Body1"), volume) < 1e-9);
// A moved or patterned copy is the same hand: the same flat pattern, as a sheet of its own.
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names.len(), 4, "{names:?}");
for name in names.iter().filter(|n| *n != "Body1" && *n != "Body1 (2)") {
let mut copy = run(&mut s, "sheet.flat_pattern", json!({"body": name}));
copy["body"] = original["body"].clone();
assert_eq!(flat_points(&copy["outline"], 1.0), flat_points(&original["outline"], 1.0), "{name}");
assert_eq!(copy["bends"], original["bends"], "{name}");
}
}
+31 -6
View File
@@ -1864,8 +1864,11 @@ fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
let cb = sk.curve_index(rb).ok_or_else(|| bad(cmd, format!("unknown entity `{rb}`")))?;
match (sk.curves.get(ca).map(|c| c.kind.clone()), sk.curves.get(cb).map(|c| c.kind.clone())) {
(Some(CurveKind::Line { a: a0, b: a1 }), Some(CurveKind::Line { a: b0, b: b1 })) => {
out.push(add_c(sk, ConstraintKind::Symmetric { p: a0, q: b0, l })?);
out.push(add_c(sk, ConstraintKind::Symmetric { p: a1, q: b1, l })?);
// Opposite sides of a rectangle run in opposite directions: pair each end
// with the end of the other line nearest its mirror image (#2961, #2940).
let (p0, p1) = if mirror_pairing_is_crossed(sk, l, [a0, a1], [b0, b1]) { (b1, b0) } else { (b0, b1) };
out.push(add_c(sk, ConstraintKind::Symmetric { p: a0, q: p0, l })?);
out.push(add_c(sk, ConstraintKind::Symmetric { p: a1, q: p1, l })?);
}
(Some(_), Some(_)) => {
let pa = round_center(sk, ca).ok_or_else(|| bad(cmd, "symmetry needs two lines or two circles/arcs"))?;
@@ -1886,6 +1889,25 @@ fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"constraints": ids, "sketch": info}))
}
/// True when the ends of line `a` mirror (about the line `l`) closer onto the ends of `b` crossed
/// (`a0` with `b1`, `a1` with `b0`) than in order. A degenerate symmetry line keeps the order.
fn mirror_pairing_is_crossed(sk: &Sketch, l: usize, a: [usize; 2], b: [usize; 2]) -> bool {
let Some((o, e)) = line_pts(sk, l) else { return false };
let d = e - o;
let len2 = d.x * d.x + d.y * d.y;
if len2 < 1e-18 {
return false;
}
let pt = |i: usize| sk.point(i).unwrap_or_default();
let near = |i: usize, j: usize| {
let v = pt(i) - o;
let t = (v.x * d.x + v.y * d.y) / len2;
let foot = o + d * t;
(foot * 2.0 - pt(i)).dist(pt(j))
};
near(a[0], b[1]) + near(a[1], b[0]) < near(a[0], b[0]) + near(a[1], b[1])
}
/// Symmetry about point `c`: two points, two circles/arcs (their centres, and equal radii) or two
/// lines (each end with the end of the other line nearest its mirror image). The constraints
/// (the construction lines show in the response's `created`).
@@ -2513,9 +2535,11 @@ fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.move_point";
let to = req_vec2(cmd, p, "to")?;
let id = target_sketch(s, p, cmd)?;
let state = s.model.result(id).map(|r| r.state.clone());
let last = state.as_ref().and_then(|state| state.sketch(id));
let (_, rep) = edit_solved(s, id, false, Analysis::Freedom, |sk, _| {
let q = point_ref(sk, p.get("point"), cmd, "point")?;
Ok(((), drag_entity(sk, Ent::Point(q), &[], to, to, false).1.solve_again()))
Ok(((), drag_entity(sk, Ent::Point(q), &[], to, to, false, last).1.solve_again()))
})?;
Ok(json!({"sketch": rep.as_ref().map(report_json)}))
}
@@ -2538,7 +2562,8 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
};
let id = target_sketch(s, p, cmd)?;
// The sketch's report as last evaluated: a live frame keeps its degrees of freedom.
let last = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| ss.report.clone());
let state = s.model.result(id).map(|r| r.state.clone());
let last = state.as_ref().and_then(|state| state.sketch(id));
let analysis = if live { Analysis::Full } else { Analysis::Freedom };
let (d, rep) = edit_solved(s, id, false, analysis, |sk, doc| {
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
@@ -2565,7 +2590,7 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
}
// The held solves work on the dimensions' current values (a solved step applies them).
doc.apply_dimension_values(&doc.param_values().0, sk)?;
let (d, left) = drag_entity(sk, e, &with, from, to, live);
let (d, left) = drag_entity(sk, e, &with, from, to, live, last);
// A live frame's held solve is its only one; otherwise the sketch's own solve reports
// its degrees of freedom.
Ok((d, if live && last.is_some() { left } else { left.solve_again() }))
@@ -2574,7 +2599,7 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
// average displacement of their moving points, as before.
let sk = s.doc.sketch(id)?;
let at = d.at(sk, from);
let rep = rep.or_else(|| last.map(|last| SolveReport { status: solidcraft_sketch::SolveStatus::Solved, failing: Vec::new(), ..last }));
let rep = rep.or_else(|| last.map(|ss| SolveReport { status: solidcraft_sketch::SolveStatus::Solved, failing: Vec::new(), ..ss.report.clone() }));
let out = json!({"sketch": rep.as_ref().map(report_json), "reached": d.reached, "at": [at.x, at.y], "retry": d.retry});
Ok((out, rep.filter(|_| live).map(|report| crate::LiveEdit { sketch: id, report })))
}
@@ -374,6 +374,66 @@ fn symmetry_moves_a_free_line_between_two_fixed_lines() {
}
}
/// A 20 x 10 rectangle (sides l1..l4; left = l4 and right = l2 run in opposite directions) and a
/// free vertical line at x = -20 as the symmetry line (#2940, #2961).
fn rectangle_and_axis(dimension_left: bool) -> (Session, Vec<String>) {
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-12, 0], "p1": [8, 10]}));
let sides = ids(&r["curves"]);
run(&mut s, "sketch.line", json!({"points": [[-20, -5], [-20, 15]], "construction": true, "ids": ["axis"]}));
if dimension_left {
run(&mut s, "sketch.dimension", json!({"entities": [sides[3].clone()], "value": 10}));
}
(s, sides)
}
/// Both sides keep their 10 mm height and their ends mirror in x about `axis_x`.
fn assert_sides_mirror_with_height(s: &Session, left: &str, right: &str, axis_x: f64) {
let sk = sketch(s);
let at = |r: String| sk.point(sk.resolve_point(&r).unwrap()).unwrap();
for side in [left, right] {
let (a, b) = (at(format!("{side}.start")), at(format!("{side}.end")));
assert!((a.y - b.y).abs() > 9.999 && (a.y - b.y).abs() < 10.001, "{side} height {:?} {:?}", a, b);
assert!((a.x - b.x).abs() < 1e-7, "{side} not vertical: {:?} {:?}", a, b);
}
let (l, r) = (at(format!("{left}.start")), at(format!("{right}.start")));
assert!((l.x + r.x - 2.0 * axis_x).abs() < 1e-6, "sides not mirrored: {l:?} {r:?}");
}
#[test]
fn symmetry_of_rectangle_sides_keeps_their_height() {
// #2940: the opposite sides of a rectangle run in opposite directions, so pairing their ends
// by index forced both heights to 0.
let (mut s, sides) = rectangle_and_axis(false);
run(&mut s, "sketch.constraint.symmetry", json!({"a": sides[3], "b": sides[1], "line": "axis"}));
assert_sides_mirror_with_height(&s, &sides[3], &sides[1], -20.0);
}
#[test]
fn symmetry_of_a_dimensioned_rectangle_side_is_accepted() {
// #2961: with a length on a side, the same symmetry was refused as "neither entity can move".
let (mut s, sides) = rectangle_and_axis(true);
run(&mut s, "sketch.constraint.symmetry", json!({"a": sides[3], "b": sides[1], "line": "axis"}));
assert_sides_mirror_with_height(&s, &sides[3], &sides[1], -20.0);
}
#[test]
fn symmetry_of_rectangle_sides_with_swapped_arguments() {
let (mut s, sides) = rectangle_and_axis(true);
run(&mut s, "sketch.constraint.symmetry", json!({"a": sides[1], "b": sides[3], "line": "axis"}));
assert_sides_mirror_with_height(&s, &sides[3], &sides[1], -20.0);
}
#[test]
fn symmetry_of_rectangle_sides_about_the_y_axis() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-12, 0], "p1": [8, 10]}));
let sides = ids(&r["curves"]);
run(&mut s, "sketch.dimension", json!({"entities": [sides[3].clone()], "value": 10}));
run(&mut s, "sketch.constraint.symmetry", json!({"a": sides[3], "b": sides[1], "line": "y"}));
assert_sides_mirror_with_height(&s, &sides[3], &sides[1], 0.0);
}
#[test]
fn midpoint_alignment_persists_undoes_and_survives_endpoint_edits() {
use solidcraft_sketch::ConstraintKind;
+41 -4
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch, TextAlign, TextExprs, TextStyle};
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Shape, Sketch, TextAlign, TextExprs, TextStyle};
/// The style parameters of `sketch.text` and `sketch.edit_text`.
macro_rules! text_style_doc {
@@ -822,6 +822,39 @@ fn guess_radius(sk: &Sketch, curves: &[usize], at: Vec2) -> f64 {
if r.is_finite() && r > 1e-6 { r } else { 1.0 }
}
/// Where the centre of a circle of radius `d` touching `sh` can lie: the line moved `d` to either
/// side, or the circle grown and shrunk by `d` (arcs as their full circle).
fn centre_paths(sh: &Shape, d: f64) -> Vec<Shape> {
match *sh {
Shape::Line { a, b } => {
(b - a).normalized().map(|u| [d, -d].map(|s| Shape::Line { a: a + u.perp() * s, b: b + u.perp() * s }).to_vec()).unwrap_or_default()
}
Shape::Round { c, r, .. } => [r + d, (r - d).abs()]
.into_iter()
.filter(|x| *x > 1e-12)
.map(|r| Shape::Round { c, r, start: 0.0, sweep: std::f64::consts::TAU })
.collect(),
}
}
/// The centre nearest `near` of a circle of radius `r` touching curves `a` and `b` (#3141): the
/// solver starts from the answer instead of from the click, which lies on a curve, where the
/// tangency has no side yet and the solve fails or moves the curves.
fn tangent_centre(sk: &Sketch, a: usize, b: usize, r: f64, near: Vec2) -> Option<Vec2> {
let (sa, sb) = (sk.shape(a)?, sk.shape(b)?);
let mut best: Option<Vec2> = None;
for x in centre_paths(&sa, r) {
for y in centre_paths(&sb, r) {
for q in solidcraft_sketch::intersections_within(&x, &y, 1e-9 * (1.0 + r)) {
if q.x.is_finite() && q.y.is_finite() && best.is_none_or(|b| q.dist(near) < b.dist(near)) {
best = Some(q);
}
}
}
}
best
}
fn tangent_curves(sk: &Sketch, p: &Value, cmd: &str, n: usize) -> Result<Vec<usize>> {
let ids = string_list(p, "curves");
if ids.len() != n {
@@ -842,10 +875,14 @@ fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Optio
if !(r0 > 1e-9 && r0 < 1e8) {
return Err(bad(cmd, "radius must be positive"));
}
// Start where the circle already nearly touches: move the centre so the guessed circle
// sits between the curves.
// Start where the circle already touches when that can be worked out (two curves and a
// radius), so that only the new circle moves.
let start = match cs.as_slice() {
[a, b] if radius.is_some() => tangent_centre(sk, *a, *b, r0, near).unwrap_or(near),
_ => near,
};
let first_new = sk.points.len();
let c = sk.add_circle(near, r0, None, None)?;
let c = sk.add_circle(start, r0, None, None)?;
let mut cons = Vec::new();
for k in &cs {
cons.push(add_c(sk, ConstraintKind::Tangent { a: *k, b: c })?);
+27 -1
View File
@@ -125,7 +125,33 @@ impl Path {
///
/// `live`: one frame of an interactive drag, whose held solves and cursor correction share
/// one frame's work budget ([`Budget::frame`]); otherwise each has a solve's own.
pub(super) fn drag_entity(sk: &mut Sketch, e: Ent, with: &[Ent], from: Vec2, to: Vec2, live: bool) -> (Dragged, Left) {
///
/// `solved`: the sketch as last evaluated. When it is unchanged and solved, a grabbed entity it
/// determined is not dragged.
pub(super) fn drag_entity(
sk: &mut Sketch,
e: Ent,
with: &[Ent],
from: Vec2,
to: Vec2,
live: bool,
solved: Option<&solidcraft_doc::SolvedSketch>,
) -> (Dragged, Left) {
// An unchanged, solved entity with no freedom cannot follow a drag. Holding it at a
// different position only makes its constraints conflict, and a large pattern would spend
// minutes trying dense solves before returning to the same geometry (#2006).
if solved.is_some_and(|ss| {
ss.report.ok()
&& ss.sketch == *sk
&& match e {
Ent::Point(q) => ss.report.point_determined.get(q),
Ent::Curve(c) => ss.report.curve_determined.get(c),
}
.copied()
.unwrap_or(false)
}) {
return (Dragged::default(), Left::Solved);
}
let Some((path, hold, n, short)) = prepare(sk, e, with, from, to) else {
return (Dragged::default(), Left::Unsolved);
};
@@ -7,6 +7,52 @@ use solidcraft_geom::Vec3;
use super::solve_counts;
use crate::Session;
#[test]
fn determined_pattern_geometry_does_not_attempt_conflicting_held_solves() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [6.5, 6.5]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 1.5}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 1.5}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
let pattern = run(
&mut s,
"sketch.pattern.rectangular",
json!({"entities": ["l1", "l2", "l3", "l4"], "count": 3, "count2": 3, "spacing": 2.2, "spacing2": -2.2}),
);
let line = pattern["curves"][0].as_str().expect("a patterned line");
let endpoint = format!("{line}.start");
let before = sketch_of(&s);
assert!(!before.curve_locked(before.curve_index(line).expect("patterned line index")));
for entity in [line, endpoint.as_str()] {
for live in [true, false] {
reset(&mut s);
let result = run(&mut s, "sketch.drag", json!({"entity": entity, "from": [5, 5], "to": [9, 12], "live": live}));
assert_eq!(result["reached"], json!(0.0));
assert_eq!(result["at"], json!([5.0, 5.0]));
assert_eq!(result["sketch"]["solved"], json!(true));
let full = usize::from(!live);
assert_eq!(solve_counts::take(), (full, full), "no held solve for determined geometry");
assert_eq!(sketch_of(&s), before);
}
}
reset(&mut s);
run(&mut s, "sketch.move_point", json!({"point": endpoint, "to": [9, 12]}));
assert_eq!(solve_counts::take(), (1, 1), "move_point also skips held solves");
assert_eq!(sketch_of(&s), before);
// A cached determined report must not suppress an outstanding dimension edit.
let dimension = before
.constraints
.iter()
.find(|c| matches!(c.kind, solidcraft_sketch::ConstraintKind::Length { l: 0, .. }))
.and_then(|c| c.param.as_ref())
.expect("the first line's dimension");
s.doc_mut().params.iter_mut().find(|p| &p.name == dimension).expect("dimension parameter").expr = "3".into();
run(&mut s, "sketch.drag", json!({"entity": line, "from": [5, 5], "to": [9, 12], "live": true}));
assert!((pt(&s, "l1.start").dist(pt(&s, "l1.end")) - 3.0).abs() < 1e-6);
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
@@ -723,3 +723,17 @@ fn a_custom_plane_origin_that_is_not_numbers_is_refused_not_zeroed() {
assert_eq!(origin_of(&mut s, json!({"origin": [20, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]})), json!([20.0, 0.0, 0.0]));
assert_eq!(origin_of(&mut s, json!({"x_dir": [0, 1, 0], "y_dir": [0, 0, 1]})), json!([0.0, 0.0, 0.0]));
}
/// A sketch on a body's face projects that face's edges from the body's display mesh. The body
/// it projects from is the model's own, not a copy that meshes it again: on a body whose curved
/// edges are slow to mesh, the copy's mesh was thrown away and the next feature meshed the body
/// a second time, which made a sketch on a face as slow as a cut.
#[test]
fn a_sketch_on_a_face_meshes_its_body_once() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
assert!(s.model.state().body("Base").unwrap().cached_mesh().is_none());
let pick = top_pick(&s, "Base");
run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick}));
run(&mut s, "sketch.finish", json!({}));
assert!(s.model.state().body("Base").unwrap().cached_mesh().is_some(), "the sketch's projection meshed a copy of the body");
}
@@ -438,6 +438,51 @@ fn new_creation_tools() {
assert!(a.iter().any(|x| close(*x, pi * 5.0 * 2.0 + pi)), "{a:?}");
}
/// #3141: a 2-tangent circle asked for near a point on one of its curves (where a click on that
/// curve puts it) is solved from the touching circle nearest that point: it lands in the corner,
/// the rectangle stays as drawn, and fixed sides no longer make it fail.
#[test]
fn two_tangent_circle_from_a_click_on_a_curve_leaves_the_curves_as_drawn() {
let side = |s: &Session, sides: &[String], on: fn(Vec2) -> bool| -> String {
let sk = sketch(s);
sides
.iter()
.find(|c| {
let i = sk.curve_index(c).unwrap();
matches!(sk.shape(i), Some(solidcraft_sketch::Shape::Line { a, b }) if on(a) && on(b))
})
.unwrap()
.clone()
};
let points = |s: &Session| sketch(s).points.iter().map(|p| p.pos).collect::<Vec<_>>();
for fixed in [false, true] {
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-800, 0], "p1": [-300, 500]}));
let sides = ids(&r["curves"]);
if fixed {
run(&mut s, "sketch.constraint.fix", json!({"entities": sides}));
}
let before = points(&s);
let bottom = side(&s, &sides, |p| p.y.abs() < 1e-9);
let left = side(&s, &sides, |p| (p.x + 800.0).abs() < 1e-9);
// Midway between a click on the bottom side and one on the left side.
let r = run(&mut s, "sketch.circle.two_tangent", json!({"curves": [bottom, left], "radius": 50, "near": [-675, 125]}));
let c = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let ci = sk.curve_index(&c).unwrap();
assert!(sk.center(ci).unwrap().dist(Vec2::new(-750.0, 50.0)) < 1e-6, "fixed {fixed}: {:?}", sk.center(ci));
assert!(close(sk.radius(ci).unwrap(), 50.0));
let after = points(&s);
assert!(before.iter().zip(&after).all(|(a, b)| a.dist(*b) < 1e-9), "fixed {fixed}: the rectangle did not move: {after:?}");
// Asked for right on the left side: still a touching circle next to it.
let r = run(&mut s, "sketch.circle.two_tangent", json!({"curves": [bottom, left], "radius": 20, "near": [-800, 30]}));
let c = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let o = sk.center(sk.curve_index(&c).unwrap()).unwrap();
assert!(close(o.y, 20.0) && close((o.x + 800.0).abs(), 20.0), "fixed {fixed}: {o:?}");
}
}
#[test]
fn ellipse_splines_conic() {
let pi = std::f64::consts::PI;
+56
View File
@@ -17,6 +17,62 @@ fn body(s: &mut Session, b: &str) -> Value {
run(s, "inspect.measure", json!({ "bodies": [b] }))["bodies"][0].clone()
}
fn stepped_block() -> Session {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "step_height", "expression": "10 mm"}));
run(&mut s, "solid.box", json!({"corner": [-10, -10, 0], "length": 20, "width": 20, "height": 10, "body_name": "Block"}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-5, -5], "p1": [5, 5]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "step_height", "operation": "join"}));
s
}
/// A column beside a step ends at the step's top, including when the To point lies on an
/// edge or corner shared with a vertical wall (#3729).
#[test]
fn extrude_to_a_planar_face_boundary_ends_at_its_plane() {
for to in [[5, 0, 20], [5, 5, 20], [0, 0, 20], [1000, 1000, 20]] {
let mut s = stepped_block();
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
run(&mut s, "sketch.circle.center", json!({"center": [8, 0], "radius": 1.5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"to": to, "operation": "join"}));
let b = body(&mut s, "Block");
let top = b["bbox"]["max"][2].as_f64().unwrap();
let volume = b["volume_mm3"].as_f64().unwrap();
assert!((top - 20.0).abs() < 1e-6, "To {to:?}: z max {top}");
assert!((volume - (5000.0 + 10.0 * PI * 1.5 * 1.5)).abs() < 0.1, "To {to:?}: volume {volume}");
}
}
/// The face chosen at an edge must also be the stored reference: raising the step must raise
/// the end of the extrusion, including after a save/reload.
#[test]
fn extrude_to_a_planar_face_boundary_follows_it_after_reload() {
for to in [[5, 0, 20], [5, 5, 20]] {
let mut s = stepped_block();
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
run(&mut s, "sketch.circle.center", json!({"center": [8, 0], "radius": 1.5}));
run(&mut s, "sketch.finish", json!({}));
// A negative reach with an offset past the target, away from the start.
run(&mut s, "solid.extrude", json!({"to": to, "to_offset": 2, "operation": "new", "body_name": "Post"}));
let b = body(&mut s, "Post");
assert!((b["bbox"]["min"][2].as_f64().unwrap() - 18.0).abs() < 1e-6, "{b}");
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let mut cold = Session::new(doc);
for current in [&mut s, &mut cold] {
run(current, "parameters.change", json!({"name": "step_height", "expression": "15 mm"}));
let b = body(current, "Post");
let volume = b["volume_mm3"].as_f64().unwrap();
assert!((b["bbox"]["min"][2].as_f64().unwrap() - 23.0).abs() < 1e-6, "{b}");
assert!((b["bbox"]["max"][2].as_f64().unwrap() - 30.0).abs() < 1e-6, "{b}");
assert!((volume - 7.0 * PI * 1.5 * 1.5).abs() < 0.1, "{b}");
assert!(current.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{:?}", current.model.results);
}
}
}
/// A square post extruded up to a horizontal cylinder (axis along x, r 10, centre z 30): its
/// top takes the cylinder's shape, so the post is shortest in the middle.
#[test]
+41
View File
@@ -374,3 +374,44 @@ fn drilled_sketch_points_break_through_a_sloping_back_face() {
assert!(reopened.model.result(id).unwrap().error.is_none());
near(volume(&mut reopened), left);
}
/// Back (z 0…6) under a smaller Front (z 14…20), and a sketch on Front's top face with points
/// over Front (`in`) and beyond its edges, over Back (`out1`, `out2`).
fn front_and_back(s: &mut Session) {
run(s, "solid.box", json!({"length": 40, "width": 40, "height": 6, "body_name": "Back"}));
run(s, "solid.box", json!({"corner": [10, 10, 14], "length": 20, "width": 20, "height": 6, "body_name": "Front"}));
run(s, "sketch.create", json!({"plane": {"face": [20, 20, 20]}, "name": "Pts", "project_edges": false}));
for (id, p) in [("in", [20, 20]), ("out1", [5, 5]), ("out2", [35, 35])] {
run(s, "sketch.point", json!({"point": p, "id": id}));
}
run(s, "sketch.finish", json!({}));
}
/// #2882: holes at the points of a sketch on a body's face drill that body, never one behind it.
#[test]
fn holes_from_a_sketch_on_a_face_drill_that_body_only() {
let mut s = Session::default();
front_and_back(&mut s);
let base = volume(&mut s);
run(&mut s, "solid.hole", json!({"sketch": "Pts", "points": ["in", "out1", "out2"], "diameter": 5, "depth": 4, "name": "H"}));
assert_eq!(s.doc.find_feature("H").unwrap().participants, vec!["Front".to_string()]);
near(base - volume(&mut s), pi() * 6.25 * 4.0);
assert!(!solid_at(&s, 20.0, 20.0, 18.0), "drilled into Front");
for (x, y) in [(5.0, 5.0), (35.0, 35.0), (20.0, 20.0)] {
assert!(solid_at(&s, x, y, 5.5) && solid_at(&s, x, y, 3.0), "Back is not drilled at ({x}, {y})");
}
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
near(base - volume(&mut reopened), pi() * 6.25 * 4.0);
}
/// Through all from Front's face stops at Front; `targets` picks another body to drill instead.
#[test]
fn face_sketch_holes_through_all_stay_in_their_body_unless_targets_say_otherwise() {
let mut s = Session::default();
front_and_back(&mut s);
run(&mut s, "solid.hole", json!({"sketch": "Pts", "points": ["in"], "diameter": 5, "name": "H"}));
assert!(!solid_at(&s, 20.0, 20.0, 15.0) && solid_at(&s, 20.0, 20.0, 3.0));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.hole", json!({"sketch": "Pts", "points": ["in"], "diameter": 5, "targets": ["Back"], "name": "H"}));
assert!(solid_at(&s, 20.0, 20.0, 15.0) && !solid_at(&s, 20.0, 20.0, 3.0));
}
+21 -2
View File
@@ -170,9 +170,19 @@ pub struct Snapshot {
pub struct Ahead(Session);
impl Ahead {
/// Run `commands` (their results stay in the shared evaluation cache).
/// What the evaluation computes, kept for the run that follows here whatever the evaluation
/// cache drops meanwhile: hand it to [`solidcraft_doc::Model::keep`] for that run.
pub fn kept(&self) -> Arc<solidcraft_doc::KeptResults> {
self.0.model.kept().unwrap_or_default()
}
/// Run `commands` (their results stay in the shared evaluation cache, and in [`Ahead::kept`]),
/// then make the display meshes and plans of the bodies they end with: the bodies the run
/// here finds share them, so the window draws, fits and picks without tessellating anything.
pub fn evaluate(self, commands: &[(String, Value)]) -> Result<()> {
self.0.preview_in_place(commands).map(|_| ())
let (s, _) = self.0.preview_in_place(commands)?;
s.world_state().warm_display();
Ok(())
}
/// Run `commands` one after another, each reply or error in turn (what they evaluate stays
@@ -431,16 +441,24 @@ impl Session {
/// Become `fresh` (another design opened, recovered or started), keeping the settings that
/// are the user's rather than the design's: `auto_project` is a preference (#680).
/// Checkpoints survive too: a batch that failed after `file.new` or `doc.open` goes back
/// from the checkpoint it took before its first step, and that checkpoint belongs to the
/// design before the batch, which is what the batch promised (#3242). The design is what a
/// checkpoint keeps, not the replaced session's file path (docs/mcp.md).
pub fn replace_design(&mut self, fresh: Session) {
let auto_project = self.auto_project;
let param_check = self.param_check;
let render_viewport = self.render_viewport.clone();
let render_jobs = self.render_jobs.scratch();
let checkpoints = std::mem::take(&mut self.checkpoints);
let next_checkpoint = self.next_checkpoint;
*self = fresh;
self.auto_project = auto_project;
self.param_check = param_check;
self.render_viewport = render_viewport;
self.render_jobs = render_jobs;
self.checkpoints = checkpoints;
self.next_checkpoint = next_checkpoint;
}
/// The design has changes not in its file: in the document, or in what is hidden.
@@ -1022,6 +1040,7 @@ impl Session {
let mut copy = self.scratch();
copy.undo.clone_from(&self.undo);
copy.redo.clone_from(&self.redo);
copy.model.keep(Some(Arc::default()));
Ahead(copy)
}
+50
View File
@@ -278,3 +278,53 @@ fn step_round_trip_of_a_plate_with_holes() {
assert!(close(&r["items"][0]["diameter"], 6.0, 1e-6) && close(&r["items"][1]["diameter"], 8.0, 1e-6), "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
// A part ring cut by `solid.combine` (two prisms through its ends, a notch through its chamfer)
// measures what it encloses, wherever it is moved. The reference mesher left a crack where the
// notch meets the chamfer's cone; the volume beside it was left out (0.2 %), and integrated
// about the world origin the loss grew with the distance (7 % moved 1000 mm away).
fn volume(s: &mut Session, body: &str) -> f64 {
run(s, "inspect.measure", json!({"bodies": [body]}))["total"]["volume_mm3"].as_f64().unwrap()
}
fn prism(s: &mut Session, offset: f64, points: Value, distance: f64, name: &str) {
run(s, "sketch.create", json!({"plane": "XY", "offset": offset}));
run(s, "sketch.line", json!({"points": points, "closed": true}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": distance, "operation": "new", "body_name": name}));
}
/// A part ring (210° of a chamfered section) less two oblique prisms and a notch.
fn cut_ring(s: &mut Session) {
let (c, si) = (15f64.to_radians().cos(), 15f64.to_radians().sin());
run(s, "sketch.create", json!({"plane": {"origin": [0, 0, 0], "x_dir": [si, c, 0], "y_dir": [0, 0, -1]}}));
let section = json!([[16.6, 0], [17.5, 0], [18.5, -1.0], [18.5, -7.0], [17.5, -8.0], [16.6, -8.0], [16.4, -7.8], [16.4, -0.2]]);
run(s, "sketch.line", json!({"points": section, "closed": true}));
run(s, "sketch.finish", json!({}));
run(s, "solid.revolve", json!({"axis": "Z", "angle": "210 deg", "body_name": "Ring"}));
let quad = |sy: f64| json!([[4.644832, 15.101839 * sy], [5.17399, 16.822301 * sy], [4.555215, 17.000295 * sy], [2.187187, 15.574386 * sy]]);
prism(s, -1.0, quad(1.0), 10.0, "T0");
prism(s, -1.0, quad(-1.0), 10.0, "T1");
prism(s, 7.1, json!([[-17.6, 0.45], [-19.5, 0.45], [-19.5, -0.45], [-17.6, -0.45]]), 1.9, "N0");
run(s, "solid.combine", json!({"target": "Ring", "tools": ["T0", "T1", "N0"], "operation": "cut"}));
}
#[test]
fn combine_cut_volume_does_not_depend_on_placement() {
let mut s = Session::default();
cut_ring(&mut s);
let here = volume(&mut s, "Ring");
// The 210° ring by Pappus less what the prisms and the notch take (a fine grid quadrature
// of each, independent of the kernel's meshes): 1005.489 − 2 × 5.639 − 0.287.
let want = 993.924;
assert!((here - want).abs() <= 2e-4 * want, "at the origin: {here} vs {want}");
let mut moved = Vec::new();
for by in [[100.0, 0.0, 0.0], [900.0, 0.0, 0.0], [-1000.0, 100.0, 0.0], [0.0, 0.0, 10000.0], [0.0, -10000.0, 0.0]] {
run(&mut s, "solid.move", json!({"bodies": ["Ring"], "translate": by}));
moved.push((by, volume(&mut s, "Ring")));
}
for (by, v) in moved {
assert!((v - here).abs() <= 1e-6 * here, "after a move by {by:?}: {v} vs {here} at the origin");
}
}
+58
View File
@@ -401,3 +401,61 @@ fn a_cut_through_one_of_two_bosses_names_only_its_own_walls() {
assert_eq!(blends, 4, "{filleted:?}");
assert_eq!(named_at(&filleted, [19.98, -4.24, 19.0]), boss);
}
/// #3902: a torus with a closed, twisted square sweep cut inside it splits the sweep's walls into
/// pieces the kernel returns in a different order on each evaluation. The pieces are numbered by
/// where they lie, so every fresh evaluation gives each piece the same name.
#[test]
fn split_pieces_keep_their_numbers_across_evaluations() {
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/closed_sweep_torus_cut.json")).unwrap();
let mut s = Session::default();
s.run_script(&script).unwrap();
let body = s.model.state().bodies.first().map(|b| b.name.clone()).expect("a body");
let named = |s: &mut Session| -> Vec<(String, [i64; 3])> {
let mut faces: Vec<(String, [i64; 3])> = listed(s, &body).into_iter().map(|(n, _, c)| (n, c.map(|x| x.round() as i64))).collect();
faces.sort();
faces
};
let first = named(&mut s);
assert!(first.iter().any(|(n, _)| n.contains('#')), "no split pieces: {first:?}");
for run in 0..10 {
let mut fresh = Session::new(solidcraft_doc::Document::clone(&s.doc));
fresh.refresh();
assert_eq!(named(&mut fresh), first, "evaluation {run}");
}
}
/// #3902: a design saved before the pieces of a face were numbered by place opens with the
/// numbers it was saved with, so a reference to `…#1` stays on the piece it was made on. The
/// block cut by the second block here has its side split in two, numbered the other way round
/// by place.
#[test]
fn a_design_saved_before_pieces_were_numbered_by_place_keeps_their_numbers() {
let script: Value = serde_json::from_str(include_str!("../../../tests/action_matrix/designs/multi-body-join-cut.json")).unwrap();
let mut s = Session::default();
s.run_script(&script).unwrap();
let by_place = listed(&mut s, "Block");
// As a build before the change saved it: its features carry no mark, and the file does not
// list the change among those it has.
let mut file: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
for f in file["features"].as_array_mut().unwrap() {
f.as_object_mut().unwrap().remove("kernel_piece_order");
}
if let Some(Value::Array(pending)) = file.get_mut("format_pending") {
pending.retain(|c| c != "face-pieces-by-place");
}
let old = solidcraft_doc::Document::from_json(&file.to_string()).unwrap();
assert!(old.features.iter().all(|f| f.kernel_piece_order), "the features of an older design keep the old numbering");
let mut reopened = Session::new(old);
reopened.refresh();
let kept = listed(&mut reopened, "Block");
let at = |faces: &[(String, bool, [f64; 3])], name: &str| faces.iter().find(|(n, _, _)| n == name).map(|(_, _, c)| *c);
// The pieces are numbered differently by place, and the older design keeps its own numbers.
let (old1, new1) = (at(&kept, "F2:side#1").unwrap(), at(&by_place, "F2:side#1").unwrap());
assert!((0..3).any(|k| (old1[k] - new1[k]).abs() > 1.0), "{kept:?} {by_place:?}");
let mut legacy = solidcraft_doc::Document::clone(&s.doc);
legacy.features.iter_mut().for_each(|f| f.kernel_piece_order = true);
let mut saved_with = Session::new(legacy);
saved_with.refresh();
assert_eq!(listed(&mut saved_with, "Block"), kept);
}
+17 -3
View File
@@ -1,6 +1,6 @@
//! Autosave and crash recovery.
//!
//! A running app keeps one recovery entry in the recovery folder: `<id>.solidcraft` (the design
//! Each open design keeps its own recovery entry in the recovery folder: `<id>.solidcraft` (the design
//! as of the last autosave), `<id>.json` (its file path, name and time) and `<id>.lock`, which
//! the app holds locked while it runs. Autosaves are written atomically, only when the design
//! has unsaved changes it hasn't written yet; a design with nothing unsaved has no entry. An
@@ -163,7 +163,7 @@ pub fn remove(dir: &Path, id: &str) -> Result<()> {
Ok(())
}
/// One app's autosaver.
/// One open design's autosaver. Keep it with that design when switching tabs.
pub struct Autosaver {
pub dir: PathBuf,
pub id: String,
@@ -232,7 +232,7 @@ impl Snapshot {
}
impl Autosaver {
/// Start autosaving into `dir` (created if needed), holding this app's lock.
/// Start autosaving into `dir` (created if needed), holding this design's lock.
pub fn new(dir: &Path, interval: Duration) -> Result<Autosaver> {
let io = |e: std::io::Error| EngineError::Other(format!("{}: {e}", dir.display()));
fs::create_dir_all(dir).map_err(io)?;
@@ -361,6 +361,20 @@ impl Autosaver {
Ok(())
}
/// The user closed this design (saving it or choosing Don't Save): discard only its entry.
pub fn discard(mut self) {
// A write still running would recreate the entry, without a lock, after it is removed:
// the next launch would offer a design the user closed.
if let Some(w) = self.writing.take() {
let _ = w.done.recv();
}
for ext in ["json", solidcraft_io::DESIGN_EXT] {
let _ = fs::remove_file(file(&self.dir, &self.id, ext));
}
self.lock = None;
let _ = fs::remove_file(file(&self.dir, &self.id, "lock"));
}
/// Closing the app: with nothing unsaved the entry goes; otherwise it stays for the next
/// launch to offer. The lock is released either way.
pub fn close(mut self, s: &Session) {
+26 -4
View File
@@ -129,6 +129,23 @@ pub(crate) fn whole(message: impl std::fmt::Display) -> String {
format!("{WHOLE}{message}")
}
/// Marks a deserializer's message that carries its own path: an error from a flattened
/// `FeatureCommon`, which serde can't locate. The path (one of its field names) and the error
/// follow as JSON.
const LOCATED: char = '\u{2}';
/// `e`, an error deserializing a flattened `FeatureCommon`, as a deserializer's message that
/// keeps its path, for [`parse_issue`].
pub(crate) fn located(e: &serde_path_to_error::Error<serde_json::Error>) -> String {
let located = serde_json::to_string(&(error_path(e), e.inner().to_string())).unwrap_or_default();
format!("{LOCATED}{located}")
}
fn error_path(e: &serde_path_to_error::Error<serde_json::Error>) -> String {
let path = e.path().to_string();
if path == "." { String::new() } else { path }
}
/// The top-level property names of a schema, following `allOf`/`anyOf`/`oneOf` and `$ref`s
/// into the root's `$defs`.
pub(crate) fn schema_keys(root: &Value) -> BTreeSet<String> {
@@ -207,10 +224,15 @@ fn nearest(key: &str, allowed: &BTreeSet<String>) -> Option<String> {
}
/// A deserialization failure as an issue, in the engine's style.
fn parse_issue(e: &serde_path_to_error::Error<serde_json::Error>) -> Issue {
let path = e.path().to_string();
let path = if path == "." { String::new() } else { path };
pub(crate) fn parse_issue(e: &serde_path_to_error::Error<serde_json::Error>) -> Issue {
let path = error_path(e);
let inner = e.inner().to_string();
// An error from a flattened `FeatureCommon`: its field continues the path.
let (path, inner) = match inner.strip_prefix(LOCATED).and_then(|r| serde_json::from_str::<(String, String)>(r).ok()) {
Some((field, inner)) if path.is_empty() => (field, inner),
Some((field, inner)) => (format!("{path}.{field}"), inner),
None => (path, inner),
};
if let Some(complete) = inner.strip_prefix(WHOLE) {
// The handler's own message, which names the caller's value: in full up to a generous
// cap, since the contract also checks keys the handler ignores (`side` without `thin`).
@@ -218,7 +240,7 @@ fn parse_issue(e: &serde_path_to_error::Error<serde_json::Error>) -> Issue {
}
let message = if let Some(field) = inner.strip_prefix("missing field `").and_then(|r| r.split('`').next()) {
let at = if path.is_empty() { field.to_string() } else { format!("{path}.{field}") };
return Issue { path: at.clone(), message: format!("`{at}` is required"), suggestion: None };
return Issue { path: at.clone(), message: truncate(&format!("`{at}` is required"), MAX_MESSAGE_LEN), suggestion: None };
} else if path.is_empty() {
inner
} else if inner.starts_with("must ") {
@@ -1,4 +1,4 @@
//! The converted commands' parameter contracts and result types.
//! Contracts and result types for the commands in `cmd/features.rs`.
//!
//! Field doc comments are the schemas' descriptions. Keys the handlers read only in some
//! situations (`sketch` without `face`, the thin-wall keys with `thin: true`) are optional
@@ -7,7 +7,6 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::types::{
AngleExpr, ConventionParam, DirectionParam, ExtentKeyword, ExtrudeOperation, FacesParam, FromPoint, IdOrName, LengthExpr, OperationParam, Point3,
@@ -161,39 +160,6 @@ pub struct ExtrudeParams {
pub thickness2: Option<LengthExpr>,
}
/// Which command to describe.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct DescribeParams {
/// The command's id, such as `solid.extrude`.
#[serde(deserialize_with = "command_id")]
pub command: String,
}
/// A command id, with the `engine.describe` handler's message for anything else.
fn command_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(s),
_ => Err(serde::de::Error::custom("must be a command id")),
}
}
/// What `engine.describe` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DescribeResult {
pub id: String,
pub label: String,
/// The command's parameter documentation, as prose.
pub params: String,
/// The parameters' JSON Schema, or `null` when the command has no contract yet.
pub input: Option<Value>,
/// The successful result's JSON Schema, or `null` when not described yet.
pub output: Option<Value>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// What a feature command returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct FeatureResult {
@@ -218,3 +184,7 @@ pub struct FeatureResult {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "features_tests.rs"]
mod tests;
+127
View File
@@ -0,0 +1,127 @@
use serde_json::{Value, json};
use super::{BoxParams, ExtrudeParams, FeatureResult};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(
input_schema_for::<BoxParams>(),
&["body_name", "body_names", "center", "corner", "height", "length", "name", "operation", "participants", "targets", "width"],
);
assert_keys(
input_schema_for::<ExtrudeParams>(),
&[
"body",
"body_name",
"body_names",
"convention",
"curves",
"direction",
"distance",
"distance2",
"extent",
"face",
"face_body",
"faces",
"from",
"name",
"operation",
"participants",
"profiles",
"side",
"sketch",
"start_offset",
"taper",
"targets",
"thickness",
"thickness2",
"thin",
"through_all",
"to",
"to_offset",
],
);
}
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("solid.box", json!({"length": 5, "width": 5, "height": 5, "operation": "bogus"})),
("solid.box", json!({"length": true, "width": 5, "height": 5})),
("solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [1, 2]})),
("solid.extrude", json!({"distance": 5, "operation": "bogus"})),
("solid.extrude", json!({"distance": 5, "direction": "sideways"})),
("solid.extrude", json!({"distance": 5, "direction": "SIDEWAYS"})),
("solid.extrude", json!({"distance": 5, "profiles": [{"point": [1]}]})),
("solid.extrude", json!({"distance": 5, "profiles": [["l1"], 7]})),
("solid.extrude", json!({"distance": 5, "profiles": []})),
("solid.extrude", json!({"distance": 5, "profiles": [true]})),
("solid.extrude", json!({"distance": 5, "faces": []})),
("solid.extrude", json!({"distance": 5, "faces": null})),
("solid.extrude", json!({"distance": 5, "faces": [[1, 2]]})),
("solid.extrude", json!({"distance": 5, "profiles": {"offset_boundaries": ["a", "a"]}})),
("solid.extrude", json!({"distance": 5, "thin": true, "thickness": 1, "side": "middle"})),
("solid.extrude", json!({"distance": 5, "thin": "true"})),
("solid.extrude", json!({"distance": 5, "to": [1, 2]})),
("solid.extrude", json!({"distance": 5, "from": "x"})),
("solid.extrude", json!({"distance": 5, "sketch": true})),
("solid.extrude", json!({"distance": 5, "convention": "per_side"})),
("solid.extrude", json!({"distance": " "})),
("solid.extrude", json!({"distance": 5, "operation": "x".repeat(1000)})),
]);
}
#[test]
fn feature_result_serializes_like_the_old_reply() {
for (warning, ms) in [(None, None), (Some("a Boolean needed a retry".to_string()), Some(12.5)), (None, Some(0.0)), (None, Some(1.0 / 3.0))] {
let old = json!({"feature": 7, "name": "Box1", "bodies": ["A", "B"], "created_bodies": ["B"], "changed_bodies": [],
"removed_bodies": ["C"], "warning": warning, "ms": ms});
let new = FeatureResult {
feature: 7,
name: "Box1".into(),
bodies: vec!["A".into(), "B".into()],
created_bodies: vec!["B".into()],
changed_bodies: vec![],
removed_bodies: vec!["C".into()],
warning: warning.clone(),
ms,
param_warnings: vec![],
};
assert_eq!(serde_json::to_value(&new).ok(), Some(old));
}
}
#[test]
fn replies_match_the_output_schema() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
let required: Vec<&str> = schema.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
assert!(!required.contains(&"param_warnings"));
let mut s = Session::default();
sketch_rectangle(&mut s);
s.param_check = ParamCheck::Warn;
let replies = [
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0]})),
s.execute("solid.extrude", &json!({"distance": 5, "operation": "join"})),
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "colour": "red"})),
];
for r in replies {
let r = r.unwrap_or_else(|e| panic!("{e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
}
// The check itself catches a wrong type, a wrong item type and a missing key. (The output
// schema leaves other keys open, so a new result key doesn't break clients.)
let ok = json!({"feature": 1, "name": "Box1", "bodies": [], "created_bodies": [], "changed_bodies": [], "removed_bodies": [], "warning": null, "ms": null});
assert!(conforms(schema, schema, &ok));
for (k, v) in [("feature", json!("1")), ("bodies", json!([1])), ("warning", json!(5)), ("ms", json!("1"))] {
let mut bad = ok.clone();
bad[k] = v;
assert!(!conforms(schema, schema, &bad), "{bad}");
}
let mut bad = ok.clone();
bad.as_object_mut().map(|o| o.remove("name"));
assert!(!conforms(schema, schema, &bad), "{bad}");
}
+42
View File
@@ -0,0 +1,42 @@
//! Contracts and result types for the commands in `cmd/inspect.rs`.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Which command to describe.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct DescribeParams {
/// The command's id, such as `solid.extrude`.
#[serde(deserialize_with = "command_id")]
pub command: String,
}
/// A command id, with the `engine.describe` handler's message for anything else.
fn command_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(s),
_ => Err(serde::de::Error::custom("must be a command id")),
}
}
/// What `engine.describe` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DescribeResult {
pub id: String,
pub label: String,
/// The command's parameter documentation, as prose.
pub params: String,
/// The parameters' JSON Schema, or `null` when the command has no contract yet.
pub input: Option<Value>,
/// The successful result's JSON Schema, or `null` when not described yet.
pub output: Option<Value>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "inspect_tests.rs"]
mod tests;
+36
View File
@@ -0,0 +1,36 @@
use serde_json::json;
use super::DescribeParams;
use crate::Session;
use crate::schema::input_schema_for;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<DescribeParams>(), &["command"]);
}
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("engine.describe", json!({"command": 5})),
("engine.describe", json!({"command": null})),
("engine.describe", json!({"command": "solid.extrud"})),
]);
}
#[test]
fn describe_returns_both_schemas() {
let mut s = Session::default();
let d = s.execute("engine.describe", &json!({"command": "solid.box"})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(d["id"], "solid.box");
assert!(d["input"]["properties"].get("length").is_some());
assert!(d["output"]["properties"].get("created_bodies").is_some());
// Its own output schema lists the warnings warn mode can add.
let own = s.execute("engine.describe", &json!({"command": "engine.describe"})).unwrap_or_else(|e| panic!("{e}"));
assert!(own["output"]["properties"].get("param_warnings").is_some(), "{own}");
let d = s.execute("engine.describe", &json!({"command": "solid.revolve"})).unwrap_or_else(|e| panic!("{e}"));
assert!(d["input"].is_null() && d["output"].is_null(), "unconverted commands have no schemas yet");
let e = s.execute("engine.describe", &json!({"command": "solid.extrud"})).err().map(|e| e.to_string());
assert!(e.as_deref().is_some_and(|e| e.contains("did you mean `solid.extrude`")), "{e:?}");
}
+18 -8
View File
@@ -22,15 +22,22 @@
//! more lenient (a value of the wrong type read as if the key were absent), the contract is the
//! stricter of the two; that difference is what warn and strict surface.
//!
//! To convert a command: declare its parameters type in `commands.rs` (and its reply type, for
//! an output schema), give its `CommandSpec` `checked!(…)`, `.contract(…)` and `.output(…)` as
//! `solid.extrude` does, and add it to `schemas_and_the_key_check_agree` and, for messages it
//! already gives, `strict_keeps_todays_errors`. Then run the whole test suite with the audit
//! recorder (see `record.rs`): a call that succeeds today but breaks the contract is either a
//! form the contract is missing or a caller's mistake.
//! Contracts live in one module per command file: `features.rs` for `cmd/features.rs`,
//! `inspect.rs` for `cmd/inspect.rs`, and so on, each with its own tests (`features_tests.rs`),
//! so conversions of different command files touch different files (apart from a new
//! module's line below).
//!
//! To convert a command: declare its parameters type in the module for its command file (and
//! its reply type, for an output schema), creating the module if it is the file's first
//! contract, from the shared types in [`types`] (the keys every feature takes come from
//! [`FeatureCommon`], which `solid.box` and `solid.extrude` predate); give its `CommandSpec`
//! `checked!(…)`, `.contract(…)` and `.output(…)` as `solid.extrude` does; and add it to that
//! module's `schemas_and_the_key_check_agree` and, for messages it already gives,
//! `strict_keeps_todays_errors`. Then run the whole test suite with the audit recorder (see
//! `record.rs`): a call that succeeds today but breaks the contract is either a form the
//! contract is missing or a caller's mistake.
mod check;
mod commands;
#[cfg_attr(target_arch = "wasm32", path = "record_wasm.rs")]
mod record;
mod types;
@@ -38,8 +45,11 @@ mod types;
#[cfg(test)]
mod tests;
// The contracts, by command file.
pub mod features;
pub mod inspect;
pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, output_schema_for, run_checked};
pub use commands::{BoxParams, DescribeParams, DescribeResult, ExtrudeParams, FeatureResult};
pub use types::*;
/// A command's `run` that checks the call against `$contract` before running `$handler`
+260 -150
View File
@@ -1,7 +1,12 @@
//! Tests of the shared types and the checking machinery, and helpers for the per-module tests
//! (`features_tests.rs`, …).
use schemars::Schema;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use super::check::schema_keys;
use super::features::{BoxParams, ExtrudeParams};
use super::*;
use crate::Session;
@@ -159,54 +164,6 @@ fn shape_problems_are_issues() {
assert!(messages(&c.check(&Value::Object(many))).iter().any(|m| m.starts_with("too many parameters")));
}
#[test]
fn schemas_and_the_key_check_agree() {
for (schema, fields) in [
(
input_schema_for::<BoxParams>(),
vec!["body_name", "body_names", "center", "corner", "height", "length", "name", "operation", "participants", "targets", "width"],
),
(
input_schema_for::<ExtrudeParams>(),
vec![
"body",
"body_name",
"body_names",
"convention",
"curves",
"direction",
"distance",
"distance2",
"extent",
"face",
"face_body",
"faces",
"from",
"name",
"operation",
"participants",
"profiles",
"side",
"sketch",
"start_offset",
"taper",
"targets",
"thickness",
"thickness2",
"thin",
"through_all",
"to",
"to_offset",
],
),
] {
let v = schema.as_value();
assert_eq!(schema_keys(v).into_iter().collect::<Vec<_>>(), fields);
assert_eq!(v.get("additionalProperties"), Some(&json!(false)), "the schema refuses unknown keys too");
assert_eq!(v.get("$schema").and_then(Value::as_str), Some("https://json-schema.org/draft/2020-12/schema"));
}
}
#[test]
fn present_null_is_refused_but_absent_is_fine() {
let base = json!({"length": 5, "width": 5, "height": 5});
@@ -228,7 +185,7 @@ fn present_null_is_refused_but_absent_is_fine() {
}
}
fn sketch_rectangle(s: &mut Session) {
pub(super) fn sketch_rectangle(s: &mut Session) {
for (id, p) in [
("sketch.create", json!({"plane": "XY"})),
("sketch.rectangle.center", json!({"center": [0, 0], "corner": [10, 10]})),
@@ -302,32 +259,12 @@ fn the_level_survives_a_new_design() {
assert_eq!(s.scratch().param_check, ParamCheck::Strict);
}
#[test]
fn feature_result_serializes_like_the_old_reply() {
for (warning, ms) in [(None, None), (Some("a Boolean needed a retry".to_string()), Some(12.5)), (None, Some(0.0)), (None, Some(1.0 / 3.0))] {
let old = json!({"feature": 7, "name": "Box1", "bodies": ["A", "B"], "created_bodies": ["B"], "changed_bodies": [],
"removed_bodies": ["C"], "warning": warning, "ms": ms});
let new = FeatureResult {
feature: 7,
name: "Box1".into(),
bodies: vec!["A".into(), "B".into()],
created_bodies: vec!["B".into()],
changed_bodies: vec![],
removed_bodies: vec!["C".into()],
warning: warning.clone(),
ms,
param_warnings: vec![],
};
assert_eq!(serde_json::to_value(&new).ok(), Some(old));
}
}
/// Whether `v` conforms to `node` of the schema `root`, for the keywords these schemas use
/// apart from `pattern`: `type`, `properties`, `required`, `additionalProperties: false`,
/// `items`, `minItems`, `maxItems`, `uniqueItems`, `enum`, `minLength`, `maxLength`, `minimum`,
/// `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `anyOf`, `not` and `$ref`. (`integer`
/// here is what serde reads as one, so `1.0` is not.)
fn conforms(root: &Value, node: &Value, v: &Value) -> bool {
pub(super) fn conforms(root: &Value, node: &Value, v: &Value) -> bool {
if let Some(name) = node.get("$ref").and_then(Value::as_str).and_then(|r| r.strip_prefix("#/$defs/")) {
return root.get("$defs").and_then(|d| d.get(name)).is_some_and(|t| conforms(root, t, v));
}
@@ -410,38 +347,6 @@ fn conforms(root: &Value, node: &Value, v: &Value) -> bool {
true
}
#[test]
fn replies_match_the_output_schema() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
let required: Vec<&str> = schema.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
assert!(!required.contains(&"param_warnings"));
let mut s = Session::default();
sketch_rectangle(&mut s);
s.param_check = ParamCheck::Warn;
let replies = [
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0]})),
s.execute("solid.extrude", &json!({"distance": 5, "operation": "join"})),
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "colour": "red"})),
];
for r in replies {
let r = r.unwrap_or_else(|e| panic!("{e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
}
// The check itself catches a wrong type, a wrong item type and a missing key. (The output
// schema leaves other keys open, so a new result key doesn't break clients.)
let ok = json!({"feature": 1, "name": "Box1", "bodies": [], "created_bodies": [], "changed_bodies": [], "removed_bodies": [], "warning": null, "ms": null});
assert!(conforms(schema, schema, &ok));
for (k, v) in [("feature", json!("1")), ("bodies", json!([1])), ("warning", json!(5)), ("ms", json!("1"))] {
let mut bad = ok.clone();
bad[k] = v;
assert!(!conforms(schema, schema, &bad), "{bad}");
}
let mut bad = ok.clone();
bad.as_object_mut().map(|o| o.remove("name"));
assert!(!conforms(schema, schema, &bad), "{bad}");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn the_recorder_keeps_a_bounded_number_of_signatures() {
@@ -459,22 +364,6 @@ fn the_recorder_keeps_a_bounded_number_of_signatures() {
assert_eq!(seen.len(), 3);
}
#[test]
fn describe_returns_both_schemas() {
let mut s = Session::default();
let d = s.execute("engine.describe", &json!({"command": "solid.box"})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(d["id"], "solid.box");
assert!(d["input"]["properties"].get("length").is_some());
assert!(d["output"]["properties"].get("created_bodies").is_some());
// Its own output schema lists the warnings warn mode can add.
let own = s.execute("engine.describe", &json!({"command": "engine.describe"})).unwrap_or_else(|e| panic!("{e}"));
assert!(own["output"]["properties"].get("param_warnings").is_some(), "{own}");
let d = s.execute("engine.describe", &json!({"command": "solid.revolve"})).unwrap_or_else(|e| panic!("{e}"));
assert!(d["input"].is_null() && d["output"].is_null(), "unconverted commands have no schemas yet");
let e = s.execute("engine.describe", &json!({"command": "solid.extrud"})).err().map(|e| e.to_string());
assert!(e.as_deref().is_some_and(|e| e.contains("did you mean `solid.extrude`")), "{e:?}");
}
/// The profiles schema and the contract agree on every listed case; the schema is deliberately
/// narrower on non-text curve ids and loops past the outer one (and describes the app's
/// `whole_boundaries` selection only by its `type`).
@@ -554,43 +443,29 @@ fn recorder_lines_have_the_documented_shape() {
assert_eq!(super::record::enabled(), cfg!(feature = "param-audit") && set);
}
/// A contract's input schema lists exactly `fields` (sorted) as its keys, refuses other keys
/// and declares draft 2020-12. Each module's `schemas_and_the_key_check_agree` calls this for
/// its contracts, so their keys change only on purpose. (Whether the keys are the ones the
/// handler reads is what the audit run checks.)
pub(super) fn assert_keys(schema: Schema, fields: &[&str]) {
let v = schema.as_value();
assert_eq!(schema_keys(v).into_iter().collect::<Vec<_>>(), fields);
assert_eq!(v.get("additionalProperties"), Some(&json!(false)), "the schema refuses unknown keys too");
assert_eq!(v.get("$schema").and_then(Value::as_str), Some("https://json-schema.org/draft/2020-12/schema"));
}
/// A call that already fails gets the same error in strict as with the check off, apart from
/// the missing-key, missing-instead-of-wrong-type, non-object, profile-index, 10,000-item and
/// over-4-KB messages the report lists.
#[test]
fn strict_keeps_todays_errors() {
let cases = [
("solid.box", json!({"length": 5, "width": 5, "height": 5, "operation": "bogus"})),
("solid.box", json!({"length": true, "width": 5, "height": 5})),
("solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [1, 2]})),
("solid.extrude", json!({"distance": 5, "operation": "bogus"})),
("solid.extrude", json!({"distance": 5, "direction": "sideways"})),
("solid.extrude", json!({"distance": 5, "direction": "SIDEWAYS"})),
("solid.extrude", json!({"distance": 5, "profiles": [{"point": [1]}]})),
("solid.extrude", json!({"distance": 5, "profiles": [["l1"], 7]})),
("solid.extrude", json!({"distance": 5, "profiles": []})),
("solid.extrude", json!({"distance": 5, "profiles": [true]})),
("solid.extrude", json!({"distance": 5, "faces": []})),
("solid.extrude", json!({"distance": 5, "faces": null})),
("solid.extrude", json!({"distance": 5, "faces": [[1, 2]]})),
("solid.extrude", json!({"distance": 5, "profiles": {"offset_boundaries": ["a", "a"]}})),
("solid.extrude", json!({"distance": 5, "thin": true, "thickness": 1, "side": "middle"})),
("solid.extrude", json!({"distance": 5, "thin": "true"})),
("solid.extrude", json!({"distance": 5, "to": [1, 2]})),
("solid.extrude", json!({"distance": 5, "from": "x"})),
("solid.extrude", json!({"distance": 5, "sketch": true})),
("solid.extrude", json!({"distance": 5, "convention": "per_side"})),
("solid.extrude", json!({"distance": " "})),
("solid.extrude", json!({"distance": 5, "operation": "x".repeat(1000)})),
("engine.describe", json!({"command": 5})),
("engine.describe", json!({"command": null})),
("engine.describe", json!({"command": "solid.extrud"})),
];
/// over-4-KB messages the report lists, and a list of picks where one finds nothing in the
/// design and a later one is malformed (the handler fails on the first, strict on the second).
/// Each module's `strict_keeps_todays_errors` calls this with its commands' cases; each runs
/// after `sketch_rectangle`.
pub(super) fn assert_strict_keeps_todays_errors(cases: &[(&str, Value)]) {
for (id, p) in cases {
let error = |level: ParamCheck| {
let mut s = Session { param_check: level, ..Session::default() };
sketch_rectangle(&mut s);
s.execute(id, &p).err().map(|e| e.to_string())
s.execute(id, p).err().map(|e| e.to_string())
};
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{p} should fail today");
@@ -601,7 +476,10 @@ fn strict_keeps_todays_errors() {
/// Every `default` in the input schemas is a value its own property schema allows.
#[test]
fn schema_defaults_are_valid() {
for root in [input_schema_for::<BoxParams>(), input_schema_for::<ExtrudeParams>()] {
let contracts: Vec<_> = crate::cmd::command_specs().into_iter().filter_map(|c| c.contract).collect();
assert!(contracts.len() >= 3);
for c in contracts {
let root = c.input_schema();
let root = root.as_value();
for (key, property) in root["properties"].as_object().into_iter().flatten() {
if let Some(d) = property.get("default") {
@@ -631,3 +509,235 @@ fn long_values_in_handler_messages_are_capped() {
let w = r["param_warnings"][0].as_str().unwrap_or_default();
assert!(w.starts_with("unknown side `xxx") && w.len() <= 4096 + "…".len(), "{} bytes", w.len());
}
/// The pick types accept what the pick readers accept. (Comparing a type with the helper it
/// calls only guards against the two drifting apart; the schema comparisons and the handler
/// tests below are the real checks.)
#[test]
fn face_and_edge_refs_match_the_pick_readers() {
let long = "f".repeat(513);
// 171 three-byte characters are 513 bytes, too long for the reader; the schema's maxLength
// counts characters, so it can't say this.
let wide = "面".repeat(171);
let face_schema = input_schema_for::<FaceRef>();
let face_schema = face_schema.as_value();
let edge_schema = input_schema_for::<EdgeRef>();
let edge_schema = edge_schema.as_value();
for v in [
json!([1, 2, 3]),
json!({"x": 1, "y": 2, "z": 3}),
json!({"point": [1, 2, 3]}),
json!({"point": {"x": 1, "y": 2, "z": 3}}),
json!({"x": "a", "point": [1, 2, 3]}),
json!("F3:1"),
json!(""),
json!(long),
json!([1, 2]),
json!([1e9, 0, 0]),
json!({"point": [1, 2]}),
json!({"body": "A", "index": 0}),
json!({"body": "A", "index": u64::MAX}),
json!({"body": "A", "index": -1}),
json!({"body": 5, "index": 0}),
json!({"body": "A"}),
json!({"point": "x", "body": "A", "index": 2}),
json!(null),
json!(5),
json!(true),
] {
let face = crate::cmd::face_ref_form(&v).is_ok();
assert_eq!(parse::<FaceRef>(v.clone()).is_some(), face, "{v}");
assert_eq!(conforms(face_schema, face_schema, &v), face, "face {v}");
let edge = crate::cmd::edge_ref_form(&v).is_ok();
assert_eq!(parse::<EdgeRef>(v.clone()).is_some(), edge, "{v}");
assert_eq!(conforms(edge_schema, edge_schema, &v), edge, "edge {v}");
}
// Where the schemas can't follow the readers: a name's cap is in bytes, and an index of
// `1.0`, which the reader refuses, is an integer to JSON Schema (`conforms` reads integers
// as serde does, so it agrees with the reader here; a standard validator would not).
assert!(parse::<FaceRef>(json!(wide)).is_none() && conforms(face_schema, face_schema, &json!(wide)));
assert!(parse::<EdgeRef>(json!({"body": "A", "index": 1.0})).is_none());
assert_eq!(edge_schema["anyOf"][2]["properties"]["index"]["type"], "integer");
assert_eq!(parse::<EdgeRef>(json!({"body": "A", "index": 4})), Some(EdgeRef::Index { body: "A".into(), index: 4 }));
assert_eq!(parse::<FaceRef>(json!({"point": [1, 2, 3]})), Some(FaceRef::Point(solidcraft_geom::Vec3::new(1.0, 2.0, 3.0))));
}
/// The pick lists take 1 to 1000 picks, as `face_points` and `edge_points` do, and so do their
/// schemas.
#[test]
fn pick_lists_match_the_list_readers() {
let faces = input_schema_for::<FaceRefs>();
let faces = faces.as_value();
let edges = input_schema_for::<EdgeRefs>();
let edges = edges.as_value();
let point = json!([1, 2, 3]);
for (v, accepted) in [
(json!([point]), true),
(json!(vec![point.clone(); 1000]), true),
(json!([point, {"point": [4, 5, 6]}]), true),
(json!([]), false),
(json!(vec![point.clone(); 1001]), false),
(json!([point, [1, 2]]), false),
(json!(point), false),
(json!(null), false),
(json!("F1"), false),
] {
assert_eq!(parse::<FaceRefs>(v.clone()).is_some(), accepted, "{v}");
assert_eq!(conforms(faces, faces, &v), accepted, "faces {v}");
assert_eq!(parse::<EdgeRefs>(v.clone()).is_some(), accepted, "{v}");
assert_eq!(conforms(edges, edges, &v), accepted, "edges {v}");
}
// Names are faces, not edges; `{body, index}` is an edge, not a face.
let mixed = json!(["F1", {"body": "A", "index": 0}]);
assert!(parse::<FaceRefs>(json!(["F1"])).is_some() && parse::<EdgeRefs>(json!(["F1"])).is_none());
assert!(parse::<FaceRefs>(mixed.clone()).is_none() && !conforms(faces, faces, &mixed));
assert_eq!(parse::<EdgeRefs>(json!([{"body": "A", "index": 0}])).map(|e| e.0.len()), Some(1));
}
#[test]
fn sketch_refs_match_sketch_id() {
let schema = input_schema_for::<SketchRef>();
let schema = schema.as_value();
for v in [json!(3), json!(2.5), json!(-1), json!("Profile"), json!(""), json!(null), json!(true), json!([1]), json!({"id": 1})] {
let sketch = crate::cmd::sketch_key(Some(&v));
assert_eq!(parse::<SketchRef>(v.clone()).map(|s| s.0), sketch, "{v}");
assert_eq!(conforms(schema, schema, &v), sketch.is_some(), "{v}");
}
}
/// A contract built from the shared types, as a converted feature command's would be: the pick
/// keys refuse `null`, as their readers do.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
#[schemars(deny_unknown_fields)]
#[allow(dead_code)]
struct Probe {
radius: LengthExpr,
#[serde(default, deserialize_with = "present")]
#[schemars(with = "FaceRefs")]
faces: Option<FaceRefs>,
#[serde(default, deserialize_with = "present")]
#[schemars(with = "EdgeRefs")]
edges: Option<EdgeRefs>,
#[serde(default, deserialize_with = "present")]
#[schemars(with = "SketchRef")]
path_sketch: Option<SketchRef>,
#[serde(flatten, deserialize_with = "feature_common")]
common: FeatureCommon,
}
#[test]
fn feature_common_flattens_into_a_contract() {
let c = TypedContract::<Probe>::new();
assert_keys(input_schema_for::<Probe>(), &["body_name", "body_names", "edges", "faces", "name", "participants", "path_sketch", "radius"]);
let ok = c.check(&json!({"radius": 1, "name": "Round", "body_name": "B", "body_names": ["B", "C"], "participants": "A"}));
assert!(ok.is_empty(), "{ok:?}");
assert!(c.check(&json!({"radius": 1, "name": null, "participants": null})).is_empty(), "null is absent, as for `str_`");
let parsed: Option<Probe> = parse(json!({"radius": 1, "name": "Round", "participants": ["A", "B"]}));
assert_eq!(parsed.map(|p| p.common.participants), Some(Some(StringList(vec!["A".into(), "B".into()]))));
// Messages and paths name the key, as for the parent's own fields.
for (p, path, message) in [
(json!({"radius": 1, "name": 5}), "name", "`name` must be text"),
(json!({"radius": 1, "body_name": [1]}), "body_name", "`body_name` must be text"),
(json!({"radius": 1, "participants": 5}), "participants", "`participants` must be a name or a list of names"),
(json!({"radius": 1, "body_names": vec!["x"; 10_001]}), "body_names", "`body_names` must have at most 10000 items"),
(json!({"radius": 1, "bodyname": "x"}), "bodyname", "unknown key `bodyname`; did you mean `body_name`?"),
(json!({"radius": 1, "faces": null}), "faces", "`faces` must be a list of points or face names"),
(json!({"radius": 1, "edges": null}), "edges", "`edges` must be a list"),
(json!({"radius": 1, "path_sketch": null}), "path_sketch", "`path_sketch` must be a sketch id or name"),
(json!({"name": "x"}), "radius", "`radius` is required"),
// The flattened keys are read after the parent's own, whatever their order.
(json!({"name": 5}), "radius", "`radius` is required"),
(json!({"body_name": 5, "radius": true}), "radius", "`radius` must be a number or an expression"),
] {
let issues = c.check(&p);
assert_eq!(messages(&issues), vec![message], "{p}");
assert_eq!(issues.first().map(|i| i.path.as_str()), Some(path), "{p}");
}
}
/// A flattened `FeatureCommon`'s errors are located below wherever its parent is.
#[test]
fn feature_common_errors_keep_their_place() {
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
#[allow(dead_code)]
struct Outer {
options: Probe,
#[serde(default)]
list: Vec<Probe>,
}
let c = TypedContract::<Outer>::new();
let probe = json!({"radius": 1});
for (p, path, message) in [
(json!({"options": {"radius": 1, "name": 5}}), "options.name", "`options.name` must be text"),
(
json!({"options": probe, "list": [probe, {"radius": 1, "participants": 5}]}),
"list[1].participants",
"`list[1].participants` must be a name or a list of names",
),
(json!({"options": probe, "list": [{"radius": 1, "faces": []}]}), "list[0].faces", "select 1…1000 faces"),
] {
let issues = c.check(&p);
assert_eq!(messages(&issues), vec![message], "{p}");
assert_eq!(issues.first().map(|i| i.path.as_str()), Some(path), "{p}");
}
}
/// The pick types refuse what the handlers refuse, with the handlers' messages.
#[test]
fn shared_types_keep_the_handlers_messages() {
let c = TypedContract::<Probe>::new();
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap_or_else(|e| panic!("{e}"));
sketch_rectangle(&mut s);
for (id, p, probe) in [
("solid.shell", json!({"thickness": 1, "faces": 5}), json!({"faces": 5})),
("solid.shell", json!({"thickness": 1, "faces": null}), json!({"faces": null})),
("solid.shell", json!({"thickness": 1, "faces": []}), json!({"faces": []})),
("solid.shell", json!({"thickness": 1, "faces": [true]}), json!({"faces": [true]})),
("solid.shell", json!({"thickness": 1, "faces": [""]}), json!({"faces": [""]})),
("solid.fillet", json!({"radius": 1, "edges": {}}), json!({"edges": {}})),
("solid.fillet", json!({"radius": 1, "edges": vec![[0, 0, 0]; 1001]}), json!({"edges": vec![[0, 0, 0]; 1001]})),
("solid.fillet", json!({"radius": 1, "edges": [[1, 2]]}), json!({"edges": [[1, 2]]})),
("solid.fillet", json!({"radius": 1, "edges": [{"body": "Body1", "index": "0"}]}), json!({"edges": [{"body": "Body1", "index": "0"}]})),
("solid.sweep", json!({"path_sketch": true, "path": ["l1"]}), json!({"path_sketch": true})),
("solid.sweep", json!({"path_sketch": null, "path": ["l1"]}), json!({"path_sketch": null})),
] {
let mut probe = probe;
probe["radius"] = json!(1);
let issue = messages(&c.check(&probe)).into_iter().next().unwrap_or_default();
let today = s.execute(id, &p).err().map(|e| e.to_string());
assert_eq!(today, Some(crate::params::bad(id, issue).to_string()), "{id} {p}");
}
}
/// The lookups that follow the shape checks the pick types share with the handlers: picks
/// by `{body, index}` and sketches by name still find the design, and still fail in order, one
/// pick at a time.
#[test]
fn picks_are_still_looked_up_one_at_a_time() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap_or_else(|e| panic!("{e}"));
let error = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default();
let fillet = |edges: Value| json!({"radius": 1, "edges": edges});
assert_eq!(
error(&mut s, "solid.fillet", fillet(json!([{"body": "Nope", "index": 0}, [1, 2]]))),
"invalid parameters for `solid.fillet`: no body `Nope`"
);
assert_eq!(
error(&mut s, "solid.fillet", fillet(json!([{"body": "Body1", "index": 999}]))),
"invalid parameters for `solid.fillet`: Body1 has no edge 999"
);
let far = error(&mut s, "solid.fillet", fillet(json!([[100, 100, 100], [1, 2]])));
assert!(far.starts_with("invalid parameters for `solid.fillet`: no edge at [100, 100, 100]"), "{far}");
let unknown = error(&mut s, "solid.shell", json!({"thickness": 1, "faces": ["NoSuchFace"]}));
assert!(!unknown.is_empty());
assert_eq!(error(&mut s, "solid.shell", json!({"thickness": 1, "faces": ["NoSuchFace", true]})), unknown, "the name is looked up first");
sketch_rectangle(&mut s);
assert_eq!(
error(&mut s, "solid.sweep", json!({"path_sketch": "Nope", "path": ["l1"]})),
"invalid parameters for `solid.sweep`: no sketch `Nope`"
);
assert_eq!(error(&mut s, "solid.extrude", json!({"distance": 5, "sketch": "Nope"})), "invalid parameters for `solid.extrude`: no sketch `Nope`");
let r = s.execute("solid.fillet", &fillet(json!([{"body": "Body1", "index": 0}]))).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(r["changed_bodies"], json!(["Body1"]), "{r}");
}
+198
View File
@@ -583,3 +583,201 @@ impl JsonSchema for ProfilesParam {
})
}
}
/// A face given to a command, as `face::face_ref` reads it: a point on the face (`[x, y, z]`,
/// `{x, y, z}` or `{point}`) or its name as `model.faces` lists it (1 to 512 bytes; the
/// schema's `maxLength` counts characters, so it can't say bytes). Whether it finds a face is
/// checked by the handler, which has the design. (Not `solidcraft_doc::FacePick`, the stored
/// pick.)
#[derive(Clone, Debug, PartialEq)]
pub enum FaceRef {
Point(Vec3),
Name(String),
}
impl<'de> Deserialize<'de> for FaceRef {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match crate::cmd::face_ref_form(&Value::deserialize(d)?) {
Ok(crate::cmd::FaceRefForm::Point(p)) => Ok(FaceRef::Point(p)),
Ok(crate::cmd::FaceRefForm::Name(n)) => Ok(FaceRef::Name(n.to_string())),
Err(why) => Err(D::Error::custom(whole(why))),
}
}
}
impl JsonSchema for FaceRef {
fn schema_name() -> Cow<'static, str> {
"FaceRef".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "A face: a point in mm on it ([x, y, z], {\"x\", \"y\", \"z\"} or {\"point\": [x, y, z]}), or its name as model.faces lists it (at most 512 bytes of UTF-8).",
"anyOf": [
point,
{"type": "object", "properties": {"point": point}, "required": ["point"]},
{"type": "string", "minLength": 1, "maxLength": 512}
]
})
}
}
/// An edge given to a fillet or chamfer, as `features::edge_points` reads it: a point on the
/// edge (`[x, y, z]`, `{x, y, z}` or `{point}`), else `{body, index}` in the body's edge order.
/// The index is whole-number JSON, as `Value::as_u64` reads it: JSON Schema also counts `1.0`
/// as an integer, which the reader refuses. Whether it finds an edge is checked by the handler,
/// which has the design. (Not `solidcraft_doc::EdgeRef`, the stored edge position.)
#[derive(Clone, Debug, PartialEq)]
pub enum EdgeRef {
Point(Vec3),
Index { body: String, index: u64 },
}
impl<'de> Deserialize<'de> for EdgeRef {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match crate::cmd::edge_ref_form(&Value::deserialize(d)?) {
Ok(crate::cmd::EdgeRefForm::Point(p)) => Ok(EdgeRef::Point(p)),
Ok(crate::cmd::EdgeRefForm::Index { body, index }) => Ok(EdgeRef::Index { body: body.to_string(), index }),
Err(why) => Err(D::Error::custom(whole(why))),
}
}
}
impl JsonSchema for EdgeRef {
fn schema_name() -> Cow<'static, str> {
"EdgeRef".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "An edge: a point in mm on it ([x, y, z], {\"x\", \"y\", \"z\"} or {\"point\": [x, y, z]}), or {\"body\", \"index\"} in the body's edge order (an index written without a fraction).",
"anyOf": [
point,
{"type": "object", "properties": {"point": point}, "required": ["point"]},
{
"type": "object",
"properties": {"body": {"type": "string"}, "index": {"type": "integer", "minimum": 0, "maximum": u64::MAX}},
"required": ["body", "index"]
}
]
})
}
}
/// A list of 1 to 1000 picks, with the handler's messages for the list itself. Like the handler,
/// it refuses `null`: an optional field of this type takes `present`.
///
/// The handler looks each pick up in the design before reading the next, so a call with a pick
/// that finds nothing and a later malformed one fails today on the first and in strict on the
/// second.
macro_rules! pick_list {
($(#[$doc:meta])* $name:ident, $item:ty, $not_list:literal, $count:literal, $description:literal) => {
$(#[$doc])*
#[derive(Clone, Debug, PartialEq)]
pub struct $name(pub Vec<$item>);
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let items = v.as_array().ok_or_else(|| D::Error::custom(whole($not_list)))?;
if items.is_empty() || items.len() > 1000 {
return Err(D::Error::custom(whole($count)));
}
items.iter().map(|item| <$item>::deserialize(item).map_err(D::Error::custom)).collect::<Result<_, _>>().map($name)
}
}
impl JsonSchema for $name {
fn schema_name() -> Cow<'static, str> {
stringify!($name).into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let item = generator.subschema_for::<$item>();
json_schema!({"description": $description, "type": "array", "items": item, "minItems": 1, "maxItems": 1000})
}
}
};
}
pick_list!(
/// The `faces` of `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face` and
/// `face.delete`, as `features::face_points` reads them. (Extrude's `faces` is a
/// [`FacesParam`].)
FaceRefs,
FaceRef,
"`faces` must be a list of points or face names",
"select 1…1000 faces",
"The faces, 1 to 1000."
);
pick_list!(
/// The `edges` of `solid.fillet` and `solid.chamfer`, as `features::edge_points` reads them.
EdgeRefs,
EdgeRef,
"`edges` must be a list",
"select 1…1000 edges",
"The edges, 1 to 1000."
);
/// A sketch referred to by its feature id (a number) or its name, as `features::sketch_id` reads
/// it, with its message for a top-level key (`path_sketch`, `solid.split_face`'s `sketch`…);
/// for a nested one (a section's `sketch`) the message names the key as `sections[0].sketch`
/// where the handler says `sections[].sketch`. The `sketch` that `features::feature_sketch`
/// reads (extrude's, revolve's…) has another message and is an [`IdOrName`]. Where the reader
/// refuses `null`, an optional field of this type takes `present`. Whether a sketch has that id
/// or name is checked by the handler.
#[derive(Clone, Debug, PartialEq)]
pub struct SketchRef(pub String);
impl<'de> Deserialize<'de> for SketchRef {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
crate::cmd::sketch_key(Some(&Value::deserialize(d)?)).map(SketchRef).ok_or_else(|| D::Error::custom("must be a sketch id or name"))
}
}
impl JsonSchema for SketchRef {
fn schema_name() -> Cow<'static, str> {
"SketchRef".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "A sketch's feature id (a number) or its name.", "type": ["number", "string"]})
}
}
/// The keys `features::add_feature` reads for every feature command, whatever else it takes:
/// the feature's name, the new bodies' names and the bodies taking part. A feature command's
/// parameters include it with
///
/// ```ignore
/// #[serde(flatten, deserialize_with = "feature_common")]
/// pub common: FeatureCommon,
/// ```
///
/// Two keys `add_feature` also reads aren't here. `targets` it reads only for primitives; the
/// other commands that read it are the ones that combine bodies. `operation` it reads for every
/// feature (a cut where the active component has no bodies takes part in all of them), but only
/// the commands that combine bodies take one; those declare it with their own type
/// ([`OperationParam`] or [`ExtrudeOperation`]), and for the rest it is a caller's mistake.
#[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema)]
pub struct FeatureCommon {
/// The feature's name in the timeline.
#[serde(default)]
pub name: Option<String>,
/// The new body's name.
#[serde(default)]
pub body_name: Option<String>,
/// Names for the new bodies, in order.
#[serde(default)]
pub body_names: Option<StringList>,
/// The bodies taking part; overrides the default scope.
#[serde(default)]
pub participants: Option<StringList>,
}
/// Deserializes a flattened [`FeatureCommon`] from the keys the parent doesn't take, and reports
/// a problem as if its fields were the parent's own, with the key in the message and in the
/// issue's path (serde's flatten otherwise loses the key). serde reads a flattened field after
/// all the parent's own, so its problems come after theirs, including a missing key.
pub fn feature_common<'de, D: Deserializer<'de>>(d: D) -> Result<FeatureCommon, D::Error> {
let v = Value::deserialize(d)?;
serde_path_to_error::deserialize(&v).map_err(|e| D::Error::custom(super::check::located(&e)))
}
+51
View File
@@ -1785,6 +1785,23 @@ fn checkpoint_restores_with_a_full_undo_history() {
assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err());
}
/// Regression (#3242): `file.new` replaced the session and its checkpoints with it, so a
/// batch that failed after `file.new` could not go back to the design it began in.
#[test]
fn a_checkpoint_survives_file_new() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 1.0, "width": 1.0, "height": 1.0}));
let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap();
run(&mut s, "file.new", json!({"name": "Replacement"}));
run(&mut s, "solid.box", json!({"length": 2.0, "width": 2.0, "height": 2.0}));
assert_eq!(run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64(), Some(8.0));
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id}));
assert_eq!(run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64(), Some(1.0));
// The undo history is the restored design's own: one step back is the empty design.
run(&mut s, "edit.undo", json!({}));
assert_eq!(run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64(), Some(0.0));
}
#[test]
fn components_occurrences_and_world_placement() {
let mut s = Session::default();
@@ -3015,6 +3032,40 @@ fn shell_directions_and_tangent_chain() {
assert!(s.execute("solid.shell", &json!({"faces": [[20, 15, 20]], "thickness": 2, "direction": "sideways"})).is_err());
}
/// #209: selecting a tangent chain must explain why no shell can remain.
#[test]
fn shell_tangent_chain_that_opens_every_face_explains_how_to_keep_walls() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 60}));
run(
&mut s,
"solid.fillet",
json!({
"edges": [[0,0,30], [40,0,30], [40,30,30], [0,30,30],
[20,0,60], [20,30,60], [0,15,60], [40,15,60],
[20,0,0], [20,30,0], [0,15,0], [40,15,0]],
"radius": 1.5
}),
);
let before = s.doc.to_json();
for direction in ["inside", "outside", "both"] {
let error = s
.execute(
"solid.shell",
&json!({
"faces": [[20,0,30]], "thickness": 2, "tangent_chain": true, "direction": direction
}),
)
.unwrap_err()
.to_string();
assert!(error.contains("Tangent Chain") && error.contains("every face") && error.contains("turn off"), "{direction}: {error}");
assert_eq!(s.doc.to_json(), before, "a refused shell must preserve the design");
}
run(&mut s, "solid.shell", json!({"faces": [[20,0,30]], "thickness": 2, "tangent_chain": false}));
assert!((volume(&mut s) - 15_305.28).abs() < 0.1);
assert_eq!(s.model.state().bodies.len(), 1);
}
/// Hole To Object (a face it meets, or a point) and holes at every point of a sketch.
#[test]
fn hole_to_object_and_at_sketch_points() {
+29 -2
View File
@@ -170,8 +170,8 @@ fn wrong_up_and_names_are_refused() {
assert_eq!(kinds(&s), ["BoxFeature", "BaseFeature"]);
}
/// `names` says what the bodies are called: the solid's own name (the default), the part's, or
/// the assembly occurrence's. Repeated names are numbered as ever.
/// Explicit naming choices select the solid, part or occurrence; automatic naming keeps
/// meaningful solid names.
#[test]
fn the_bodies_are_named_after_the_solid_the_product_or_the_instance() {
let path = frame_file("names");
@@ -185,6 +185,7 @@ fn the_bodies_are_named_after_the_solid_the_product_or_the_instance() {
// The post is placed twice: one component, two occurrences, its body built once (#1386).
let solids = ["Cut-Extrude3", "Boss-Extrude1"];
assert_eq!(named(json!({"path": path})), solids);
assert_eq!(named(json!({"path": path, "names": "auto"})), solids);
assert_eq!(named(json!({"path": path, "names": "solid"})), solids);
assert_eq!(named(json!({"path": path, "names": "product"})), ["Plate", "Post"]);
assert_eq!(named(json!({"path": path, "names": "Instance"})), ["Plate-1", "Post-1"]);
@@ -228,3 +229,29 @@ fn a_step_file_can_be_inserted_at_a_matrix() {
assert!(e.contains("not rigid"), "{e}");
assert!(d.doc.features.is_empty() && d.doc.occurrences.is_empty());
}
/// Generated body labels become part names, and the saved assembly retains its components
/// and repeated placements without needing the source file.
#[test]
fn automatic_part_names_and_assembly_survive_reopening() {
let path = frame_file("automatic");
let text = std::fs::read_to_string(&path).unwrap().replace("'Cut-Extrude3'", "'Body1'").replace("'Boss-Extrude1'", "'Body 2 (3)'");
std::fs::write(&path, text).unwrap();
let mut s = Session::default();
let r = run(&mut s, "file.insert_step", json!({"path": path}));
assert_eq!(body_names(&s), ["Plate", "Post"]);
let top = r["component"].as_u64().unwrap();
let parts: Vec<_> = s.doc.components.iter().filter(|c| c.parent == top).collect();
assert_eq!(parts.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["Plate", "Post"]);
assert_eq!(s.doc.occurrences.iter().filter(|o| o.component == parts[1].id && o.parent == top).count(), 2);
let design = std::path::Path::new(&path).with_file_name("automatic.solidcraft");
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
std::fs::remove_file(&path).unwrap();
let mut again = Session::default();
run(&mut again, "doc.open", json!({"path": design.to_string_lossy()}));
assert_eq!(body_names(&again), ["Plate", "Post"]);
assert_eq!(again.doc.components, s.doc.components);
assert_eq!(again.doc.occurrences, s.doc.occurrences);
assert!(near(extent(&again).0, [0.0, 0.0, 0.0]) && near(extent(&again).1, [40.0, 34.0, 20.0]));
assert!((total_volume(&mut again) - (40.0 * 4.0 * 20.0 + 2.0 * 4.0 * 30.0 * 4.0)).abs() < 1e-6);
}
+86 -9
View File
@@ -4,6 +4,7 @@
use std::f64::consts::PI;
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use crate::Session;
@@ -92,6 +93,89 @@ fn modelled_full_length_fine_threads_on_large_shafts() {
}
}
/// A modelled thread on a shaft thicker than it turns the shaft down to the major diameter
/// over the threaded length, then cuts the thread: an M6 over the top 8 mm of an 8 mm shaft
/// leaves a 6 mm threaded section on an 8 mm stub, its turned face named for later features.
/// A shaft of the thread's own size is cut as it is.
#[test]
fn a_shaft_thicker_than_a_modelled_thread_is_turned_down_to_it() {
let (p, len) = (1.0, 8.0);
let root = 3.0 - 5.0 * p * 3f64.sqrt() / 16.0;
let groove = per_turn(root, 3.0, |r| p / 4.0 + 2.0 * (r - root) * (PI / 6.0).tan()) * len / p;
for (shaft, turned) in [(8.0, true), (6.0, false)] {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": shaft / 2.0, "height": 12, "body_name": "Shaft"}));
run(&mut s, "solid.thread", json!({"face": [shaft / 2.0, 0, 11], "designation": "M6", "length": len, "modeled": true}));
let ring = PI * (shaft * shaft / 4.0 - 9.0) * len;
let want = PI * shaft * shaft / 4.0 * 12.0 - ring - groove;
let got = volume(&mut s, "Shaft");
assert!((got - want).abs() < groove * 0.03, "M6 on {shaft} mm: {got} mm³, want {want}");
let st = s.model.state();
let Some(b) = st.body("Shaft") else { panic!("no shaft") };
assert!(b.body.validity().is_empty(), "{shaft} mm: {:?}", b.body.validity());
// Nothing past the major diameter over the threaded length; the stub keeps its size.
let round = |z: f64, r: f64| {
(0..24).filter(|i| b.body.contains(Vec3::new(r * (PI * *i as f64 / 12.0).cos(), r * (PI * *i as f64 / 12.0).sin(), z))).count()
};
assert_eq!(round(8.0, 3.05), 0, "{shaft} mm: the threaded part is turned to 6 mm");
assert_eq!(round(2.0, shaft / 2.0 - 0.05), 24, "{shaft} mm: the stub is whole");
let names = solidcraft_doc::naming::face_names(b);
let fid = s.doc.features.last().map(|f| f.id).unwrap_or(0);
let named = |role: &str| names.iter().any(|n| n.starts_with(&format!("F{fid}:{role}")));
assert_eq!(named("turned"), turned, "{shaft} mm: {names:?}");
assert_eq!(named("shoulder:"), turned, "{shaft} mm: {names:?}");
}
// A 9 mm shaft is close enough to M8 to be offered it; it was refused as too thick (#2688).
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 4.5, "height": 10, "body_name": "Shaft"}));
run(&mut s, "solid.thread", json!({"face": [4.5, 0, 5], "designation": "M8", "modeled": true}));
let root = 4.0 - 5.0 * 1.25 * 3f64.sqrt() / 16.0;
let groove = per_turn(root, 4.0, |r| 1.25 / 4.0 + 2.0 * (r - root) * (PI / 6.0).tan()) * 10.0 / 1.25;
let (want, got) = (PI * 16.0 * 10.0 - groove, volume(&mut s, "Shaft"));
assert!((got - want).abs() < groove * 0.03, "M8 on 9 mm: {got} mm³, want {want}");
}
/// Issue 2705: an M14 over the top 10 mm of a 14.6 mm boss on a plate. Turned to 14 mm first:
/// 1600 + π 7.3² 15 − π (7.3² − 7²) 10 − 5 turns of 50.189 mm³ (tests/kernel-cases/threads/2705).
#[test]
fn an_m14_thread_on_a_14_6_mm_boss_turns_it_to_14_mm() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [-10, -10, 0], "length": 20, "width": 20, "height": 4, "body_name": "Part"}));
run(&mut s, "solid.cylinder", json!({"radius": 7.3, "height": 15, "base": [0, 0, 4], "operation": "join", "targets": ["Part"]}));
run(&mut s, "solid.thread", json!({"face": [7.3, 0, 17], "designation": "M14", "length": 10, "modeled": true}));
let got = volume(&mut s, "Part");
assert!((got - 3725.5127).abs() < 3725.5127 * 0.01, "{got} mm³");
}
/// A shaft thinner than the thread's minor diameter is still refused, and so is a cosmetic
/// thread on a shaft thicker than it (a note changes nothing to turn). A shaft the part goes on
/// round (standing against a wall) is not turned into the wall.
#[test]
fn a_thin_shaft_or_one_the_part_surrounds_is_refused() {
let refused = |s: &mut Session, body: &str, p: Value| {
let before = volume(s, body);
let e = s.execute("solid.thread", &p).err().map(|e| e.to_string()).unwrap_or_default();
assert!((volume(s, body) - before).abs() < 1e-9, "{p}: {e}");
e
};
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 2.4, "height": 10, "body_name": "Shaft"}));
let e = refused(&mut s, "Shaft", json!({"face": [2.4, 0, 5], "designation": "M6", "modeled": true}));
assert!(e.contains("does not fit a 4.80 mm shaft") || e.contains("too thin"), "{e}");
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 4, "height": 10, "body_name": "Shaft"}));
let e = refused(&mut s, "Shaft", json!({"face": [4, 0, 5], "designation": "M6"}));
assert!(e.contains("an M6 thread does not fit a 8.00 mm shaft"), "{e}");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [2, -10, 0], "length": 10, "width": 20, "height": 10, "body_name": "Part"}));
run(&mut s, "solid.cylinder", json!({"radius": 4, "height": 10, "operation": "join", "targets": ["Part"]}));
let e = refused(&mut s, "Part", json!({"face": [-4, 0, 5], "designation": "M6", "modeled": true}));
assert!(e.contains("this 8 mm shaft is too thick for a modelled M6 thread") && e.contains("part around it"), "{e}");
}
#[test]
fn non_finite_thread_designations_are_refused_without_changing_the_design() {
let mut s = Session::default();
@@ -174,9 +258,9 @@ fn a_modelled_thread_that_would_cut_nothing_is_refused() {
/// Issue 2688: an M8 modelled thread in a 6 mm hole (narrower than its 6.65 mm minor diameter)
/// was refused as "the face is too far from the thread's diameter". It says what is wrong and
/// what to drill, as a hole that is too wide does; and the same for a shaft that is too thick.
/// what to drill, as a hole that is too wide does. (A shaft too thick is turned down.)
#[test]
fn a_hole_too_narrow_or_a_shaft_too_thick_says_which_and_what_fits() {
fn a_hole_too_narrow_says_so_and_what_fits() {
let mut s = hex_nut(6.0);
let before = volume(&mut s, "Nut");
let e = s.execute("solid.thread", &json!({"face": [3.0, 0, 2], "designation": "M8", "modeled": true})).err().map(|e| e.to_string());
@@ -184,13 +268,6 @@ fn a_hole_too_narrow_or_a_shaft_too_thick_says_which_and_what_fits() {
assert!(e.contains("this 6 mm hole is too narrow for a modelled M8 thread") && e.contains("drill 6.75 mm for M8"), "{e}");
assert!(!e.contains("too far"), "{e}");
assert!((volume(&mut s, "Nut") - before).abs() < 1e-9);
let mut s = Session::default();
// 9 mm is close enough to M8 to be offered it, too thick to cut it.
run(&mut s, "solid.cylinder", json!({"radius": 4.5, "height": 10, "body_name": "Shaft"}));
let e = s.execute("solid.thread", &json!({"face": [4.5, 0, 5], "designation": "M8", "modeled": true})).err().map(|e| e.to_string());
let e = e.unwrap_or_default();
assert!(e.contains("this 9 mm shaft is too thick for a modelled M8 thread") && e.contains("make the shaft 8 mm for M8"), "{e}");
}
/// The limit is a quarter of the basic depth 5H/8: a hole up to 4.78 mm for M5 (4.86 mm for
+13 -1
View File
@@ -8,6 +8,9 @@ pub mod envelope;
pub const VERSION: u32 = 3;
pub const MAX_ITEMS: usize = 1_000_000;
const MAX_DEPTH: usize = 16;
/// Most control points along one direction of a spline (a helical thread flank written as one
/// spline has thousands along the helix); the item budget bounds a surface's grid as a whole.
pub const MAX_SPLINE_CONTROLS: usize = 8192;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -195,7 +198,7 @@ fn knots(values: &[f64], count: usize, budget: &mut Budget) -> Result<()> {
budget.add(values.len())?;
numbers(values.iter().copied())?;
let degree = values.len().checked_sub(count.saturating_add(1)).ok_or(Invalid("spline degree"))?;
require((2..=4096).contains(&count) && (1..=16).contains(&degree) && degree < count, "spline degree or size")?;
require((2..=MAX_SPLINE_CONTROLS).contains(&count) && (1..=16).contains(&degree) && degree < count, "spline degree or size")?;
require(values.windows(2).all(|w| w.first() <= w.get(1)), "unordered knots")?;
require(values.get(degree) < values.get(count), "empty spline domain")?;
// No knot may have multiplicity greater than degree + 1.
@@ -482,6 +485,15 @@ mod tests {
assert!(surface(&p, 0, &mut Budget(0)).is_err());
assert!(curve(&Curve::Line { start: [0.; 3], end: [1.; 3] }, MAX_DEPTH + 1, &mut Budget(0)).is_err());
assert!(Budget(MAX_ITEMS).add(1).is_err());
// Control points along one direction: up to the cap, not one more (a clamped cubic).
let clamped = |count: usize| {
let mut k = vec![0.0; 4];
k.extend((1..count - 3).map(|i| i as f64));
k.extend([(count - 3) as f64; 4]);
k
};
assert!(knots(&clamped(MAX_SPLINE_CONTROLS), MAX_SPLINE_CONTROLS, &mut Budget(0)).is_ok());
assert!(knots(&clamped(MAX_SPLINE_CONTROLS + 1), MAX_SPLINE_CONTROLS + 1, &mut Budget(0)).is_err());
}
#[test]
+33 -6
View File
@@ -36,7 +36,10 @@ pub enum EvalSurface {
/// `origin + u·du + v·dv`.
Plane { origin: Vec3, du: Vec3, dv: Vec3 },
/// A rational tensor B-spline; control point `(i, j)` is `ctrl[j·count[0] + i]`.
Nurbs { degree: [usize; 2], knots: [Vec<f64>; 2], count: [usize; 2], ctrl: Vec<[f64; 4]> },
/// `closed[d]`: the surface joins itself along direction `d` (a B-spline tube's way round).
/// Such a direction is periodic: a parameter outside the knot range is moved by whole
/// ranges back into it, so a face whose boundary runs across the join still evaluates.
Nurbs { degree: [usize; 2], knots: [Vec<f64>; 2], count: [usize; 2], ctrl: Vec<[f64; 4]>, closed: [bool; 2] },
/// `profile(u)` turned by the angle `v` (right-handed) about the axis through `origin`
/// along the unit `axis`, then mapped by the affine `transform` (row-major 3×4); with
/// `swap` the parameters are exchanged first (the surface is evaluated at `(v, u)`).
@@ -128,7 +131,7 @@ impl TessPlan {
.iter()
.map(|s| match s {
EvalSurface::Plane { origin, du, dv } => EvalSurface::Plane { origin: point(*origin), du: vector(*du), dv: vector(*dv) },
EvalSurface::Nurbs { degree, knots, count, ctrl } => EvalSurface::Nurbs {
EvalSurface::Nurbs { degree, knots, count, ctrl, closed } => EvalSurface::Nurbs {
degree: *degree,
knots: knots.clone(),
count: *count,
@@ -140,6 +143,9 @@ impl TessPlan {
[q.x, q.y, q.z, c[3]]
})
.collect(),
// Which directions the surface joins itself along is a property of its
// parameters: moving the surface does not change it.
closed: *closed,
},
EvalSurface::Revolved { profile, origin, axis, transform, swap } => {
// `m` (as rows) after the surface's own affine map.
@@ -235,7 +241,20 @@ impl EvalSurface {
}
match self {
EvalSurface::Plane { origin, du, dv } => Some((*origin + *du * u + *dv * v, *du, *dv)),
EvalSurface::Nurbs { degree, knots, count, ctrl } => nurbs_derivs(*degree, knots, *count, ctrl, u, v),
EvalSurface::Nurbs { degree, knots, count, ctrl, closed } => {
// The knot range of direction `d`: the parameters the surface is defined over.
let range = |d: usize| -> Option<(f64, f64)> {
let k = knots.get(d)?;
Some((*k.get(*degree.get(d)?)?, *k.get(*count.get(d)?)?))
};
// A direction the surface joins itself along repeats, so a parameter outside the
// range is moved by whole ranges back into it. Any other direction is left alone.
let wrap = |d: usize, t: f64| match range(d) {
Some((a, b)) if closed.get(d).copied().unwrap_or(false) && b > a && !(a..=b).contains(&t) => a + (t - a).rem_euclid(b - a),
_ => t,
};
nurbs_derivs(*degree, knots, *count, ctrl, wrap(0, u), wrap(1, v))
}
EvalSurface::Revolved { profile, origin, axis, transform, swap } => {
let (s, a) = if *swap { (v, u) } else { (u, v) };
let (c, dc) = profile.eval(s)?;
@@ -390,6 +409,7 @@ mod tests {
knots: [vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]],
count: [3, 2],
ctrl: vec![[1.0, 0.0, 0.0, 1.0], [w, w, 0.0, w], [0.0, 1.0, 0.0, 1.0], [1.0, 0.0, 2.0, 1.0], [w, w, 2.0 * w, w], [0.0, 1.0, 2.0, 1.0]],
closed: [false, false],
};
let revolved = EvalSurface::Revolved {
profile: line(Vec3::new(2.0, 0.0, 0.0), Vec3::new(3.0, 0.0, 1.0)),
@@ -511,7 +531,13 @@ mod tests {
fn bilinear_nurbs_patch_and_derivatives() {
// Degree (1, 1) over [0, 2] × [0, 1] with corners lifted: a hyperbolic paraboloid.
let ctrl = vec![[0.0, 0.0, 0.0, 1.0], [2.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [2.0, 1.0, 1.0, 1.0]];
let s = EvalSurface::Nurbs { degree: [1, 1], knots: [vec![0.0, 0.0, 2.0, 2.0], vec![0.0, 0.0, 1.0, 1.0]], count: [2, 2], ctrl };
let s = EvalSurface::Nurbs {
degree: [1, 1],
knots: [vec![0.0, 0.0, 2.0, 2.0], vec![0.0, 0.0, 1.0, 1.0]],
count: [2, 2],
ctrl,
closed: [false, false],
};
let Some((p, du, dv)) = s.derivs(1.0, 0.5) else { panic!("evaluates") };
assert!(close(p, Vec3::new(1.0, 0.5, 0.25), 1e-12), "{p:?}");
assert!(close(du, Vec3::new(1.0, 0.0, 0.25), 1e-12), "{du:?}");
@@ -533,7 +559,7 @@ mod tests {
}
let Ok(reference) = crate::bspline::NurbsSurface::new([3, 2], knots.clone(), &pts, &wts) else { panic!("valid surface") };
let ctrl = reference.homogeneous().iter().map(|h| [h.x, h.y, h.z, h.w]).collect();
let s = EvalSurface::Nurbs { degree: [3, 2], knots, count: [nu, nv], ctrl };
let s = EvalSurface::Nurbs { degree: [3, 2], knots, count: [nu, nv], ctrl, closed: [false, false] };
for i in 0..=8 {
for j in 0..=8 {
let (u, v) = (i as f64 / 8.0, j as f64 / 4.0);
@@ -557,12 +583,13 @@ mod tests {
#[test]
fn hostile_surfaces_do_not_panic() {
let bad = [
EvalSurface::Nurbs { degree: [9, 1], knots: [vec![0.0; 3], vec![0.0; 3]], count: [1, 1], ctrl: vec![] },
EvalSurface::Nurbs { degree: [9, 1], knots: [vec![0.0; 3], vec![0.0; 3]], count: [1, 1], ctrl: vec![], closed: [false, false] },
EvalSurface::Nurbs {
degree: [1, 1],
knots: [vec![0.0, 0.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]],
count: [2, 2],
ctrl: vec![[0.0; 4]; 4],
closed: [false, false],
},
EvalSurface::Revolved {
profile: EvalCurve { degree: 0, knots: vec![], ctrl: vec![] },
+2 -1
View File
@@ -466,8 +466,9 @@ fn pack(plans: &[&TessPlan]) -> Result<Packed> {
push3(&mut data, *du);
push3(&mut data, *dv);
}
EvalSurface::Nurbs { degree, knots, count, ctrl } => {
EvalSurface::Nurbs { degree, knots, count, ctrl, closed } => {
head[0] = 1;
head[11] = u32::from(closed[0]) | (u32::from(closed[1]) << 1);
head[1] = word(degree[0])?;
head[2] = word(degree[1])?;
head[3] = word(count[0])?;
+15 -2
View File
@@ -27,7 +27,8 @@ struct Surface {
// Revolved: parameters exchanged; mirror transform (normal reversed).
swap: u32,
mirror: u32,
_pad: u32,
// NURBS: bit 0 the surface joins itself along u, bit 1 along v (the direction is periodic).
closed: u32,
};
struct Vertex {
@@ -159,8 +160,20 @@ fn rational_der(a: vec4<f32>, da: vec4<f32>) -> vec3<f32> {
return (da.xyz - rational_point(a) * da.w) / a.w;
}
fn nurbs(s: Surface, u: f32, v: f32) -> Derivs {
// A parameter outside the knot range of a direction the surface joins itself along, moved by
// whole ranges back into it (the CPU evaluator's `EvalSurface::derivs` does the same).
fn wrap_closed(knots: u32, p: u32, n: u32, t: f32, closed: bool) -> f32 {
if (!closed) { return t; }
let a = data[knots + p];
let b = data[knots + n];
if (!(b > a) || (t >= a && t <= b)) { return t; }
return a + (t - a) - floor((t - a) / (b - a)) * (b - a);
}
fn nurbs(s: Surface, u_in: f32, v_in: f32) -> Derivs {
var r: Derivs;
let u = wrap_closed(s.knots_u, s.pu, s.nu, u_in, (s.closed & 1u) != 0u);
let v = wrap_closed(s.knots_v, s.pv, s.nv, v_in, (s.closed & 2u) != 0u);
let su = span_of(s.knots_u, s.pu, s.nu, u);
let sv = span_of(s.knots_v, s.pv, s.nv, v);
let bu0 = basis_funs(s.knots_u, su, s.pu, u);
+216 -35
View File
@@ -20,7 +20,15 @@ const VERSION: &str = "solidcraft-container/1";
const LEGACY_VERSION: &str = "solvecraft-container/1";
const PREVIEW_VERSION: &str = "solidcraft-preview/1";
const MAX_ENTRIES: usize = 10_000;
const MAX_EXPANDED: usize = 512 << 20;
/// An embedded STEP source may be as large as an import accepts. It is always stored
/// Deflate-compressed, so the file itself stays within [`MAX_DESIGN_BYTES`] (#3221).
const MAX_STEP_PART: usize = crate::MAX_STEP_BYTES;
/// Deflate level for STEP sources. Level 1 uses fixed Huffman codes: a 270 MiB STEP export
/// shrank to 70 MB in 1.6 s, at level 2 to 47 MB in 3.1 s; higher levels gain little more.
const STEP_LEVEL: i64 = 2;
/// Every other part is bounded by the file size limit.
const MAX_PART: usize = MAX_DESIGN_BYTES;
const MAX_EXPANDED: usize = MAX_STEP_PART + MAX_DESIGN_BYTES;
const MESH_MAGIC: &[u8; 8] = b"SCMESH01";
const MAX_PREVIEW_BYTES: usize = 8 << 20;
const MAX_PREVIEW_TRIANGLES: usize = 100_000;
@@ -65,7 +73,7 @@ struct PackageWriter {
impl PackageWriter {
fn part(&mut self, name: &str, bytes: &[u8], compressed: bool) -> Result<()> {
if bytes.len() > MAX_DESIGN_BYTES {
if bytes.len() > if name.ends_with(".step") { MAX_STEP_PART } else { MAX_PART } {
return Err(invalid("part too large"));
}
if !self.names.insert(name.to_owned()) {
@@ -76,10 +84,21 @@ impl PackageWriter {
return Err(invalid("package too large"));
}
let method = if compressed { zip::CompressionMethod::Deflated } else { zip::CompressionMethod::Stored };
let options = zip::write::SimpleFileOptions::default().compression_method(method).compression_level(compressed.then_some(1));
let level = if name.ends_with(".step") { STEP_LEVEL } else { 1 };
let options = zip::write::SimpleFileOptions::default().compression_method(method).compression_level(compressed.then_some(level));
self.zip.start_file(name, options).map_err(invalid)?;
self.zip.write_all(bytes).map_err(invalid)
}
/// Would `entries` more optional parts of `extra` bytes, stored uncompressed, still keep
/// the file within `budget` and its expanded size within the reader's budget?
fn room(&self, entries: usize, extra: usize, budget: usize) -> bool {
// The bytes written so far, plus what the open compressed part may still hold back.
let written = self.zip.get_ref().map_or(usize::MAX, |c| c.get_ref().len()).saturating_add(1 << 20);
let directory = self.names.len().saturating_add(entries).saturating_mul(256).saturating_add(65_536);
self.names.len().saturating_add(entries) <= MAX_ENTRIES
&& written.saturating_add(extra).saturating_add(directory) <= budget
&& self.expanded.saturating_add(extra) <= MAX_EXPANDED
}
fn blob(&mut self, bytes: &[u8], extension: &str, compressed: bool) -> Result<Value> {
self.resolved =
self.resolved.checked_add(bytes.len()).filter(|&n| n <= MAX_EXPANDED).ok_or_else(|| invalid("resolved payloads too large"))?;
@@ -89,9 +108,70 @@ impl PackageWriter {
}
}
/// Counts what is written without keeping it.
#[derive(Default)]
struct Measure {
position: u64,
length: u64,
}
impl Write for Measure {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.position = self.position.saturating_add(bytes.len() as u64);
self.length = self.length.max(self.position);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Seek for Measure {
fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
let target = match position {
SeekFrom::Start(n) => Some(n),
SeekFrom::End(n) => self.length.checked_add_signed(n),
SeekFrom::Current(n) => self.position.checked_add_signed(n),
};
self.position = target.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "seek out of range"))?;
Ok(self.position)
}
}
/// Room kept in a design file for everything besides one large embedded STEP source.
const STEP_RESERVE: usize = 4 << 20;
/// Can an import's STEP text be saved in a design? It must be no larger than an import
/// accepts, and, compressed as a save stores it, leave room in a design file for the rest of
/// the design. Checked when the file is imported, so it never imports and then fails to save.
pub fn check_step_storable(step: &str) -> Result<()> {
let limit = MAX_DESIGN_BYTES - STEP_RESERVE;
if step.len() > MAX_STEP_PART {
return Err(IoError::Invalid(format!("STEP file too large ({} MB, limit {} MB)", step.len() >> 20, MAX_STEP_PART >> 20)));
}
// Deflate adds at most a few bytes per 16 KiB block: smaller text always fits.
if step.len() <= limit - (limit >> 10) {
return Ok(());
}
let mut zip = zip::ZipWriter::new(Measure::default());
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated).compression_level(Some(STEP_LEVEL));
zip.start_file("blobs/source.step", options).map_err(invalid)?;
zip.write_all(step.as_bytes()).map_err(invalid)?;
let compressed = usize::try_from(zip.finish().map_err(invalid)?.length).unwrap_or(usize::MAX);
if compressed > limit {
return Err(IoError::Invalid(format!(
"STEP file too large to save in a design: {} MB of STEP text compresses to {} MB, over the {} MB a design file may hold",
step.len() >> 20,
compressed >> 20,
limit >> 20
)));
}
Ok(())
}
/// Write a compact container, deduplicating payloads by SHA-256. Mesh arrays are
/// stored directly for low save/open latency; small metadata uses level-one
/// Deflate. Use the explicit Compact option to compress STEP and meshes too.
/// stored directly for low save/open latency; STEP sources and small metadata use
/// fast Deflate. Use the explicit Compact option to compress meshes too.
pub fn write_design_container(doc: &Document) -> Result<Vec<u8>> {
write_design_container_with_compression(doc, DesignCompression::Fast)
}
@@ -145,7 +225,9 @@ fn write_container(
for feature in &doc.features {
let replacements = match &feature.kind {
FeatureKind::Import { step, .. } => {
let blob = writer.blob(step.as_bytes(), "step", compression == DesignCompression::Compact)?;
// STEP is text and shrinks several times: always compressed, so an import as large
// as MAX_STEP_BYTES still fits in a design file.
let blob = writer.blob(step.as_bytes(), "step", true)?;
fields([("step", Value::String(String::new())), ("step_blob", blob)])
}
FeatureKind::MeshImport { meshes, .. } => {
@@ -190,14 +272,7 @@ fn write_container(
// The model cache is optional: one that does not fit the container is left out, never a
// failed save.
let cache = geometry.and_then(|g| native_cache::encode_model(g, &metadata).ok()).filter(|(manifest, data)| {
let fits = writer.names.len().saturating_add(2) <= MAX_ENTRIES
&& writer
.expanded
.saturating_add(manifest.len())
.saturating_add(data.len())
.saturating_add(writer.names.len().saturating_mul(256))
.saturating_add(65_536)
<= budget;
let fits = writer.room(2, manifest.len().saturating_add(data.len()), budget);
if !fits {
log::info!("the model cache ({} MB) does not fit the design file: saved without it", (manifest.len() + data.len()) >> 20);
}
@@ -211,14 +286,7 @@ fn write_container(
writer.part(payload_entry, &data, true)?;
}
if let Some((manifest, data)) = preview.and_then(|p| encode_preview(p, &metadata).ok())
&& writer.names.len().saturating_add(2) <= MAX_ENTRIES
&& writer
.expanded
.saturating_add(manifest.len())
.saturating_add(data.len())
.saturating_add(writer.names.len().saturating_mul(256))
.saturating_add(65_536)
<= budget
&& writer.room(2, manifest.len().saturating_add(data.len()), budget)
{
writer.part("cache/preview.json", &manifest, false)?;
writer.part("cache/preview.bin", &data, false)?;
@@ -228,7 +296,7 @@ fn write_container(
}
let bytes = writer.zip.finish().map_err(invalid)?.into_inner();
if bytes.len() > MAX_DESIGN_BYTES {
return Err(invalid("compressed file too large"));
return Err(invalid(format!("the design would be {} MB, over the {} MB a design file may hold", bytes.len() >> 20, MAX_DESIGN_BYTES >> 20)));
}
Ok(bytes)
}
@@ -345,7 +413,8 @@ impl<R: Read + Seek> PackageReader<R> {
return Err(invalid("duplicate or invalid archive entry"));
}
total = total.checked_add(part.size()).ok_or_else(|| invalid("expanded size overflow"))?;
if part.size() > MAX_DESIGN_BYTES as u64 || total > MAX_EXPANDED as u64 {
let limit = if name.ends_with(".step") { MAX_STEP_PART } else { MAX_PART };
if part.size() > limit as u64 || total > MAX_EXPANDED as u64 {
return Err(invalid("expanded package too large"));
}
}
@@ -360,7 +429,7 @@ impl<R: Read + Seek> PackageReader<R> {
self.document.as_ref().filter(|d| d.len() <= limit).cloned().ok_or_else(|| invalid("metadata budget"))
}
fn part(&mut self, name: &str) -> Result<Vec<u8>> {
self.limited_part(name, MAX_DESIGN_BYTES)
self.limited_part(name, if name.ends_with(".step") { MAX_STEP_PART } else { MAX_PART })
}
fn limited_part(&mut self, name: &str, limit: usize) -> Result<Vec<u8>> {
let part = self.zip.by_name(name).map_err(|e| invalid(format!("required part `{name}`: {e}")))?;
@@ -369,7 +438,9 @@ impl<R: Read + Seek> PackageReader<R> {
return Err(invalid("part too large"));
}
let mut bytes = Vec::with_capacity(expected as usize);
part.take(limit as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
// A compressed part may expand beyond its declared size (a decompression bomb): stop
// one byte past it instead of at the limit.
part.take(expected + 1).read_to_end(&mut bytes).map_err(invalid)?;
if bytes.len() > limit || bytes.len() as u64 != expected {
return Err(invalid("part too large or length mismatch"));
}
@@ -676,7 +747,7 @@ mod tests {
#[test]
fn seekable_preview_defers_large_source_blob_until_document_read() {
let mut doc = Document::new("Deferred source");
let step = "ISO-10303-21;".repeat(200_000);
let step = noise(2_600_000);
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new(), skip: Vec::new() }, None)
.unwrap();
let preview = DesignPreview { meshes: vec![mesh()] };
@@ -685,12 +756,12 @@ mod tests {
let source = CountingReader { source: Cursor::new(bytes), count: count.clone() };
let mut reader = DesignContainerReader::new(source).unwrap();
assert_eq!(reader.preview().unwrap().meshes[0].positions, preview.meshes[0].positions);
// ZIP directory scans may touch a bounded suffix of a stored blob. They
// ZIP directory scans may touch a bounded suffix of a source blob. They
// must not load its multi-megabyte payload to obtain the saved picture.
assert!(count.get() < 256 * 1024, "read {} bytes before display", count.get());
let restored = reader.read_document().unwrap();
assert_eq!(restored.to_json(), doc.to_json());
assert!(count.get() >= step.len());
assert!(count.get() >= step.len() / 4);
}
#[test]
@@ -698,9 +769,11 @@ mod tests {
let doc = document();
let preview = DesignPreview { meshes: vec![mesh()] };
let mut bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
let marker = b"ISO-10303-21;";
let offset = bytes.windows(marker.len()).position(|part| part == marker).unwrap();
bytes[offset] ^= 1;
// The first part written is the (compressed) STEP source: damage its data.
let name = u16::from_le_bytes([bytes[26], bytes[27]]) as usize;
let extra = u16::from_le_bytes([bytes[28], bytes[29]]) as usize;
assert!(bytes[30..30 + name].ends_with(b".step"));
bytes[30 + name + extra + 2] ^= 0x55;
let mut reader = DesignContainerReader::new(Cursor::new(bytes)).unwrap();
assert!(reader.preview().is_some());
assert!(reader.read_document().is_err());
@@ -752,7 +825,7 @@ mod tests {
#[test]
fn native_cache_round_trip_and_preview_defer_original_sources() {
let mut doc = Document::new("Native cache");
let step = "ISO-10303-21;".repeat(200_000);
let step = noise(2_600_000);
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new(), skip: Vec::new() }, None)
.unwrap();
let bodies = native_geometry();
@@ -767,7 +840,7 @@ mod tests {
assert!(!reader.preview().unwrap().meshes.is_empty());
assert!(count.get() < 256 * 1024, "read {} bytes before native display", count.get());
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
assert!(count.get() >= step.len());
assert!(count.get() >= step.len() / 4);
}
#[test]
@@ -1159,7 +1232,7 @@ mod tests {
assert!(crate::read_design(&duplicate).is_err());
let mut bomb = bytes.clone();
let central = bomb.windows(4).position(|v| v == b"PK\x01\x02").unwrap();
bomb[central + 24..central + 28].copy_from_slice(&((MAX_DESIGN_BYTES + 1) as u32).to_le_bytes());
bomb[central + 24..central + 28].copy_from_slice(&((MAX_STEP_PART + 1) as u32).to_le_bytes());
assert!(crate::read_design(&bomb).unwrap_err().to_string().contains("expanded package too large"));
let mut excessive = bytes;
let end = excessive.windows(4).rposition(|v| v == b"PK\x05\x06").unwrap();
@@ -1167,6 +1240,114 @@ mod tests {
assert!(crate::read_design(&excessive).is_err());
}
/// Printable text that compresses poorly (93 characters: about 6.5 bits each). No `*`, so
/// it never ends a STEP comment.
fn noise(length: usize) -> String {
let mut state = 0x2545_f491_4f6c_dd1du64;
(0..length)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
let c = b'!' + ((state >> 32) % 93) as u8;
char::from(if c >= b'*' { c + 1 } else { c })
})
.collect()
}
/// A valid STEP cube, padded with a comment to `length` bytes. `noisy` padding barely
/// compresses; otherwise it is repetitive like the text of a real large export.
fn padded_step(length: usize, noisy: bool) -> String {
use solidcraft_geom::Vec3;
let cube = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
let step = solidcraft_kernel::step_export(&[&cube], "padded").unwrap();
let (head, tail) = step.split_at(step.find("DATA;").unwrap() + 5);
let end = length - tail.len() - 2;
let mut out = String::with_capacity(length);
out.push_str(head);
out.push_str("\n/*");
if noisy {
out.push_str(&noise(end - out.len()));
} else {
let mut line = 0u64;
while out.len() + 64 < end {
line += 1;
out.push_str(&format!("#{line}=CARTESIAN_POINT('',({}.,{}.5,-{}.25));\n", line % 997, line % 89, line % 13));
}
out.extend(std::iter::repeat_n(' ', end - out.len()));
}
out.push_str("*/");
out.push_str(tail);
assert_eq!(out.len(), length);
out
}
/// #3221: a STEP file larger than a design part used to import, then fail to save.
#[test]
fn step_over_the_part_limit_imports_saves_compressed_and_reopens() {
let step = padded_step(MAX_DESIGN_BYTES + (1 << 20), false);
let imported = crate::step_import_feature(step.as_bytes(), "large.step").unwrap();
drop(step);
let mut doc = Document::new("Large import");
doc.add_feature(imported.kind, None).unwrap();
let bytes = write_design_container(&doc).unwrap();
assert!(bytes.len() < MAX_DESIGN_BYTES / 2, "saved {} MB", bytes.len() >> 20);
let restored = crate::read_design(&bytes).unwrap();
let (Some(FeatureKind::Import { step: before, .. }), Some(FeatureKind::Import { step: after, .. })) =
(doc.features.first().map(|f| &f.kind), restored.features.first().map(|f| &f.kind))
else {
panic!("the reopened design lost its import");
};
assert!(before.len() > MAX_DESIGN_BYTES && before == after, "the reopened STEP text differs");
let reopened = crate::step_import_feature(after.as_bytes(), "large.step").unwrap();
assert_eq!(reopened.body_names, imported.body_names);
assert_eq!(reopened.import.bodies.len(), 1);
}
#[test]
fn step_that_cannot_be_saved_even_compressed_is_refused_at_import() {
let step = padded_step(MAX_DESIGN_BYTES + (80 << 20), true);
let message = crate::step_import_feature(step.as_bytes(), "noisy.step").err().unwrap().to_string();
assert!(message.contains("too large to save in a design"), "{message}");
}
#[test]
fn designs_with_uncompressed_step_sources_still_open() {
// Saves before #3221 stored the STEP source without compression.
let doc = document();
let entries = parts(&write_design_container(&doc).unwrap());
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, bytes) in &entries {
let method = if name.ends_with(".step") { zip::CompressionMethod::Stored } else { zip::CompressionMethod::Deflated };
zip.start_file(name, zip::write::SimpleFileOptions::default().compression_method(method)).unwrap();
zip.write_all(bytes).unwrap();
}
let old = zip.finish().unwrap().into_inner();
assert!(old.windows(13).any(|v| v == b"ISO-10303-21;"));
assert_eq!(crate::read_design(&old).unwrap(), doc);
}
#[test]
fn decompression_bomb_is_refused_without_expanding_it() {
// 64 MiB that deflate to a few hundred kilobytes, declared as a 1 KiB STEP source.
let mut doc = Document::new("Bomb");
let step = format!("ISO-10303-21;{}", " ".repeat(64 << 20));
doc.add_feature(FeatureKind::Import { file: "bomb.step".into(), step: step.into(), components: Vec::new(), skip: Vec::new() }, None).unwrap();
let honest = write_design_container(&doc).unwrap();
assert!(honest.len() < 1 << 20);
let central = honest.windows(4).position(|v| v == b"PK\x01\x02").unwrap();
let mut bomb = honest.clone();
// The STEP source is the first part: its local header is at 0.
bomb[22..26].copy_from_slice(&1024u32.to_le_bytes());
bomb[central + 24..central + 28].copy_from_slice(&1024u32.to_le_bytes());
let message = crate::read_design(&bomb).unwrap_err().to_string();
assert!(message.contains("declared uncompressed size") || message.contains("length mismatch"), "{message}");
// An honest declaration over the limit is refused before the part is read at all.
let mut over = honest;
over[central + 24..central + 28].copy_from_slice(&((MAX_STEP_PART + 1) as u32).to_le_bytes());
assert!(crate::read_design(&over).unwrap_err().to_string().contains("expanded package too large"));
}
#[test]
fn rejects_invalid_references_and_repeated_payload_expansion() {
let bytes = write_design_container(&document()).unwrap();
+1 -9
View File
@@ -17,15 +17,7 @@ pub(super) fn write(writer: &mut PackageWriter, png: &[u8], document: &[u8]) ->
}
let key = serde_json::to_vec(&json!({"format": FORMAT, "app": env!("CARGO_PKG_VERSION"), "document": digest(document), "payload": digest(png)}))
.map_err(invalid)?;
if writer.names.len().saturating_add(2) > MAX_ENTRIES
|| writer
.expanded
.saturating_add(key.len())
.saturating_add(png.len())
.saturating_add(writer.names.len().saturating_mul(256))
.saturating_add(65_536)
> MAX_DESIGN_BYTES
{
if !writer.room(2, key.len().saturating_add(png.len()), MAX_DESIGN_BYTES) {
return Ok(());
}
writer.part("cache/thumbnail.json", &key, false)?;
-4
View File
@@ -542,8 +542,6 @@ mod tests {
/// self-inserts fan out to 6^16 visits for no output. Both shapes must be refused quickly.
#[test]
fn empty_blocks_do_not_fan_out() {
let t = std::time::Instant::now();
let fanout = format!(
"0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nA\n{}0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nINSERT\n2\nA\n0\nENDSEC\n0\nEOF\n",
"0\nINSERT\n2\nA\n".repeat(6)
@@ -557,7 +555,5 @@ mod tests {
);
let g = read_dxf(&array).unwrap_or_default();
assert!(g.is_empty(), "an empty block has no geometry to place");
assert!(t.elapsed() < std::time::Duration::from_secs(5), "took {:?}", t.elapsed());
}
}
+33 -12
View File
@@ -101,8 +101,10 @@ pub fn is_iges_path(path: &str) -> bool {
/// nothing), so the caller chooses.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum StepNames {
/// The solid's own name in the file, else the name of the product that holds it.
/// Keep meaningful solid names; replace placeholders with the nearest named product.
#[default]
Auto,
/// The solid's own name in the file, else the name of the product that holds it.
Solid,
/// The name of the product (part) that holds the body, else the solid's name.
Product,
@@ -112,9 +114,10 @@ pub enum StepNames {
}
impl StepNames {
/// `solid`, `product` or `instance` (any case); `None` for anything else.
/// `auto`, `solid`, `product` or `instance` (any case); `None` for anything else.
pub fn parse(s: &str) -> Option<StepNames> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Some(StepNames::Auto),
"solid" => Some(StepNames::Solid),
"product" => Some(StepNames::Product),
"instance" => Some(StepNames::Instance),
@@ -126,7 +129,16 @@ impl StepNames {
/// A name that says nothing about its body: empty, or the placeholder a file or the importer
/// gives to something it has no name for.
fn generic_name(n: &str) -> bool {
n.trim().is_empty() || n.starts_with('(') || n.eq_ignore_ascii_case("import") || n.eq_ignore_ascii_case("unnamed")
let n = n.trim();
n.is_empty()
|| n.starts_with('(')
|| n.eq_ignore_ascii_case("import")
|| n.eq_ignore_ascii_case("unnamed")
|| ["body", "solid", "shell"].iter().any(|prefix| {
n.get(..prefix.len()).is_some_and(|s| s.eq_ignore_ascii_case(prefix))
&& n.get(prefix.len()..)
.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit() || c.is_ascii_whitespace() || matches!(c, '_' | '-' | '(' | ')')))
})
}
/// The name an imported body gets under the `names` choice; the solid's name when the chosen
@@ -134,6 +146,8 @@ fn generic_name(n: &str) -> bool {
fn body_name(b: &solidcraft_kernel::ImportedBody, names: StepNames) -> String {
let usable = |n: &&String| !generic_name(n);
let chosen = match names {
StepNames::Auto if !generic_name(&b.name) => Some(&b.name),
StepNames::Auto => b.path.iter().rev().find(usable).or_else(|| b.instance.as_ref().filter(usable)),
StepNames::Solid => None,
StepNames::Product => b.path.last().filter(usable),
StepNames::Instance => b.instance.as_ref().filter(usable).or(b.path.last().filter(usable)),
@@ -146,8 +160,7 @@ pub struct StepFeature {
/// Timeline name: the root product's name, else the file name.
pub name: String,
pub kind: solidcraft_doc::FeatureKind,
/// Body names from the file, as [`StepNames`] chose them (by default the solid names, else
/// the product names).
/// Body names from the file, as [`StepNames`] chose them (automatic by default).
pub body_names: Vec<String>,
pub warnings: Vec<String>,
/// The file as read. Hold it until the feature has been evaluated: the timeline then reuses
@@ -203,6 +216,8 @@ pub fn step_import_feature_named(bytes: &[u8], file: &str, names: StepNames) ->
let text = step_text(bytes);
if is_iges_path(file) { solidcraft_kernel::iges_to_step(&text).map_err(IoError::Invalid)? } else { (text, Vec::new()) }
};
// The feature keeps this text and a save stores it: refuse now what could not be saved.
design::check_step_storable(&text)?;
let imp = solidcraft_kernel::step_import_shared(&text)?;
let stem = std::path::Path::new(file)
.file_stem()
@@ -748,7 +763,7 @@ mod tests {
let step = String::from_utf8(export_with_metadata(&m.state(), &[], Format::Step, "t", &meta).unwrap()).unwrap();
assert!(step.contains("FILE_DESCRIPTION(('Vented Geh\\X2\\00E4\\X0\\use for a Pi'),'2;1');"), "{}", &step[..300]);
assert!(step.contains("FILE_NAME('t.step','',('Jane O''Brien'),('ACME'),"), "{}", &step[..300]);
assert_eq!(step_import_feature(step.as_bytes(), "t.step").unwrap().body_names, ["Body1"], "still reads back");
assert_eq!(step_import_feature(step.as_bytes(), "t.step").unwrap().body_names, ["t"], "still reads back");
let iges = String::from_utf8(export_with_metadata(&m.state(), &[], Format::Iges, "t", &meta).unwrap()).unwrap();
assert!(iges.contains("12HJane O'Brien") && iges.contains("4HACME"));
let model_xml = |bytes: Vec<u8>| {
@@ -812,7 +827,7 @@ mod tests {
let f = step_import_feature(s.as_bytes(), "/tmp/My Part.step").unwrap();
// Named after the exported product (the export was called `t`).
assert_eq!(f.name, "t");
assert_eq!(f.body_names, ["Body1"]);
assert_eq!(f.body_names, ["t"]);
assert_eq!(f.body_names.len(), 1);
assert!(matches!(&f.kind, FeatureKind::Import { file, .. } if file == "My Part.step"));
let mut imported = Document::new("Imported STEP round trip");
@@ -862,9 +877,8 @@ mod tests {
step_export_products(&products, 0, &StepHeader::default()).unwrap()
}
/// The bodies of a STEP file are named after the solid (the default), the product that holds
/// them, or the assembly occurrence; a name the file leaves out or fills with a placeholder
/// falls back to the next one.
/// Automatic names keep meaningful solid labels and replace placeholders with part names.
/// Explicit choices still select the solid, product or occurrence.
#[test]
fn step_import_names_follow_the_choice() {
let text = frame_step();
@@ -872,10 +886,15 @@ mod tests {
assert_eq!(names(&text, StepNames::Solid), ["Cut-Extrude3", "Boss-Extrude1", "Boss-Extrude1"]);
assert_eq!(names(&text, StepNames::Product), ["Plate", "Post", "Post"]);
assert_eq!(names(&text, StepNames::Instance), ["Plate-1", "Post-1", "Post-2"]);
// Without a choice it is the solid's name, as before.
// Meaningful solid names survive automatic naming, including SolidCraft exports.
assert_eq!(step_import_feature(text.as_bytes(), "frame.step").unwrap().body_names, names(&text, StepNames::Solid));
let generic = text.replace("'Cut-Extrude3'", "'Body1'").replace("'Boss-Extrude1'", "'Body 2 (3)'");
assert_eq!(step_import_feature(generic.as_bytes(), "frame.step").unwrap().body_names, ["Plate", "Post", "Post"]);
assert_eq!(names(&generic, StepNames::Solid), ["Body1", "Body 2 (3)", "Body 2 (3)"]);
let named = generic.replace("'Body1'", "'Body housing'");
assert_eq!(names(&named, StepNames::Auto), ["Body housing", "Post", "Post"]);
// The timeline entry is the root product's name whatever the bodies are called.
for choice in [StepNames::Solid, StepNames::Product, StepNames::Instance] {
for choice in [StepNames::Auto, StepNames::Solid, StepNames::Product, StepNames::Instance] {
assert_eq!(step_import_feature_named(text.as_bytes(), "frame.step", choice).unwrap().name, "Frame");
}
// A product the file leaves unnamed says nothing: its bodies keep the solid's name.
@@ -887,6 +906,8 @@ mod tests {
assert_eq!(names(&part, StepNames::Solid), ["Body1"]);
assert_eq!(names(&part, StepNames::Product), ["Bracket"]);
assert_eq!(names(&part, StepNames::Instance), ["Bracket"]);
assert_eq!(names(&part, StepNames::Auto), ["Bracket"]);
assert_eq!(StepNames::parse(" AuTo "), Some(StepNames::Auto));
assert_eq!(StepNames::parse(" Product "), Some(StepNames::Product));
assert_eq!(StepNames::parse("INSTANCE"), Some(StepNames::Instance));
assert_eq!(StepNames::parse("label"), None);
+15
View File
@@ -467,6 +467,21 @@ outer
survives("3MF (production extension)", &mutations(&production, false, 0x2089), usize::MAX, |b| {
crate::read_3mf(b).ok().map(|(objects, _)| objects.iter().map(|o| o.triangles.len()).sum())
});
// Its model parts' XML mutated and zipped again: mutations of the zip mostly stop at its
// checksums, these reach the `p:path` and `objectid` handling.
let parts = crate::threemf::tests::production_parts();
for (k, (name, xml)) in parts.iter().enumerate() {
let rezipped: Vec<Vec<u8>> = mutations(xml.as_bytes(), true, 0x2089 + k as u64)
.iter()
.map(|m| {
let entries: Vec<(&str, &[u8])> = parts.iter().map(|(n, x)| (*n, if n == name { m.as_slice() } else { x.as_bytes() })).collect();
crate::threemf::tests::package_bytes(&entries)
})
.collect();
survives(&format!("3MF (production extension, {name} mutated)"), &rezipped, usize::MAX, |b| {
crate::read_3mf(b).ok().map(|(objects, _)| objects.iter().map(|o| o.triangles.len()).sum())
});
}
}
#[test]
+395 -81
View File
@@ -8,10 +8,12 @@
//! precision so the receiver gets the tessellation exactly. Reading: every build item becomes one mesh
//! (components flattened with their transforms), converted to millimetres. Components and build
//! items may name an object in another model part of the package (`p:path`, the 3MF Production
//! extension, which slicers use for multi-part projects); those parts are read too. Packages are
//! untrusted: sizes, entry counts, model parts, mesh sizes and component nesting are capped.
//! extension, which slicers use for multi-part projects); those parts are read too. Only the root
//! model's build counts (readers must ignore the others), and a part that can't be read fails
//! only the placements that need it. Packages are untrusted: sizes, entry counts, model parts,
//! mesh sizes and component nesting are capped.
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap};
use std::fmt::Write as _;
use std::io::{Cursor, Read, Write};
@@ -352,10 +354,30 @@ fn bad<T>(msg: impl Into<String>) -> Result<T> {
Err(IoError::Invalid(format!("3MF: {}", msg.into())))
}
/// The name a part is looked up by: no leading `/`, in lower case (part names compare
/// case-insensitively).
/// The name a part is looked up by: `%XX` escapes decoded, no leading `/`, in lower case (part
/// names are URIs and compare case-insensitively).
fn part_key(name: &str) -> String {
name.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase()
let b = name.as_bytes();
let hex = |k: usize| b.get(k).and_then(|h| char::from(*h).to_digit(16));
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while let Some(&c) = b.get(i) {
match (c, hex(i + 1), hex(i + 2)) {
(b'%', Some(hi), Some(lo)) => {
out.push((hi * 16 + lo) as u8);
i += 3;
}
(b'\\', ..) => {
out.push(b'/');
i += 1;
}
_ => {
out.push(c);
i += 1;
}
}
}
String::from_utf8_lossy(&out).trim_start_matches('/').to_ascii_lowercase()
}
fn read_part(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Option<Vec<u8>>> {
@@ -363,20 +385,22 @@ fn read_part(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Opt
let Some(idx) = (0..zip.len()).find(|i| zip.name_for_index(*i).and_then(|n| n.ok()).is_some_and(|n| part_key(&n) == want)) else {
return Ok(None);
};
read_entry(zip, idx, name).map(Some)
match read_entry(zip, idx, name, MAX_PART)? {
Some(data) => Ok(Some(data)),
None => bad(format!("{name} expands to more than 1 GB")),
}
}
fn read_entry(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, idx: usize, name: &str) -> Result<Vec<u8>> {
/// Entry `idx` uncompressed, or `None` when it expands to more than `limit` bytes (zip bombs stop
/// there).
fn read_entry(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, idx: usize, name: &str, limit: u64) -> Result<Option<Vec<u8>>> {
let mut f = zip.by_index(idx).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?;
if f.size() > MAX_PART {
return bad(format!("{name} expands to {} bytes", f.size()));
if f.size() > limit {
return Ok(None);
}
let mut out = Vec::with_capacity(f.size().min(16 << 20) as usize);
(&mut f).take(MAX_PART + 1).read_to_end(&mut out).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?;
if out.len() as u64 > MAX_PART {
return bad(format!("{name} expands to more than 1 GB"));
}
Ok(out)
(&mut f).take(limit.saturating_add(1)).read_to_end(&mut out).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?;
Ok((out.len() as u64 <= limit).then_some(out))
}
fn attrs(e: &BytesStart) -> HashMap<String, String> {
@@ -435,10 +459,16 @@ fn det(m: &Xf) -> f64 {
m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6])
}
/// A component or build item: object `id` placed by `xf`, in model part `part` (its key, from
/// `p:path`) or, without one, in the part that names it.
/// A model part named by `p:path`: its key ([`part_key`]) and the name as written, for messages.
struct PartName {
key: String,
path: String,
}
/// A component or build item: object `id` placed by `xf`, in model part `part` or, without one,
/// in the part that names it.
struct Ref {
part: Option<String>,
part: Option<PartName>,
id: u32,
xf: Xf,
}
@@ -453,19 +483,29 @@ struct Object {
}
/// One model part. Positions and translations are in millimetres (converted with the part's
/// unit as it is read), so objects of parts in different units fit together.
/// unit as it is read), so objects of parts in different units fit together. Objects are kept in
/// id order, so the parts they name are read, and reported, in the same order every run.
#[derive(Default)]
struct Model {
unit: f64,
objects: HashMap<u32, Object>,
objects: BTreeMap<u32, Object>,
materials: HashMap<u32, Vec<Option<[f32; 3]>>>,
build: Vec<Ref>,
}
/// The model parts read from a package, by key, and the key of the root model.
/// The model parts of a package by key: the root model's key, the parts read, the parts that are
/// in the package but could not be read (with the error), and each part's name as first written.
struct Package {
root: String,
parts: HashMap<String, Model>,
failed: HashMap<String, String>,
names: HashMap<String, String>,
}
impl Package {
/// Part `key` as the package names it.
fn shown<'a>(&'a self, key: &'a str) -> &'a str {
self.names.get(key).map_or(key, String::as_str)
}
}
fn color(s: &str) -> Option<[f32; 3]> {
@@ -489,11 +529,19 @@ fn unit_mm(u: &str) -> Option<f64> {
})
}
fn parse_model(xml: &[u8]) -> Result<Model> {
/// The model part's name in `p:path`, if any.
fn part_name(a: &HashMap<String, String>) -> Option<PartName> {
a.get("path").map(|p| PartName { key: part_key(p), path: p.clone() })
}
/// Model part `xml`. Only the `root` model's build items are read: the spec has readers ignore
/// the build sections of the other parts (slicers leave them empty or point them anywhere).
fn parse_model(xml: &[u8], root: bool) -> Result<Model> {
let mut rd = Reader::from_reader(xml);
rd.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut m = Model { unit: 1.0, ..Default::default() };
let mut m = Model::default();
let mut unit = 1.0;
let mut depth = 0usize;
let mut cur: Option<(u32, Object)> = None;
let mut cur_mat: Option<u32> = None;
@@ -541,7 +589,7 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
b"model" => {
seen_model = true;
if let Some(u) = a.get("unit") {
m.unit = unit_mm(u).ok_or_else(|| IoError::Invalid(format!("3MF: unknown unit `{u}`")))?;
unit = unit_mm(u).ok_or_else(|| IoError::Invalid(format!("3MF: unknown unit `{u}`")))?;
}
}
b"basematerials" => {
@@ -573,7 +621,7 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
return bad("too many vertices");
}
let (Some(x), Some(y), Some(z)) = (num("x"), num("y"), num("z")) else { return bad("vertex without x, y, z") };
o.positions.push(Vec3::new(x, y, z) * m.unit);
o.positions.push(Vec3::new(x, y, z) * unit);
}
b"triangle" => {
let Some((_, o)) = cur.as_mut() else { return bad("triangle outside an object") };
@@ -586,11 +634,13 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
b"component" => {
let Some((_, o)) = cur.as_mut() else { return bad("component outside an object") };
let target = id("objectid").ok_or_else(|| IoError::Invalid("3MF: component without objectid".into()))?;
o.components.push(Ref { part: a.get("path").map(|p| part_key(p)), id: target, xf: in_mm(parse_xf(a.get("transform"))?, m.unit) });
// The spec allows `p:path` only in the root model's components; a reader can still
// follow it from any part (loops and depth are checked when placing).
o.components.push(Ref { part: part_name(&a), id: target, xf: in_mm(parse_xf(a.get("transform"))?, unit) });
}
b"item" => {
b"item" if root => {
let target = id("objectid").ok_or_else(|| IoError::Invalid("3MF: build item without objectid".into()))?;
m.build.push(Ref { part: a.get("path").map(|p| part_key(p)), id: target, xf: in_mm(parse_xf(a.get("transform"))?, m.unit) });
m.build.push(Ref { part: part_name(&a), id: target, xf: in_mm(parse_xf(a.get("transform"))?, unit) });
}
_ => {}
}
@@ -602,29 +652,30 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
Ok(m)
}
/// Queue the model parts `m` names that were not seen yet.
fn queue_parts(m: &Model, seen: &mut HashSet<String>, pending: &mut Vec<String>) -> Result<()> {
for key in m.objects.values().flat_map(|o| &o.components).chain(&m.build).filter_map(|r| r.part.as_ref()) {
if !seen.contains(key) {
/// Queue the model parts `m` names that were not seen yet, noting how each is spelled (`m.build`
/// is empty but for the root).
fn queue_parts(m: &Model, names: &mut HashMap<String, String>, pending: &mut Vec<String>) -> Result<()> {
for p in m.objects.values().flat_map(|o| &o.components).chain(&m.build).filter_map(|r| r.part.as_ref()) {
if !names.contains_key(&p.key) {
// A package holds no more parts than it has entries.
if seen.len() >= MAX_ENTRIES {
if names.len() >= MAX_ENTRIES {
return bad(format!("objects name more than {MAX_ENTRIES} model parts"));
}
seen.insert(key.clone());
pending.push(key.clone());
names.insert(p.key.clone(), p.path.clone());
pending.push(p.key.clone());
}
}
Ok(())
}
/// An error reading model part `key`, saying which part it was.
fn in_part(key: &str, e: IoError) -> IoError {
/// An error reading model part `name`, saying which part it was.
fn in_part(name: &str, e: IoError) -> String {
match e {
IoError::Invalid(msg) => {
let msg = msg.strip_prefix("3MF: ").or_else(|| msg.strip_prefix("3MF ")).unwrap_or(&msg);
IoError::Invalid(format!("3MF model part `{key}`: {msg}"))
format!("3MF model part `{name}`: {msg}")
}
e => e,
e => e.to_string(),
}
}
@@ -638,15 +689,22 @@ fn flatten<'a>(
stack: &mut Vec<(&'a str, u32)>,
total: &mut usize,
) -> Result<()> {
if stack.len() > MAX_COMPONENT_DEPTH || stack.contains(&(part, id)) {
return bad("components nested too deeply or recursive");
// Loops are keyed by (part, object): parts number their objects on their own, so object 1 of
// the root placing object 1 of another part is no loop.
if stack.contains(&(part, id)) {
return bad("components place each other in a loop");
}
let m = pkg.parts.get(part).ok_or_else(|| IoError::Invalid(format!("3MF: no model part `{part}`")))?;
if stack.len() > MAX_COMPONENT_DEPTH {
return bad(format!("components nested more than {MAX_COMPONENT_DEPTH} deep"));
}
let Some(m) = pkg.parts.get(part) else {
return Err(IoError::Invalid(pkg.failed.get(part).cloned().unwrap_or_else(|| format!("3MF: no model part `{}`", pkg.shown(part)))));
};
let o = m.objects.get(&id).ok_or_else(|| {
IoError::Invalid(if part == pkg.root {
format!("3MF: missing object {id}")
} else {
format!("3MF: missing object {id} in model part `{part}`")
format!("3MF: missing object {id} in model part `{}`", pkg.shown(part))
})
})?;
// Every placement costs at least one unit of the budget, so objects that only place other
@@ -695,7 +753,8 @@ fn flatten<'a>(
}
stack.push((part, id));
for c in &o.components {
flatten(pkg, c.part.as_deref().unwrap_or(part), c.id, &compose(&c.xf, xf), out, stack, total)?;
// Without `p:path` a component is in the part that names it, not in the root.
flatten(pkg, c.part.as_ref().map_or(part, |p| p.key.as_str()), c.id, &compose(&c.xf, xf), out, stack, total)?;
}
stack.pop();
Ok(())
@@ -733,40 +792,56 @@ pub fn read_3mf(bytes: &[u8]) -> Result<(Vec<MeshObject>, Vec<String>)> {
}
}
let xml = read_part(&mut zip, &root)?.ok_or_else(|| IoError::Invalid(format!("3MF: no model part `{root}`")))?;
let root = part_key(&root);
let model = parse_model(&xml)?;
let mut model_bytes = xml.len();
let root_name = root;
let root = part_key(&root_name);
let model = parse_model(&xml, true)?;
// All model parts together may expand to 1 GB; only one is held as XML at a time.
let mut model_bytes = xml.len() as u64;
drop(xml);
// The model parts objects name (production extension), each read once. The entries are
// indexed first, so a package naming thousands of parts costs no scan per name; a name
// with no entry is left out, and the placements that need it say so.
// indexed first, so a package naming thousands of parts costs no scan per name. A name with
// no entry is left out and a part that can't be read is noted: either way only the
// placements that need it fail, and say why.
let mut entries = HashMap::new();
for i in 0..zip.len() {
if let Some(Ok(n)) = zip.name_for_index(i) {
entries.entry(part_key(&n)).or_insert(i);
}
}
let mut seen = HashSet::from([root.clone()]);
let mut names = HashMap::from([(root.clone(), root_name)]);
let mut pending = Vec::new();
queue_parts(&model, &mut seen, &mut pending)?;
queue_parts(&model, &mut names, &mut pending)?;
let mut parts = HashMap::from([(root.clone(), model)]);
let mut failed = HashMap::new();
while let Some(key) = pending.pop() {
let Some(&idx) = entries.get(&key) else { continue };
let xml = read_entry(&mut zip, idx, &key)?;
model_bytes = model_bytes.saturating_add(xml.len());
if model_bytes as u64 > MAX_PART {
return bad("model parts expand to more than 1 GB");
let name = names.get(&key).cloned().unwrap_or_else(|| key.clone());
let read = match read_entry(&mut zip, idx, &name, MAX_PART.saturating_sub(model_bytes)) {
Ok(Some(xml)) => {
model_bytes = model_bytes.saturating_add(xml.len() as u64);
parse_model(&xml, false)
}
// The package-wide caps refuse the whole package, not just this part.
Ok(None) => return bad("model parts expand to more than 1 GB"),
Err(e) => Err(e),
};
match read {
Ok(m) => {
queue_parts(&m, &mut names, &mut pending)?;
parts.insert(key, m);
}
Err(e) => {
failed.insert(key, in_part(&name, e));
}
}
let m = parse_model(&xml).map_err(|e| in_part(&key, e))?;
queue_parts(&m, &mut seen, &mut pending)?;
parts.insert(key, m);
}
let pkg = Package { root, parts };
let pkg = Package { root, parts, failed, names };
let Some(model) = pkg.parts.get(&pkg.root) else { return bad("no root model") };
let mut out = Vec::new();
let mut warnings = Vec::new();
let mut total = 0usize;
for (k, item) in model.build.iter().enumerate() {
let part = item.part.as_deref().unwrap_or(&pkg.root);
let part = item.part.as_ref().map_or(pkg.root.as_str(), |p| p.key.as_str());
let mut mo = MeshObject::default();
match flatten(&pkg, part, item.id, &item.xf, &mut mo, &mut Vec::new(), &mut total) {
Ok(()) if !mo.triangles.is_empty() => {
@@ -814,16 +889,21 @@ pub(crate) mod tests {
}
/// A package of these parts (name, contents).
fn package_parts(parts: &[(&str, &str)]) -> Vec<u8> {
pub(crate) fn package_bytes(parts: &[(&str, &[u8])]) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for (name, data) in parts {
zip.start_file(*name, o).unwrap();
zip.write_all(data.as_bytes()).unwrap();
zip.write_all(data).unwrap();
}
zip.finish().unwrap().into_inner()
}
/// A package of these text parts (name, contents).
fn package_parts(parts: &[(&str, &str)]) -> Vec<u8> {
package_bytes(&parts.iter().map(|(n, d)| (*n, d.as_bytes())).collect::<Vec<_>>())
}
fn package(model: &str, rels: Option<&str>) -> Vec<u8> {
let mut parts: Vec<(&str, &str)> = rels.map(|r| ("_rels/.rels", r)).into_iter().collect();
parts.push(("3D/3dmodel.model", model));
@@ -850,9 +930,9 @@ pub(crate) mod tests {
o.triangles.iter().map(|t| p(t[0]).dot(p(t[1]).cross(p(t[2])))).sum::<f64>() / 6.0
}
/// A multi-part project as slicers save it: the root model's object is two components in
/// another model part, which holds the meshes.
pub(crate) fn production_package() -> Vec<u8> {
/// The model parts (name, XML) of a multi-part project as slicers save it: the root model's
/// object is two components in another model part, which holds the meshes.
pub(crate) fn production_parts() -> [(&'static str, String); 2] {
let root = format!(
r#"<?xml version="1.0"?><model unit="millimeter" {PRODUCTION}><resources><object id="10" type="model" name="Pair"><components>
<component p:path="/3D/Objects/object_1.model" objectid="1"/>
@@ -860,7 +940,12 @@ pub(crate) mod tests {
</components></object></resources><build><item objectid="10" transform="1 0 0 0 1 0 0 0 1 0 0 2"/></build></model>"#
);
let objects = model_part("millimeter", &format!("{}{}", tet_object(1, "A", 1.0), tet_object(2, "B", 2.0)));
package_parts(&[("3D/3dmodel.model", &root), (OBJECTS_PART, &objects)])
[("3D/3dmodel.model", root), (OBJECTS_PART, objects)]
}
pub(crate) fn production_package() -> Vec<u8> {
let [(r, root), (o, objects)] = production_parts();
package_parts(&[(r, &root), (o, &objects)])
}
/// Objects kept in a model part of their own (3MF Production extension) come in with their
@@ -916,22 +1001,40 @@ pub(crate) mod tests {
r#"<object id="1" type="model"><components><component p:path="/3D/3dmodel.model" objectid="10"/></components></object>"#,
);
let many: String = (0..=MAX_ENTRIES).map(|i| format!(r#"<component p:path="/p{i}.model" objectid="1"/>"#)).collect();
// Each with the message it must give: before the production extension was read, every
// one of these failed alike, on the first `p:path`.
let cases = [
// A part that isn't in the package, one outside it and an empty name.
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/missing.model" objectid="1"/>"#))]),
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/../../etc/passwd" objectid="1"/>"#)), (OBJECTS_PART, &tet)]),
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="" objectid="1"/>"#)), (OBJECTS_PART, &tet)]),
// A part that isn't in the package (named as written), one outside it and an empty name.
(
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/Missing.model" objectid="1"/>"#))]),
"no model part `/3D/Objects/Missing.model`",
),
(
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/../../etc/passwd" objectid="1"/>"#)), (OBJECTS_PART, &tet)]),
"no model part `/../../etc/passwd`",
),
(package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="" objectid="1"/>"#)), (OBJECTS_PART, &tet)]), "no model part ``"),
// An object the part doesn't have.
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/object_1.model" objectid="7"/>"#)), (OBJECTS_PART, &tet)]),
(
package_parts(&[
("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/object_1.model" objectid="7"/>"#)),
(OBJECTS_PART, &tet),
]),
"missing object 7 in model part `/3D/Objects/object_1.model`",
),
// A part that is no model.
package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, "not xml")]),
(
package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, "not xml")]),
"model part `/3D/Objects/object_1.model`: no <model> element",
),
// Parts that place each other in a loop.
package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, &looped)]),
(package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, &looped)]), "components place each other in a loop"),
// More part names than a package can hold.
package_parts(&[("3D/3dmodel.model", &root(&many))]),
(package_parts(&[("3D/3dmodel.model", &root(&many))]), "objects name more than 10000 model parts"),
];
for (i, c) in cases.iter().enumerate() {
assert!(read_3mf(c).is_err(), "case {i}");
for (i, (c, msg)) in cases.iter().enumerate() {
let err = read_3mf(c).map(|_| ()).unwrap_err().to_string();
assert!(err.contains(msg), "case {i}: {err}");
}
// A placement whose part is missing is reported; the others still come in.
let two = format!(
@@ -942,6 +1045,221 @@ pub(crate) mod tests {
assert!(warnings.iter().any(|w| w.contains("build item 2") && w.contains("gone.model")), "{warnings:?}");
}
/// The root model: `objects`, and a build of `items`.
fn root_model(objects: &str, items: &str) -> String {
format!(r#"<?xml version="1.0"?><model unit="millimeter" {PRODUCTION}><resources>{objects}</resources><build>{items}</build></model>"#)
}
/// Object `id` made of components: (`p:path`, or "" for none, and object id).
fn assembly(id: u32, components: &[(&str, u32)]) -> String {
let c: String = components
.iter()
.map(|(path, oid)| match *path {
"" => format!(r#"<component objectid="{oid}"/>"#),
path => format!(r#"<component p:path="{path}" objectid="{oid}"/>"#),
})
.collect();
format!(r#"<object id="{id}" type="model"><components>{c}</components></object>"#)
}
/// A component without `p:path` in another part is in that part, not in the root, which has
/// an object of the same id too (T1 of the review of #2127).
#[test]
fn components_without_a_path_stay_in_their_part() {
let root =
root_model(&format!("{}{}", tet_object(2, "Root", 5.0), assembly(10, &[("/3D/Objects/object_1.model", 1)])), r#"<item objectid="10"/>"#);
let objects = model_part("millimeter", &format!("{}{}", assembly(1, &[("", 2)]), tet_object(2, "Child", 2.0)));
let (objs, warnings) = read_3mf(&package_parts(&[("3D/3dmodel.model", &root), (OBJECTS_PART, &objects)])).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(objs.len(), 1);
assert!((volume(&objs[0]) - 8.0 / 6.0).abs() < 1e-9, "the child's object 2, not the root's: {}", volume(&objs[0]));
}
/// Parts number their objects on their own, so root object 1 placing object 1 of another part
/// is no loop (T2).
#[test]
fn the_same_id_in_two_parts_is_no_loop() {
let root = root_model(&assembly(1, &[("/3D/Objects/object_1.model", 1)]), r#"<item objectid="1"/>"#);
let objects = model_part("millimeter", &tet_object(1, "A", 1.0));
let (objs, warnings) = read_3mf(&package_parts(&[("3D/3dmodel.model", &root), (OBJECTS_PART, &objects)])).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert!(objs.len() == 1 && (volume(&objs[0]) - 1.0 / 6.0).abs() < 1e-9);
}
/// A package shaped like a slicer project (Bambu Studio, OrcaSlicer): object UUIDs, printable
/// build items, thumbnails, slicer configs, and the meshes in a part of their own with an empty
/// build (T3). Each object on the plate is one body with all its volumes. The slicer's config
/// (names, which volumes are modifiers) isn't read, so the bodies are named `MeshN` and the
/// modifier comes in as solid.
#[test]
fn slicer_project_package() {
let ns = r#"xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:BambuStudio="http://schemas.bambulab.com/package/2021" xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06" requiredextensions="p""#;
let uuid = |n: u32| format!("{n:08x}-61cb-4c03-9d28-80fed5dfa1dc");
let component = |oid: u32| {
format!(
r#"<component p:path="/3D/Objects/object_1.model" objectid="{oid}" p:UUID="{}" transform="1 0 0 0 1 0 0 0 1 0 0 0"/>"#,
uuid(100 + oid)
)
};
let root = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<model unit="millimeter" xml:lang="en-US" {ns}>
<metadata name="Application">BambuStudio-02.03.00.70</metadata>
<metadata name="BambuStudio:3mfVersion">1</metadata>
<resources>
<object id="2" p:UUID="{}" type="model"><components>{}</components></object>
<object id="4" p:UUID="{}" type="model"><components>{}{}</components></object>
</resources>
<build p:UUID="{}">
<item objectid="2" p:UUID="{}" transform="1 0 0 0 1 0 0 0 1 128 128 0" printable="1"/>
<item objectid="4" p:UUID="{}" transform="1 0 0 0 1 0 0 0 1 90 128 0" printable="1"/>
</build>
</model>"#,
uuid(2),
component(1),
uuid(4),
component(3),
component(5),
uuid(200),
uuid(201),
uuid(202)
);
let mesh = |id: u32, s: f64| tet_object(id, "", s).replace(r#" name="""#, &format!(r#" p:UUID="{}""#, uuid(300 + id)));
let objects = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<model unit="millimeter" xml:lang="en-US" {ns}>
<metadata name="BambuStudio:3mfVersion">1</metadata>
<resources>{}{}{}</resources>
<build/>
</model>"#,
mesh(1, 10.0),
mesh(3, 20.0),
mesh(5, 4.0)
);
let settings = r#"<?xml version="1.0" encoding="UTF-8"?>
<config>
<object id="2"><metadata key="name" value="Cube"/><part id="1" subtype="normal_part"><metadata key="name" value="Cube"/></part></object>
<object id="4"><metadata key="name" value="Bracket"/><part id="3" subtype="normal_part"/><part id="5" subtype="modifier_part"/></object>
<plate><metadata key="plater_id" value="1"/><metadata key="thumbnail_file" value="Metadata/plate_1.png"/></plate>
</config>"#;
let content_types = r#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/><Default Extension="png" ContentType="image/png"/></Types>"#;
let rels = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel-1" Type="{MODEL_REL}"/><Relationship Target="/Metadata/plate_1.png" Id="rel-2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail"/></Relationships>"#
);
let model_rels = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/Objects/object_1.model" Id="rel-1" Type="{MODEL_REL}"/></Relationships>"#
);
let png: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR";
let package = package_bytes(&[
("[Content_Types].xml", content_types.as_bytes()),
("_rels/.rels", rels.as_bytes()),
("3D/3dmodel.model", root.as_bytes()),
("3D/_rels/3dmodel.model.rels", model_rels.as_bytes()),
(OBJECTS_PART, objects.as_bytes()),
("Metadata/model_settings.config", settings.as_bytes()),
("Metadata/project_settings.config", br#"{"layer_height": "0.2"}"#),
("Metadata/plate_1.png", png),
("Metadata/plate_1_small.png", png),
]);
let (objs, warnings) = read_3mf(&package).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let names: Vec<&str> = objs.iter().map(|o| o.name.as_str()).collect();
assert_eq!(names, ["Mesh1", "Mesh2"]);
assert_eq!((objs[0].triangles.len(), objs[1].triangles.len()), (4, 8));
assert!((volume(&objs[0]) - 1000.0 / 6.0).abs() < 1e-6, "{}", volume(&objs[0]));
assert!((volume(&objs[1]) - (8000.0 + 64.0) / 6.0).abs() < 1e-6, "{}", volume(&objs[1]));
assert!(objs[0].positions.iter().all(|p| p.x >= 128.0 - 1e-9 && p.y >= 128.0 - 1e-9));
}
/// Placements in every part draw on one budget (T4): three parts fanning out 300 ways each
/// (27 million placements of an empty object) stop at it, and only the build item that went
/// over fails.
#[test]
fn parts_share_the_placement_budget() {
let t = std::time::Instant::now();
let fan = |id: u32, path: &str| assembly(id, &vec![(path, 1); 300]);
let root = root_model(&fan(10, "/3D/Objects/a.model"), r#"<item p:path="/3D/Objects/a.model" objectid="9"/><item objectid="10"/>"#);
let a = model_part("millimeter", &format!("{}{}", fan(1, "/3D/Objects/b.model"), tet_object(9, "Tet", 1.0)));
let b = model_part("millimeter", &fan(1, "/3D/Objects/c.model"));
let c = model_part("millimeter", r#"<object id="1" type="model"/>"#);
let package =
package_parts(&[("3D/3dmodel.model", &root), ("3D/Objects/a.model", &a), ("3D/Objects/b.model", &b), ("3D/Objects/c.model", &c)]);
let (objs, warnings) = read_3mf(&package).unwrap();
assert_eq!(objs.len(), 1);
assert!(warnings.len() == 1 && warnings[0].starts_with("build item 2") && warnings[0].contains("placements"), "{warnings:?}");
assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed());
}
/// A loop through three parts with different object ids is found, and a chain of parts deeper
/// than the cap stops there while one level less still comes in (T5).
#[test]
fn loops_and_deep_chains_across_parts() {
let read = |parts: &[(String, String)]| read_3mf(&package_parts(&parts.iter().map(|(n, x)| (n.as_str(), x.as_str())).collect::<Vec<_>>()));
let part = |name: &str, objects: String| (name.to_string(), model_part("millimeter", &objects));
let looped = [
("3D/3dmodel.model".to_string(), root_model("", r#"<item p:path="/3D/Objects/a.model" objectid="1"/>"#)),
part("3D/Objects/a.model", assembly(1, &[("/3D/Objects/b.model", 2)])),
part("3D/Objects/b.model", assembly(2, &[("/3D/Objects/c.model", 3)])),
part("3D/Objects/c.model", assembly(3, &[("/3D/Objects/a.model", 1)])),
];
let err = read(&looped).map(|_| ()).unwrap_err().to_string();
assert!(err.contains("components place each other in a loop"), "{err}");
// Object k of part k places object k + 1 of part k + 1; the last one is a mesh.
let chain = |n: u32| {
let name = |k: u32| format!("3D/Objects/c{k}.model");
let mut parts = vec![("3D/3dmodel.model".to_string(), root_model("", &format!(r#"<item p:path="/{}" objectid="1"/>"#, name(1))))];
for k in 1..=n {
let object = if k == n { tet_object(k, "End", 1.0) } else { assembly(k, &[(format!("/{}", name(k + 1)).as_str(), k + 1)]) };
parts.push(part(&name(k), object));
}
read(&parts)
};
let deepest = MAX_COMPONENT_DEPTH as u32 + 1;
let (objs, warnings) = chain(deepest).unwrap();
assert!(objs.len() == 1 && warnings.is_empty(), "{warnings:?}");
let err = chain(deepest + 1).map(|_| ()).unwrap_err().to_string();
assert!(err.contains(&format!("components nested more than {MAX_COMPONENT_DEPTH} deep")), "{err}");
}
/// A part that can't be read fails only the placements that need it, and none when only
/// objects nobody places name it. The build sections of other parts are ignored, even ones
/// pointing at a thumbnail or missing their object id (T6).
#[test]
fn broken_parts_and_the_builds_of_other_parts() {
let root = root_model(
&assembly(20, &[("/3D/Objects/unused.model", 1)]),
r#"<item p:path="/3D/Objects/object_1.model" objectid="1"/><item p:path="/3D/Objects/Broken.model" objectid="1"/>"#,
);
let objects = format!(
r#"<?xml version="1.0"?><model unit="millimeter" {PRODUCTION}><resources>{}</resources><build><item p:path="/Metadata/plate_1.png" objectid="1"/><item/></build></model>"#,
tet_object(1, "A", 1.0)
);
let package = package_parts(&[
("3D/3dmodel.model", &root),
(OBJECTS_PART, &objects),
("3D/Objects/broken.model", "not xml"),
("3D/Objects/unused.model", "<model><resources><object id="),
("Metadata/plate_1.png", "\u{89}PNG"),
]);
let (objs, warnings) = read_3mf(&package).unwrap();
assert_eq!(objs.len(), 1);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(
warnings[0].starts_with("build item 2") && warnings[0].contains("model part `/3D/Objects/Broken.model`: no <model> element"),
"{warnings:?}"
);
}
/// Part names are URIs: a `%XX` escape matches the character it stands for, either way round.
#[test]
fn escaped_part_names() {
let root = root_model("", r#"<item p:path="/3D/Objects/my%20part.model" objectid="1"/><item p:path="/3D/Objects/café.model" objectid="1"/>"#);
let tet = model_part("millimeter", &tet_object(1, "A", 1.0));
let package = package_parts(&[("3D/3dmodel.model", &root), ("3D/Objects/my part.model", &tet), ("3D/Objects/caf%C3%A9.model", &tet)]);
let (objs, warnings) = read_3mf(&package).unwrap();
assert!(warnings.is_empty() && objs.len() == 2, "{warnings:?}");
}
#[test]
fn package_is_well_formed() {
let mut t = tetra("A <&> \"b\"");
@@ -1034,7 +1352,6 @@ pub(crate) mod tests {
/// allocation, which no panic guard can intercept.
#[test]
fn many_placements_of_a_big_object_stay_bounded() {
let t = std::time::Instant::now();
let verts: String = (0..100_000).map(|i| format!("<vertex x=\"{}\" y=\"{}\" z=\"0\"/>", i % 100, i / 100)).collect();
let model = format!(
"{}<object id=\"1\" type=\"model\"><mesh><vertices>{verts}</vertices>\
@@ -1046,7 +1363,6 @@ pub(crate) mod tests {
assert_eq!(objs.len(), 10_000);
// Only the three vertices the one triangle reaches are copied per placement.
assert!(objs.iter().all(|o| o.positions.len() == 3 && o.triangles.len() == 1), "{:?}", objs.first().map(|o| o.positions.len()));
assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed());
}
/// Objects that only place other objects count toward the budget too: six levels of 300
@@ -1054,7 +1370,6 @@ pub(crate) mod tests {
/// budget instead of running for days.
#[test]
fn nested_fan_out_of_empty_objects_is_bounded() {
let t = std::time::Instant::now();
let components = |child: u32| (0..300).map(|_| format!(r#"<component objectid="{child}"/>"#)).collect::<String>();
let mut objects = r#"<object id="1" type="model"/>"#.to_string();
for id in 2..=7 {
@@ -1063,7 +1378,6 @@ pub(crate) mod tests {
let model = format!(r#"<model unit="millimeter" {PRODUCTION}><resources>{objects}</resources><build><item objectid="7"/></build></model>"#);
let err = read_3mf(&package(&model, None)).unwrap_err().to_string();
assert!(err.contains("placements"), "{err}");
assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed());
}
/// Triangles are budgeted across every placement, so fan-out cannot outrun the cap.
+81 -17
View File
@@ -23,6 +23,16 @@ pub struct Body {
/// Bounding-box diagonal, measured by [`Body::new`] while it read the vertices anyway; 0
/// means "not measured, walk the geometry". Every kernel tolerance is a fraction of it.
pub(crate) size: f64,
/// How far this body's face boundaries may lie off their surfaces (mm) and still be taken as
/// lying on them. `Some` for a body read from a file that declares its own numeric
/// uncertainty (a STEP file's `GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT`, see
/// `crate::step_in::Units::tol`); `None` for a body this kernel built, which has no such
/// declaration.
///
/// A band face's ring edges are held to it when the importer splits the band (see
/// `crate::step_in::split`). The display planner takes it only where it is more than its own
/// bound, a hundredth of the body (see `accept` in [`crate::tess::plan_face`]).
pub(crate) boundary_tol: Option<f64>,
/// The last volume measured for this solid and the tolerance it was measured at, shared by
/// clones because they share the solid. A boolean measures its operands, and a later boolean
/// takes the result as an operand again, so the same body is measured twice otherwise.
@@ -541,6 +551,17 @@ thread_local! {
pub(crate) static REPHASED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The volume one closed shell encloses, signed by the way its faces face (positive when they
/// face out of it), from a mesh as coarse as the orientation mesh of a solid of its size.
/// `None` when it cannot be meshed.
pub(crate) fn shell_volume(shell: &mt::Shell) -> Option<f64> {
let bounds: Aabb3 = shell.vertex_iter().map(|v| from_p3(v.point())).collect();
let span = if bounds.is_empty() { 1.0 } else { bounds.diagonal() };
let deflection = Deflection { display: true, ..Deflection::chord(orientation_tolerance(span)) };
let v = guard("shell volume", || Ok(shell.robust_triangulation_within(deflection).to_polygon().volume())).ok()?;
v.is_finite().then_some(v)
}
/// Deflection the orientation mesh uses: coarse enough for a large solid, fine enough that a
/// small one still tessellates. `unmeshed_faces` counts at the same value, so the count the
/// orientation mesh produced is reused instead of meshing the body again.
@@ -625,6 +646,7 @@ impl Body {
Ok(Body {
solid: std::sync::Arc::new(solid),
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
boundary_tol: None,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: Some((tol, missing)),
mesh: None,
@@ -666,6 +688,7 @@ impl Body {
Ok(Body {
solid: std::sync::Arc::new(solid),
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
boundary_tol: None,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: coarse,
mesh: None,
@@ -695,7 +718,12 @@ impl Body {
return Err(KernelError::Invalid("degenerate placement".into()));
}
let rigid = crate::ops::rigid_placement(&a);
Body::placed_as_is(mt::builder::transformed(&*self.solid, *m), det < 0.0, rigid.then_some(self.coarse_missing).flatten())
let out = Body::placed_as_is(mt::builder::transformed(&*self.solid, *m), det < 0.0, rigid.then_some(self.coarse_missing).flatten())?;
// A placement that does not change distances leaves the boundaries exactly as far off
// their surfaces as they were, so the file's own figure still describes them (see
// [`Body::boundary_tol`]); one that scales the part does not, and the copy declares
// nothing. The same rule as the mesh count above.
Ok(if rigid { out.with_boundary_tol(self.boundary_tol) } else { out })
}
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
@@ -981,6 +1009,14 @@ impl Body {
self.color
}
/// The same body, with how far its boundaries may lie off their surfaces (mm) as the file it
/// was read from declares (see [`Body::boundary_tol`]). A value that is not finite and above
/// zero clears the declaration, which puts the body back on the kernel's own bound.
pub(crate) fn with_boundary_tol(mut self, tol: Option<f64>) -> Body {
self.boundary_tol = tol.filter(|t| t.is_finite() && *t > 0.0);
self
}
/// The same body with a display colour.
pub fn with_color(mut self, c: Option<[f32; 3]>) -> Body {
self.color = c.filter(|c| c.iter().all(|x| x.is_finite())).map(|c| c.map(|x| x.clamp(0.0, 1.0)));
@@ -1230,15 +1266,19 @@ impl Body {
if let Some(m) = &self.mesh {
return Ok(m.to_mesh());
}
// A face trimmed by a helix (a modelled thread from another CAD system) turns more than
// once around its cylinder; truck meshes it with triangles across the solid (#1289).
// Such a body is meshed from its display plan, which cuts the face along a seam.
if guard("tessellate", || Ok(crate::tess::seam::turns_more_than_once(&self.solid))).unwrap_or(false) {
// Two kinds of face truck cannot mesh in its surface's parameters: one trimmed by a
// helix, which turns more than once around its cylinder and comes out with triangles
// across the solid (#1289), and one that runs across the join of a B-spline surface that
// closes on itself, which comes out folded over itself or with a hole in it.
// Such a body is meshed from its display plan, which cuts and unwraps those faces.
let planned =
guard("tessellate", || Ok(crate::tess::seam::turns_more_than_once(&self.solid) || crate::tess::seam::closes_on_itself(&self.solid)));
if planned.unwrap_or(false) {
let turn = (angle.is_finite() && angle > 0.0)
.then(|| crate::tess::size::Turn { angle: angle.max(MIN_DISPLAY_ANGLE), floor: tol * DISPLAY_ANGLE_FLOOR });
match guard("tessellate", || crate::tess::plan(&self.solid, tol, turn)) {
match guard("tessellate", || crate::tess::plan(&self.solid, tol, turn, self.boundary_tol)) {
Ok((plan, _)) => return Ok(plan.evaluate()),
Err(e) => log::debug!("helical faces: no display plan ({e}); meshing the reference way"),
Err(e) => log::debug!("no display plan ({e}); meshing the reference way"),
}
}
let deflection = if angle.is_finite() && angle > 0.0 {
@@ -1347,7 +1387,7 @@ impl Body {
// The same angle and floor as the reference mesher's angular display mesh.
let turn =
angle.filter(|a| a.is_finite()).map(|a| crate::tess::size::Turn { angle: a.max(MIN_DISPLAY_ANGLE), floor: tol * DISPLAY_ANGLE_FLOOR });
guard("tessellate", || crate::tess::plan(&self.solid, tol, turn).map(|(plan, _)| plan))
guard("tessellate", || crate::tess::plan(&self.solid, tol, turn, self.boundary_tol).map(|(plan, _)| plan))
}
/// Planar faces whose boundary crosses itself: two of its loops crossing each other, or a
@@ -1432,25 +1472,49 @@ impl Body {
Ok(crossed)
}
/// Faces with no triangles in a coarse tessellation (the mesher failed or gave up).
/// Faces with no triangles of their own, and so not shown: none in a coarse tessellation,
/// and none in the display plan either (the plan is what the viewport draws, and it meshes
/// faces the reference mesher gives up on). Only faces the coarse tessellation misses are
/// looked for in the plan, so a sound body costs no planning.
pub fn unmeshed_faces(&self) -> usize {
if self.is_mesh() {
return 0;
}
let tol = orientation_tolerance(self.size());
if let Some((cached_tol, missing)) = self.coarse_missing
&& cached_tol == tol
{
return missing;
// The count [`Body::new`] already took at this tolerance, else the same count made now.
let missing = match self.coarse_missing {
Some((cached_tol, missing)) if cached_tol == tol => missing,
_ => {
let Ok(m) = self.tessellate(tol) else { return self.face_count() };
let mut has = vec![false; self.face_count()];
for f in &m.tri_face {
if let Some(h) = has.get_mut(*f as usize) {
*h = true;
}
}
has.iter().filter(|h| !**h).count()
}
};
// A body the coarse mesh covers whole is shown whole: no plan is made for it, whether the
// count came from the cache or from the mesh above.
if missing == 0 {
return 0;
}
let Ok(m) = self.tessellate(tol) else { return self.face_count() };
self.unmeshed_in_plan(tol).unwrap_or(missing)
}
/// Faces the display plan leaves without triangles; `None` when the body has no plan.
fn unmeshed_in_plan(&self, tol: f64) -> Option<usize> {
let plan = self.display_plan(tol).ok()?;
let mut has = vec![false; self.face_count()];
for f in &m.tri_face {
if let Some(h) = has.get_mut(*f as usize) {
for f in &plan.faces {
if !f.triangles.is_empty()
&& let Some(h) = has.get_mut(f.face as usize)
{
*h = true;
}
}
has.iter().filter(|h| !**h).count()
Some(has.iter().filter(|h| !**h).count())
}
/// Display mesh: like [`Body::tessellate`], with seam edges flagged. Meshed from the
+47 -12
View File
@@ -1986,6 +1986,19 @@ fn loft_reversed(sections: &[(Plane, Loop2)]) -> Result<bool> {
}
fn loft_numbered_wires(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Result<(Vec<mt::Wire>, Vec<Vec<Vec<usize>>>)> {
let mut wires: Vec<mt::Wire> = Vec::new();
// Per section, the profile segments under each of its wire's edges.
let mut edge_sources: Vec<Vec<Vec<usize>>> = Vec::new();
for (p, r) in loft_section_loops(sections, reversed)? {
wires.push(uniform_wire(&p, &r.lp)?);
edge_sources.push(r.from);
}
Ok((wires, edge_sources))
}
/// The sections of a loft as the wires are built from them: in loft order, oriented alike,
/// re-split to the same number of segments and started where they line up best.
pub(crate) fn loft_section_loops(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Result<Vec<(Plane, NumberedLoop)>> {
let n = sections.iter().map(|(_, l)| l.lp.segs.len()).max().unwrap_or(0);
if n == 0 || n > MAX_SEGS {
return Err(KernelError::Invalid("empty section".into()));
@@ -2000,28 +2013,51 @@ fn loft_numbered_wires(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Re
// with the most segments.
let n0 = ordered.first().map(|(p, _)| p.normal()).unwrap_or(Vec3::Z);
let parallel = ordered.iter().all(|(p, _)| p.normal().dot(n0).abs() > 1.0 - 1e-9);
// Whether a section faces the first one's way is followed neighbour to neighbour, not by its
// normal against the first's: planes turning about an axis pass 90 degrees and would be
// flipped, folding the loft (#2847).
let mut facing = true;
let mut before = n0;
let oriented: Vec<(Plane, NumberedLoop)> = ordered
.iter()
.map(|(p, l)| {
let ccw = p.normal().dot(n0) >= 0.0;
(*p, l.oriented(!ccw))
let n = p.normal();
if n.dot(before) < 0.0 {
facing = !facing;
}
before = n;
(*p, l.oriented(!facing))
})
.collect();
let lead = oriented.iter().max_by_key(|(_, l)| l.lp.segs.len()).ok_or_else(|| KernelError::Invalid("no sections".into()))?;
let lead_c = lead.0.to_world(lead.1.lp.centroid());
let lead_dirs: Vec<Vec3> = lead.1.lp.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect();
let mut wires: Vec<mt::Wire> = Vec::new();
// Per section, the profile segments under each of its wire's edges.
let mut edge_sources: Vec<Vec<Vec<usize>>> = Vec::new();
// The share of the lead's perimeter before each of its vertices: where a circle section
// breaks when the lead's vertex directions do not go round in order.
let lead_len: f64 = lead.1.lp.segs.iter().map(Seg2::length).sum();
let fractions: Vec<f64> = lead
.1
.lp
.segs
.iter()
.scan(0.0, |before, s| {
let f = *before / lead_len;
*before += s.length();
Some(f)
})
.collect();
let mut out: Vec<(Plane, NumberedLoop)> = Vec::new();
for (p, l) in &oriented {
let c = l.lp.centroid();
// Break angles: the lead section's vertex directions seen in this plane.
let angles: Vec<f64> = lead_dirs.iter().map(|d| Vec2::new(d.dot(p.x), d.dot(p.y)).angle()).collect();
let mut r = l.resample(n, &angles);
// Circle resampling creates increasing local angles. An antiparallel sketch
// plane must run clockwise locally to keep the same winding in world space.
// Curving a run through a right angle does not reverse a section's winding.
if parallel && p.normal().dot(n0) < 0.0 {
// plane must run clockwise locally to keep the same winding in world space (the lead's
// directions go round clockwise there). Curving a run through a right angle does not
// reverse a section's winding.
let flip = parallel && p.normal().dot(n0) < 0.0;
let mut r = l.resample(n, &angles, &fractions, flip);
if flip {
r = r.oriented(true);
}
// Start where every vertex lines up best with the lead's vertex of the same number (the
@@ -2036,10 +2072,9 @@ fn loft_numbered_wires(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Re
if let Some(k) = (0..m).min_by(|a, b| fit(*b).total_cmp(&fit(*a))) {
r.rotate_left(k);
}
wires.push(uniform_wire(p, &r.lp)?);
edge_sources.push(r.from);
out.push((*p, r));
}
Ok((wires, edge_sources))
Ok(out)
}
/// Ruled faces between each two neighbouring section wires; each comes from the section edges
+9 -4
View File
@@ -228,8 +228,10 @@ fn a_small_hole_on_a_large_plate_has_a_round_rim() {
let segments = (TAU / DISPLAY_ANGLE).ceil() as usize;
assert_eq!(segments, 18);
for z in [0.0, 2.0] {
// The chord tolerance alone: each half circle is one segment, the rim a line.
assert_eq!(rim(&coarse, centre, 2.0, z).0, 2);
// The chord tolerance alone leaves each half circle one segment, the rim a line no face
// can be bounded by: its edges are divided again, and the rim stays a coarse polygon.
let (n, _) = rim(&coarse, centre, 2.0, z);
assert!((3..segments).contains(&n), "{n} segments around the rim at the chord tolerance");
let (n, widest) = rim(&round, centre, 2.0, z);
assert!(n >= segments, "{n} segments around the rim at z = {z}");
assert!(widest <= DISPLAY_ANGLE * 1.001, "a segment spans {} degrees", widest.to_degrees());
@@ -291,9 +293,12 @@ fn a_face_crowded_with_holes_gets_a_coarser_angle_and_bounded_work() {
let boundary = |m: &Mesh, z: f64| m.edges.iter().filter(|e| e.iter().all(|p| (p.z - z).abs() < 1e-6)).map(|e| e.len() - 1).sum::<usize>();
let budget = truck_meshalgo::tessellation::Deflection::MAX_ANGLE_BOUNDARY;
for z in [0.0, 2.0] {
assert_eq!(boundary(&coarse, z), 4 + 2 * centres.len());
// At the chord tolerance alone each rim is divided only as far as a face can be bounded
// by it (two segments, a line, are divided again).
assert!(boundary(&coarse, z) < 6 * centres.len(), "{}", boundary(&coarse, z));
let n = boundary(&round, z);
assert!(n > boundary(&coarse, z), "the rims are still rounder than the chord tolerance leaves them: {n}");
let (rounder, chord) = (rim(&round, centres[0], 2.0, z).0, rim(&coarse, centres[0], 2.0, z).0);
assert!(rounder >= chord, "the rims are at least as round as the chord tolerance leaves them: {rounder} < {chord}");
assert!(n <= budget, "{n} segments bound the face at z = {z}");
}
// Every rim got the same share, and the walls follow their rims.
+397 -98
View File
@@ -9,6 +9,8 @@
use std::collections::HashMap;
use std::fmt::Write as _;
mod assembly;
/// Largest IGES file we read.
pub const MAX_IGES_BYTES: usize = 512 << 20;
const MAX_ENTITIES: usize = 2_000_000;
@@ -347,6 +349,11 @@ struct Nurbs {
impl Nurbs {
fn eval(&self, t: f64) -> Option<V3> {
let h = self.eval_h(t)?;
(h[3].abs() > 1e-300).then(|| [h[0] / h[3], h[1] / h[3], h[2] / h[3]])
}
/// The homogeneous point (x·w, y·w, z·w, w) at `t`.
fn eval_h(&self, t: f64) -> Option<[f64; 4]> {
let p = self.deg;
let n = self.pts.len();
if n == 0 || self.knots.len() != n + p + 1 {
@@ -368,14 +375,49 @@ impl Nurbs {
d[j] = [0, 1, 2, 3].map(|c| (1.0 - alpha) * x[c] + alpha * y[c]);
}
}
let h = d[p];
(h[3].abs() > 1e-300).then(|| [h[0] / h[3], h[1] / h[3], h[2] / h[3]])
d.get(p).copied()
}
fn range(&self) -> (f64, f64) {
(self.knots.get(self.deg).copied().unwrap_or(0.0), self.knots.get(self.pts.len()).copied().unwrap_or(0.0))
}
}
/// A circular arc in its definition plane (at height `c[2]`), counter-clockwise from `a0` to
/// `a1` (radians, `a1 > a0`).
struct Arc {
c: V3,
rad: f64,
a0: f64,
a1: f64,
}
impl Arc {
/// The arc placed by `x`, as an exact rational quadratic B-spline (pieces of at most a
/// quarter turn).
fn nurbs(&self, x: &Xf) -> Nurbs {
let sweep = self.a1 - self.a0;
let pieces = (sweep / std::f64::consts::FRAC_PI_2).ceil().clamp(1.0, 8.0) as usize;
let step = sweep / pieces as f64;
let w = (step / 2.0).cos();
let on = |t: f64, k: f64| x.point([self.c[0] + k * t.cos(), self.c[1] + k * t.sin(), self.c[2]]);
let mut pts = Vec::with_capacity(2 * pieces + 1);
let mut knots = vec![0.0; 3];
for i in 0..pieces {
let t = self.a0 + step * i as f64;
let (p, m) = (on(t, self.rad), on(t + step / 2.0, self.rad / w));
pts.push([p[0], p[1], p[2], 1.0]);
pts.push([m[0] * w, m[1] * w, m[2] * w, w]);
if i > 0 {
knots.extend([i as f64 / pieces as f64; 2]);
}
}
let p = on(self.a1, self.rad);
pts.push([p[0], p[1], p[2], 1.0]);
knots.extend([1.0; 3]);
Nurbs { deg: 2, knots, pts }
}
}
struct Tr<'a> {
f: &'a Iges,
o: Out,
@@ -387,6 +429,8 @@ struct Tr<'a> {
curve_work: usize,
/// Composite curves (102) on the path being expanded.
composites: Vec<i64>,
/// Farthest a trim loop's end was moved to close a gap (mm; see `close_gaps`).
snapped: f64,
}
fn xf_key(x: &Xf) -> [u64; 12] {
@@ -423,6 +467,11 @@ impl<'a> Tr<'a> {
}
/// The entity's own transformation (its chain), in millimetres.
fn own_xf(&self, seq: i64, depth: usize) -> R<Xf> {
self.own_xf_in(seq, depth, self.f.unit)
}
/// The entity's own transformation with translations in units of `unit` millimetres (1 for
/// a curve in a surface's parameter space, which has no length unit).
fn own_xf_in(&self, seq: i64, depth: usize, unit: f64) -> R<Xf> {
let de = self.de(seq)?;
if de.xform <= 0 || depth > MAX_DEPTH {
return Ok(Xf::ID);
@@ -437,13 +486,15 @@ impl<'a> Tr<'a> {
for (col, x) in r.iter_mut().enumerate() {
*x = self.n(t, 1 + row * 4 + col)?;
}
r[3] *= self.f.unit;
r[3] *= unit;
}
let parent = self.own_xf(t, depth + 1)?;
let parent = self.own_xf_in(t, depth + 1, unit)?;
Ok(parent.then(&Xf(m)))
}
fn pt(&self, seq: i64, i: usize) -> R<V3> {
let u = self.f.unit;
self.pt_in(seq, i, self.f.unit)
}
fn pt_in(&self, seq: i64, i: usize, u: f64) -> R<V3> {
Ok([self.n(seq, i)? * u, self.n(seq, i + 1)? * u, self.n(seq, i + 2)? * u])
}
/// A point entity (116) or direction (123), placed.
@@ -489,20 +540,8 @@ impl<'a> Tr<'a> {
Ok(vec![Seg::along(c, |t| add(a, mul(sub(b, a), t)))])
}
100 => {
let u = self.f.unit;
let zt = self.n(seq, 1)? * u;
let c = [self.n(seq, 2)? * u, self.n(seq, 3)? * u, zt];
let s = [self.n(seq, 4)? * u, self.n(seq, 5)? * u, zt];
let e = [self.n(seq, 6)? * u, self.n(seq, 7)? * u, zt];
let rad = norm(sub(s, c));
if !(rad > 0.0) {
return Err(format!("entity {seq}: arc of zero radius"));
}
let a0 = (s[1] - c[1]).atan2(s[0] - c[0]);
let mut a1 = (e[1] - c[1]).atan2(e[0] - c[0]);
while a1 <= a0 + 1e-12 {
a1 += std::f64::consts::TAU;
}
let Arc { c, rad, a0, a1 } = self.arc(seq)?;
let zt = c[2];
let at = |t: f64| x.point([c[0] + rad * t.cos(), c[1] + rad * t.sin(), zt]);
let z = unit(x.dir([0.0, 0.0, 1.0])).ok_or("degenerate arc frame")?;
let xa = unit(x.dir([1.0, 0.0, 0.0])).ok_or("degenerate arc frame")?;
@@ -571,7 +610,62 @@ impl<'a> Tr<'a> {
}
}
/// A circular arc (100) in its definition plane, in millimetres.
fn arc(&self, seq: i64) -> R<Arc> {
let u = self.f.unit;
let zt = self.n(seq, 1)? * u;
let c = [self.n(seq, 2)? * u, self.n(seq, 3)? * u, zt];
let s = [self.n(seq, 4)? * u, self.n(seq, 5)? * u, zt];
let e = [self.n(seq, 6)? * u, self.n(seq, 7)? * u, zt];
let rad = norm(sub(s, c));
if !(rad > 0.0) {
return Err(format!("entity {seq}: arc of zero radius"));
}
let a0 = (s[1] - c[1]).atan2(s[0] - c[0]);
let mut a1 = (e[1] - c[1]).atan2(e[0] - c[0]);
while a1 <= a0 + 1e-12 {
a1 += std::f64::consts::TAU;
}
Ok(Arc { c, rad, a0, a1 })
}
/// A surface of revolution (120) whose generatrix is the circular arc `seq`, about the axis
/// through `o` along `d`. A STEP surface of revolution turns the whole of its curve, and the
/// whole circle turns into a whole sphere or torus (a sphere twice over when the arc is
/// centred on the axis): the face's trim cannot pick its piece out of that, and the face
/// meshed as the full ball. An arc in a plane through the axis is a sphere or a torus; any
/// other arc turns as an exact rational B-spline of just the arc.
fn revolved_arc(&mut self, seq: i64, xf: &Xf, o: V3, d: V3) -> R<u64> {
let x = xf.then(&self.own_xf(seq, 0)?);
let arc = self.arc(seq)?;
let n = unit(x.dir([0.0, 0.0, 1.0])).ok_or("degenerate arc frame")?;
let (centre, rad) = (x.point(arc.c), arc.rad * x.scale());
let off = sub(centre, o);
let h = dot(off, d);
let rho = norm(sub(off, mul(d, h)));
let tol = self.tol.max(rad * 1e-9);
if dot(n, d).abs() < 1e-7 && dot(off, n).abs() <= tol {
let ax = self.o.placement(add(o, mul(d, h)), d, perp(d));
if rho <= tol {
return Ok(self.o.add(format!("SPHERICAL_SURFACE('',#{ax},{})", r(rad))));
}
if rad < rho {
return Ok(self.o.add(format!("TOROIDAL_SURFACE('',#{ax},{},{})", r(rho), r(rad))));
}
}
let nb = arc.nurbs(&x);
let curve = self.write_nurbs_curve(&nb);
let (pa, dd) = (self.o.point(o), self.o.dir(d));
let a1 = self.o.add(format!("AXIS1_PLACEMENT('',#{pa},#{dd})"));
Ok(self.o.add(format!("SURFACE_OF_REVOLUTION('',#{curve},#{a1})")))
}
fn nurbs_curve(&self, seq: i64, x: &Xf) -> R<Nurbs> {
self.nurbs_curve_in(seq, x, self.f.unit)
}
fn nurbs_curve_in(&self, seq: i64, x: &Xf, unit: f64) -> R<Nurbs> {
/// Knot spans shorter than this are moved onto (0, 1).
const MIN_KNOT_SPAN: f64 = 1e-4;
let k = self.i(seq, 1)?;
let m = self.i(seq, 2)?;
if !(0..=MAX_LIST as i64).contains(&k) || !(1..=32).contains(&m) || m > k.max(1) + 1 {
@@ -591,17 +685,27 @@ impl<'a> Tr<'a> {
if !(w > 0.0) {
return Err(format!("entity {seq}: non-positive weight"));
}
let p = x.point(self.pt(seq, base_p + 3 * j)?);
let p = x.point(self.pt_in(seq, base_p + 3 * j, unit)?);
pts.push([p[0] * w, p[1] * w, p[2] * w, w]);
}
if knots.windows(2).any(|w| w[1] < w[0]) {
return Err(format!("entity {seq}: knots out of order"));
}
let nb = Nurbs { deg: m, knots, pts };
let mut nb = Nurbs { deg: m, knots, pts };
let (a, b) = nb.range();
if !(b > a) {
return Err(format!("entity {seq}: empty parameter range"));
}
// Some exporters write a curve over a knot span of 1e-7 or less. The STEP side reads a
// span that small as a single value and refused the curve, losing the faces it bounds.
// Moving and scaling the knots onto (0, 1) leaves the curve's shape as it is; nothing
// else refers to its parameters.
if b - a < MIN_KNOT_SPAN {
let scale = 1.0 / (b - a);
for k in &mut nb.knots {
*k = (*k - a) * scale;
}
}
Ok(nb)
}
@@ -784,6 +888,9 @@ impl<'a> Tr<'a> {
let ax_x = x.then(&self.own_xf(axis, 0)?);
let (a, b) = (ax_x.point(self.pt(axis, 1)?), ax_x.point(self.pt(axis, 4)?));
let d = unit(sub(b, a)).ok_or("zero-length revolution axis")?;
if self.de(generatrix)?.typ == 100 {
return self.revolved_arc(generatrix, &x, a, d);
}
let segs = self.curve(generatrix, &x, 1)?;
let [seg] = segs.as_slice() else { return Err(format!("entity {seq}: composite generatrix is not read")) };
let (pa, dd) = (self.o.point(a), self.o.dir(d));
@@ -806,6 +913,61 @@ impl<'a> Tr<'a> {
}
}
/// The start point of a curve in a surface's parameter space (the B curve of a curve on a
/// surface, 142): its (u, v) are the first two coordinates. Lines, arcs, B-splines and
/// composites of them, as the specification lists for parameter-space curves.
fn param_start(&self, seq: i64, depth: usize) -> R<V3> {
if depth > MAX_DEPTH {
return Err("parameter-space curves nested too deeply".into());
}
let de = self.de(seq)?;
let x = self.own_xf_in(seq, 0, 1.0)?;
let p = match de.typ {
110 => self.pt_in(seq, 1, 1.0)?,
100 => [self.n(seq, 4)?, self.n(seq, 5)?, self.n(seq, 1)?],
126 => {
let nb = self.nurbs_curve_in(seq, &Xf::ID, 1.0)?;
nb.eval(nb.range().0).ok_or_else(|| format!("entity {seq}: B-spline cannot be evaluated"))?
}
102 if self.i(seq, 1)? > 0 => self.param_start(self.i(seq, 2)?, depth + 1)?,
t => return Err(format!("parameter-space curve type {t} is not read")),
};
Ok(x.point(p))
}
/// The point of a B-spline surface (128) at parameters (u, v), placed as `surface` places it.
fn surface_point(&self, seq: i64, xf: &Xf, u: f64, v: f64) -> R<V3> {
if self.de(seq)?.typ != 128 {
return Err(format!("entity {seq}: only B-spline surfaces are evaluated"));
}
let x = xf.then(&self.own_xf(seq, 0)?);
let (k1, k2, m1, m2) = (self.i(seq, 1)?, self.i(seq, 2)?, self.i(seq, 3)?, self.i(seq, 4)?);
if !(0..=4096).contains(&k1) || !(0..=4096).contains(&k2) || !(1..=32).contains(&m1) || !(1..=32).contains(&m2) {
return Err(format!("entity {seq}: bad B-spline surface size"));
}
let (k1, k2, m1, m2) = (k1 as usize, k2 as usize, m1 as usize, m2 as usize);
let (n1, n2) = (k1 + m1 + 2, k2 + m2 + 2);
let s_knots: Vec<f64> = (0..n1).map(|j| self.n(seq, 10 + j)).collect::<R<_>>()?;
let t_knots: Vec<f64> = (0..n2).map(|j| self.n(seq, 10 + n1 + j)).collect::<R<_>>()?;
let base_w = 10 + n1 + n2;
let base_p = base_w + (k1 + 1) * (k2 + 1);
// Each row of the net at u, then the column of those points at v (homogeneous, so the
// weights combine as the rational tensor product does).
let mut column = Vec::with_capacity(k2 + 1);
for j in 0..=k2 {
let mut row = Vec::with_capacity(k1 + 1);
for i in 0..=k1 {
let idx = j * (k1 + 1) + i;
let w = self.n(seq, base_w + idx)?;
let p = x.point(self.pt(seq, base_p + 3 * idx)?);
row.push([p[0] * w, p[1] * w, p[2] * w, w]);
}
let h = Nurbs { deg: m1, knots: s_knots.clone(), pts: row }.eval_h(u).ok_or("surface cannot be evaluated")?;
column.push(h);
}
Nurbs { deg: m2, knots: t_knots, pts: column }.eval(v).ok_or_else(|| "surface cannot be evaluated".into())
}
/// The natural boundary of a B-spline surface (its four edge curves), for trimmed surfaces
/// with no outer curve.
fn natural_boundary(&mut self, seq: i64, xf: &Xf) -> R<Vec<Seg>> {
@@ -874,24 +1036,35 @@ struct Sewer {
}
impl Sewer {
fn vertex(&mut self, p: V3) -> usize {
fn key(&self, p: V3) -> [i64; 3] {
// Neighbouring buckets are this key plus or minus one, so leave room for that: a
// coordinate near f64's range saturates the cast and the addition would overflow.
let c = |x: f64| (x / self.tol).floor().clamp(-9e15, 9e15) as i64;
let key = [c(p[0]), c(p[1]), c(p[2])];
[c(p[0]), c(p[1]), c(p[2])]
}
/// The vertex within the tolerance of `p`, if there is one.
fn find(&self, p: V3) -> Option<usize> {
let key = self.key(p);
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
if let Some(list) = self.grid.get(&[key[0] + dx, key[1] + dy, key[2] + dz]) {
for &i in list {
if self.verts.get(i).is_some_and(|q| norm(sub(*q, p)) <= self.tol) {
return i;
return Some(i);
}
}
}
}
}
}
None
}
fn vertex(&mut self, p: V3) -> usize {
if let Some(i) = self.find(p) {
return i;
}
let key = self.key(p);
self.verts.push(p);
self.grid.entry(key).or_default().push(self.verts.len() - 1);
self.verts.len() - 1
@@ -1067,12 +1240,21 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
}
let f = parse(text)?;
let tol = (f.resolution * f.unit).clamp(1e-6, 0.05).max(1e-5);
let mut t =
Tr { f: &f, o: Out { lines: Vec::new() }, warnings: Vec::new(), tol, surf_cache: HashMap::new(), curve_work: 0, composites: Vec::new() };
let mut t = Tr {
f: &f,
o: Out { lines: Vec::new() },
warnings: Vec::new(),
tol,
surf_cache: HashMap::new(),
curve_work: 0,
composites: Vec::new(),
snapped: 0.0,
};
// Header: units and context.
let mm = t.o.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
let rad = t.o.add("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))");
let sr = t.o.add("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())");
let unc_line = t.o.lines.len();
let unc = t.o.add(format!("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{mm},'distance_accuracy_value','')", r(tol)));
let ctx = t.o.add(format!(
"(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{unc}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{mm},#{rad},#{sr}))REPRESENTATION_CONTEXT('',''))"
@@ -1082,77 +1264,49 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
// Entities used by others (a B-rep's faces, a trimmed surface's curves) are not roots;
// physically dependent entities have status digits 01 in columns 3–4, but files are lax:
// collect references instead.
let mut solids: Vec<(u64, String)> = Vec::new();
let assembly = assembly::Assembly::read(&t, &seqs)?;
let mut solids = HashMap::new();
let mut styles: Vec<(u64, [f32; 3])> = Vec::new();
let mut n = 0usize;
let mut fallback = String::new();
let watch = crate::watch::current();
for &s in &seqs {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
let Some(de) = f.des.get(&s) else { continue };
if de.typ != 186 {
continue;
}
n += 1;
// A name property (406 form 15) among the solid's trailing pointers, else the label.
let named = f.params.get(&s).and_then(|p| {
p.iter().skip(4).filter_map(|x| int(x)).filter(|x| *x > 0).find_map(|ptr| {
let d = f.des.get(&usize::try_from(ptr).ok()?)?;
(d.typ == 406 && d.form == 15).then(|| f.params.get(&usize::try_from(ptr).ok()?)?.get(2).cloned()).flatten()
})
});
let name = match named.filter(|x| !x.trim().is_empty()) {
Some(x) => x.trim().to_string(),
None if de.label.is_empty() => format!("Body{n}"),
None => de.label.clone(),
let name = if let Some(name) = assembly::entity_name(&f, s) {
name
} else {
fallback.clear();
let _ = write!(fallback, "Body{n}");
fallback.as_str()
};
match brep(&mut t, s as i64, &mut styles) {
Ok((id, color)) => {
let named = {
// Re-label the solid with its name.
let line = t.o.lines.get(id as usize - 1).cloned().unwrap_or_default();
line.replacen("('',", &format!("('{}',", name.replace('\'', "''")), 1)
};
if let Some(l) = t.o.lines.get_mut(id as usize - 1) {
*l = named;
*l = l.replacen("('',", &format!("('{}',", name.replace('\'', "''")), 1);
}
if let Some(c) = color {
styles.push((id, c));
}
solids.push((id, name));
solids.insert(s, id);
}
Err(e) => t.warn(format!("solid {name}: not read: {e}")),
}
}
// Trimmed and bounded surfaces not used by anything else: sewn into shells.
let mut faces = Vec::new();
let mut sw = Sewer { tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() };
if solids.is_empty() {
for &s in &seqs {
let Some(de) = f.des.get(&s) else { continue };
if de.typ != 144 && de.typ != 143 && !(de.typ == 128 && is_root(&f, s)) {
continue;
}
match trimmed(&mut t, &mut sw, s as i64) {
Ok(fb) => faces.push(fb),
Err(e) => t.warn(format!("surface {s}: not read: {e}")),
}
}
if !faces.is_empty() {
orient(&mut faces);
let (shell, closed) = write_shell(&mut t.o, &sw, &faces, &mut styles);
let item = if closed {
t.o.add(format!("MANIFOLD_SOLID_BREP('Body1',#{shell})"))
} else {
t.warn("the surfaces do not close into a solid: imported as an open body");
t.o.add(format!("SHELL_BASED_SURFACE_MODEL('Body1',(#{shell}))"))
};
solids.push((item, "Body1".into()));
assembly.write(&mut t, &seqs, &solids, &mut styles, ctx)?;
if t.snapped > 0.0 {
// The STEP reader takes vertices up to ten times the stated accuracy off their curves:
// state enough to cover the ends `close_gaps` moved, and no more.
if let Some(l) = t.o.lines.get_mut(unc_line) {
*l = format!("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{mm},'distance_accuracy_value','')", r(tol.max(t.snapped * 0.2)));
}
}
if solids.is_empty() {
let why = t.warnings.first().cloned().unwrap_or_else(|| "no solids or trimmed surfaces".into());
return Err(format!("IGES import: nothing to read ({why})"));
}
let items = solids.iter().map(|(i, _)| format!("#{i}")).collect::<Vec<_>>().join(",");
t.o.add(format!("ADVANCED_BREP_SHAPE_REPRESENTATION('',({items}),#{ctx})"));
for (item, c) in styles {
let rgb = t.o.add(format!("COLOUR_RGB('',{},{},{})", r(c[0] as f64), r(c[1] as f64), r(c[2] as f64)));
let fasc = t.o.add(format!("FILL_AREA_STYLE_COLOUR('',#{rgb})"));
@@ -1173,23 +1327,140 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
Ok((s, t.warnings))
}
/// A surface entity nobody refers to (a bare B-spline patch is a face of its own).
fn is_root(f: &Iges, seq: usize) -> bool {
let target = seq.to_string();
!f.params
.iter()
.any(|(s, p)| *s != seq && f.des.get(s).is_some_and(|d| d.typ != 406 && d.typ != 402) && p.iter().skip(1).any(|x| x.trim() == target))
/// Sew surfaces within their owning group, keeping different parts separate.
fn sew_surfaces(
t: &mut Tr,
seqs: impl Iterator<Item = usize>,
name: &str,
styles: &mut Vec<(u64, [f32; 3])>,
faces: &mut Vec<FaceB>,
sw: &mut Sewer,
) -> R<Option<u64>> {
faces.clear();
sw.verts.clear();
sw.grid.clear();
sw.edges.clear();
sw.by_ends.clear();
let watch = crate::watch::current();
let mut drafts = Vec::new();
for seq in seqs {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
match trimmed(t, sw.tol, seq as i64) {
Ok(face) => drafts.push(face),
Err(e) => t.warn(format!("surface {seq}: not read: {e}")),
}
}
close_gaps(t, &mut drafts, sw.tol);
for d in drafts {
let loops = d.loops.iter().map(|l| l.iter().map(|s| sw.edge(s)).collect()).collect();
faces.push(FaceB { surface: d.surface, sense: true, loops, color: d.color });
}
if faces.is_empty() {
return Ok(None);
}
orient(faces);
let (shell, closed) = write_shell(&mut t.o, sw, faces, styles);
let name = name.replace('\'', "''");
Ok(Some(if closed {
t.o.add(format!("MANIFOLD_SOLID_BREP('{name}',#{shell})"))
} else {
t.warn("the surfaces do not close into a solid: imported as an open body");
t.o.add(format!("SHELL_BASED_SURFACE_MODEL('{name}',(#{shell}))"))
}))
}
/// A face read from a trimmed or bounded surface, its loops not yet sewn.
struct Draft {
surface: u64,
loops: Vec<Vec<Seg>>,
color: Option<[f32; 3]>,
}
/// Gaps between the pieces of a trim loop wider than this (mm) are left open: past it, the ends
/// may be different corners of the model rather than one corner written loosely.
const MAX_TRIM_GAP: f64 = 0.1;
/// ...and a gap must also be at most this share of the shorter piece on either side.
const MAX_TRIM_GAP_SHARE: f64 = 0.05;
/// Close the small gaps between consecutive pieces of the faces' trim loops. Some exporters
/// write each piece's model-space curve separately, and consecutive pieces can then miss by a
/// few micrometres to a tenth of a millimetre, well past the file's stated resolution: the loop
/// does not close, its face is lost and the shell stays open. The two ends of each such gap, and
/// every other piece end at either of them (the neighbouring faces at that corner), move to
/// their mean, so all the faces there sew to one vertex. A gap wider than `MAX_TRIM_GAP`, or not
/// small next to its pieces, is left open; the sewing tolerance stays the file's.
fn close_gaps(t: &mut Tr, faces: &mut [Draft], tol: f64) {
let length = |s: &Seg| norm(sub(s.q, s.a)) + norm(sub(s.mid, s.q)) + norm(sub(s.q3, s.mid)) + norm(sub(s.b, s.q3));
// The gaps' ends as vertices, joined into groups (union-find).
let mut ends = Sewer { tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() };
let mut parent: Vec<usize> = Vec::new();
let root = |parent: &[usize], mut i: usize| {
while let Some(&p) = parent.get(i).filter(|p| **p != i) {
i = p;
}
i
};
for l in faces.iter().flat_map(|f| f.loops.iter()) {
for (i, prev) in l.iter().enumerate() {
let Some(next) = l.get((i + 1) % l.len()) else { continue };
let gap = norm(sub(next.a, prev.b));
if gap <= tol || gap > MAX_TRIM_GAP || gap > MAX_TRIM_GAP_SHARE * length(prev).min(length(next)) {
continue;
}
let (a, b) = (ends.vertex(prev.b), ends.vertex(next.a));
while parent.len() < ends.verts.len() {
parent.push(parent.len());
}
let (ra, rb) = (root(&parent, a), root(&parent, b));
if let Some(x) = parent.get_mut(ra) {
*x = rb;
}
}
}
if ends.verts.is_empty() {
return;
}
// Each group's mean; a group that spreads wider than a gap may be is left alone.
let mut sums: HashMap<usize, (V3, usize)> = HashMap::new();
for (i, p) in ends.verts.iter().enumerate() {
let e = sums.entry(root(&parent, i)).or_insert(([0.0; 3], 0));
e.0 = add(e.0, *p);
e.1 += 1;
}
let mean: HashMap<usize, V3> = sums.into_iter().map(|(r, (s, n))| (r, mul(s, 1.0 / n as f64))).collect();
let mut to: Vec<Option<V3>> = ends.verts.iter().enumerate().map(|(i, _)| mean.get(&root(&parent, i)).copied()).collect();
for (i, p) in ends.verts.iter().enumerate() {
if to.get(i).copied().flatten().is_some_and(|m| norm(sub(m, *p)) > MAX_TRIM_GAP / 2.0) {
let r = root(&parent, i);
for (k, x) in to.iter_mut().enumerate() {
if root(&parent, k) == r {
*x = None;
}
}
}
}
let mut moved = 0.0f64;
let mut snap = |p: &mut V3| {
if let Some(m) = ends.find(*p).and_then(|i| to.get(i).copied().flatten()) {
moved = moved.max(norm(sub(m, *p)));
*p = m;
}
};
for s in faces.iter_mut().flat_map(|f| f.loops.iter_mut()).flat_map(|l| l.iter_mut()) {
snap(&mut s.a);
snap(&mut s.b);
}
t.snapped = t.snapped.max(moved);
}
/// A trimmed (144) or bounded (143) surface, or a bare B-spline surface, as a face.
fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R<FaceB> {
fn trimmed(t: &mut Tr, tol: f64, seq: i64) -> R<Draft> {
let de = t.de(seq)?;
let xf = t.own_xf(seq, 0)?;
let color = color_of(t, seq);
let loop_of = |_t: &mut Tr, sw: &mut Sewer, segs: Vec<Seg>| -> Vec<(usize, bool)> {
let tol = sw.tol;
segs.iter().filter(|s| norm(sub(s.a, s.b)) > tol || norm(sub(s.a, s.mid)) > tol).map(|s| sw.edge(s)).collect()
};
let loop_of = |segs: Vec<Seg>| -> Vec<Seg> { segs.into_iter().filter(|s| norm(sub(s.a, s.b)) > tol || norm(sub(s.a, s.mid)) > tol).collect() };
match de.typ {
144 => {
let surf_seq = t.i(seq, 1)?;
@@ -1197,17 +1468,17 @@ fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R<FaceB> {
let n1 = t.i(seq, 2)?;
let n2 = t.i(seq, 3)?.clamp(0, MAX_LIST as i64) as usize;
let mut loops = Vec::new();
let outer = if n1 == 0 { t.natural_boundary(surf_seq, &xf)? } else { cos_curve(t, t.i(seq, 4)?, &xf)? };
loops.push(loop_of(t, sw, outer));
let outer = if n1 == 0 { t.natural_boundary(surf_seq, &xf)? } else { cos_curve(t, t.i(seq, 4)?, &xf, surf_seq)? };
loops.push(loop_of(outer));
for k in 0..n2 {
let segs = cos_curve(t, t.i(seq, 5 + k)?, &xf)?;
loops.push(loop_of(t, sw, segs));
let segs = cos_curve(t, t.i(seq, 5 + k)?, &xf, surf_seq)?;
loops.push(loop_of(segs));
}
loops.retain(|l| !l.is_empty());
if loops.is_empty() {
return Err("no boundary".into());
}
Ok(FaceB { surface, sense: true, loops, color })
Ok(Draft { surface, loops, color })
}
143 => {
let surf_seq = t.i(seq, 2)?;
@@ -1217,25 +1488,33 @@ fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R<FaceB> {
for k in 0..n {
let b = t.i(seq, 4 + k)?;
let segs = boundary_141(t, b, &xf)?;
loops.push(loop_of(t, sw, segs));
loops.push(loop_of(segs));
}
loops.retain(|l| !l.is_empty());
if loops.is_empty() {
return Err("no boundary".into());
}
Ok(FaceB { surface, sense: true, loops, color })
Ok(Draft { surface, loops, color })
}
_ => {
let surface = t.surface(seq, &Xf::ID)?;
let outer = t.natural_boundary(seq, &Xf::ID)?;
let l = loop_of(t, sw, outer);
Ok(FaceB { surface, sense: true, loops: vec![l], color })
let l = loop_of(outer);
Ok(Draft { surface, loops: vec![l], color })
}
}
}
/// The model-space curve of a curve on a surface (142).
fn cos_curve(t: &mut Tr, seq: i64, xf: &Xf) -> R<Vec<Seg>> {
/// The model-space curve of a curve on a surface (142) bounding a trimmed `surface`, its pieces
/// head to tail.
///
/// A curve on a surface is one curve given twice: B in the surface's parameter space and C in
/// model space, both running the same way (C = S∘B). A composite C must run each piece on from
/// the end of the one before, but some exporters write its pieces in order with some of them
/// reversed, so the loop does not close and the face is lost. The pieces are turned to run on,
/// starting where S(B) starts (the end of the first piece there), so the loop also keeps the
/// sense the file gives it (outer loop and holes the opposite ways round).
fn cos_curve(t: &mut Tr, seq: i64, xf: &Xf, surface: i64) -> R<Vec<Seg>> {
let de = t.de(seq)?;
if de.typ != 142 {
return t.curve(seq, xf, 0);
@@ -1245,7 +1524,27 @@ fn cos_curve(t: &mut Tr, seq: i64, xf: &Xf) -> R<Vec<Seg>> {
if c <= 0 {
return Err("a curve on a surface without its model-space curve".into());
}
t.curve(c, &x, 0)
let segs = t.curve(c, &x, 0)?;
let b = t.i(seq, 3).unwrap_or(0);
let start = if b > 0 { t.param_start(b, 0).and_then(|p| t.surface_point(surface, xf, p[0], p[1])).ok() } else { None };
Ok(head_to_tail(segs, start, t.tol))
}
/// Pieces of a composite curve turned so that each starts where the one before ends, the first
/// at the end nearer `start` (when known). A closed piece (its ends within `tol`, the sewing
/// tolerance that decides they are one vertex) keeps its direction.
fn head_to_tail(segs: Vec<Seg>, start: Option<V3>, tol: f64) -> Vec<Seg> {
let mut prev = start;
segs.into_iter()
.map(|s| {
let s = match prev {
Some(p) if norm(sub(s.a, s.b)) > tol && norm(sub(s.b, p)) < norm(sub(s.a, p)) => s.reversed(),
_ => s,
};
prev = Some(s.b);
s
})
.collect()
}
/// The model-space curves of a boundary (141), each in its sense.
+280
View File
@@ -0,0 +1,280 @@
//! IGES groups (402) and subfigures (308/408), from the published IGES specification.
//! Definitions become STEP products; instances refer to them without copying geometry.
//! Reference: https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nbsir88-3813.pdf
use super::*;
use std::collections::HashSet;
const MAX_GROUPS: usize = 10_000;
const MAX_INSTANCES: usize = 100_000;
const MAX_REFERENCES: usize = 4 * MAX_ENTITIES;
struct Group {
seq: usize,
members: Vec<usize>,
}
struct Instance {
definition: usize,
transform: Xf,
}
pub(super) struct Assembly {
groups: Vec<Group>,
indices: HashMap<usize, usize>,
instances: HashMap<usize, Instance>,
referenced: HashSet<usize>,
surface_references: HashSet<usize>,
}
impl Assembly {
pub(super) fn read(t: &Tr, seqs: &[usize]) -> R<Self> {
let mut out = Self {
groups: Vec::new(),
indices: HashMap::new(),
instances: HashMap::new(),
referenced: HashSet::new(),
surface_references: HashSet::new(),
};
let mut references = 0usize;
let watch = crate::watch::current();
for &seq in seqs {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
let de = t.de(seq as i64)?;
if de.typ == 308 || (de.typ == 402 && matches!(de.form, 1 | 7 | 14 | 15)) {
if out.groups.len() >= MAX_GROUPS {
return Err("too many IGES assembly definitions".into());
}
let field = if de.typ == 308 { 3 } else { 1 };
let count = usize::try_from(t.i(seq as i64, field)?).map_err(|_| "negative IGES group size")?;
references = references.saturating_add(count);
if count > MAX_LIST || references > MAX_REFERENCES {
return Err("IGES assembly references exceed the work limit".into());
}
let p = t.p(seq as i64)?;
let entries = p.get(field + 1..field + 1 + count).ok_or("truncated IGES group")?;
let mut members = Vec::with_capacity(count);
for entry in entries {
let child = int(entry).and_then(|x| usize::try_from(x).ok()).filter(|x| *x > 0).ok_or("invalid IGES group reference")?;
t.de(child as i64)?;
members.push(child);
out.referenced.insert(child);
}
out.indices.insert(seq, out.groups.len());
out.groups.push(Group { seq, members });
} else if de.typ == 408 {
if out.instances.len() >= MAX_INSTANCES {
return Err("too many IGES assembly instances".into());
}
let definition = usize::try_from(t.i(seq as i64, 1)?).map_err(|_| "invalid IGES subfigure reference")?;
if t.de(definition as i64)?.typ != 308 {
return Err("IGES instance does not refer to a subfigure definition".into());
}
let p = t.p(seq as i64)?;
let scale = match p.get(5).map(|s| s.trim()) {
None | Some("") => 1.0,
Some(s) => num(s).filter(|x| *x != 0.0).ok_or("invalid IGES instance scale")?,
};
let [x, y, z] = t.pt(seq as i64, 2)?;
let local = Xf([[scale, 0.0, 0.0, x], [0.0, scale, 0.0, y], [0.0, 0.0, scale, z]]);
let transform = t.own_xf(seq as i64, 0)?.then(&local).then(&t.own_xf(definition as i64, 0)?);
if !transform.0.iter().flatten().all(|v| v.is_finite()) {
return Err("non-finite IGES instance placement".into());
}
out.instances.insert(seq, Instance { definition, transform });
out.referenced.insert(definition);
}
}
// Index geometry references once instead of rescanning all parameters for each patch.
for (&seq, params) in &t.f.params {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
if t.f.des.get(&seq).is_some_and(|d| matches!(d.typ, 308 | 402 | 406 | 408)) {
continue;
}
for p in params.iter().skip(1) {
let p = p.trim();
if p.starts_with('0') {
continue;
}
if let Ok(id) = p.parse::<usize>()
&& id != seq
&& t.f.des.get(&id).is_some_and(|d| d.typ == 128)
{
out.surface_references.insert(id);
}
}
}
let mut depths = HashMap::with_capacity(out.groups.len());
let mut placements = out.groups.iter().filter(|g| !out.referenced.contains(&g.seq)).count()
+ out.instances.keys().filter(|s| !out.referenced.contains(*s)).count();
for group in &out.groups {
placements += group.members.iter().filter(|s| out.indices.contains_key(*s) || out.instances.contains_key(*s)).count();
if placements > MAX_INSTANCES {
return Err("too many IGES assembly placements".into());
}
out.depth(group.seq, 0, &mut depths)?;
}
Ok(out)
}
fn depth(&self, seq: usize, depth: usize, seen: &mut HashMap<usize, Option<usize>>) -> R<usize> {
if depth >= MAX_DEPTH {
return Err("IGES assembly nesting is too deep".into());
}
if let Some(height) = seen.get(&seq) {
return height.ok_or_else(|| "cyclic IGES assembly".into());
}
seen.insert(seq, None);
let group = self.indices.get(&seq).and_then(|i| self.groups.get(*i)).ok_or("missing IGES assembly definition")?;
let mut height = 1;
for child in &group.members {
let definition = self.instances.get(child).map(|i| i.definition).unwrap_or(*child);
if self.indices.contains_key(&definition) {
height = height.max(1 + self.depth(definition, depth + 1, seen)?);
}
}
if height > MAX_DEPTH {
return Err("IGES assembly nesting is too deep".into());
}
seen.insert(seq, Some(height));
Ok(height)
}
pub(super) fn write(&self, t: &mut Tr, seqs: &[usize], solids: &HashMap<usize, u64>, styles: &mut Vec<(u64, [f32; 3])>, ctx: u64) -> R<()> {
let app = t.o.add("APPLICATION_CONTEXT('mechanical design')");
let pc = t.o.add(format!("PRODUCT_CONTEXT('',#{app},'mechanical')"));
let dc = t.o.add(format!("PRODUCT_DEFINITION_CONTEXT('part definition',#{app},'design')"));
let origin = t.o.placement([0.0; 3], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let mut products = HashMap::with_capacity(self.groups.len() + 1);
let mut items = Vec::new();
let mut list = String::new();
let mut fallback = String::new();
let mut made = 0usize;
let f = t.f;
let mut faces = Vec::new();
let mut sewer = Sewer { tol: t.tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() };
let watch = crate::watch::current();
// A synthetic root holds loose geometry and top-level groups/instances.
for group in std::iter::once(None).chain(self.groups.iter().map(Some)) {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
let seq = group.map(|g| g.seq).unwrap_or(0);
let name = if seq == 0 {
"Import"
} else if let Some(name) = entity_name(f, seq) {
name
} else {
fallback.clear();
let _ = write!(fallback, "Group {seq}");
fallback.as_str()
};
items.clear();
let members = group.map(|g| g.members.as_slice()).unwrap_or(seqs);
items.extend(members.iter().filter(|s| seq != 0 || !self.referenced.contains(*s)).filter_map(|s| solids.get(s).copied()));
let surfaces = members
.iter()
.copied()
.filter(|s| seq != 0 || !self.referenced.contains(s))
.filter(|s| f.des.get(s).is_some_and(|d| matches!(d.typ, 143 | 144) || (d.typ == 128 && !self.surface_references.contains(s))));
if let Some(item) = sew_surfaces(t, surfaces, if seq == 0 { "Body1" } else { name }, styles, &mut faces, &mut sewer)? {
items.push(item);
}
made += items.len();
list.clear();
let _ = write!(list, "#{origin}");
for item in &items {
let _ = write!(list, ",#{item}");
}
let rep = t.o.add(format!("ADVANCED_BREP_SHAPE_REPRESENTATION('',({list}),#{ctx})"));
let name = name.replace('\'', "''");
let product = t.o.add(format!("PRODUCT('{name}','{name}','',(#{pc}))"));
let formation = t.o.add(format!("PRODUCT_DEFINITION_FORMATION('','',#{product})"));
let pd = t.o.add(format!("PRODUCT_DEFINITION('design','',#{formation},#{dc})"));
let shape = t.o.add(format!("PRODUCT_DEFINITION_SHAPE('','',#{pd})"));
t.o.add(format!("SHAPE_DEFINITION_REPRESENTATION(#{shape},#{rep})"));
products.insert(seq, (pd, rep));
}
if made == 0 {
let why = t.warnings.first().map(String::as_str).unwrap_or("no solids or trimmed surfaces");
return Err(format!("IGES import: nothing to read ({why})"));
}
for group in std::iter::once(None).chain(self.groups.iter().map(Some)) {
let parent = group.map(|g| g.seq).unwrap_or(0);
let members = group.map(|g| g.members.as_slice()).unwrap_or(seqs);
for &child in members {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
if parent == 0 && self.referenced.contains(&child) {
continue;
}
let (definition, transform) = match self.instances.get(&child) {
Some(i) => (i.definition, i.transform),
None if self.indices.contains_key(&child) => (child, t.own_xf(child as i64, 0)?),
_ => continue,
};
let (parent_pd, parent_rep) = products.get(&parent).copied().ok_or("missing IGES assembly parent")?;
let (child_pd, child_rep) = products.get(&definition).copied().ok_or("missing IGES assembly child")?;
let label = entity_name(f, child).unwrap_or("").replace('\'', "''");
let nauo = t.o.add(format!("NEXT_ASSEMBLY_USAGE_OCCURRENCE('{child}','{label}','',#{parent_pd},#{child_pd},$)"));
let shape = t.o.add(format!("PRODUCT_DEFINITION_SHAPE('','',#{nauo})"));
let op = transformation(&mut t.o, transform)?;
let rr = t.o.add(format!("(REPRESENTATION_RELATIONSHIP('','',#{child_rep},#{parent_rep})REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{op})SHAPE_REPRESENTATION_RELATIONSHIP())"));
t.o.add(format!("CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{rr},#{shape})"));
}
}
Ok(())
}
}
fn transformation(out: &mut Out, xf: Xf) -> R<u64> {
if !xf.0.iter().flatten().all(|v| v.is_finite()) {
return Err("non-finite IGES assembly placement".into());
}
let [x, y, z] = [xf.dir([1.0, 0.0, 0.0]), xf.dir([0.0, 1.0, 0.0]), xf.dir([0.0, 0.0, 1.0])];
let [sx, sy, sz] = [norm(x), norm(y), norm(z)];
let scale = xf.scale();
if [sx, sy, sz, scale].iter().any(|s| !s.is_finite() || *s <= 1e-300) {
return Err("singular IGES assembly placement".into());
}
let (x, y, z, o) = (out.dir(mul(x, 1.0 / sx)), out.dir(mul(y, 1.0 / sy)), out.dir(mul(z, 1.0 / sz)), out.point(xf.point([0.0; 3])));
Ok(out.add(format!("CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM('','',#{x},#{y},#{o},{},#{z},{},{})", r(sx), r(sy), r(sz))))
}
/// Name properties follow the associativity pointers; member pointers are not properties.
pub(super) fn entity_name(f: &Iges, seq: usize) -> Option<&str> {
let de = f.des.get(&seq)?;
let p = f.params.get(&seq)?;
let count = |i: usize| p.get(i).and_then(|s| int(s)).and_then(|x| usize::try_from(x).ok()).filter(|n| *n <= MAX_LIST);
let end = match de.typ {
186 => count(3)?.checked_mul(2)?.checked_add(4)?,
308 => count(3)?.checked_add(4)?,
402 => count(1)?.checked_add(2)?,
408 => 6,
_ => p.len(),
};
let property = (|| {
let after = end.checked_add(count(end)?)?.checked_add(1)?;
let n = count(after)?;
p.get(after + 1..after + 1 + n)?.iter().find_map(|s| {
let id = int(s).and_then(|x| usize::try_from(x).ok())?;
let d = f.des.get(&id)?;
if d.typ != 406 || d.form != 15 {
return None;
}
f.params.get(&id)?.get(2).and_then(|s| name_text(s))
})
})();
property.or_else(|| if de.typ == 308 { p.get(2).and_then(|s| name_text(s)) } else { None }).or_else(|| name_text(&de.label))
}
/// Match the component-name limit before copying or repeating a shared name property.
fn name_text(s: &str) -> Option<&str> {
let end = s.char_indices().nth(128).map(|(i, _)| i).unwrap_or(s.len());
let name = s.get(..end)?.trim();
(!name.is_empty()).then_some(name)
}
+333 -9
View File
@@ -85,10 +85,16 @@ fn hostile_iges_never_panics() {
/// An IGES file from (type, form, parameters) entities, in order (pointers are 2k + 1).
fn iges_file(ents: &[(i64, i64, String)]) -> String {
let placed: Vec<(i64, i64, String, i64)> = ents.iter().map(|(t, f, p)| (*t, *f, p.clone(), 0)).collect();
iges_file_placed(&placed)
}
/// Like [`iges_file`], with each entity's transformation matrix pointer (0 for none).
fn iges_file_placed(ents: &[(i64, i64, String, i64)]) -> String {
let mut out = vec![format!("{:<72}S{:>7}", "test", 1), format!("{:<72}G{:>7}", "1H,,1H;,,,,,,,,,,,1.,2,2HMM,,,,1.E-6;", 1)];
let mut p_lines: Vec<(usize, String)> = Vec::new();
let mut starts = Vec::new();
for (k, (t, _, p)) in ents.iter().enumerate() {
for (k, (t, _, p, _)) in ents.iter().enumerate() {
starts.push(p_lines.len() + 1);
let text = format!("{t},{p};");
let chars: Vec<char> = text.chars().collect();
@@ -96,9 +102,9 @@ fn iges_file(ents: &[(i64, i64, String)]) -> String {
p_lines.push((2 * k + 1, c.iter().collect()));
}
}
for (k, (t, form, _)) in ents.iter().enumerate() {
for (k, (t, form, _, xf)) in ents.iter().enumerate() {
let count = p_lines.iter().filter(|(d, _)| *d == 2 * k + 1).count();
out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", t, starts[k], 0, 0, 0, 0, 0, 0, "00000000", 2 * k + 1));
out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", t, starts[k], 0, 0, 0, 0, xf, 0, "00000000", 2 * k + 1));
out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", t, 0, 0, count, form, 0, 0, "", 0, 2 * k + 2));
}
for (i, (d, l)) in p_lines.iter().enumerate() {
@@ -108,15 +114,158 @@ fn iges_file(ents: &[(i64, i64, String)]) -> String {
out.join("\n")
}
/// A unit-cube-like box (10 × 20 × 30) made of six trimmed bilinear B-spline patches whose
/// boundaries are lines: sewn into a closed solid.
/// A spherical cap written the way analytic IGES exports write one: a surface of revolution
/// (120) whose generatrix is a short circular arc (100) centred on the axis, turned into the
/// meridian plane by its transformation matrix (124), trimmed by a circle round the axis. The arc
/// is only the part of the circle the cap needs; read as the whole circle, the revolution was a
/// whole sphere twice over and the cap meshed as the full ball.
#[test]
fn trimmed_surfaces_sew_into_a_solid() {
let c =
|i: usize| -> [f64; 3] { [if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }] };
fn an_arc_revolved_about_its_centre_is_a_cap_not_a_ball() {
let (r, polar) = (50.0f64, 30f64.to_radians());
let (rim_r, rim_z) = (r * polar.sin(), r * polar.cos());
let ents: Vec<(i64, i64, String, i64)> = vec![
// 1: the meridian plane: local x stays x, local y becomes z.
(124, 0, "1.,0.,0.,0.,0.,0.,-1.,0.,0.,1.,0.,0.".into(), 0),
// 3: the axis, 5: the arc from the rim (60° in its plane) to the pole (90°).
(110, 0, "0.,0.,0.,0.,0.,1.".into(), 0),
(100, 0, format!("0.,0.,0.,{:?},{:?},0.,{r:?}", rim_r, rim_z), 1),
// 7: the surface, 9: the rim (a full circle at the cap's height), 11: on the surface.
(120, 0, format!("3,5,0.,{:?}", std::f64::consts::TAU), 0),
(100, 0, format!("{rim_z:?},0.,0.,{rim_r:?},0.,{rim_r:?},0."), 0),
(142, 0, "0,7,0,9,2".into(), 0),
// 13: the cap.
(144, 0, "7,1,0,11".into(), 0),
];
let imp = iges_import(&iges_file_placed(&ents)).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
let mesh = imp.bodies[0].body.tessellate(0.01).unwrap();
let (mut lo, mut hi) = ([f64::MAX; 3], [f64::MIN; 3]);
for p in &mesh.positions {
for (k, x) in [p.x, p.y, p.z].into_iter().enumerate() {
lo[k] = lo[k].min(x);
hi[k] = hi[k].max(x);
}
}
for k in 0..2 {
assert!((lo[k] + rim_r).abs() < 0.1 && (hi[k] - rim_r).abs() < 0.1, "cap is {lo:?} .. {hi:?}");
}
assert!((lo[2] - rim_z).abs() < 0.1 && (hi[2] - r).abs() < 0.1, "cap is {lo:?} .. {hi:?}");
// Area of a cap: 2π r h.
let area = measure(&imp.bodies[0].body).unwrap().area;
let cap = std::f64::consts::TAU * r * (r - rim_z);
assert!(rel(area, cap) < 1e-3, "area {area} vs {cap}");
}
/// An exported box inside a named group and nested subfigures, placed twice. The second
/// placement composes its translation and scale with a directory transformation.
fn assembly_file(form: i64, scale: f64) -> String {
let body = box_solid(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0)).unwrap();
let text = iges_export_bodies(&[ExportBody { name: "Body1".into(), body: &body, color: None }], &StepHeader::default()).unwrap();
let f = parse(&text).unwrap();
let mut ids: Vec<_> = f.des.keys().copied().collect();
ids.sort_unstable();
let solid = *ids.iter().find(|&&s| f.des[&s].typ == 186).unwrap();
let mut transforms: HashMap<_, _> = f.des.iter().map(|(seq, d)| (*seq, d.xform)).collect();
let mut ents: Vec<_> = ids
.iter()
.enumerate()
.map(|(k, seq)| {
assert_eq!(*seq, 2 * k + 1);
let d = &f.des[seq];
let p = f.params[seq]
.iter()
.skip(1)
.map(|s| if s.is_empty() || num(s).is_some() { s.clone() } else { format!("{}H{s}", s.chars().count()) })
.collect::<Vec<_>>()
.join(",");
(d.typ, d.form, p)
})
.collect();
let definition = 2 * ents.len() + 1;
ents.push((308, 0, format!("0,8HLead rod,1,{solid}")));
let first = 2 * ents.len() + 1;
ents.push((408, 0, format!("{definition},10.,0.,0.,1.")));
let rotation = 2 * ents.len() + 1;
ents.push((124, 0, "0.,-1.,0.,100.,1.,0.,0.,0.,0.,0.,1.,0.".into()));
let second = 2 * ents.len() + 1;
ents.push((408, 0, format!("{definition},0.,20.,0.,{scale}")));
transforms.insert(second, rotation as i64);
let frame = 2 * ents.len() + 1;
ents.push((308, 0, format!("1,5HFrame,2,{first},{second}")));
let property = 2 * ents.len() + 1;
ents.push((406, 15, "1,6HMotion".into()));
ents.push((402, form, format!("1,{frame},0,1,{property}")));
iges_file(&ents)
.lines()
.map(|line| {
if line.as_bytes()[72] != b'D' {
return line.to_string();
}
let seq: usize = line[73..80].trim().parse().unwrap();
match transforms.get(&seq) {
Some(xf) => format!("{}{:>8}{}", &line[..48], xf, &line[56..]),
None => line.to_string(),
}
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn groups_and_subfigure_instances_keep_names_structure_and_placements() {
for (form, scale) in [(1, 1.0), (7, 2.0), (14, 1.0), (15, 2.0)] {
let imp = iges_import(&assembly_file(form, scale)).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert_eq!(imp.bodies.len(), 2);
assert_eq!(imp.tree.len(), 1);
let motion = &imp.tree[0].children[0];
assert_eq!(motion.name, "Motion");
let frame = &motion.children[0];
assert_eq!(frame.name, "Frame");
assert_eq!(frame.children.len(), 2);
assert!(frame.children.iter().all(|n| n.name == "Lead rod"));
assert!(imp.bodies.iter().all(|b| b.path == ["Import", "Motion", "Frame", "Lead rod"]));
assert_eq!(imp.bodies[0].source_item, imp.bodies[1].source_item);
let a = measure(&imp.bodies[0].body).unwrap();
let b = measure(&imp.bodies[1].body).unwrap();
assert!(rel(a.volume, 24.0) < 1e-6 && rel(b.volume, 24.0 * scale.powi(3)) < 1e-6);
assert!(a.bbox.min.dist(Vec3::new(10.0, 0.0, 0.0)) < 1e-6 && a.bbox.max.dist(Vec3::new(12.0, 3.0, 4.0)) < 1e-6);
assert!(b.bbox.min.dist(Vec3::new(80.0 - 3.0 * scale, 0.0, 0.0)) < 1e-6);
assert!(b.bbox.max.dist(Vec3::new(80.0, 2.0 * scale, 4.0 * scale)) < 1e-6);
}
}
#[test]
fn invalid_assembly_references_and_counts_fail_before_geometry() {
for (params, message) in [
("0,4HLoop,1,1".into(), "cyclic"),
("0,4HPart,1,999".into(), "missing entity"),
("0,4HPart,-1".into(), "negative IGES group size"),
(format!("0,4HPart,{}", MAX_LIST + 1), "work limit"),
] {
let error = iges_import(&iges_file(&[(308, 0, params)])).unwrap_err();
assert!(matches!(&error, crate::KernelError::Invalid(text) if text.contains(message)), "{error}");
}
let mut chain = Vec::new();
for k in 0..=MAX_DEPTH {
chain.push((308, 0, if k == MAX_DEPTH { "0,4HPart,0".into() } else { format!("0,4HPart,1,{}", 2 * k + 3) }));
}
let error = iges_import(&iges_file(&chain)).unwrap_err();
assert!(matches!(&error, crate::KernelError::Invalid(text) if text.contains("nesting is too deep")), "{error}");
let members = "1,".repeat(100_000);
let many = [(308, 0, "0,4HPart,0".into()), (308, 0, format!("1,5HFrame,100001,{members}1"))];
let error = iges_import(&iges_file(&many)).unwrap_err();
assert!(matches!(&error, crate::KernelError::Invalid(text) if text.contains("too many IGES assembly placements")), "{error}");
}
/// Six trimmed bilinear patches with line boundaries, forming a 10 × 20 × 30 box.
fn trimmed_box(ents: &mut Vec<(i64, i64, String)>, x: f64) -> Vec<usize> {
let c = |i: usize| -> [f64; 3] {
[x + if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }]
};
// Faces as corner quads (counter-clockwise seen from outside).
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
let mut ents: Vec<(i64, i64, String)> = Vec::new();
let mut faces = Vec::new();
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
for q in quads {
let (a, b, cc, d) = (c(q[0]), c(q[1]), c(q[2]), c(q[3]));
@@ -134,7 +283,15 @@ fn trimmed_surfaces_sew_into_a_solid() {
ents.push((142, 0, format!("0,{s_de},0,{comp},2")));
let cos = 2 * ents.len() - 1;
ents.push((144, 0, format!("{s_de},1,0,{cos}")));
faces.push(2 * ents.len() - 1);
}
faces
}
#[test]
fn trimmed_surfaces_sew_into_a_solid() {
let mut ents = Vec::new();
trimmed_box(&mut ents, 0.0);
let imp = iges_import(&iges_file(&ents)).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert!(imp.bodies[0].closed);
@@ -142,6 +299,49 @@ fn trimmed_surfaces_sew_into_a_solid() {
assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area);
}
/// Trim pieces whose ends miss each other by a hundredth of a millimetre, far past the file's
/// stated resolution: the loop is closed and the box sews into a solid. A gap of half a
/// millimetre is no corner written loosely, and stays open.
#[test]
fn trim_loops_with_small_gaps_still_close() {
let with_gap = |gap: f64| {
let mut ents = Vec::new();
trimmed_box(&mut ents, 0.0);
// The edge from (0, 0, 0) towards (0, 20, 0) stops `gap` short of that corner, in both
// faces that run along it (the first face's first line, the fifth face's last).
ents[1].2 = format!("0.,0.,0.,0.,{:?},0.", 20.0 - gap);
ents[36].2 = format!("0.,{:?},0.,0.,0.,0.", 20.0 - gap);
iges_import(&iges_file(&ents))
};
let imp = with_gap(0.01).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert!(imp.bodies[0].closed);
let m = measure(&imp.bodies[0].body).unwrap();
assert!(rel(m.volume, 6000.0) < 1e-3 && rel(m.area, 2200.0) < 1e-3, "{} {}", m.volume, m.area);
if let Ok(imp) = with_gap(0.5) {
assert!(imp.bodies.iter().all(|b| !b.closed), "a 0.5 mm gap was closed");
}
}
#[test]
fn surfaces_in_separate_groups_remain_separate_parts() {
let mut ents = Vec::new();
for (name, x) in [("Left", 0.0), ("Right", 100.0)] {
let faces = trimmed_box(&mut ents, x);
let members = faces.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(",");
ents.push((308, 0, format!("0,{}H{name},{},{members}", name.len(), faces.len())));
}
let imp = iges_import(&iges_file(&ents)).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert_eq!(imp.bodies.len(), 2);
assert_eq!(imp.tree[0].children.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["Left", "Right"]);
for (body, x) in imp.bodies.iter().zip([0.0, 100.0]) {
let m = measure(&body.body).unwrap();
assert!(body.closed && rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6);
assert!(m.bbox.min.dist(Vec3::new(x, 0.0, 0.0)) < 1e-6 && m.bbox.max.dist(Vec3::new(x + 10.0, 20.0, 30.0)) < 1e-6);
}
}
#[test]
fn a_drill_point_round_trips_through_iges() {
// A blind hole with a cone tip: the tip's face closes at its apex (its seam runs out to the
@@ -209,3 +409,127 @@ fn directory_entries_cannot_share_their_way_past_the_file_size() {
assert!(error.contains("parameter data is too large"), "{error}");
assert!(t.elapsed() < std::time::Duration::from_secs(10), "took {:?}", t.elapsed());
}
/// A curve on a surface (142) for a trimmed patch: model-space lines through `corners` with the
/// pieces `reversed` written end to start (as some exporters write composite trim curves), and
/// the same loop in the patch's parameter space through `uv`. Returns the 142's pointer.
fn trim_loop(ents: &mut Vec<(i64, i64, String)>, surface: usize, corners: &[[f64; 3]], uv: &[[f64; 2]], reversed: &[usize]) -> usize {
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
let mut model = Vec::new();
let mut param = Vec::new();
for k in 0..corners.len() {
let (p0, p1) = (corners[k], corners[(k + 1) % corners.len()]);
let (p0, p1) = if reversed.contains(&k) { (p1, p0) } else { (p0, p1) };
ents.push((110, 0, format!("{},{}", fmt(p0), fmt(p1))));
model.push(2 * ents.len() - 1);
let (q0, q1) = (uv[k], uv[(k + 1) % uv.len()]);
ents.push((110, 0, format!("{},{}", fmt([q0[0], q0[1], 0.0]), fmt([q1[0], q1[1], 0.0]))));
param.push(2 * ents.len() - 1);
}
let list = |v: &[usize]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(",");
ents.push((102, 0, format!("{},{}", model.len(), list(&model))));
let c = 2 * ents.len() - 1;
ents.push((102, 0, format!("{},{}", param.len(), list(&param))));
let b = 2 * ents.len() - 1;
// Created by projection, preferring the parameter-space curve (PREF 1).
ents.push((142, 0, format!("0,{surface},{b},{c},1")));
2 * ents.len() - 1
}
/// A bilinear B-spline patch through corners `a` (u, v = 0), `b` (u = 1), `c` (u, v = 1) and
/// `d` (v = 1).
fn bilinear_patch(a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]) -> (i64, i64, String) {
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
(128, 0, format!("1,1,1,1,0,0,1,0,0,0.,0.,1.,1.,0.,0.,1.,1.,1.,1.,1.,1.,{},{},{},{},0.,1.,0.,1.", fmt(a), fmt(b), fmt(d), fmt(c)))
}
/// The box of `trimmed_surfaces_sew_into_a_solid` with some pieces of every trim curve written
/// end to start (the first piece on half the faces): each face's loop still closes and the box
/// is a solid.
#[test]
fn trim_curves_with_reversed_pieces_still_close_their_faces() {
let c =
|i: usize| -> [f64; 3] { [if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }] };
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
let mut ents: Vec<(i64, i64, String)> = Vec::new();
for (fi, q) in quads.iter().enumerate() {
let corners = q.map(c);
ents.push(bilinear_patch(corners[0], corners[1], corners[2], corners[3]));
let surface = 2 * ents.len() - 1;
let reversed: &[usize] = if fi % 2 == 0 { &[0, 2] } else { &[1, 3] };
let cos = trim_loop(&mut ents, surface, &corners, &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], reversed);
ents.push((144, 0, format!("{surface},1,0,{cos}")));
}
let imp = iges_import(&iges_file(&ents)).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert!(imp.bodies[0].closed);
let m = measure(&imp.bodies[0].body).unwrap();
assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area);
}
/// A plate with a square hole whose trim curve starts with a reversed piece: where the
/// parameter-space curve starts decides which way the hole runs, so it stays a hole.
#[test]
fn a_hole_whose_first_trim_piece_is_reversed_stays_a_hole() {
let mut ents: Vec<(i64, i64, String)> = vec![bilinear_patch([0.0; 3], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0], [0.0, 10.0, 0.0])];
let outer = trim_loop(
&mut ents,
1,
&[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0], [0.0, 10.0, 0.0]],
&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
&[],
);
// Clockwise about the patch's normal, as a hole runs.
let hole = trim_loop(
&mut ents,
1,
&[[3.0, 3.0, 0.0], [3.0, 7.0, 0.0], [7.0, 7.0, 0.0], [7.0, 3.0, 0.0]],
&[[0.3, 0.3], [0.3, 0.7], [0.7, 0.7], [0.7, 0.3]],
&[0],
);
ents.push((144, 0, format!("1,1,1,{outer},{hole}")));
let imp = iges_import(&iges_file(&ents)).unwrap();
assert_eq!(imp.bodies.len(), 1);
assert!(!imp.warnings.iter().any(|w| w.contains("not read") || w.contains("face #")), "{:?}", imp.warnings);
let m = measure(&imp.bodies[0].body).unwrap();
assert!(rel(m.area, 84.0) < 1e-6, "area {}", m.area);
}
/// The box of `trimmed_surfaces_sew_into_a_solid` with one edge written as a B-spline (126)
/// over a knot span of 1e-7 or less, as some exporters write short curves: the STEP side read
/// that span as a single value and refused the curve, and both faces it bounds were lost.
#[test]
fn a_curve_over_a_tiny_knot_span_still_bounds_its_faces() {
let c =
|i: usize| -> [f64; 3] { [if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }] };
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
for span in [1e-7f64, 1e-9, 1e-13] {
let mut ents: Vec<(i64, i64, String)> = Vec::new();
for (fi, q) in quads.iter().enumerate() {
let (a, b, cc, d) = (c(q[0]), c(q[1]), c(q[2]), c(q[3]));
let surf = format!("1,1,1,1,0,0,1,0,0,0.,0.,1.,1.,0.,0.,1.,1.,1.,1.,1.,1.,{},{},{},{},0.,1.,0.,1.", fmt(a), fmt(b), fmt(d), fmt(cc));
ents.push((128, 0, surf));
let s_de = 2 * ents.len() - 1;
let mut lines = Vec::new();
for (k, (p0, p1)) in [(a, b), (b, cc), (cc, d), (d, a)].into_iter().enumerate() {
if fi == 0 && k == 0 {
ents.push((126, 0, format!("1,1,0,0,1,0,0.,0.,{span:E},{span:E},1.,1.,{},{},0.,{span:E},0.,0.,1.", fmt(p0), fmt(p1))));
} else {
ents.push((110, 0, format!("{},{}", fmt(p0), fmt(p1))));
}
lines.push(2 * ents.len() - 1);
}
ents.push((102, 0, format!("4,{}", lines.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(","))));
let comp = 2 * ents.len() - 1;
ents.push((142, 0, format!("0,{s_de},0,{comp},2")));
let cos = 2 * ents.len() - 1;
ents.push((144, 0, format!("{s_de},1,0,{cos}")));
}
let imp = iges_import(&iges_file(&ents)).unwrap();
assert!(imp.warnings.is_empty(), "span {span}: {:?}", imp.warnings);
assert!(imp.bodies[0].closed, "span {span}");
let m = measure(&imp.bodies[0].body).unwrap();
assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "span {span}: {} {}", m.volume, m.area);
}
}
+2
View File
@@ -153,6 +153,7 @@ mod tspline;
#[cfg(test)]
mod untouched_faces_tests;
pub mod watch;
mod whole_face;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_max, fillet_max_each};
pub use blend_corner::fillet_radii;
@@ -220,6 +221,7 @@ pub use sweep_path::{RailFit, SweepControl, SweepOrientation, sweep_path, sweep_
pub use topo::{CylinderFace, cylinder_end_at, cylinder_face, cylinder_face_at};
pub use topo::{TopoCounts, merged_topology, seam_flags};
pub use tspline::tspline_sheet;
pub use whole_face::{WholeFace, cut_takes_only_the_face, cut_tool, face_prism, matching_face};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum KernelError {
+141 -5
View File
@@ -22,15 +22,21 @@ pub struct BodyMeasure {
pub merged: TopoCounts,
}
/// Per-face sums: area, 6 × signed volume and 24 × first moments (divergence theorem, origin 0).
fn face_sums(m: &Mesh, nf: usize) -> Vec<(f64, f64, Vec3)> {
let mut out = vec![(0.0, 0.0, Vec3::ZERO); nf];
/// Per-face sums: area, 6 × signed volume and 24 × first moments (divergence theorem), taken
/// about `o` (the moments relative to it), and the sum of the area vectors (zero over a closed
/// surface). Over a closed surface the volume does not depend on `o`; over one that leaves a
/// gap, the gap is closed by a cone to `o`, so `o` near the body keeps that cone small.
fn face_sums(m: &Mesh, nf: usize, o: Vec3) -> Vec<(f64, f64, Vec3, Vec3)> {
let mut out = vec![(0.0, 0.0, Vec3::ZERO, Vec3::ZERO); nf];
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let (Some([a, b, c]), Some(slot)) = (m.tri(t), out.get_mut(*f as usize)) else { continue };
let n = (b - a).cross(c - a) * 0.5;
let (a, b, c) = (a - o, b - o, c - o);
let d = a.dot(b.cross(c));
slot.0 += (b - a).cross(c - a).len() * 0.5;
slot.0 += n.len();
slot.1 += d;
slot.2 += (a + b + c) * d;
slot.3 += n;
}
out
}
@@ -152,7 +158,13 @@ fn mass(b: &Body, topology: bool) -> Result<(MassMeasure, Option<Mesh>)> {
None => None,
};
let nf = b.face_count();
let (sf, sm, sc) = (face_sums(&fine, nf), face_sums(&medium, nf), face_sums(&coarse, nf));
// About the box's centre when the meshes leave only a crack: see `face_sums`. A body that is
// open on purpose (a surface, or a cup whose lid is still a separate sheet) has no volume of
// its own; it keeps the origin, so such bodies still compare with each other when the centre
// of their boxes moves (a crowned lid rises, the open cup under it doesn't change).
let (surface, gap) = face_sums(&fine, nf, Vec3::ZERO).iter().fold((0.0, Vec3::ZERO), |s, f| (s.0 + f.0, s.1 + f.3));
let o = Some(fine.bounds().center()).filter(|c| c.is_finite() && gap.len() <= 0.01 * surface).unwrap_or(Vec3::ZERO);
let (sf, sm, sc) = (face_sums(&fine, nf, o), face_sums(&medium, nf, o), face_sums(&coarse, nf, o));
let close = |x: f64, y: f64| (x - y).abs() <= 0.01 * x.abs().max(y.abs()) + 1e-9;
let (mut area, mut v6, mut mom) = (0.0, 0.0, Vec3::ZERO);
for ((f, m), c) in sf.iter().zip(&sm).zip(&sc) {
@@ -168,6 +180,13 @@ fn mass(b: &Body, topology: bool) -> Result<(MassMeasure, Option<Mesh>)> {
v6 += pick.1;
mom += pick.2;
}
// The moments back about the origin. A gap the meshes leave (a crack where a trimmed face
// meets a hole cut into it) is closed by a cone to the box's centre, which keeps it small
// (a crack of 0.3 mm² moved a part ring's volume by 7 % about the origin 1000 mm away; about
// the centre, by 0.1 %). Every body is measured by the same meshes this way, so a body and the
// pieces a boolean cuts it into add up: a closed display plan measured instead would mesh
// some faces more coarsely than the sums do and break that.
let mom = o * (4.0 * v6) + mom;
let volume = v6 / 6.0;
let centroid = if v6.abs() > 1e-12 { mom / (4.0 * v6) } else { fine.bounds().center() };
Ok((MassMeasure { volume, area, centroid, bbox: coarse.bounds().union(&fine.bounds()) }, topo))
@@ -197,4 +216,121 @@ mod tests {
assert!((got - want).abs() < 1e-3 * want, "{got} against {want}");
assert!(measure(&ring).unwrap().merged.faces > 0);
}
/// A 10 mm cube at `at`, each side in n × n squares of two triangles, one face per side;
/// without its first `open` triangles.
fn cube(at: Vec3, n: usize, open: usize) -> Mesh {
let mut m = Mesh::default();
let s = 10.0;
for axis in 0..3 {
for side in [0.0, 1.0] {
for i in 0..n {
for j in 0..n {
let corner = |a: usize, b: usize| {
let mut c = [0.0; 3];
c[axis] = side * s;
c[(axis + 1) % 3] = a as f64 * s / n as f64;
c[(axis + 2) % 3] = b as f64 * s / n as f64;
at + Vec3::new(c[0], c[1], c[2])
};
let k = m.positions.len() as u32;
m.positions.extend([corner(i, j), corner(i + 1, j), corner(i + 1, j + 1), corner(i, j + 1)]);
m.triangles.extend(if side > 0.0 { [[k, k + 1, k + 2], [k, k + 2, k + 3]] } else { [[k, k + 2, k + 1], [k, k + 3, k + 2]] });
m.tri_face.extend([(2 * axis) as u32 + side as u32; 2]);
}
}
}
}
m.triangles.drain(..open);
m.tri_face.drain(..open);
m
}
/// The summed per-face sums of `m` about `o`, with the moments about the origin (as
/// `measure` passes them on).
fn sums(m: &Mesh, o: Vec3) -> (f64, f64, Vec3, Vec3) {
let (area, v6, mom, normal) =
face_sums(m, 6, o).iter().fold((0.0, 0.0, Vec3::ZERO, Vec3::ZERO), |s, f| (s.0 + f.0, s.1 + f.1, s.2 + f.2, s.3 + f.3));
(area, v6, o * (4.0 * v6) + mom, normal)
}
/// A cube 1000 mm from the origin with one 0.5 mm² facet left out: about the box's centre
/// the sums miss a cone of that facet to the centre (0.08 %), where about the origin they are
/// 17 % over. Closed, it measures the same about either.
#[test]
fn a_mesh_left_open_is_measured_about_its_centre() {
let at = Vec3::new(1000.0, 1000.0, 1000.0);
let (closed, open) = (cube(at, 10, 0), cube(at, 10, 1));
let centre = at + Vec3::new(5.0, 5.0, 5.0);
for o in [Vec3::ZERO, centre] {
let (area, v6, mom, normal) = sums(&closed, o);
assert!((area - 600.0).abs() < 1e-9 && (v6 / 6.0 - 1000.0).abs() < 1e-6 && normal.len() < 1e-9);
assert!((mom / (4.0 * v6) - centre).len() < 1e-6 * at.len(), "{o:?}");
}
let (_, v6_origin, _, normal) = sums(&open, Vec3::ZERO);
assert!((normal.len() - 0.5).abs() < 1e-9);
// The facet faces the origin: leaving it out adds its cone to the origin, 167 mm³.
assert!((v6_origin / 6.0 - (1000.0 + 0.5 * 1000.0 / 3.0)).abs() < 1e-3, "{}", v6_origin / 6.0);
let (_, v6, mom, _) = sums(&open, centre);
assert!((v6 / 6.0 - (1000.0 - 0.5 * 5.0 / 3.0)).abs() < 1e-6, "{}", v6 / 6.0);
assert!((mom / (4.0 * v6) - centre).len() < 0.01, "{:?}", mom / (4.0 * v6));
}
// A body's measured volume does not depend on where it sits: the same revolved ring of cones
// and cylinders, moved far from the origin, measures what it measures at the origin.
use crate::{revolve, transform};
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
use std::f64::consts::{PI, TAU};
/// Ring cross-section in (radius, height): cones top and bottom, cylinders inside and out.
const PROFILE: [(f64, f64); 6] = [(5.0, 0.0), (9.0, 0.0), (11.0, 2.0), (11.0, 6.0), (8.0, 8.0), (5.0, 8.0)];
/// Pappus: 2π × the section's first moment about the axis.
fn ring_volume() -> f64 {
let mut moment6 = 0.0;
for (i, &(x0, y0)) in PROFILE.iter().enumerate() {
let (x1, y1) = PROFILE[(i + 1) % PROFILE.len()];
moment6 += (x0 + x1) * (x0 * y1 - x1 * y0);
}
TAU * moment6 / 6.0
}
fn revolved(plane: &Plane, pts: &[Vec2]) -> Body {
let region = Region2 { outer: Loop2::polygon(pts), holes: vec![] };
revolve(plane, &[region], Vec2::ZERO, Vec2::Y, TAU).unwrap().pop().unwrap()
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs()
}
#[test]
fn ring_volume_does_not_depend_on_placement() {
let pts: Vec<Vec2> = PROFILE.iter().map(|&(r, z)| Vec2::new(r, z)).collect();
let base = revolved(&Plane::XZ, &pts);
let at_origin = measure(&base).unwrap();
assert!(rel(at_origin.volume, ring_volume()) < 1e-4, "{} vs {}", at_origin.volume, ring_volume());
for d in [100.0, 1000.0, 10000.0] {
for axis in [Vec3::X, Vec3::Y, Vec3::Z, Vec3::new(1.0, 1.0, 1.0)] {
let m = measure(&transform(&base, axis * d, Vec3::ZERO, Vec3::Z, 0.0).unwrap()).unwrap();
assert!(rel(m.volume, at_origin.volume) < 1e-6, "moved {d} along {axis:?}: {} vs {}", m.volume, at_origin.volume);
let off = m.centroid - at_origin.centroid - axis * d;
assert!(off.len() < 1e-6 * d, "centroid moved {d} along {axis:?}: off by {off:?}");
}
}
}
#[test]
fn cylinder_and_cone_volumes_far_from_the_origin() {
let (r, h) = (7.0, 12.0);
// A frustum, radii 7 and 3, height 10: πh/3 (R² + Rr + r²).
let frustum = [Vec2::new(0.0, 0.0), Vec2::new(7.0, 0.0), Vec2::new(3.0, 10.0), Vec2::new(0.0, 10.0)];
let cone_volume = PI * 10.0 / 3.0 * (49.0 + 21.0 + 9.0);
for at in [Vec3::ZERO, Vec3::new(5000.0, -3000.0, 8000.0)] {
let cyl = measure(&cylinder(at, Vec3::Z, r, h).unwrap()).unwrap();
assert!(rel(cyl.volume, PI * r * r * h) < 5e-4, "cylinder at {at:?}: {}", cyl.volume);
let cone = measure(&revolved(&Plane { origin: at, ..Plane::XZ }, &frustum)).unwrap();
assert!(rel(cone.volume, cone_volume) < 5e-4, "frustum at {at:?}: {} vs {cone_volume}", cone.volume);
}
}
}

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