Compare commits

...

30 Commits

Author SHA1 Message Date
bherbruck a9879fa668 Release 0.7.0 (#508)
Bump the workspace to 0.7.0 and write the 0.7.0 release notes. Minor bump: the design format moved to 15 (sketches on cylinder caps and sheet metal panels), so 0.6 can't open 0.7 saves, and new features landed (Select Other, per-edge flanges, sketches on caps and sheet metal faces).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:15:08 -04:00
bherbruck 45bc22fc30 fix: faces of an extrude drawn over a face's projected edges keep an exact source (#505)
A profile traced over a face sketch's projected boundary left the extrude's caps and sides without an exact planar source, so a sketch or offset plane on the new top face was refused. The drawn curve is now the edge's source where it covers a projection. Clearer fixed-plane message; regression tests in the engine, UI harness and design corpus, plus a corpus-wide flat-face attachment check (known gaps under #491-#495).

Fixes #489

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:01:35 -04:00
bherbruck 4aaa5457b3 feat(ui): Select Other — pick what lies under something else (#503)
Hold the left button still (long-press on touch) or right-click › Select Other… to list everything under the cursor: sketch points and curves ("Line 3", "Point (arc 2 centre)"), vertices, edges, faces, bodies, profiles, planes and axes, filtered like a click and by the open dialog's active input. Hovering a row lights it up; clicking selects it (Shift/Ctrl add) or feeds the dialog. A repeat click on the same spot with nothing changed selects the next thing there; a double-click keeps its own actions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:56:57 -04:00
bherbruck 1fd4a2f782 README demo for 0.6: a Raspberry Pi 4 case (#504)
Replace the 0.2.1 enclosure recording with a 40 s take of the current desktop UI building a Raspberry Pi 4 case: a sketch dimensioned with the board's parameters, extrude, fillets and shell, an M2.5 standoff patterned on the 58 x 49 mm hole rectangle, port cut-outs, floor vents, a colour, then height 24 -> 32 mm in Parameters. The demo script gains the pi story (now the default).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:56:53 -04:00
onenewcode 00baf1d883 fix: follow feature renames in references (#486)
Fixes #401
2026-10-12 22:39:13 -04:00
Mqrius 91de39d3fe fix(kernel): revolving adjacent profiles on the axis makes no zero-area faces (#490)
Profiles revolved together merge into one region that keeps a vertex where
each shared edge met the axis. With the axis edge in several pieces the loop
skipped the builders for a profile with an edge on the axis, and the generic
sweep turned each piece into a degenerate face that booleans failed on.
Runs of collinear axis lines are merged into one first.

Fixes #487

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:38:48 -04:00
bherbruck 219816d282 Name untitled designs after their file (#483)
* fix: name an untitled design after its file on save and open

Design tabs all read "Untitled" because a design's name only ever came
from file.new. Saving a design still under the default name now names it
after the file (bracket.solidcraft -> "bracket"), and opening a file whose
stored name is the default shows its file name. Names chosen by the user
are kept. The rename is part of the save, so it is not an undo step and
does not leave the design marked as changed.

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

* test: expect the file's name after saving an untitled design

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:19:26 -04:00
Mqrius 2880525e6b fix(ui): the Combine dialog previews without an error, so OK is enabled (#481)
The live preview also evaluates each command as a new body, to show the tool of a join or cut
(as_new_body sets every `operation` to "new"). A combine has no new-body form: its operation is
between bodies that already exist, and solid.combine rejects "new" ("operation must be join, cut
or intersect"). That error became the preview's error even when the combine itself worked, and
since OK is greyed while the preview has an error, a combine could not be applied from its
dialog at all. as_new_body now leaves solid.combine alone, so its preview shows the result.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:50:05 -04:00
Irakli Kourtis aa156f4a9f fix: report every fillet refusal of a radius too big as too large (#477)
`fit: reduce` acts only on refusals marked too large. Three kinds weren't:
the corner build's contact lines leaving their faces or running backwards,
and a curved chain's contact reaching past the end of the seam it trims. Two
more were hidden: a later edge's too-large refusal lost to an earlier edge's
other error, and the corner build's lost to the local blend's in `fillet`.
The messages are unchanged.

Refs #293

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:49:00 -04:00
Irakli Kourtis ccc36dc1f9 fix: shell a body whose curved edge is rounded smaller than the wall (#476)
Moved in by more than its radius, a round collapses and its neighbours meet
along a new edge. That edge was always built as a straight line keyed by its
ends, so on a disc whose circular edge is rounded the four arcs where the floor
meets the side became one chord used by four faces, and the shell failed with
"This shell is not oriented and closed" (#319).

The new edge now runs where the edge's face meets the face across the round
(the round's far side, crossed as a strip), through the edge's middle moved
onto both: a line or a circular arc, checked to lie on both moved faces, and
refused otherwise. Twins are the new edges with the same ends through the same
middle.

Fixes #319

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:48:27 -04:00
bherbruck 0fe032438f Note sheet metal panel anchors under format 15 (#482)
Claude-Session: https://claude.ai/code/session_01HWLPLy7ug7URSK5WybjRNa

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:41:58 -04:00
William Dunn 72c5d974b8 fix(ui): draw sketch constraint glyphs in red (#414)
* fix(ui): draw sketch constraint glyphs in red

Traditional CAD convention marks constraint glyphs in red. glyph_edge
and glyph_text in both themes now use red values. glyph_bg is
unchanged. The same glyph box switches to the error colour when a
constraint fails. The new reds are chosen to stay distinct from that
error colour. They are also distinct from the blue used for selection
and hover. See the pull request body for the before and after RGB
values and the contrast reasoning.

* fix(ui): match constraint toolbar icons to the red glyph colour

The CONSTRAINTS panel buttons and their drop-down rows drew their accent
strokes in the normal sketch tool green. They now use glyph_text, the same
red the matching constraint glyph box uses in the sketch view. A new
panel_accent helper in toolbar.rs picks the colour: the constraint red for
the CONSTRAINTS panel, the existing sketch green for every other sketch
tool, and the plain accent everywhere else. No other toolbar styling
changed.
2026-10-12 21:37:42 -04:00
Irakli Kourtis 201e9d5f9f Patch fills non-planar edge loops with one smooth face (#250) (#472)
* Add a one-surface fill of closed 3D loops to geom

A closed loop of curves that does not lie in a plane has no exact surface;
the usual answer is one smooth face fitted over it. fill_loop does that:

- the loop's best-fit plane (Newell normal), with a frame chosen from the
  loop itself so the result does not depend on where the loop starts, its
  direction, or where the part sits
- the loop seen along that normal must be simple and must not fold or run
  along the normal; otherwise it is refused ("not supported yet"), never
  filled with wrong geometry
- one non-rational bicubic B-spline height field over the loop's box,
  fitted by least squares to the loop's samples (The NURBS Book 9.4.1,
  9.4.3, eq. 9.62/9.65) with a thin-plate smoothness term
  lambda*A*int(h_uu^2 + 2 h_uv^2 + h_vv^2), A one grid cell's area, so the
  fit is the same at any scale; only planes are reproduced exactly
- span counts per direction (long narrow loops fit), refined until the
  loop is within tolerance, else refused with the deviation reached

bspline: bisected span search, basis functions on a stack array, one
clamping helper shared by curves and surfaces, one binomial helper.

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

* Patch fills non-planar loops of edges with one smooth face (#250)

surface.patch with edges refused any loop that is not planar ("not
supported yet: patching a loop that is not planar"). It now fills it:

- planar loops still get one exact planar face, but planarity is certified
  conservatively: lines by their ends, B-spline/NURBS edges by their control
  points, intersection curves by their samples lying on a support plane
  within tolerance; a loop that can't be certified is filled instead
- other loops get one face from geom's fill_loop, bounded by the body's own
  chained edges, whole (no split): its history is one generated face whose
  edges are the body's, and it stitches back into a closed solid
- intersection-curve edges are sampled by a seeded, guarded double
  projection shared by the planarity test and the fill (plane_fit)
- the face tolerance is 10x the planarity tolerance (documented: later
  operations take the face to contain its edges within it)
- edge picking runs inside the guard; the geom->truck conversion is shared
  with the T-spline sheets (spline_conv); the Newell plane fit is shared with
  STEP import

Tests: 3- to 8-sided, concave (dart, arrow, plus), flat-corner, two-edge and
imported rims fill as one face and stitch closed (volumes against an
independent integral); finite mesh normals, same result for any pick order
or placement; non-height-field loops and too-fine detail are refused.

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

* Test the Patch dialog filling a non-planar loop of picked edges

The Patch dialog's only test used a sketch profile. Picking the four
curved rim edges of a box cut by a sideways cylinder now makes one
surface.patch command whose result is a single smooth face over the rim.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:37:01 -04:00
NOWN-DEV af0440aaab feat: sketches on a sheet metal face attach to its panel (#474)
A sketch on a flat face of a sheet metal body was refused ("this face has no exact planar
source"), so the only way was a plane frozen in space. When the sheet changes (a flange's
angle, the thickness), the frozen sketch stays behind: a cut from it fails on the slanted
flange or leaves a blind pocket in the thicker sheet.

`sketch.create` with a face of a sheet metal body now attaches the sketch to the panel the
face belongs to. It keeps the face's name, `sheet:<body>:<panel>:<top|bottom>` (the panel is
the base or a flange's feature tag), and takes the panel's exact plane from its flat region
and fold each time the model is evaluated, so the sketch follows the sheet through parameter
changes, unfold and refold. Its coordinates are the flat pattern's (the bottom face's y
mirrored). A face that is gone is reported as a lost attachment, never replaced by the
nearest face. The attachment follows a renamed or copied body, shows as `sheet_face` in the
command result and as its own status in Redefine Sketch Plane. `fixed: true` still freezes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:36:58 -04:00
Irakli Kourtis 49956c2797 Sketch on a cylinder primitive's caps; format 15 (#460)
* Sketch on a cylinder primitive's caps (#420)

A solid.cylinder's caps carried no construction certificate, so a sketch
on its top or bottom was refused unless fixed:true (#420's crossed
cylinders, built from primitives).

- kernel: FaceSource::CylinderCap { producer, high, plane };
  Body::with_cylinder_certificate turns a cylinder's two extrude caps into
  certified caps. kernel::cylinder's output is unchanged for its other
  callers.
- doc: FaceCertificate::CylinderCap { high }, named F<id>:top / F<id>:bottom
  as the cylinder's face names already are; New Body and Join cylinders
  are certified, shells carry it as their planar source.
- format 15: designs can store the new certificate kind; version 14
  designs need no change, and older builds ask for an update instead of
  meeting an unknown kind.

Tests: top and bottom attachments follow a height and base edit and a
reopen; crossed joined cylinders keep both caps attachable; a shelled
cylinder's floor keeps its cap identity; malformed certificate JSON is
refused.

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

* Format 15 also covers sketch_axis links (#448)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:32:07 -04:00
selune b24e478bc1 Read design metadata and previews through a bounded seekable archive (#462) 2026-10-12 21:27:49 -04:00
NOWN-DEV 9ab25cfd4a fix: sketches on planes built from the model can project, intersect, include and hold text (#459)
Project, Intersect, Include 3D Geometry and Text resolved the active sketch's
plane without the model, so on a construction plane that is built from geometry
(perpendicular to a plane through a sketch line, through three points, midway
between two faces, tangent, along a path) they stopped with "this plane is built
from the model: evaluate first". Evaluation resolves the same plane against the
model before the sketch, in its component's frame; the sketch commands now do
the same, in one place, for adding a link, refreshing the links on an edit,
snapping a point onto an edge and the internal face profiles.

Tests: a sketch on a plane through a sloped sketch line projects a point and a
line of that sketch, follows a parameter that tilts the plane (also after reopen
and after another edit), holds text, intersects a body and includes an edge; the
same on a three-point plane and a midplane.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:27:28 -04:00
Irakli Kourtis 1ae6e9beb4 Ctrl+left drag on empty space orbits in every navigation preset (#471)
A left press with Ctrl (or Cmd) held, no Shift or Alt, on empty space orbits
for that press, unless the preset binds Ctrl+left itself. It only orbits
once the pointer moves: a Ctrl+click still adds to the selection, and
Ctrl+drag on sketch geometry still drags it without snapping. Works with a
tool active, without adding geometry. Ctrl+box selection from empty space
becomes the orbit; Shift+box selection still adds.

Preferences › Navigation and the UI tour mention it. Test drives every
preset through real pointer events.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:27:20 -04:00
Irakli Kourtis fc1d70cc3b docs: describe the sketch solver as it is (#475)
The README called the solver damped Gauss–Newton. It now places what the
constraints fix by construction (loci intersections and rigid-cluster
decomposition, after Owen 1991 and Fudos–Hoffmann 1997) and runs
Levenberg–Marquardt on the rest, with the rank of its Jacobian giving the
remaining degrees of freedom. The constraint list also gains the ones it was
missing (point on curve, curvature, point-to-line, arc sweep, arc length, slot
length), and live drags are described.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:26:54 -04:00
bherbruck dd67472252 Orbit from empty space turns around the visible geometry (#464)
With "Orbit around: Point under the cursor", a drag that starts over
empty space used to pivot on the point under the cursor at the target's
depth, so an orbit begun near the edge of the view swung the model
around an off-screen point. It now pivots on the centre of the visible
bodies and sketches (the same bounds Fit frames), or on the view centre
when nothing is visible. A drag that starts on the model is unchanged.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:01:49 -04:00
bherbruck 9062c739d8 docs: link preview cards for the docs site and web app (#457)
* docs: add OpenGraph and Twitter card tags to the book and web app

Links to the docs site and the browser app now unfurl with a preview card.
The book gets the tags through theme/head.hbs (og:title follows the page
title); the web app's index.html carries its own, pointing at /app/. Both
use the social preview image, now served from the book at /solidcraft/og.png.

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

* docs: list the social preview image in ATTRIBUTION.md

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:45:21 -04:00
NOWN-DEV fb4c1d74b9 feat: edge flanges with their own angle and height per edge (#447)
One edge flange feature gave all its flanges one height and one angle. A
part whose flanges lean differently took one feature per angle, and
flanges of separate features don't close toward each other, so its
corners stayed open.

- sheet.flange (edge): an entry of `edges` is a point [x, y, z] as before,
  or {point, angle?, height?} whose angle and height (expressions, checked
  like the feature's) replace the feature's for that edge. Bad input is
  refused with the edge named. Before, the object form was read for its
  point and the rest was ignored without a word.
- The feature stores its edges as FlangeEdge. An edge without values of
  its own is written as the plain point it always was, so designs without
  them are read and written as before; an unknown key in an edge is
  refused, not dropped.
- The edges' values are feature inputs of their own (Edge angle, Edge
  height, after the feature's), so parameters, renames and
  parameters.change reach them. timeline.edit names the edges again when
  it changes them, so each value stays with its edge.
- SheetBody::add_flanges_each trims every edge by its own setback and
  adds the flanges. The corners between them close as between equal
  flanges: the closing already works from the two flanges it joins (each
  one's fold and leg), so the flat pattern follows by hand from the
  angles, the heights and the rule (the tests derive it).
- The Flange dialog shows a row per picked edge with an angle and a
  height box (empty: the dialog's value) once more than one edge is
  picked, and brings them back on Edit Feature.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:33:49 -04:00
Irakli Kourtis 0dee9deec1 Sketch constraints accept x / y for the sketch's own axes (#394) (#448)
`sketch.constraint.symmetry` (line), `parallel`, `perpendicular`,
`collinear`, `tangent` and `coincident` (point on curve) now take `x` /
`y` for the sketch's own axes, matching what `solid.revolve` means by
lowercase x|y. A real curve with that id still wins.

An axis becomes a linked construction line on it: a new
`LinkSource::SketchAxis` resolved in sketch coordinates, so it stays on
the sketch's axis on any plane, is shared by every constraint on that
axis, is left out of profiles and is reported in `created`. Equal,
concentric, midpoint and horizontal/vertical keep refusing axes, since
the axis line's finite length or fixed direction would make them
meaningless.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:31:35 -04:00
Irakli Kourtis 722bd007c8 Join keeps exact face attachments on faces it leaves unchanged (#420) (#453)
A Union dropped every construction certificate, so after any join no face
could take a durable sketch: sketch.create failed with "this face has no
exact planar source" even on a face the join never touched (#420, and the
"CREATE SKETCH: operation failed" reports #344, #195).

- kernel: map planar sources through a Union from both operands, with the
  same exact-support rule Cut uses. Coplanar supports of the two operands
  carry distinct identities, so a face merged from both stays without one.
- doc: Join extrude and Join box tools are certified like new bodies; a
  cap moved off its designed height (into the target, or past a curved To
  face) is not certified. Join producers are accepted when certifying.

Tests: both operands' faces attach and follow an upstream edit and a
reopen; a merged coplanar face is refused and fixed:true works; a face a
join covers whole is lost without moving to the new roof. Tests that used
a joined face as their "uncertified" example now use a merged face; the
trimmed-face case in a_sketch_on_a_face_stays_on_that_face now stays
attached to the surviving piece.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:31:32 -04:00
NOWN-DEV 9e60080b10 fix: fit the plane of a thin sewn face about its own centre (#441)
kernel::sew refused a solid with a thin planar face away from the origin: a slab
30 x 2 mm and 0.00013 mm thick with a corner at (-300, -200, 5), turned 0.45 rad
about x, failed with "sew: face 1: cannot attach a plane to a wire that is not on
one plane", though its faces are flat to 1e-14 mm.

truck's plane fit sums the cross products of a boundary's control points about a
centre and takes the points' sum for that centre (it divides by the number of wires,
not of points): eight times as far from the origin as a rectangle of four lines. The
sum is the same about any centre, but its rounding grows with the square of the
centre's distance, and on a thin face, whose normal is short, it turns the normal far
enough for truck's own 1e-6 flatness test to fail: truck finds the slab's four thin
faces 3.0e-7 to 2.1e-6 mm out of the planes it fitted.

A planar face keeps truck's plane wherever truck gives one. Where truck refuses, the
same fit is made on a copy of the boundary moved so that the mean of its control
points is the origin, and the plane is moved back; a boundary that leaves its plane
or has no area is refused as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:27:13 -04:00
NOWN-DEV 455ca86a6c fix: write each lump of a body as its own STEP solid (#455)
A body of several pieces (a plate and the islands a cut leaves inside its
holes, such as letter counters) was written as one BREP_WITH_VOIDS whose
other shells were voids. Export now writes one solid per lump, each with
its face colours; a real void stays a void of its lump.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:27:09 -04:00
bherbruck 6fde5ff55b Update funding information in FUNDING.yml 2026-10-12 20:26:45 -04:00
Mqrius 7f92093cc8 fix(kernel): revolve with edges on the axis is built around the origin, then moved (#452)
A full revolve of a profile with more than one edge on the axis (a spherical shell's half ring:
both radial lines lie on it) took the generic sweep, built in place. Truck tessellates the
degenerate faces those edges sweep reliably only with the axis through the world origin, so on a
sketch plane off it the body measured about a third too large (a shell from r 18.5 to 40, 10 mm
above XY: 324 339 mm³ instead of 241 561), and booleans with it failed or removed the wrong side.

Such profiles are now swept about an axis through the origin and translated into place, as
revolve_touching_axis already does for profiles with one edge on the axis.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:25:46 -04:00
selune ddd528abaf fix(ui): accept toolbar drops across panel labels and gaps (#454) 2026-10-12 20:25:38 -04:00
selune 5c7149e57d fix(ui): put Point on the default sketch toolbar (#445) 2026-10-12 20:13:31 -04:00
110 changed files with 7710 additions and 593 deletions
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: [bherbruck]
ko_fi: bherbruck
+43 -49
View File
@@ -5,58 +5,53 @@ parameter to watch the timeline rebuild. [Documentation](https://bherbruck.githu
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
**Why 0.6:** this is a minor bump because two things change the files you work with: the
rename to SolidCraft and the new design file format. Files from earlier builds open fine, but
builds 0.5 and older can't open designs saved by 0.6.
**Why 0.7:** this is a minor bump because the design file format moved to version 15 (sketches
on cylinder caps and sheet metal panels need new references), and because it adds new features.
**What's new**
- **New design file format.** Saves are a zip container by default; plain JSON is still one
click away (details below).
- **Sheet metal:** flange corners close at any angle, and cut-outs fold across bends at any
angle, including curved bends.
- **Crash reports:** after a crash, Help ▸ Crash Reports… shows what happened with a ready-to-file
issue draft. Reports stay on your computer; nothing is sent. `solidcraft-cli crashes` lists and
replays them.
- **Snap package** for x86_64 and aarch64 on every release, alongside the AppImage, `.deb`, `.rpm`
and Flatpak.
- **Trackpad navigation** (two-finger pan, pinch zoom), a fillet option that shrinks a radius too
big for its edges, cleaner dialogs and toolbar panels, and the unfinished Generative Design
workspace is gone.
- Many modelling fixes: extrude/revolve in moved components, To Object picks, very flat arcs,
touching lines and arcs in booleans, and `solidcraft-cli run` now fails when a feature fails.
- **Sketching on stacked extrudes works again.** Creating a sketch on the top face of an extrude
stacked on another failed with "operation failed" since 0.3
([#489](https://github.com/bherbruck/solidcraft/issues/489)). It's fixed, and every flat face
of the test models is now checked for this on every change.
- **Select Other:** hold or right-click to pick something hidden under something else; click
again to cycle through what's underneath.
- **Tabs are named after their file** instead of "Untitled".
- **Modelling:** a flange angle and height per edge, Patch fills non-planar loops, sketches on
cylinder caps and on sheet metal faces, and a feature rename updates everything that refers to
it.
- **Fixes:** revolve with edges on the axis, shell and fillet with small rounds, Join keeps face
attachments, and STEP export writes each lump of a body as its own solid.
- **Navigation and UI:** orbiting from empty space turns around the visible geometry, Ctrl+drag
orbits in every preset, toolbar drops land across labels and gaps, Combine previews before OK,
and sketch constraints accept the sketch's own x / y axes.
- Opening a design reads its metadata and preview through a bounded, seekable reader; the docs
and web app have link preview cards.
**New file format.** Save and Save As now write `.solidcraft` designs as a zip container
that stores imported STEP, mesh and image data as binary, once each, at full precision, so
designs with imports save and open faster. What that means for you:
- Your existing `.solidcraft` and `.solvecraft` files (plain JSON) still open as before; the
format is detected from the contents.
- Want plain JSON (for diffs or scripts)? Use **File ▸ Save As JSON…**, or pick a
`.json` name in Save As; later saves keep JSON. **File ▸ Export Design as JSON…** writes a
separate JSON copy. From the CLI, `solidcraft-cli run … --save design.json`; from MCP or
scripts, pass `"format": "json"` to `save` / `file.save`.
- SolidCraft 0.5 and older can't open designs saved by 0.6, as a container or as JSON (the
JSON format tag is now `solidcraft/14`). Keep a copy of a file you may still open in 0.5.
**File format.** Designs are now format 15 (`solidcraft/15`). SolidCraft 0.6 and older can't
open designs saved by 0.7, as a container or as JSON; keep a copy of a file you may still open
in 0.6. Files from every earlier version still open in 0.7.
**Renamed to SolidCraft.** Everything now uses the new name: the `solidcraft` and
`solidcraft-cli` programs, `.solidcraft` designs, `SOLIDCRAFT_*` environment variables,
settings folders, package names and the `solidcraft` MCP server. Upgrading from SolveCraft:
- `.solvecraft` designs still open (double-click, Open, drag-drop, the CLI and MCP); new saves
are `.solidcraft`.
- Settings, preferences and recovery files are copied to the new folders on first launch; the
old folders are left in place. `SOLVECRAFT_*` environment variables are still read.
- `solvecraft-cli` remains as a deprecated alias for one release; it forwards to
`solidcraft-cli` and prints a note. Agents: `claude mcp add solidcraft -- solidcraft-cli mcp`;
an existing `claude mcp add solvecraft -- solvecraft-cli mcp` setup keeps working.
- Windows: the `.msi` upgrades the existing installation in place (now in
`Program Files\SolidCraft`). Linux: the `solidcraft` `.deb`/`.rpm` replaces the `solvecraft`
package. The Flatpak app ID stays `ai.storyteller.solvecraft`, so `flatpak update` just works,
and existing AppImages find the update.
- macOS: quit the app, move the old `SolveCraft.app` to the Trash (your designs and settings
stay), then drag `SolidCraft.app` from the DMG onto Applications and pin it in the Dock.
- Details: [compatibility notes](https://github.com/bherbruck/solidcraft/blob/main/docs/compatibility.md).
**Known limitations**
- Some faces still need the fixed-plane fallback for sketches: side faces Join merged from two
extrudes ([#491](https://github.com/bherbruck/solidcraft/issues/491)), faces split by a later
feature ([#492](https://github.com/bherbruck/solidcraft/issues/492)), Cut walls and chamfer
faces ([#493](https://github.com/bherbruck/solidcraft/issues/493)), faces after Revolve,
Offset Face or Boss ([#494](https://github.com/bherbruck/solidcraft/issues/494)) and sheet
metal thickness faces ([#495](https://github.com/bherbruck/solidcraft/issues/495)).
- Cutting a coaxial cylinder from a thin disc can lose a little volume
([#500](https://github.com/bherbruck/solidcraft/issues/500)); an edge flange with different
per-edge angles leaves gaps at the corners
([#501](https://github.com/bherbruck/solidcraft/issues/501)).
- More in [ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
Documentation, the browser app and the Flatpak feed use the SolidCraft project Pages URLs;
old-name Pages addresses are not permanent aliases.
**Upgrading from 0.5 or earlier.** 0.6 renamed SolveCraft to SolidCraft and made the zip
container the default save format. `.solvecraft` designs, old settings folders and
`SOLVECRAFT_*` variables are still picked up, and `solvecraft-cli` still forwards to
`solidcraft-cli`. Want plain JSON? Use **File ▸ Save As JSON…** or `--save design.json`. On
macOS, replace `SolveCraft.app` with `SolidCraft.app`. Details:
[compatibility notes](https://github.com/bherbruck/solidcraft/blob/main/docs/compatibility.md).
Documentation, the browser app and the Flatpak feed use the SolidCraft project Pages URLs.
**Which file?**
- **Windows:** `SolidCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
@@ -89,6 +84,5 @@ The builds aren't signed yet. On macOS, right-click `SolidCraft.app` in Applicat
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolidCraft.app`).
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
Licences are built in (Help ▸ About ▸ Licences, `solidcraft-cli licences`); the packages also
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
[ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
install `THIRD-PARTY-LICENSES.txt`.
+3 -2
View File
@@ -37,8 +37,9 @@ Generated-in-code assets are original and have no file to list:
| assets/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
| assets/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
| docs/screenshots/enclosure-demo-0.2.1.gif | SolidCraft contributors | original electronics enclosure screen recording of the actual SolidCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
| docs/screenshots/enclosure-demo-0.2.1.mp4 | SolidCraft contributors | original electronics enclosure screen recording of the actual SolidCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.6.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.6.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
| docs/book/src/og.png | SolidCraft contributors | original 1280 × 640 social preview card (the SolidCraft mark, name and tagline), served by the docs site for link previews | MIT OR Apache-2.0 |
| docs/book/src/generated/sample-plate.png | SolidCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/bracket.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/enclosure.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
Generated
+13 -13
View File
@@ -3290,7 +3290,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
@@ -3307,7 +3307,7 @@ dependencies = [
[[package]]
name = "solidcraft-cli"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3316,7 +3316,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"log",
"serde",
@@ -3331,7 +3331,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"log",
"serde",
@@ -3347,7 +3347,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"serde",
"serde_json",
@@ -3356,7 +3356,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"quick-xml 0.38.4",
"serde",
@@ -3371,7 +3371,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"log",
"serde",
@@ -3386,7 +3386,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3394,7 +3394,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"image",
"serde",
@@ -3404,7 +3404,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"serde",
"serde_json",
@@ -3415,7 +3415,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -3430,7 +3430,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
@@ -4853,7 +4853,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.6.0"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+11 -7
View File
@@ -6,9 +6,9 @@ Sketch, constrain, and build solids with a parametric feature timeline. Change a
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
model through the built-in MCP server.
![SolidCraft: draw a rounded electronics enclosure, sketch its USB port directly on a face, add four mounting bosses and resize it through Parameters](docs/screenshots/enclosure-demo-0.2.1.gif)
![SolidCraft 0.6: model a Raspberry Pi 4 case from an empty design: a sketch dimensioned with parameters, extrude, fillets and shell, standoffs patterned on the board's mounting holes, port cut-outs, vent slots, a colour and a height change in Parameters](docs/screenshots/demo-0.6.gif)
*Start with an empty design and draw the original parametric electronics enclosure in the English desktop UI: rounded corners, a shell, a USB port sketched directly on the wall and a four-boss pattern. Change width from 80 → 100 mm and wall thickness from 2 → 3 mm in Parameters and watch it rebuild. Original animated pointer and click cues; 4× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/enclosure-demo-0.2.1.mp4), generated with the [demo script](examples/demo/record_ui.sh).*
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots and a colour, then height 24 → 32 mm in Parameters and it all rebuilds. 6× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.6.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
## Download
@@ -81,11 +81,15 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
the boundary and keeps existing extrusions usable.
Curve drawing and picking adapt together to zoom and display scale, including small circles
at Fit and sketches in placed components.
- **Constraints and dimensions** solved by our own solver (damped Gauss–Newton with
degree-of-freedom analysis): coincident, horizontal/vertical, parallel, perpendicular, tangent,
equal, concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
radius, diameter and angle dimensions driven by parameters. Over-constraining edits are
rejected; fully constrained geometry turns black.
- **Constraints and dimensions** solved by our own solver. Points the constraints fix relative
to known geometry are placed exactly by construction (loci intersections and rigid-cluster
decomposition, after Owen 1991 and Fudos–Hoffmann 1997); Levenberg–Marquardt solves the rest,
and the rank of its Jacobian gives the remaining degrees of freedom. Constraints: coincident,
point on curve, horizontal/vertical, parallel, perpendicular, tangent, curvature (G2), equal,
concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
point-to-line, radius, diameter, angle, arc sweep, arc length and slot length dimensions driven
by parameters. Over-constraining edits are rejected; fully constrained geometry turns black.
Dragging solves the sketch every frame and rebuilds the model once, when the drag ends.
Select a line or its two endpoints, type its dimension and press Enter to commit one undo
step. Fit keeps sketch geometry clear of the palette and navigation controls.
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
+7 -1
View File
@@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap | in progress | 36 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
@@ -61,6 +61,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Dimensioning and error reporting
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
arguments reject such a name when an older design's construction plane already has it. Use
the other alias, the construction feature's numeric ID or an explicit plane; existing
@@ -285,6 +287,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
record their coordinate frame; legacy wires migrate from known provenance without changing
coordinates. Broken legacy links with missing provenance can require explicit refresh or relink.
See [occurrence workflows](docs/occurrence-workflows.md).
- Toolbar drops on panel labels, gaps and margins append commands with insertion feedback;
button drops keep insert-before ordering.
- Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel.
Parameters opens the user/model variable editor; Configurations opens the configuration table.
Configure remains available in the panel dropdown.
@@ -317,6 +321,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
exact holes and retained islands, including the union of multiple selected profiles, and
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
tapered or bend-crossing machining refuses atomically instead of restoring material later.
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
+6 -2
View File
@@ -17,6 +17,10 @@ hostile variants of it never panic.
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
| `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) |
| `split-body-pieces` | v0.3.2 (07e263d) | a slot along X cuts a block into two bodies; a fillet, a chamfer and an appearance refer to the pieces by body name (`Body1`, `Body1 (1)`) |
| `issue-489-stacked-join` | v0.6.0 (809ecf7), desktop | a 70 × 84 extrude, then a 70 × 62 strip joined on its top face, sketched partly over the face's projected edges (issue #489; expected values from main at 2880525) |
The scripts that made them are in `scripts/`. Never regenerate a file with a newer build: the
point is that it was written by the old one. Add new files for new eras instead.
The scripts that made them are in `scripts/` (`issue-489-stacked-join` was drawn in the desktop
app and has none). Every flat face of every final body must also take an attached sketch and an
offset plane; the faces that can't yet are listed with their issues in `design_corpus.rs`.
Never regenerate a file with a newer build: the point is that it was written by the old one. Add new files for new eras instead.
@@ -0,0 +1,157 @@
{
"bodies": [
{
"area_mm2": 25751.999999999945,
"bbox": {
"max": [
35.0,
42.0,
48.00000000000001
],
"min": [
-35.0,
-42.000000000000014,
0.0
]
},
"center_of_mass": [
5.524909928104648e-14,
-1.0945382096961234e-12,
22.366336633663238
],
"density_g_cm3": 1.29,
"edges": 24,
"face_types": {
"plane": 10
},
"faces": 10,
"kernel": {
"edges": 24,
"faces": 10,
"shells": 1,
"vertices": 16
},
"mass_g": 328.33079999999734,
"material": "Default",
"name": "Body1",
"vertices": 16,
"volume_mm3": 254519.9999999979
}
],
"body_count": 1,
"params": [
{
"comment": "",
"error": null,
"expression": "84",
"model": true,
"name": "d1",
"unit": "mm",
"value": 84.0
},
{
"comment": "",
"error": null,
"expression": "70",
"model": true,
"name": "d2",
"unit": "mm",
"value": 70.0
},
{
"comment": "",
"error": null,
"expression": "62",
"model": true,
"name": "d4",
"unit": "mm",
"value": 62.0
}
],
"sketches": [
{
"curves": 6,
"dof": 0,
"id": 1,
"name": "Sketch1",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 10,
"dof": 1,
"id": 3,
"name": "Sketch2",
"profiles": 3,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 1,
"ms": 0.131943,
"name": "Sketch1",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 2,
"ms": 1.0812709999999999,
"name": "Extrude1",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 3,
"ms": 0.450002,
"name": "Sketch2",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 4,
"ms": 5.191514000000001,
"name": "Extrude2",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
}
],
"total": {
"area_mm2": 25751.999999999945,
"edges": 24,
"faces": 10,
"mass_g": 328.33079999999734,
"vertices": 16,
"volume_mm3": 254519.9999999979
}
}
@@ -0,0 +1,539 @@
{
"features": [
{
"id": 1,
"name": "Sketch1",
"plane": {
"name": "XY",
"type": "origin"
},
"sketch": {
"constraints": [
{
"id": "k1",
"l": 0,
"type": "horizontal"
},
{
"id": "k2",
"l": 2,
"type": "horizontal"
},
{
"id": "k3",
"l": 1,
"type": "vertical"
},
{
"id": "k4",
"l": 3,
"type": "vertical"
},
{
"id": "k5",
"l": 4,
"p": 5,
"type": "midpoint"
},
{
"id": "k6",
"p": 0,
"q": 5,
"type": "coincident"
},
{
"id": "k7",
"l": 1,
"param": "d1",
"type": "length",
"value": 84.0
},
{
"id": "k8",
"l": 0,
"param": "d2",
"type": "length",
"value": 70.0
}
],
"counters": {
"k": 8,
"l": 6,
"p": 5
},
"curves": [
{
"a": 1,
"b": 2,
"construction": false,
"id": "l1",
"type": "line"
},
{
"a": 2,
"b": 3,
"construction": false,
"id": "l2",
"type": "line"
},
{
"a": 3,
"b": 4,
"construction": false,
"id": "l3",
"type": "line"
},
{
"a": 4,
"b": 1,
"construction": false,
"id": "l4",
"type": "line"
},
{
"a": 1,
"b": 3,
"construction": true,
"id": "l5",
"type": "line"
},
{
"a": 2,
"b": 4,
"construction": true,
"id": "l6",
"type": "line"
}
],
"points": [
{
"fixed": true,
"id": "origin",
"pos": [
0.0,
0.0
]
},
{
"fixed": false,
"id": "p1",
"pos": [
-35.0,
-42.0
]
},
{
"fixed": false,
"id": "p2",
"pos": [
35.0,
-42.0
]
},
{
"fixed": false,
"id": "p3",
"pos": [
35.0,
42.0
]
},
{
"fixed": false,
"id": "p4",
"pos": [
-35.0,
42.0
]
},
{
"fixed": false,
"id": "p5",
"pos": [
0.0,
0.0
]
}
]
},
"suppressed": false,
"type": "sketch"
},
{
"extent": {
"direction": "positive",
"distance": "30"
},
"id": 2,
"name": "Extrude1",
"operation": "new_body",
"param_names": [
"d3"
],
"profiles": {
"loops": [
[
"l1",
"l2",
"l3",
"l4"
]
],
"type": "curves"
},
"sketch": 1,
"suppressed": false,
"type": "extrude"
},
{
"id": 3,
"name": "Sketch2",
"plane": {
"anchor": {
"author_path": [],
"component": 0,
"coordinates": [
0.0,
0.0
],
"path": [],
"target": {
"body": "Body1",
"certificate": {
"high": true,
"holes": [],
"kind": "extrude_cap",
"outer": [
"l1",
"l2",
"l3",
"l4"
],
"sketch": 1
},
"name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
"origin_feature": 2,
"point": [
0.0,
0.0,
30.0
]
},
"x_axis": [
1.0,
0.0
]
},
"at": [
2.012802880253332,
7.4635742926698185,
30.000000000000114
],
"name": "F2:top",
"plane": {
"origin": [
0.0,
0.0,
30.0
],
"x_dir": [
1.0,
0.0,
0.0
],
"y_dir": [
0.0,
1.0,
0.0
]
},
"type": "face"
},
"sketch": {
"constraints": [
{
"id": "k1",
"l": 4,
"type": "horizontal"
},
{
"id": "k2",
"l": 6,
"type": "horizontal"
},
{
"id": "k3",
"l": 5,
"type": "vertical"
},
{
"id": "k4",
"l": 7,
"type": "vertical"
},
{
"id": "k5",
"l": 8,
"p": 9,
"type": "midpoint"
},
{
"id": "k6",
"p": 0,
"q": 9,
"type": "coincident"
},
{
"id": "k7",
"p": 7,
"param": "d4",
"q": 6,
"text": [
-0.2,
9.200000000000003
],
"type": "distance",
"value": 62.0
}
],
"counters": {
"j": 1,
"k": 7,
"l": 10,
"p": 9
},
"curves": [
{
"a": 1,
"b": 2,
"construction": false,
"id": "l1",
"link": "j1",
"type": "line"
},
{
"a": 3,
"b": 1,
"construction": false,
"id": "l2",
"link": "j1",
"type": "line"
},
{
"a": 2,
"b": 4,
"construction": false,
"id": "l3",
"link": "j1",
"type": "line"
},
{
"a": 4,
"b": 3,
"construction": false,
"id": "l4",
"link": "j1",
"type": "line"
},
{
"a": 5,
"b": 6,
"construction": false,
"id": "l5",
"type": "line"
},
{
"a": 6,
"b": 7,
"construction": false,
"id": "l6",
"type": "line"
},
{
"a": 7,
"b": 8,
"construction": false,
"id": "l7",
"type": "line"
},
{
"a": 8,
"b": 5,
"construction": false,
"id": "l8",
"type": "line"
},
{
"a": 5,
"b": 7,
"construction": true,
"id": "l9",
"type": "line"
},
{
"a": 6,
"b": 8,
"construction": true,
"id": "l10",
"type": "line"
}
],
"links": [
{
"auto_face": true,
"face_name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
"id": "j1",
"kind": "project",
"source": {
"at": [
11.666666666666666,
-13.999999999999995,
30.0
],
"body": "Body1",
"type": "face"
}
}
],
"points": [
{
"fixed": true,
"id": "origin",
"pos": [
0.0,
0.0
]
},
{
"fixed": true,
"id": "p1",
"link": "j1",
"pos": [
-35.0,
-42.0
]
},
{
"fixed": true,
"id": "p2",
"link": "j1",
"pos": [
-35.0,
42.0
]
},
{
"fixed": true,
"id": "p3",
"link": "j1",
"pos": [
35.0,
-42.0
]
},
{
"fixed": true,
"id": "p4",
"link": "j1",
"pos": [
35.0,
42.0
]
},
{
"fixed": false,
"id": "p5",
"pos": [
-35.0,
-31.000000000002185
]
},
{
"fixed": false,
"id": "p6",
"pos": [
35.0,
-31.00000000000349
]
},
{
"fixed": false,
"id": "p7",
"pos": [
35.0,
30.99999999999603
]
},
{
"fixed": false,
"id": "p8",
"pos": [
-35.0,
30.999999999995318
]
},
{
"fixed": false,
"id": "p9",
"pos": [
0.0,
0.0
]
}
]
},
"suppressed": false,
"type": "sketch"
},
{
"extent": {
"direction": "positive",
"distance": "18"
},
"id": 4,
"name": "Extrude2",
"operation": "join",
"param_names": [
"d5"
],
"profiles": {
"loops": [
[
"l1",
"l5",
"l4",
"l7"
]
],
"type": "curves"
},
"sketch": 3,
"suppressed": false,
"type": "extrude"
}
],
"format": "solidcraft/14",
"name": "Untitled",
"next_id": 6,
"params": [
{
"expr": "84",
"model": true,
"name": "d1",
"unit": "mm"
},
{
"expr": "70",
"model": true,
"name": "d2",
"unit": "mm"
},
{
"expr": "62",
"model": true,
"name": "d4",
"unit": "mm"
}
],
"units": "mm"
}
+166
View File
@@ -131,6 +131,172 @@ fn body_names_saved_before_exact_history_keep_their_pieces() {
assert!(s.doc.appearances.bodies.contains_key("Body1 (1)"));
}
/// Flat faces that can't take an attached sketch yet, by cause: (design, body, the faces'
/// planes as outward normal and distance from the origin, or every flat face of the body,
/// issue). Face indices are not used: they can change between builds. Each issue has an
/// ignored test below that checks its faces; a listed plane whose face attaches fails the main
/// check until it is removed from here.
type Planes = &'static [([f64; 3], f64)];
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 9] = [
("issue-489-stacked-join", "Body1", Some(&[([1., 0., 0.], 35.), ([-1., 0., 0.], 35.)]), 491),
("issue-489-stacked-join", "Body1", Some(&[([0., 0., 1.], 30.)]), 492),
("m0-revolve-primitives", "Body2", Some(&[([-FRAC_1_SQRT_2, 0., FRAC_1_SQRT_2], -21. * FRAC_1_SQRT_2)]), 493),
("split-body-pieces", "Body1", Some(&[([0., FRAC_1_SQRT_2, FRAC_1_SQRT_2], 38.5 * FRAC_1_SQRT_2), ([0., -1., 0.], -18.)]), 493),
("split-body-pieces", "Body1 (1)", Some(&[([0., 1., 0.], 12.)]), 493),
("m0-revolve-primitives", "Body1", None, 494),
("naming-split-pieces", "Body1", None, 494),
("modern-plastic", "Body1", None, 494),
("modern-sheet-metal", "Body1", None, 495),
];
use std::f64::consts::FRAC_1_SQRT_2;
/// The issue a face is listed under, and whether it is listed by its plane (not as every face).
fn known_without_attachment(design: &str, body: &str, normal: [f64; 3], distance: f64) -> Option<(u32, bool)> {
let same = |(n, d): &([f64; 3], f64)| (0..3).all(|i| (n[i] - normal[i]).abs() < 1e-6) && (d - distance).abs() < 1e-4;
NO_ATTACHMENT_YET.iter().find_map(|(d, b, planes, issue)| {
if *d != design || *b != body {
return None;
}
match planes {
None => Some((*issue, false)),
Some(planes) => planes.iter().any(same).then_some((*issue, true)),
}
})
}
fn undo_to(s: &mut Session, features: usize) {
for _ in 0..4 {
if s.doc.features.len() <= features {
return;
}
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(s.doc.features.len(), features, "undo did not remove the added feature");
}
/// Try a sketch and an offset plane on every flat face of every final body of every corpus
/// design, through the same face pick the desktop sends (body, face index, occurrence path, a
/// point on it), without `fixed`. Undo leaves the design as it was for the next face. Returns
/// (design, body, face, issue it is known under, what went wrong or None).
fn flat_face_attachments() -> Vec<(String, String, usize, Option<(u32, bool)>, Option<String>)> {
use solidcraft_engine::doc::{FaceAttachmentStatus, apply_point};
let mut out = Vec::new();
for p in corpus() {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let mut s = open(&p);
let state = s.model.state();
let placed = s.placed_document();
let mut picks = Vec::new();
for (world, definition, component, path, frame) in placed.body_frames_with_source(&state) {
let Some(body) = state.bodies.iter().find(|b| b.definition_name() == definition && placed.model_body_component(b) == component) else {
continue;
};
let mesh = body.body.tessellate(0.01).unwrap();
for index in 0..body.body.face_count() {
if body.body.planar_face(index).is_none() {
continue;
}
// The middle of the face's largest triangle: well inside the face.
let point = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, f)| **f as usize == index)
.filter_map(|(t, _)| mesh.tri(t))
.max_by(|[a, b, c], [d, e, f]| (*b - *a).cross(*c - *a).len().total_cmp(&(*e - *d).cross(*f - *d).len()))
.map(|[a, b, c]| (a + b + c) * (1.0 / 3.0));
let Some(point) = point else { continue };
let point = apply_point(&frame, point);
let normal =
body.body.planar_face(index).map(|plane| solidcraft_engine::doc::apply_vector(&frame, plane.normal())).unwrap_or_default();
let key = ([normal.x, normal.y, normal.z], normal.dot(point));
picks.push((world.clone(), index, key, json!({"face": point, "body": world, "index": index, "path": path})));
}
}
let before = s.doc.features.len();
for (body, index, (normal, distance), plane) in picks {
let mut problem = None;
match s.execute("sketch.create", &json!({"plane": plane})) {
Ok(created) => {
let id = created["sketch"].as_u64().unwrap_or(0);
match s.placed_document().sketch_face_attachment(id, &s.model) {
FaceAttachmentStatus::Healthy { .. } | FaceAttachmentStatus::Sheet { .. } => {}
other => problem = Some(format!("sketch attachment {other:?}")),
}
s.execute("sketch.finish", &json!({})).unwrap();
undo_to(&mut s, before);
}
Err(e) => problem = Some(format!("sketch.create: {e}")),
}
match s.execute("construct.plane.offset", &json!({"base": plane, "offset": 5})) {
Ok(_) => undo_to(&mut s, before),
Err(e) => problem = Some(problem.map_or(String::new(), |p| p + "; ") + &format!("construct.plane.offset: {e}")),
}
let known = known_without_attachment(&name, &body, normal, distance);
out.push((name.clone(), body, index, known, problem));
}
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{name}: left with errors after undo");
}
out
}
/// Every flat face of every corpus design takes a durable attachment (issue #489: the top face
/// of an extrude sketched over a face's projected boundary had "no exact planar source"),
/// except the faces listed in [`NO_ATTACHMENT_YET`] under their open issues.
#[test]
fn every_flat_face_of_every_corpus_design_takes_an_attached_sketch() {
let all = flat_face_attachments();
assert!(all.len() > 50, "only {} faces checked", all.len());
let problems: Vec<String> = all
.iter()
.filter_map(|(design, body, face, known, problem)| match (known, problem) {
(None, Some(problem)) => Some(format!("{design}: {body} face {face}: {problem}")),
(Some((issue, true)), None) => Some(format!("{design}: {body} face {face} attaches now: remove it from NO_ATTACHMENT_YET (#{issue})")),
_ => None,
})
.collect();
assert!(problems.is_empty(), "{} of {} faces:\n{}", problems.len(), all.len(), problems.join("\n"));
}
fn known_faces_attach(issue: u32) {
let problems: Vec<String> = flat_face_attachments()
.into_iter()
.filter(|(.., known, problem)| known.is_some_and(|(known, _)| known == issue) && problem.is_some())
.map(|(design, body, face, _, problem)| format!("{design}: {body} face {face}: {}", problem.unwrap_or_default()))
.collect();
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
#[test]
#[ignore = "fails until #491 is fixed"]
fn a_side_face_join_merged_from_two_extrudes_takes_an_attached_sketch() {
known_faces_attach(491);
}
#[test]
#[ignore = "fails until #492 is fixed"]
fn a_face_split_into_pieces_by_a_later_feature_takes_an_attached_sketch() {
known_faces_attach(492);
}
#[test]
#[ignore = "fails until #493 is fixed"]
fn cut_walls_and_chamfer_faces_take_an_attached_sketch() {
known_faces_attach(493);
}
#[test]
#[ignore = "fails until #494 is fixed"]
fn faces_after_revolve_offset_face_and_boss_take_an_attached_sketch() {
known_faces_attach(494);
}
#[test]
#[ignore = "fails until #495 is fixed"]
fn sheet_metal_thickness_faces_take_an_attached_sketch() {
known_faces_attach(495);
}
/// A small deterministic generator (xorshift).
struct Rng(u64);
impl Rng {
+15
View File
@@ -4,6 +4,21 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>SolidCraft</title>
<meta name="description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="SolidCraft">
<meta property="og:title" content="SolidCraft: try it in your browser">
<meta property="og:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta property="og:url" content="https://bherbruck.github.io/solidcraft/app/">
<meta property="og:image" content="https://bherbruck.github.io/solidcraft/og.png">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="640">
<meta property="og:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="SolidCraft: try it in your browser">
<meta name="twitter:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta name="twitter:image" content="https://bherbruck.github.io/solidcraft/og.png">
<meta name="twitter:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
<link data-trunk rel="rust" data-bin="solidcraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="copy-file" href="fonts/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="fonts/OFL.txt" data-target-path="fonts" />
+2 -1
View File
@@ -263,7 +263,8 @@ impl FeatureKind {
dir(direction);
}
FeatureKind::Thread { face, .. } => pt(face),
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::SheetFlange { edges, .. } => edges.iter_mut().for_each(|e| pt(&mut e.point)),
FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::SheetConvert { face, .. } => pt(face),
FeatureKind::SheetFold { a, b, fixed, .. } => {
pt(a);
+12
View File
@@ -84,6 +84,10 @@ pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<St
if let Some(old) = crate::naming::legacy_name(name) {
return format!("{}{}", crate::naming::LEGACY_MARK, remap_topology(old, ids, bodies));
}
// A flat face of a sheet: `sheet:<body>:<panel>:<side>`, the panel a flange's feature token.
if let Some((body, face)) = crate::sheet::split_sheet_face_name(name) {
return crate::sheet::sheet_face_name(bodies.get(body).map_or(body, String::as_str), &remap_topology(face, ids, bodies));
}
// Imported names belong to the complete canonical body prefix, even when that prefix
// happens to contain text resembling a feature token.
if let Some((body, suffix)) = name.rsplit_once(":face:")
@@ -464,6 +468,14 @@ mod clone_tests {
assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label");
}
#[test]
fn a_sheet_face_follows_its_body_and_its_flange() {
let ids = BTreeMap::from([(1, 2), (10, 20)]);
let bodies = BTreeMap::from([("Body1".into(), "Body1 (copy)".into())]);
assert_eq!(remap_topology("sheet:Body1:F1.0:top", &ids, &bodies), "sheet:Body1 (copy):F2.0:top");
assert_eq!(remap_topology("sheet:Body1:base:bottom", &ids, &bodies), "sheet:Body1 (copy):base:bottom");
assert_eq!(remap_topology("sheet:Other:F10.1:top", &ids, &bodies), "sheet:Other:F20.1:top");
}
#[test]
fn parameter_remap_is_simultaneous_and_token_scoped() {
let map = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]);
assert_eq!(clone_expression("d1 + d2 + d10 + 2 mm", &map), "d2 + d3 + d10 + 2 mm");
+120 -14
View File
@@ -151,11 +151,34 @@ pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solidcraft_k
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FaceCertificate {
Box { axis: u8, high: bool },
ExtrudeCap { sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>> },
ExtrudeSide { sketch: u64, curve: String },
Imported { payload_sha256: String, item: u64, face: u64 },
ShellPlanar { source_producer: u64, source: Box<FaceCertificate>, inner: bool },
Box {
axis: u8,
high: bool,
},
/// A cylinder primitive's cap: `high` is its `top` (the far end), else its `bottom`.
CylinderCap {
high: bool,
},
ExtrudeCap {
sketch: u64,
high: bool,
outer: Vec<String>,
holes: Vec<Vec<String>>,
},
ExtrudeSide {
sketch: u64,
curve: String,
},
Imported {
payload_sha256: String,
item: u64,
face: u64,
},
ShellPlanar {
source_producer: u64,
source: Box<FaceCertificate>,
inner: bool,
},
}
impl FaceCertificate {
@@ -177,6 +200,7 @@ impl FaceCertificate {
pub fn validate(&self) -> Result<()> {
let valid = match self {
Self::Box { axis, .. } => *axis <= 2,
Self::CylinderCap { .. } => true,
Self::ExtrudeCap { sketch, outer, holes, .. } => *sketch != 0 && valid_signature(outer, holes),
Self::ExtrudeSide { sketch, curve } => *sketch != 0 && !curve.is_empty() && curve.len() <= 128,
Self::Imported { payload_sha256, item, face } => {
@@ -206,6 +230,11 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()>
&& fields.get("axis").and_then(serde_json::Value::as_u64).is_some_and(|axis| axis <= 2)
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
}
Some("cylinder_cap") => {
fields.len() == 2
&& fields.keys().all(|key| ["kind", "high"].contains(&key.as_str()))
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
}
Some("extrude_cap") => {
fields.len() == 5
&& fields.keys().all(|key| ["kind", "sketch", "high", "outer", "holes"].contains(&key.as_str()))
@@ -252,6 +281,7 @@ pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> R
let axis = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid shell source axis".into()))?;
format!("box:{}{axis}", if *high { "+" } else { "-" })
}
FaceCertificate::CylinderCap { high } => format!("cylinder:{}", cylinder_cap_role(*high)),
FaceCertificate::ExtrudeSide { curve, .. } => format!("side:{curve}"),
FaceCertificate::ExtrudeCap { high, outer, holes, .. } => format!("cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes)),
FaceCertificate::Imported { payload_sha256, item, face } => format!("import:{payload_sha256}:{item}:{face}"),
@@ -260,10 +290,16 @@ pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> R
Ok(format!("shell:{}:{role}", if inner { "inner" } else { "outer" }))
}
/// A cylinder cap's role, as the cylinder's face names call it.
pub(crate) fn cylinder_cap_role(high: bool) -> &'static str {
if high { "top" } else { "bottom" }
}
pub(crate) fn flat_source_certificate(source: &solidcraft_kernel::FaceSource) -> Option<(u64, FaceCertificate)> {
use solidcraft_kernel::FaceSource;
match source {
FaceSource::Box { producer: Some(producer), axis, high, .. } => Some((*producer, FaceCertificate::Box { axis: *axis, high: *high })),
FaceSource::CylinderCap { producer, high, .. } => Some((*producer, FaceCertificate::CylinderCap { high: *high })),
FaceSource::CertifiedExtrudeSide { producer, sketch, curve, .. } => {
Some((*producer, FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() }))
}
@@ -283,6 +319,9 @@ pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solidc
(FaceCertificate::Box { axis, high }, FaceSource::Box { producer: Some(current), axis: current_axis, high: current_high, .. }) => {
producer == *current && axis == current_axis && high == current_high
}
(FaceCertificate::CylinderCap { high }, FaceSource::CylinderCap { producer: current, high: current_high, .. }) => {
producer == *current && high == current_high
}
(
FaceCertificate::ExtrudeSide { sketch, curve },
FaceSource::CertifiedExtrudeSide { producer: current, sketch: current_sketch, curve: current_curve, .. },
@@ -309,7 +348,9 @@ pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solidc
/// Match a generated segment to one exact analytic sketch curve. Coincident
/// candidates are ambiguous and never resolved by distance or candidate order.
pub(crate) struct ExactSketchCurves<'a> {
segments: Vec<(&'a str, Vec<Seg2>)>,
/// Each analytic curve's id, whether it is projected (linked) reference geometry, and its
/// segments.
segments: Vec<(&'a str, bool, Vec<Seg2>)>,
}
impl<'a> ExactSketchCurves<'a> {
@@ -340,22 +381,33 @@ impl<'a> ExactSketchCurves<'a> {
} else {
sketch.sketch.segs(index)
};
segments.push((curve.id.as_str(), originals));
segments.push((curve.id.as_str(), curve.link.is_some(), originals));
}
Self { segments }
}
/// The one sketch curve a generated segment lies on. A curve drawn over projected
/// reference geometry (a profile traced over a face's live boundary) is that segment's
/// source: the projection only marks where it was drawn. Two drawn curves, or two
/// projections, over the same segment stay ambiguous.
pub(crate) fn find(&self, generated: &Seg2) -> Option<String> {
let mut found = None;
for (id, originals) in &self.segments {
let mut drawn = None;
let mut projected = None;
for (id, linked, originals) in &self.segments {
if originals.iter().any(|original| exact_segment_contains(original, generated)) {
if found.is_some() {
return None;
let slot = if *linked { &mut projected } else { &mut drawn };
if slot.is_some() {
*slot = Some(None);
} else {
*slot = Some(Some(*id));
}
found = Some(*id);
}
}
found.map(str::to_owned)
match (drawn, projected) {
(Some(id), _) => id.map(str::to_owned),
(None, Some(id)) => id.map(str::to_owned),
(None, None) => None,
}
}
}
@@ -411,7 +463,8 @@ impl Document {
| Some(solidcraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. })
| Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. })
| Some(solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), .. })
| Some(solidcraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer,
| Some(solidcraft_kernel::FaceSource::Box { producer: Some(producer), .. })
| Some(solidcraft_kernel::FaceSource::CylinderCap { producer, .. }) => *producer,
_ => name
.strip_prefix('F')
.and_then(|name| name.split_once(':'))
@@ -481,6 +534,11 @@ impl Document {
target.name = format!("F{producer}:{}{axis_name}", if *high { "+" } else { "-" });
target.certificate = Some(FaceCertificate::Box { axis: *axis, high: *high });
}
Some(solidcraft_kernel::FaceSource::CylinderCap { producer, high, .. }) => {
target.origin_feature = *producer;
target.name = format!("F{producer}:{}", cylinder_cap_role(*high));
target.certificate = Some(FaceCertificate::CylinderCap { high: *high });
}
Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. }) => {
target.origin_feature = *producer;
target.name = format!("F{producer}:cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes));
@@ -512,6 +570,31 @@ mod tests {
SolvedSketch { feature: 1, name: "sources".into(), plane: solidcraft_geom::Plane::XY, sketch, report, profiles: Vec::new() }
}
#[test]
fn cylinder_cap_certificate_json_is_exact() {
for high in [true, false] {
let valid = json!({"kind":"cylinder_cap","high":high});
assert!(validate_certificate_json(&valid).is_ok());
let certificate: FaceCertificate = serde_json::from_value(valid.clone()).unwrap();
assert_eq!(certificate, FaceCertificate::CylinderCap { high });
assert_eq!(serde_json::to_value(&certificate).unwrap(), valid);
}
let shelled = json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"cylinder_cap","high":false},"inner":true});
assert!(validate_certificate_json(&shelled).is_ok());
let certificate: FaceCertificate = serde_json::from_value(shelled).unwrap();
assert_eq!(shell_certificate_role(&FaceCertificate::CylinderCap { high: false }, true).unwrap(), "shell:inner:cylinder:bottom");
assert!(certificate.validate().is_ok());
for bad in [
json!({"kind":"cylinder_cap"}),
json!({"kind":"cylinder_cap","high":1}),
json!({"kind":"cylinder_cap","high":null}),
json!({"kind":"cylinder_cap","high":true,"axis":2}),
json!({"kind":"cylinder_cap","high":true,"extra":[[[]]]}),
] {
assert!(validate_certificate_json(&bad).is_err(), "{bad}");
}
}
#[test]
fn prepared_sources_keep_exact_identity_and_reject_ambiguity() {
use solidcraft_geom::Vec2;
@@ -537,6 +620,29 @@ mod tests {
assert_eq!(ExactSketchCurves::new(&moved).find(&partial), None);
}
#[test]
fn a_curve_drawn_over_a_projected_boundary_is_the_source() {
use solidcraft_geom::Vec2;
// A face sketch's live boundary, with a profile edge traced over it (issue #489).
let mut sketch = solidcraft_sketch::Sketch::new();
let projected = sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("edge")).unwrap();
sketch.curves[projected].link = Some("boundary".into());
let sketch = solved(sketch);
let edge = Seg2::Line { a: Vec2::new(10., 0.), b: Vec2::ZERO };
assert_eq!(ExactSketchCurves::new(&sketch).find(&edge).as_deref(), Some("edge"), "a lone projection is its own source");
let mut traced = sketch.clone();
traced.sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("drawn")).unwrap();
assert_eq!(ExactSketchCurves::new(&traced).find(&edge).as_deref(), Some("drawn"));
// Two drawn curves over it, or two projections, stay ambiguous.
let mut twice = traced.clone();
twice.sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("again")).unwrap();
assert_eq!(ExactSketchCurves::new(&twice).find(&edge), None);
let mut projections = sketch.clone();
let other = projections.sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("other")).unwrap();
projections.sketch.curves[other].link = Some("edge link".into());
assert_eq!(ExactSketchCurves::new(&projections).find(&edge), None);
}
#[test]
#[ignore = "manual optimized certification microbenchmark; no timing assertion"]
fn benchmark_prepared_extrude_sources() {
+9 -1
View File
@@ -529,7 +529,15 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
let stem = crate::naming::strip_piece(&former);
let current_names = crate::naming::face_names(current);
let merged = current_names.iter().position(|name| name == stem).unwrap();
assert!(current.body.face_was_coalesced(merged), "new exact generated-wall construction supplies the loss-only witness");
// The Join leaves Case's -x wall on its own support, so the wall keeps its exact box
// identity through the Join and the Cut: one face again, no longer the saved piece.
assert!(
matches!(
current.body.face_sources().and_then(|sources| sources.get(merged)).cloned().flatten(),
Some(solidcraft_kernel::FaceSource::Box { axis: 0, high: false, .. })
),
"the merged wall carries its exact construction identity"
);
assert!(!current_names.contains(&former));
let mut sketch = Sketch::new();
sketch.add_link(LinkKind::Project, LinkSource::Face { body: "Case".into(), at }, &resolved.geom).unwrap();
+18 -7
View File
@@ -108,9 +108,10 @@ impl Document {
use super::FaceCertificate;
let Some(feature) = self.feature(producer) else { return false };
match (certificate, &feature.kind) {
(FaceCertificate::Box { .. }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => true,
(FaceCertificate::ExtrudeCap { sketch, .. }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => sketch == current && extent.taper.is_none(),
(FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => {
(FaceCertificate::Box { .. }, FeatureKind::Box { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => true,
(FaceCertificate::CylinderCap { .. }, FeatureKind::Cylinder { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => true,
(FaceCertificate::ExtrudeCap { sketch, .. }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => sketch == current && extent.taper.is_none(),
(FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
sketch == current && extent.taper.is_none()
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Circle { .. } | solidcraft_sketch::CurveKind::Arc { .. }))))
}
@@ -129,14 +130,17 @@ impl Document {
let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?;
if let Some(certificate) = &target.certificate {
let valid = match (certificate, &feature.kind) {
(super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => {
(super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
["x", "y", "z"].get(*axis as usize).is_some_and(|axis| role == format!("{}{axis}", if *high { "+" } else { "-" }))
}
(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
(super::FaceCertificate::CylinderCap { high }, FeatureKind::Cylinder { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
role == super::cylinder_cap_role(*high)
}
(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
sketch == producer_sketch && extent.taper.is_none()
&& role == format!("cap:{}:{}", if *high { "high" } else { "low" }, super::cap_key(outer, holes))
}
(super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
(super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
sketch == producer_sketch && extent.taper.is_none() && role == format!("side:{curve}")
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Circle { .. } | solidcraft_sketch::CurveKind::Arc { .. }))))
}
@@ -192,7 +196,10 @@ impl Document {
}
return Ok(indices);
}
if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) {
if matches!(
target.certificate,
Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::CylinderCap { .. } | super::FaceCertificate::ExtrudeCap { .. })
) {
let indices: Vec<_> = body
.body
.face_sources()
@@ -205,6 +212,10 @@ impl Document {
Some(super::FaceCertificate::Box { axis, high }),
Some(solidcraft_kernel::FaceSource::Box { producer: Some(producer), axis: current_axis, high: current_high, .. }),
) => *producer == target.origin_feature && axis == current_axis && high == current_high,
(
Some(super::FaceCertificate::CylinderCap { high }),
Some(solidcraft_kernel::FaceSource::CylinderCap { producer, high: current_high, .. }),
) => *producer == target.origin_feature && high == current_high,
(
Some(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }),
Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap {
+74 -3
View File
@@ -269,6 +269,73 @@ impl EmbossFace {
}
}
/// One edge of an edge flange feature: a point on the sheet's edge and, where given, this
/// edge's own angle and height (expressions) in place of the feature's. In a design file an
/// edge with neither is the point `[x, y, z]`, as every edge was before; one with its own
/// values is `{"point": [x, y, z], "angle": "…", "height": "…"}`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FlangeEdge {
pub point: Vec3,
pub angle: Option<String>,
pub height: Option<String>,
}
impl FlangeEdge {
/// An edge that takes the feature's angle and height.
pub fn at(point: Vec3) -> Self {
Self { point, angle: None, height: None }
}
}
impl Serialize for FlangeEdge {
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
if self.angle.is_none() && self.height.is_none() {
return self.point.serialize(s);
}
let mut m = s.serialize_map(None)?;
m.serialize_entry("point", &self.point)?;
if let Some(a) = &self.angle {
m.serialize_entry("angle", a)?;
}
if let Some(h) = &self.height {
m.serialize_entry("height", h)?;
}
m.end()
}
}
impl<'de> Deserialize<'de> for FlangeEdge {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
use serde::de::value::{MapAccessDeserializer, SeqAccessDeserializer};
/// The object form; a key this build doesn't know is refused, not dropped.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Own {
point: Vec3,
#[serde(default)]
angle: Option<String>,
#[serde(default)]
height: Option<String>,
}
struct Edge;
impl<'de> serde::de::Visitor<'de> for Edge {
type Value = FlangeEdge;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a point [x, y, z] or an edge {point, angle?, height?}")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, seq: A) -> std::result::Result<FlangeEdge, A::Error> {
Vec3::deserialize(SeqAccessDeserializer::new(seq)).map(FlangeEdge::at)
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> std::result::Result<FlangeEdge, A::Error> {
let o = Own::deserialize(MapAccessDeserializer::new(map))?;
Ok(FlangeEdge { point: o.point, angle: o.angle, height: o.height })
}
}
d.deserialize_any(Edge)
}
}
/// Feature definitions.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -659,9 +726,10 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
reverse: bool,
},
/// Edge flanges on sheet edges (points on the top or bottom edges).
/// Edge flanges on sheet edges (points on the top or bottom edges). `height` and `angle`
/// hold for every edge that doesn't carry its own (see [`FlangeEdge`]).
SheetFlange {
edges: Vec<Vec3>,
edges: Vec<FlangeEdge>,
height: String,
angle: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1521,9 +1589,12 @@ impl FeatureKind {
v.extend([length, draft, thickness].into_iter().flatten().map(String::as_str));
}
FeatureKind::SheetContour { distance, .. } => v.push(distance),
FeatureKind::SheetFlange { height, angle, radius, .. } => {
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
v.extend([height.as_str(), angle]);
v.extend(radius.iter().map(String::as_str));
for e in edges {
v.extend([&e.angle, &e.height].into_iter().flatten().map(String::as_str));
}
}
FeatureKind::SheetFold { angle, radius, .. } => {
v.push(angle);
+54 -3
View File
@@ -2108,7 +2108,9 @@ fn feature_tools_with_constructor(
if untrimmed {
*constructor = Some(ExtrudeConstructor { plane: ss.plane, interval: (lo + off, hi + off) });
}
if *operation == Operation::NewBody && extent.taper.is_none() {
// A Join tool's faces outlive the boolean where they stay on the result, so they are
// certified as a new body's are (see `Body::with_preserved_union_planar_sources`).
if matches!(operation, Operation::NewBody | Operation::Join) && extent.taper.is_none() {
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
let cap_bindings = crate::config::extrude_cap_bindings(&ss, &bodies, 10_000_000);
let source_curves = cap_bindings.as_ref().map(|_| crate::config::ExactSketchCurves::new(&ss));
@@ -2127,8 +2129,20 @@ fn feature_tools_with_constructor(
_ => None,
})
.collect();
let caps =
let mut caps =
cap_bindings.as_ref().and_then(|bindings| bindings.get(body_index)).cloned().unwrap_or_else(|| vec![None; curves.len()]);
// A cap moved off its designed height (into a Join target, or past a
// curved To face) is not the feature's start or end face.
let nominal = |plane: &solidcraft_geom::Plane| {
let height = (plane.origin - ss.plane.origin).dot(n);
let tolerance = 1e-9 * (1.0 + height.abs() + (lo + off).abs() + (hi + off).abs());
(height - (lo + off)).abs() <= tolerance || (height - (hi + off)).abs() <= tolerance
};
for (cap, source) in caps.iter_mut().zip(body.face_sources().into_iter().flatten()) {
if matches!(source, Some(kernel::FaceSource::ExtrudeCap { plane, .. }) if !nominal(plane)) {
*cap = None;
}
}
// Certificates set face sources only: the faces, and their lineage, stay.
let body = body.with_extrude_certificates(f.id, *sketch, &curves)?.with_extrude_cap_certificates(f.id, *sketch, &caps)?;
Ok(Traced { body, lineage })
@@ -3060,6 +3074,38 @@ fn smooth_chain(b: &ModelBody, picks: &[Vec3]) -> Vec<Vec3> {
out
}
/// `plane` with the sheet face it names (see `sheet::plane_sheet_face`) replaced by that face's current
/// plane in the frame of component `comp`, or an error when the face is gone: a lost
/// attachment is reported, never moved to the nearest face.
pub(crate) fn sheet_face_plane_in_frame(doc: &Document, st: &ModelState, comp: u64, plane: &PlaneRef) -> Result<PlaneRef> {
Ok(match plane {
PlaneRef::Face { name: Some(name), anchor: None, .. } if crate::sheet::plane_sheet_face(plane).is_some() => {
let lost = || DocError::Invalid(format!("lost face attachment: the sheet face `{name}` no longer exists; reattach the sketch to a face"));
let (body, face) = crate::sheet::sheet_face_plane(st, name).ok_or_else(lost)?;
if doc.occurrences.is_empty() && doc.body_offsets.is_empty() {
return Ok(PlaneRef::Custom { plane: face });
}
// The body's own coordinates into the component's frame, as `state_in_frame` places its bodies.
let source = st.body(&body).ok_or_else(lost)?;
let inverse = crate::mat_inverse(&doc.component_transform(comp)).ok_or_else(lost)?;
let m = crate::mat_mul(&inverse, &doc.component_transform(doc.model_body_component(source)));
let m = doc.model_body_offset(source).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m);
let placed = Plane::new(crate::apply_point(&m, face.origin), crate::apply_vector(&m, face.x), crate::apply_vector(&m, face.y));
PlaneRef::Custom { plane: placed.ok_or_else(lost)? }
}
PlaneRef::Offset { base, distance } => {
PlaneRef::Offset { base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?), distance: distance.clone() }
}
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => PlaneRef::AtAngle {
base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?),
axis_origin: *axis_origin,
axis_dir: *axis_dir,
angle: angle.clone(),
},
other => other.clone(),
})
}
/// The plane of the body face a sketch sits on, found again: among planar faces with the
/// picked plane's normal, the one nearest `at`. The picked frame moves along its normal onto it.
fn face_plane(st: &ModelState, picked: &Plane, at: Vec3, name: Option<&str>) -> (Plane, Option<String>) {
@@ -3191,6 +3237,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
};
let (plane, face_warning) = match (anchor, plane) {
(Some(_), plane) => (doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?, None),
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
}
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref()),
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
@@ -3624,11 +3674,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
let corner = point(vals, corner)?;
let b = kernel::box_solid(corner, corner + s)?;
let b = if *operation == Operation::NewBody { b.with_box_certificate(f.id)? } else { b };
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b };
apply_traced(st, f, vec![crate::lineage::box_tool(b)], *operation, &[])
}
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
let b = kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_cylinder_certificate(f.id)? } else { b };
apply_traced(st, f, vec![crate::lineage::cylinder_tool(b)], *operation, &[])
}
FeatureKind::Sphere { center, radius, operation } => {
+21 -3
View File
@@ -415,8 +415,21 @@ pub(super) fn sheet_eval(
Ok(())
}
FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body } => {
let i = sheet_for(st, body, edges.first().copied())?;
let i = sheet_for(st, body, edges.first().map(|e| e.point))?;
let (h, a) = (val(vals, height, Kind::Length)?, val(vals, angle, Kind::Angle)?);
// Each edge's own height and angle, else the feature's.
let mut own = Vec::with_capacity(edges.len());
for e in edges {
let height = match &e.height {
Some(x) => val(vals, x, Kind::Length)?,
None => h,
};
let angle = match &e.angle {
Some(x) => val(vals, x, Kind::Angle)?,
None => a,
};
own.push((height, angle));
}
let rr = match radius {
Some(e) => val(vals, e, Kind::Length)?,
None => {
@@ -428,9 +441,14 @@ pub(super) fn sheet_eval(
if sh.flat {
return Err(DocError::Invalid("refold the sheet before adding flanges".into()));
}
let picks = sheet_picks(sh, f, edges, warning);
let points: Vec<Vec3> = edges.iter().map(|e| e.point).collect();
let each: Vec<sheet::EdgeFlange> = sheet_picks(sh, f, &points, warning)
.into_iter()
.zip(own)
.map(|(pick, (height, angle))| sheet::EdgeFlange { pick, height, angle })
.collect();
let n0 = sh.flanges.len();
let (_, notes) = sh.add_flanges_noted(&picks, h, a, rr, position, *flip)?;
let (_, notes) = sh.add_flanges_each(&each, rr, position, *flip)?;
if !notes.is_empty() {
let notes = notes.join("; ");
*warning = Some(match warning.take() {
+10 -1
View File
@@ -117,11 +117,13 @@ impl Document {
}
}
/// All status planes are world-space. A lost plane is a repair guide only.
/// All status planes are world-space. A lost plane is a repair guide only. `Sheet` is a sketch on
/// a flat face of a sheet metal body, attached by the name of its panel (`sheet::sheet_face_name`).
#[derive(Clone, Debug)]
pub enum FaceAttachmentStatus {
Fixed { plane: Option<Plane> },
Legacy { plane: Option<Plane> },
Sheet { plane: Option<Plane> },
Healthy { plane: Plane, target: ConfigFaceTarget, path: Vec<u64> },
Lost { message: String, repair_plane: Option<Plane> },
}
@@ -210,6 +212,13 @@ impl Document {
self.resolve_plane(&vals, plane, 0).ok()
});
let current = world.and_then(|world| current.and_then(|plane| map_plane(&world, &plane).ok()));
if crate::sheet::plane_sheet_face(plane).is_some() {
let state = model.state_before(feature.id);
return match crate::eval::sheet_face_plane_in_frame(self, &state, feature.component, plane) {
Ok(_) => FaceAttachmentStatus::Sheet { plane: current },
Err(error) => FaceAttachmentStatus::Lost { message: error.to_string(), repair_plane: current },
};
}
return if has_face_reference(plane) {
FaceAttachmentStatus::Legacy { plane: current }
} else {
+8 -2
View File
@@ -46,13 +46,18 @@
//! legacy mark (`?F2:top#2`) and are found again as the build that saved them found them.
//! Pieces a feature splits a body into are named after their faces; features saved before
//! keep the kernel's shell order (`shell_piece_order`), which their stored body names mean.
//! - 15: sketch face anchors and face bindings can name a cylinder primitive's cap
//! (`cylinder_cap`), sketches can link lines to their own axes (`sketch_axis`), and sketch
//! anchors can name a sheet metal panel side (`sheet:<body>:<panel>:<side>`). Version 14
//! designs need no transformation; the version stamp lets older readers request an update
//! instead of encountering an unknown certificate or link kind.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 14;
pub const FORMAT: u32 = 15;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -69,7 +74,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 13] = [
const JSON_STEPS: [JsonStep; 14] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -83,6 +88,7 @@ const JSON_STEPS: [JsonStep; 13] = [
|_| Ok(()),
|_| Ok(()),
v13_to_v14,
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
+10 -1
View File
@@ -223,12 +223,21 @@ impl FeatureKind {
v.push(("Catch length", catch_length, L));
}
FeatureKind::SheetContour { distance, .. } => v.push(("Distance", distance, L)),
FeatureKind::SheetFlange { height, angle, radius, .. } => {
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
v.push(("Height", height, L));
v.push(("Angle", angle, A));
if let Some(r) = radius {
v.push(("Bend radius", r, L));
}
// The edges' own values come last, so the inputs above keep their names.
for e in edges {
if let Some(a) = &mut e.angle {
v.push(("Edge angle", a, A));
}
if let Some(h) = &mut e.height {
v.push(("Edge height", h, L));
}
}
}
FeatureKind::SheetFold { angle, radius, .. } => {
v.push(("Angle", angle, A));
+4
View File
@@ -259,6 +259,9 @@ fn resolve_uncached(
_ => vec![LinkGeom::Point(o)],
}
}
LinkSource::SketchAxis { name } => {
vec![solidcraft_sketch::sketch_axis_geom(name).ok_or_else(|| DocError::Unknown(format!("sketch axis `{name}`")))?]
}
LinkSource::Text { text, at, height, angle } => solidcraft_sketch::text_geometry(text, *at, *height, *angle)?,
LinkSource::Plane { name } => {
let pr =
@@ -321,6 +324,7 @@ fn certified_planar_construction(body: &crate::ModelBody, index: usize) -> bool
Some(
solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(_), .. }
| solidcraft_kernel::FaceSource::Box { producer: Some(_), .. }
| solidcraft_kernel::FaceSource::CylinderCap { .. }
| solidcraft_kernel::FaceSource::CertifiedExtrudeSide { plane: Some(_), .. }
| solidcraft_kernel::FaceSource::CertifiedExtrudeCap { .. }
)
+496 -20
View File
@@ -298,6 +298,15 @@ pub struct SheetFlange {
pub tag: String,
}
/// One edge flange to make: a pick on the sheet's edge, and that flange's height (to the outer
/// face, from the virtual sharp) and angle (radians).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EdgeFlange {
pub pick: Vec3,
pub height: f64,
pub angle: f64,
}
/// A sheet metal body (see the module docs).
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SheetBody {
@@ -1368,38 +1377,54 @@ impl SheetBody {
position: &str,
flip: bool,
) -> Result<(Vec<usize>, Vec<String>)> {
if !(angle > 1e-6 && angle <= std::f64::consts::PI - 1e-6) {
return Err(DocError::Invalid("the flange angle must be between 0 and 180°".into()));
}
if picks.is_empty() || picks.len() > 1000 {
let each: Vec<EdgeFlange> = picks.iter().map(|&pick| EdgeFlange { pick, height, angle }).collect();
self.add_flanges_each(&each, radius, position, flip)
}
/// Edge flanges that each have their own height and angle, in one go: every edge is trimmed
/// back by its own setback first, then the flanges are added and the corners between them
/// are closed. The closing works from the two flanges it joins (each one's own fold and
/// leg), so unequal flanges close like equal ones.
pub fn add_flanges_each(&mut self, edges: &[EdgeFlange], radius: f64, position: &str, flip: bool) -> Result<(Vec<usize>, Vec<String>)> {
if edges.is_empty() || edges.len() > 1000 {
return Err(DocError::Invalid("pick 1…1000 edges".into()));
}
let sharp = (radius + self.t) * (angle / 2.0).tan();
let setback = match position {
"inside" | "" => sharp,
"outside" => 0.0,
"middle" | "mid" => sharp / 2.0,
other => return Err(DocError::Invalid(format!("unknown bend position `{other}` (inside, outside or middle)"))),
};
let leg = height - sharp;
if leg < -1e-9 {
return Err(DocError::Invalid(format!("the flange height must be at least {sharp:.3} mm for this bend")));
// With several edges a message says which one it is about.
let on = |k: usize| if edges.len() > 1 { format!("edge {}: ", k + 1) } else { String::new() };
// Each edge's setback and leg (the panel past the bend) follow its own angle and height.
let mut sizes = Vec::with_capacity(edges.len());
for (k, e) in edges.iter().enumerate() {
if !(e.angle > 1e-6 && e.angle <= std::f64::consts::PI - 1e-6) {
return Err(DocError::Invalid(format!("{}the flange angle must be between 0 and 180°", on(k))));
}
let sharp = (radius + self.t) * (e.angle / 2.0).tan();
let setback = match position {
"inside" | "" => sharp,
"outside" => 0.0,
"middle" | "mid" => sharp / 2.0,
other => return Err(DocError::Invalid(format!("unknown bend position `{other}` (inside, outside or middle)"))),
};
let leg = e.height - sharp;
if !(leg >= -1e-9) {
return Err(DocError::Invalid(format!("{}the flange height must be at least {sharp:.3} mm for this bend", on(k))));
}
sizes.push((setback, leg));
}
// Find every edge before anything moves.
let mut found = Vec::new();
for p in picks {
let (panel, a, b, top, dist) = self.edge_at(*p).ok_or_else(|| DocError::Invalid("no sheet edge there".into()))?;
for (e, &(setback, leg)) in edges.iter().zip(&sizes) {
let (panel, a, b, top, dist) = self.edge_at(e.pick).ok_or_else(|| DocError::Invalid("no sheet edge there".into()))?;
if dist > self.t * 2.0 + 1e-3 {
return Err(DocError::Invalid("pick an edge of the sheet's top or bottom face".into()));
}
let d = (b - a).normalized().ok_or_else(|| DocError::Invalid("edge".into()))?;
found.push((panel, a, b, d, top));
found.push((panel, a, b, d, top, setback, leg, e.angle));
}
for (panel, a, b, _, _) in &found {
self.trim(*panel, *a, *b, setback)?;
for (panel, a, b, _, _, setback, _, _) in &found {
self.trim(*panel, *a, *b, *setback)?;
}
let mut made = Vec::new();
for (panel, a, b, d, top) in found {
for (panel, a, b, d, top, setback, leg, angle) in found {
let outward = Vec2::new(d.y, -d.x);
let line_o = a - outward * setback;
// The trimmed edge: along d, on the moved line.
@@ -2000,6 +2025,72 @@ impl SheetBody {
best.map(|(_, q)| q)
}
/// The persistent name of a panel: `base`, a flange's tag, or `#<index>` for an untagged
/// flange; `flat` for the whole sheet while it is unfolded.
fn panel_name(&self, pi: Option<usize>) -> String {
match pi {
_ if self.flat => "flat".into(),
None => "base".into(),
Some(i) => match self.flanges.get(i) {
Some(f) if !f.tag.is_empty() => f.tag.clone(),
_ => format!("#{i}"),
},
}
}
/// The plane of a flat face of a panel (`top`: the face at the thickness, else the bottom
/// one), facing out of the sheet. Its coordinates are the flat pattern's (the bottom
/// face's mirrored in y), so a sketch on it measures as the flat pattern does.
fn panel_face_plane(&self, world: &Mat, top: bool) -> Option<Plane> {
let (x, y) = (apply_vector(world, Vec3::X), apply_vector(world, Vec3::Y));
Plane::new(apply_point(world, Vec3::new(0.0, 0.0, if top { self.t } else { 0.0 })), x, if top { y } else { y * -1.0 })
}
/// The flat face of the sheet that a point lies on (sheet coordinates, within `tol`, which
/// stays below a quarter of the thickness): its persistent name (`<panel>:top` or
/// `<panel>:bottom`) and its plane (see [`panel_face`](Self::panel_face)).
pub fn panel_face_at(&self, p: Vec3, tol: f64) -> Option<(String, Plane)> {
let tol = tol.min(self.t * 0.25);
let flat = if self.flat { self.flat_regions().ok()? } else { Vec::new() };
let panels = if self.flat { vec![(None, self.base.clone())] } else { self.panels() };
for (pi, region) in panels {
let world = self.world(pi);
let Some(inverse) = mat_inverse(&world) else { continue };
let q = apply_point(&inverse, p);
let top = (q.z - self.t).abs() < q.z.abs();
if (if top { (q.z - self.t).abs() } else { q.z.abs() }) > tol {
continue;
}
let q2 = Vec2::new(q.x, q.y);
if if self.flat { flat.iter().any(|r| r.contains(q2)) } else { region.contains(q2) } {
return Some((format!("{}:{}", self.panel_name(pi), if top { "top" } else { "bottom" }), self.panel_face_plane(&world, top)?));
}
}
None
}
/// The plane of the flat face with this persistent name (see [`panel_face_at`](Self::panel_face_at)).
/// A face named while the sheet was folded follows its panel; one named unfolded (`flat`)
/// is the flat sheet's own plane.
pub fn panel_face(&self, name: &str) -> Option<Plane> {
let (panel, side) = name.rsplit_once(':')?;
let top = match side {
"top" => true,
"bottom" => false,
_ => return None,
};
let pi = match panel {
"flat" | "base" => None,
tag => Some(
self.flanges
.iter()
.position(|f| f.tag == tag)
.or_else(|| tag.strip_prefix('#')?.parse::<usize>().ok().filter(|i| *i < self.flanges.len()))?,
),
};
self.panel_face_plane(&self.world(pi), top)
}
/// Map a region in a world plane to flat coordinates through a panel's placement.
pub fn region_to_flat(&self, panel: Option<usize>, plane: &Plane, r: &Region2) -> Result<Region2> {
region_complexity(std::slice::from_ref(r))?;
@@ -2298,6 +2389,52 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
Ok(sheet)
}
/// The name a sketch keeps for a flat face of a sheet: `sheet:<body>:<panel>:<top|bottom>`.
pub fn sheet_face_name(body: &str, face: &str) -> String {
format!("sheet:{body}:{face}")
}
/// Point the sheet face a sketch plane is attached to (also beneath an offset or a rotation)
/// at body `new`, when it is on body `old`.
pub fn rename_plane_sheet_body(plane: &mut crate::PlaneRef, old: &str, new: &str) {
match plane {
crate::PlaneRef::Face { name: Some(name), anchor: None, .. } => {
let renamed = split_sheet_face_name(name).filter(|(body, _)| *body == old).map(|(_, face)| sheet_face_name(new, face));
if let Some(renamed) = renamed {
*name = renamed;
}
}
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } => rename_plane_sheet_body(base, old, new),
_ => {}
}
}
/// The sheet face a sketch plane is attached to (also beneath an offset or a rotation): its
/// [`sheet_face_name`].
pub fn plane_sheet_face(plane: &crate::PlaneRef) -> Option<&str> {
match plane {
crate::PlaneRef::Face { name: Some(name), anchor: None, .. } if split_sheet_face_name(name).is_some() => Some(name),
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } => plane_sheet_face(base),
_ => None,
}
}
/// The body and the `<panel>:<side>` of a [`sheet_face_name`].
pub fn split_sheet_face_name(name: &str) -> Option<(&str, &str)> {
let rest = name.strip_prefix("sheet:")?;
let (head, side) = rest.rsplit_once(':')?;
let (body, panel) = head.rsplit_once(':')?;
(!body.is_empty() && !panel.is_empty() && !side.is_empty()).then_some(())?;
Some((body, rest.get(body.len() + 1..)?))
}
/// The plane of the sheet face with a [`sheet_face_name`], in the body's own coordinates.
pub fn sheet_face_plane(st: &crate::ModelState, name: &str) -> Option<(String, Plane)> {
let (body, face) = split_sheet_face_name(name)?;
let plane = st.sheets.iter().find(|s| s.body == body)?.panel_face(face)?;
Some((body.to_string(), plane))
}
/// Persistent names of the sheet edges nearest each world point (empty when none).
pub fn sheet_edge_names(st: &crate::ModelState, pts: &[Vec3]) -> Vec<String> {
pts.iter()
@@ -2368,6 +2505,85 @@ mod tests {
assert!((s.flat_size().unwrap().1 - 73.685298).abs() < 1e-5);
}
#[test]
fn flat_faces_of_a_sheet_are_found_and_named_by_panel() {
use std::f64::consts::FRAC_PI_2;
// Steel, 2.5 thick, R = T: a 90° flange on the y = 0 edge stands between the planes
// y = 0 (its outer face) and y = 2.5 (its inner face) from z = 5 up.
let mut s = plate(100.0, 60.0);
s.add_flange(Vec3::new(50.0, 0.0, 2.5), 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
s.flanges[0].tag = "F7.0".into();
let face = |s: &SheetBody, p: Vec3| s.panel_face_at(p, 1e-6);
let (name, plane) = face(&s, Vec3::new(50.0, 30.0, 2.5)).unwrap();
assert_eq!(name, "base:top");
assert!(plane.normal().dist(Vec3::Z) < 1e-12 && (plane.origin.z - 2.5).abs() < 1e-12);
// The top face's coordinates are the flat pattern's; the bottom face's mirror y.
assert!(plane.to_local(Vec3::new(30.0, 20.0, 2.5)).dist(Vec2::new(30.0, 20.0)) < 1e-12);
let (name, plane) = face(&s, Vec3::new(50.0, 30.0, 0.0)).unwrap();
assert_eq!(name, "base:bottom");
assert!(plane.normal().dist(Vec3::Z * -1.0) < 1e-12 && plane.origin.z.abs() < 1e-12);
assert!(plane.to_local(Vec3::new(30.0, 20.0, 0.0)).dist(Vec2::new(30.0, -20.0)) < 1e-12);
// The flange's inner face is the sheet's top side there; 7 mm up it is flat y = 5 − BA − 7.
let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5);
let (inner, plane) = face(&s, Vec3::new(30.0, 2.5, 12.0)).unwrap();
assert_eq!(inner, "F7.0:top");
assert!(plane.normal().dist(Vec3::Y) < 1e-12, "{:?}", plane.normal());
assert!(plane.to_local(Vec3::new(30.0, 2.5, 12.0)).dist(Vec2::new(30.0, 5.0 - ba - 7.0)) < 1e-9);
let (outer, plane) = face(&s, Vec3::new(30.0, 0.0, 12.0)).unwrap();
assert_eq!(outer, "F7.0:bottom");
assert!(plane.normal().dist(Vec3::Y * -1.0) < 1e-12, "{:?}", plane.normal());
// A name gives the same plane again.
for p in [Vec3::new(50.0, 30.0, 2.5), Vec3::new(50.0, 30.0, 0.0), Vec3::new(30.0, 2.5, 12.0), Vec3::new(30.0, 0.0, 12.0)] {
let (name, plane) = face(&s, p).unwrap();
assert_eq!(s.panel_face(&name), Some(plane), "{name}");
}
// Inside the sheet, on the bend, beside the sheet, above the flange: no flat face.
for p in [Vec3::new(50.0, 30.0, 1.0), Vec3::new(50.0, 1.0, 1.0), Vec3::new(150.0, 30.0, 2.5), Vec3::new(30.0, 2.5, 40.0)] {
assert!(face(&s, p).is_none(), "{p:?}");
}
// Names that are no face, or no flange, give no plane.
for name in ["base", "base:side", "nobody:top", "#7:top", "F8.0:top", ""] {
assert!(s.panel_face(name).is_none(), "{name}");
}
// A flange without a tag is named by its index.
s.flanges[0].tag.clear();
assert_eq!(face(&s, Vec3::new(30.0, 2.5, 12.0)).map(|f| f.0).as_deref(), Some("#0:top"));
assert!(s.panel_face("#0:bottom").is_some());
// Unfolded, the whole sheet is one panel.
s.flat = true;
assert_eq!(face(&s, Vec3::new(50.0, -10.0, 2.5)).map(|f| f.0).as_deref(), Some("flat:top"));
assert!(s.panel_face("flat:bottom").is_some());
}
#[test]
fn a_pick_beside_a_thin_sheet_takes_the_nearer_face() {
// The tolerance of a pick on a large part is far above a thin sheet's thickness, and
// never reaches a quarter of it here: the nearer side wins, the middle is no face.
let rv = RuleValues { thickness: 0.5, ..steel() };
let s = base_flange(
"Thin",
"Sheet",
&rv,
Plane::XY,
rect(Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(100.0, 60.0), Vec2::new(0.0, 60.0)),
);
let at = |z: f64| s.panel_face_at(Vec3::new(50.0, 30.0, z), 5.0).map(|f| f.0);
assert_eq!(at(0.4).as_deref(), Some("base:top"));
assert_eq!(at(0.1).as_deref(), Some("base:bottom"));
assert_eq!(at(0.25), None);
assert_eq!(at(3.0), None);
}
#[test]
fn sheet_face_names_split_from_the_right() {
assert_eq!(split_sheet_face_name(&sheet_face_name("Body1", "F7.0:top")), Some(("Body1", "F7.0:top")));
// A body name may hold colons; the panel and the side never do.
assert_eq!(split_sheet_face_name("sheet:Part:2:base:bottom"), Some(("Part:2", "base:bottom")));
for name in ["", "sheet", "sheet:Body1", "sheet:Body1:top", "sheet::base:top", "sheet:Body1::top", "sheet:Body1:base:", "Body1:base:top"] {
assert_eq!(split_sheet_face_name(name), None, "{name}");
}
}
#[test]
fn contour_flange_matches_fusion() {
// Oracle 72: XZ chain (0,0)→(40,0)→(40,−30)→(70,−30), 50 wide.
@@ -2895,4 +3111,264 @@ mod tests {
}
}
}
/// Edge flanges on `base`, each with its own height and angle, added in one call. Returns the
/// sheet and the notes on corners left open.
fn flanges_each(base: Region2, rv: &RuleValues, each: &[EdgeFlange]) -> (SheetBody, Vec<String>) {
let mut s = base_flange("Body1", "AL2", rv, Plane::XY, base);
let notes = s.add_flanges_each(each, rv.bend_radius, "inside", false).unwrap().1;
(s, notes)
}
/// The 120 x 90 tray's four flanges (bottom, right, top, left), each with its own angle
/// (degrees) and height.
fn tray_each(rv: &RuleValues, angles: [f64; 4], heights: [f64; 4]) -> (SheetBody, Vec<String>) {
let base = rect(Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0));
let t = rv.thickness;
let picks = [Vec3::new(60.0, 0.0, t), Vec3::new(120.0, 45.0, t), Vec3::new(60.0, 90.0, t), Vec3::new(0.0, 45.0, t)];
let each: Vec<EdgeFlange> = (0..4).map(|i| EdgeFlange { pick: picks[i], height: heights[i], angle: angles[i].to_radians() }).collect();
flanges_each(base, rv, &each)
}
/// The material of a base flange bent up, for asking whether a folded point lies in it. Worked
/// out from the fold on its own (not through `map3`): the panel through the inverse of its fold,
/// the bend from the radius and turn about the bend's axis.
struct Material {
back: Mat,
panel: Vec<Vec2>,
axis: (Vec3, Vec3),
p: Vec2,
d: Vec2,
span: (f64, f64),
radius: f64,
angle: f64,
t: f64,
}
impl Material {
fn of(s: &SheetBody, i: usize) -> Material {
let f = &s.flanges[i];
assert!(f.parent.is_none() && f.angle > 0.0);
let (o, k, _) = s.bend_axis(f);
Material {
back: mat_inverse(&s.fold(i)).unwrap(),
panel: corners_of(&s.panel(f)),
axis: (o, k),
p: f.p,
d: f.d,
span: s.zone_span(f),
radius: f.radius,
angle: f.angle,
t: s.t,
}
}
/// How deep (mm) `q` lies inside the flange's panel or bend; None when it is outside both.
fn depth(&self, q: Vec3) -> Option<f64> {
let flat = apply_point(&self.back, q);
let m = self.panel.len();
let sides = (0..m).map(|j| {
let (a, b) = (self.panel[j], self.panel[(j + 1) % m]);
(b - a).cross(Vec2::new(flat.x, flat.y) - a) / (b - a).len()
});
let in_panel = sides.fold(flat.z.min(self.t - flat.z), f64::min);
// The bend turns the sheet under its axis toward the flange: a point's distance from
// the axis is its radius, its turn from straight below the axis its place across the bend.
let (o, k) = self.axis;
let rel = q - o;
let perp = rel - k * rel.dot(k);
let down = Vec3::Z * -1.0;
let (rho, turn) = (perp.len(), perp.dot(k.cross(down)).atan2(perp.dot(down)));
let u = (Vec2::new(q.x, q.y) - self.p).dot(self.d);
let in_bend = [rho - self.radius, self.radius + self.t - rho, turn * rho, (self.angle - turn) * rho, u - self.span.0, self.span.1 - u]
.into_iter()
.fold(f64::INFINITY, f64::min);
let depth = in_panel.max(in_bend);
(depth > 0.0).then_some(depth)
}
}
/// Folded, the two flanges at corner `k` keep out of each other: no point of one's bend or
/// panel near the corner (sampled every 0.5 mm or finer, at three heights through the sheet)
/// lies in the other's. Each sample is also found inside its own flange, which checks
/// [`Material`] against the fold that places the solid's faces.
fn flanges_keep_clear(s: &SheetBody, k: &Corner, what: &str) {
let (ma, mb) = (Material::of(s, k.a), Material::of(s, k.b));
for (i, at_end, own, other) in [(k.a, k.a_end, &ma, &mb), (k.b, k.b_end, &mb, &ma)] {
let f = &s.flanges[i];
let ba = s.allowance(f);
let mut samples = Vec::new();
// The bend's 12 mm nearest the corner.
let (u0, u1) = s.zone_span(f);
for iu in 0..48 {
let du = 0.02 + 0.25 * iu as f64;
let u = if at_end { u1 - du } else { u0 + du };
for is in 0..24 {
samples.push((Piece::Zone(i), f.p + f.d * u + f.n * (ba * (is as f64 + 0.5) / 24.0)));
}
}
// The panel's 25 mm nearest its end, over its whole height.
let (q0, q1, inward) = if at_end {
let b = f.p + f.n * ba + f.d * (f.len + f.ext.1);
(b, b + f.n * f.leg + f.d * (f.slope.1 * f.leg), f.d * -1.0)
} else {
let a = f.p + f.n * ba - f.d * f.ext.0;
(a, a + f.n * f.leg - f.d * (f.slope.0 * f.leg), f.d)
};
let rows = (f.leg / 0.5).ceil().max(1.0) as usize;
for ir in 0..rows {
let on_end = q0 + (q1 - q0) * ((ir as f64 + 0.5) / rows as f64);
for iu in 0..50 {
samples.push((Piece::Panel(Some(i)), on_end + inward * (0.02 + 0.5 * iu as f64)));
}
}
for (piece, flat) in samples {
for z in [0.1 * s.t, 0.5 * s.t, 0.9 * s.t] {
let q = s.map3(piece, flat, z);
assert!(own.depth(q).is_some(), "{what}: flange {i}: its own point {flat:?} at {z} ({piece:?}) is not found in it");
let d = other.depth(q);
assert!(
d.is_none_or(|d| d < 1e-6),
"{what}: flange {i}'s {piece:?} at {flat:?}, height {z}, lies {d:?} mm inside the other flange"
);
}
}
}
}
/// The panel of flange `i` of the tray by hand, in the flat pattern. Take a flange at θ on the
/// edge y = 0 and its neighbour at φ on x = 0, T the thickness. A point of the first one's inner
/// face, w up its outer face from the virtual sharp, is (x, −w·cosθ + T·sinθ, w·sinθ + T·cosθ);
/// the inner faces are the planes y·sinθ + z·cosθ = T and x·sinφ + z·cosφ = T, and the plane
/// halving them passes through the corner with normal (sinφ, −sinθ, cosφ − cosθ), of length
/// √(2 − 2·cosθ·cosφ). Keeping the inner face gap/2 from it gives the panel's end
/// x(w) = ((g/2)·√(2 − 2·cosθ·cosφ) + T·(1 − cosθ·cosφ) − w·sinθ·cosφ) / sinφ for w = s0 … h, at
/// flat y = s0 − BA − (w − s0). Each flange is taken in its own frame: the origin at the start
/// corner of its edge, u along the edge, v into the base; flange i − 1 is at its start and
/// flange i + 1 at its end.
fn hand_panel(rv: &RuleValues, angles: [f64; 4], heights: [f64; 4], i: usize) -> Vec<Vec2> {
let (t, r, g) = (rv.thickness, rv.bend_radius, rv.gap);
let (o, d, v, len) = [
(Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0), 120.0),
(Vec2::new(120.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(-1.0, 0.0), 90.0),
(Vec2::new(120.0, 90.0), Vec2::new(-1.0, 0.0), Vec2::new(0.0, -1.0), 120.0),
(Vec2::new(0.0, 90.0), Vec2::new(0.0, -1.0), Vec2::new(1.0, 0.0), 90.0),
][i];
let th = angles[i].to_radians();
let (s0, ba) = ((r + t) * (th / 2.0).tan(), th * (r + rv.k_factor * t));
let x_end = |phi: f64, w: f64| {
let c = th.cos() * phi.cos();
((g / 2.0) * (2.0 - 2.0 * c).sqrt() + t * (1.0 - c) - w * th.sin() * phi.cos()) / phi.sin()
};
let (prev, next) = (angles[(i + 3) % 4].to_radians(), angles[(i + 1) % 4].to_radians());
let flat = |u: f64, w: f64| o + d * u + v * (s0 - ba - (w - s0));
let h = heights[i];
vec![flat(x_end(prev, s0), s0), flat(x_end(prev, h), h), flat(len - x_end(next, h), h), flat(len - x_end(next, s0), s0)]
}
/// The tray's flat area by hand: the base inside the four bend start lines, every bend zone
/// (the base's trimmed edge by the bend allowance) and every panel of [`hand_panel`]. With
/// s0 = (R + T)·tan(θ/2) a flange's setback and BA = θ·(R + K·T) its bend allowance, the base
/// is (120 − s0_left − s0_right) by (90 − s0_bottom − s0_top).
fn hand_flat_area(rv: &RuleValues, angles: [f64; 4], heights: [f64; 4]) -> f64 {
let s0 = angles.map(|a| (rv.bend_radius + rv.thickness) * (a.to_radians() / 2.0).tan());
let ba = angles.map(|a| a.to_radians() * (rv.bend_radius + rv.k_factor * rv.thickness));
let (w, h) = (120.0 - s0[3] - s0[1], 90.0 - s0[0] - s0[2]);
let panels: f64 = (0..4).map(|i| signed_area(&hand_panel(rv, angles, heights, i)).abs()).sum();
w * h + (ba[0] + ba[2]) * w + (ba[1] + ba[3]) * h + panels
}
/// 60°, 90°, 105° and 120° on the tray's bottom, right, top and left edges.
const MIXED: [f64; 4] = [60.0, 90.0, 105.0, 120.0];
#[test]
fn flanges_with_their_own_angle_and_height_close_at_their_corners() {
// Four angles on a 120 x 90 tray, then four heights as well, in one call: every panel ends
// where the hand derivation says, whatever its neighbour's angle and height.
let rv = alu();
for (heights, what) in [([38.6; 4], "one height"), ([38.6, 30.0, 45.0, 25.0], "four heights")] {
let (s, notes) = tray_each(&rv, MIXED, heights);
assert!(notes.is_empty(), "{what}: {notes:?}");
for i in 0..4 {
let (f, th) = (&s.flanges[i], MIXED[i].to_radians());
assert!((f.angle - th).abs() < 1e-12, "{what}: flange {i}");
assert!((f.leg - (heights[i] - (rv.bend_radius + rv.thickness) * (th / 2.0).tan())).abs() < 1e-9, "{what}: flange {i}");
let got = corners_of(&s.panel(f));
assert_eq!(got.len(), 4, "{what}: flange {i}");
for q in hand_panel(&rv, MIXED, heights, i) {
assert!(got.iter().any(|p| p.dist(q) < 1e-9), "{what}: flange {i}: {q:?} not in {got:?}");
}
}
// The flat pattern is the base, the bend zones and those panels, nothing more or less;
// folded it holds the same material (`holds_the_flat_material`), so the volume is this
// area times the thickness.
let (area, want) = (flat_area(&s), hand_flat_area(&rv, MIXED, heights));
assert!((area - want).abs() < 1e-6, "{what}: flat area {area} vs {want}");
let corners = s.corners(&[0, 1, 2, 3]);
assert_eq!(corners.len(), 4, "{what}");
ends_are_gap_apart(&s, &corners, rv.gap, what);
for k in &corners {
flanges_keep_clear(&s, k, what);
}
holds_the_flat_material(&s, what);
}
}
#[test]
fn a_trapezoid_with_unequal_angles_and_heights_closes_at_its_acute_and_obtuse_corners() {
// 77.5° and 102.5° base corners; flanges at 60°, 105°, 75° and 120°, each its own height.
let pts = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(100.0, 90.0), Vec2::new(20.0, 90.0)];
let picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(110.0, 45.0, 2.0), Vec3::new(60.0, 90.0, 2.0), Vec3::new(10.0, 45.0, 2.0)];
let (angles, heights) = ([60.0f64, 105.0, 75.0, 120.0], [38.6, 30.0, 45.0, 25.0]);
let each: Vec<EdgeFlange> = (0..4).map(|i| EdgeFlange { pick: picks[i], height: heights[i], angle: angles[i].to_radians() }).collect();
let (s, notes) = flanges_each(Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] }, &alu(), &each);
assert!(notes.is_empty(), "{notes:?}");
for i in 0..4 {
let (f, th) = (&s.flanges[i], angles[i].to_radians());
assert!((f.angle - th).abs() < 1e-12 && (f.leg - (heights[i] - 3.0 * (th / 2.0).tan())).abs() < 1e-9, "flange {i}: {f:?}");
}
let corners = s.corners(&[0, 1, 2, 3]);
assert_eq!(corners.len(), 4);
ends_are_gap_apart(&s, &corners, 0.5, "trapezoid");
for k in &corners {
flanges_keep_clear(&s, k, "trapezoid");
}
holds_the_flat_material(&s, "trapezoid");
}
/// The angles of the sweep below.
const SWEEP: [f64; 7] = [45.0, 60.0, 75.0, 90.0, 105.0, 120.0, 135.0];
#[test]
fn every_pair_of_angles_closes_at_any_base_corner() {
// A parallelogram base (120 and 90 long) with a corner of 60°, 90° or 120° at the origin;
// flanges 38.6 high on its two edges there, in one call, at every pair of the angles: 147
// corners, each closed with the gap, the flanges clear of each other, the part one valid
// shell holding the flat pattern's material.
let rv = alu();
let mut cases = 0;
for alpha in [60.0f64, 90.0, 120.0] {
let u = Vec2::new(alpha.to_radians().cos(), alpha.to_radians().sin());
let pts = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 0.0) + u * 90.0, u * 90.0];
for a in SWEEP {
for b in SWEEP {
let what = format!("corner {alpha}°, flanges {a}° and {b}°");
let each = [
EdgeFlange { pick: Vec3::new(60.0, 0.0, 2.0), height: 38.6, angle: a.to_radians() },
EdgeFlange { pick: Vec3::new(u.x * 45.0, u.y * 45.0, 2.0), height: 38.6, angle: b.to_radians() },
];
let (s, notes) = flanges_each(Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] }, &rv, &each);
assert!(notes.is_empty(), "{what}: {notes:?}");
assert!((s.flanges[0].angle - a.to_radians()).abs() < 1e-12 && (s.flanges[1].angle - b.to_radians()).abs() < 1e-12, "{what}");
let corners = s.corners(&[0, 1]);
assert_eq!(corners.len(), 1, "{what}");
ends_are_gap_apart(&s, &corners, rv.gap, &what);
flanges_keep_clear(&s, &corners[0], &what);
holds_the_flat_material(&s, &what);
cases += 1;
}
}
}
assert_eq!(cases, 147);
}
}
+10 -1
View File
@@ -464,6 +464,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
// Later features refer to bodies by name.
let mut failed = None;
for f in d.features.iter_mut().filter(|f| f.id != fid) {
if let FeatureKind::Sketch { plane, .. } = &mut f.kind {
solidcraft_doc::sheet::rename_plane_sheet_body(plane, &old, name);
}
if let FeatureKind::Sketch { plane, sketch } = &mut f.kind
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
&& anchor.target.body == old
@@ -614,7 +617,13 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
s.pending_moves = trial.pending_moves;
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() { "exact_face" } else { "fixed" }),
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() {
"exact_face"
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
"sheet_face"
} else {
"fixed"
}),
_ => None,
});
Ok(json!({ "sketch": id,"attachment":attachment }))
+22 -2
View File
@@ -1,5 +1,7 @@
//! Undo/redo, the timeline, deletion and selection.
use std::collections::BTreeMap;
use serde_json::{Value, json};
use solidcraft_doc::{Feature, FeatureKind};
@@ -22,7 +24,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("delete")
.key("Delete")
.params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"),
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name"),
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name (references to it follow)"),
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
@@ -163,15 +165,25 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let id = feature_id(s, p.get("feature"), cmd)?;
let name =
str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
let old = s.doc.feature(id).map(|f| f.name.clone()).ok_or_else(|| bad(cmd, "feature"))?;
if s.doc.features.iter().any(|f| f.name == name && f.id != id) {
return Err(bad(cmd, format!("another feature is called `{name}`")));
}
if s.doc.feature(id).is_some_and(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })) {
super::features::check_plane_name(name, cmd)?;
}
if let Some(f) = s.doc_mut().feature_mut(id) {
if name == old {
return Ok(json!({"feature": id, "name": name}));
}
let names = BTreeMap::from([(old.clone(), name.to_string())]);
let sketches = BTreeMap::new();
let doc = s.doc_mut();
if let Some(f) = doc.feature_mut(id) {
f.name = name.to_string();
}
for f in doc.features.iter_mut() {
f.kind.remap_refs(&sketches, &names);
}
Ok(json!({"feature": id, "name": name}))
}
@@ -322,6 +334,14 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
nf.edge_refs = solidcraft_doc::edge_refs(&before, body, a);
nf.edge_names = solidcraft_doc::edge_names_for(&before, body, a);
}
// New or reordered flange edges: their names now, so each edge's own angle and height stay
// with the edge they were given for.
if let (FeatureKind::SheetFlange { edges: a, .. }, FeatureKind::SheetFlange { edges: b, .. }) = (&nf.kind, &f.kind) {
let points = |e: &[solidcraft_doc::FlangeEdge]| e.iter().map(|x| x.point).collect::<Vec<_>>();
if points(a) != points(b) {
nf.edge_names = solidcraft_doc::sheet::sheet_edge_names(&s.model.state_before(id), &points(a));
}
}
// Profiles picked by index or point are kept by their curves, as when the feature was made.
if nf.kind.clone().profile_sels_mut() != f.kind.clone().profile_sels_mut() {
super::features::stable_profiles(s, &mut nf.kind)?;
+154 -3
View File
@@ -142,7 +142,8 @@ fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
let component = run(&mut s, "component.create", json!({"name":"Source"}));
let occurrence = component["occurrence"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Stock"}));
run(&mut s, "solid.press_pull", json!({"face":[5,5,20],"distance":5}));
// A top merged from two joined boxes has no single certified source: the legacy path.
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"corner":[40,0,0],"operation":"join"}));
run(&mut s, "component.activate", json!({"component":0}));
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":false}));
let state = s.model.state();
@@ -153,12 +154,12 @@ fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
.faces(1e-4)
.unwrap()
.iter()
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 25.0).abs() < 1e-6)
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 20.0).abs() < 1e-6)
.unwrap()
.index;
let before = s.doc.to_json();
let error =
super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,25],"body":"Stock","index":index,"path":[occurrence]}), "internal")
super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,20],"body":"Stock","index":index,"path":[occurrence]}), "internal")
.unwrap_err();
assert!(error.to_string().contains("capture the source occurrence position"), "{error}");
assert_eq!(s.doc.to_json(), before);
@@ -229,3 +230,153 @@ fn blind_cut_inside_a_cylinder_keeps_only_planes_and_cylinders() {
assert_eq!(body["face_types"], json!({"cylinder": 2, "plane": 12}), "{m}");
assert_eq!(body["faces"], 14, "{m}");
}
fn face_anchor(s: &Session, sketch: u64) -> solidcraft_doc::FaceAnchor {
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch).unwrap().kind else {
panic!("sketch {sketch} has no face anchor")
};
(**anchor).clone()
}
#[test]
fn join_keeps_exact_face_attachments_from_both_operands() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-10,-10],"p1":[10,10]}));
run(&mut s, "sketch.finish", json!({}));
let base = run(&mut s, "solid.extrude", json!({"distance":40,"name":"Base"}))["feature"].as_u64().unwrap();
// A cross bar joined through the base (the shape of #420) and a boss joined on its top.
run(&mut s, "sketch.create", json!({"plane":"YZ"}));
run(&mut s, "sketch.circle.center", json!({"center":[0,20],"radius":5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":20,"symmetric":true,"operation":"join"}));
run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,40]}}));
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":4}));
run(&mut s, "sketch.finish", json!({}));
let boss = run(&mut s, "solid.extrude", json!({"distance":10,"operation":"join","name":"Boss"}))["feature"].as_u64().unwrap();
assert_eq!(s.model.state().bodies.len(), 1);
// The base's top around the boss, and the boss's own top (the Union's second operand).
let on_base = run(&mut s, "sketch.create", json!({"plane":{"face":[8,8,40]}}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.finish", json!({}));
let on_boss = run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,50]}}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.finish", json!({}));
assert_eq!(face_anchor(&s, on_base).target.origin_feature, base);
assert_eq!(face_anchor(&s, on_boss).target.origin_feature, boss);
// The attachments follow their faces through an upstream edit and a reopen.
run(&mut s, "timeline.edit", json!({"feature":"Base","set":{"extent":{"distance":"50"}}}));
s.refresh();
assert!(s.model.results.iter().all(|result| result.error.is_none()), "{:?}", s.model.results);
let state = s.model.state();
assert!((state.sketch(on_base).unwrap().plane.origin.z - 50.0).abs() < 1e-6);
assert!((state.sketch(on_boss).unwrap().plane.origin.z - 60.0).abs() < 1e-6);
let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
reopened.refresh();
assert!(reopened.model.results.iter().all(|result| result.error.is_none()), "{:?}", reopened.model.results);
assert!((reopened.model.state().sketch(on_boss).unwrap().plane.origin.z - 60.0).abs() < 1e-6);
}
#[test]
fn join_leaves_a_face_merged_from_both_operands_without_an_attachment() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10}));
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"corner":[20,0,0],"operation":"join"}));
// Both boxes' tops share one support: no single construction identity owns it.
let error = s.execute("sketch.create", &json!({"plane":{"face":[10,10,10]}})).unwrap_err().to_string();
assert!(error.contains("fixed:true"), "{error}");
run(&mut s, "sketch.create", json!({"plane":{"face":[10,10,10],"fixed":true}}));
// An unshared support keeps its box role.
run(&mut s, "sketch.finish", json!({}));
let side = run(&mut s, "sketch.create", json!({"plane":{"face":[0,10,5]}}))["sketch"].as_u64().unwrap();
assert!(matches!(face_anchor(&s, side).target.certificate, Some(solidcraft_doc::config::FaceCertificate::Box { axis: 0, high: false })));
}
#[test]
fn join_covering_a_whole_attached_face_loses_the_attachment_without_moving_it() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":10,"name":"Block"}));
let top = run(&mut s, "sketch.create", json!({"plane":{"face":[30,15,10]},"name":"Top"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.circle.center", json!({"center":[30,15],"radius":3}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":"Top","distance":5,"operation":"join","name":"Peg"}));
// An earlier join whose roof replaces Block's whole top.
run(&mut s, "timeline.roll_to", json!({"feature":"Block"}));
run(&mut s, "sketch.create", json!({"plane":"XY","name":"B"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-5,-5],"p1":[45,35]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":"B","distance":20,"operation":"join","name":"Boss"}));
run(&mut s, "timeline.roll_to", json!({}));
let failed = |s: &Session, id: u64| s.model.results.iter().find(|r| r.id == id).unwrap().error.is_some();
assert!(failed(&s, top), "the attachment never follows the substitute roof");
assert!(s.model.state().sketch(top).is_none());
run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true}));
assert!(!failed(&s, top));
assert!((s.model.state().sketch(top).unwrap().plane.origin.z - 10.).abs() < 1e-9);
}
fn certificate_of(s: &Session, sketch: u64) -> Option<solidcraft_doc::config::FaceCertificate> {
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch).unwrap().kind else {
return None;
};
anchor.target.certificate.clone()
}
#[test]
fn cylinder_primitive_caps_take_attachments_that_follow_edits_and_reopen() {
use solidcraft_doc::config::FaceCertificate;
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":40,"name":"Post"}));
let top = run(&mut s, "sketch.create", json!({"plane":{"face":[2,1,40]}}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.finish", json!({}));
let bottom = run(&mut s, "sketch.create", json!({"plane":{"face":[2,1,0]}}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.finish", json!({}));
assert_eq!(certificate_of(&s, top), Some(FaceCertificate::CylinderCap { high: true }));
assert_eq!(certificate_of(&s, bottom), Some(FaceCertificate::CylinderCap { high: false }));
let heights = |s: &Session| {
let state = s.model.state();
(state.sketch(top).unwrap().plane.origin.z, state.sketch(bottom).unwrap().plane.origin.z)
};
run(&mut s, "timeline.edit", json!({"feature":"Post","set":{"height":"55","base":[0,0,5]}}));
s.refresh();
assert!(s.model.results.iter().all(|result| result.error.is_none()), "{:?}", s.model.results);
let (high, low) = heights(&s);
assert!((high - 60.).abs() < 1e-9 && (low - 5.).abs() < 1e-9, "{high} {low}");
let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
reopened.refresh();
assert!(reopened.model.results.iter().all(|result| result.error.is_none()), "{:?}", reopened.model.results);
assert_eq!(heights(&reopened), (high, low));
}
#[test]
fn crossed_cylinder_primitives_keep_their_caps_attachable() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":40}));
run(&mut s, "solid.cylinder", json!({"radius":5,"height":40,"base":[-20,0,20],"axis":"X","operation":"join"}));
assert_eq!(s.model.state().bodies.len(), 1);
for face in [[0., 0., 40.], [20., 0., 20.]] {
let sketch = run(&mut s, "sketch.create", json!({"plane":{"face":face}}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.finish", json!({}));
assert!(certificate_of(&s, sketch).is_some(), "{face:?}");
}
}
#[test]
fn shelled_cylinder_floor_keeps_its_cap_identity() {
use solidcraft_doc::config::FaceCertificate;
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":40}));
run(&mut s, "solid.shell", json!({"faces":[[2,1,40]],"thickness":2}));
let floor = run(&mut s, "sketch.create", json!({"plane":{"face":[2,1,2]}}))["sketch"].as_u64().unwrap();
assert!(
matches!(
certificate_of(&s, floor),
Some(FaceCertificate::ShellPlanar { source, inner: true, .. }) if *source == FaceCertificate::CylinderCap { high: false }
),
"{:?}",
certificate_of(&s, floor)
);
}
+8 -2
View File
@@ -387,9 +387,11 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
(solidcraft_doc::edge_refs(&st, body, edges), solidcraft_doc::edge_names_for(&st, body, edges))
}
// Sheet metal edges are named by the sheet model.
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => {
(Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), edges))
FeatureKind::SheetFlange { edges, .. } => {
let points: Vec<_> = edges.iter().map(|e| e.point).collect();
(Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), &points))
}
FeatureKind::SheetHem { edges, .. } => (Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), edges)),
_ => (Vec::new(), Vec::new()),
};
let face_names = {
@@ -1530,3 +1532,7 @@ mod offset_edit_guard_tests;
#[cfg(test)]
#[path = "offset_plane_face_tests.rs"]
mod offset_plane_face_tests;
#[cfg(test)]
#[path = "stacked_face_tests.rs"]
mod stacked_face_tests;
+111 -4
View File
@@ -132,7 +132,10 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
}
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let doc = solidcraft_io::read_design(&bytes)?;
let mut doc = solidcraft_io::read_design(&bytes)?;
if let Some(stem) = default_name_for(&doc.name, path) {
doc.name = stem;
}
if let Some(notify) = s.open_preview.take()
&& let Some(preview) = solidcraft_io::read_design_preview(&bytes)
{
@@ -141,10 +144,50 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
*s = Session::new(doc);
s.path = Some(path.to_string());
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors}))
Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "name": s.doc.name}))
}
/// The name a design never given one by its user is shown under.
const DEFAULT_NAME: &str = "Untitled";
/// A design still under the default name takes its file's name (`bracket.solidcraft` →
/// "bracket"); a name the user chose is kept. `None` when nothing changes.
fn default_name_for(name: &str, path: &str) -> Option<String> {
let name = name.trim();
if !name.is_empty() && name != DEFAULT_NAME {
return None;
}
let stem = std::path::Path::new(path).file_stem()?.to_string_lossy().trim().to_string();
(!stem.is_empty()).then_some(stem)
}
/// Rename the design, and the undo and redo states that still carry the old name, so undo
/// does not bring the default name back and leave the design looking changed.
fn rename_design(s: &mut Session, from: &str, to: &str) {
s.doc_mut().name = to.to_string();
for snap in s.undo.iter_mut().chain(s.redo.iter_mut()) {
if snap.doc.name == from {
std::sync::Arc::make_mut(&mut snap.doc).name = to.to_string();
}
}
}
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> {
// Saving an unnamed design names it after its file (not an undoable edit).
let renamed = default_name_for(&s.doc.name, path).map(|stem| (s.doc.name.clone(), stem));
if let Some((from, to)) = &renamed {
rename_design(s, from, to);
}
let r = write_file(s, path, format, compression, preview);
if r.is_err()
&& let Some((from, to)) = &renamed
{
rename_design(s, to, from);
}
r
}
fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> {
let compression = match compression {
"fast" => solidcraft_io::DesignCompression::Fast,
"compact" => solidcraft_io::DesignCompression::Compact,
@@ -159,7 +202,7 @@ fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview:
solidcraft_io::write_atomic(std::path::Path::new(path), &bytes, true).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
s.path = Some(path.to_string());
s.mark_saved();
Ok(json!({"path": path, "bytes": bytes.len()}))
Ok(json!({"path": path, "bytes": bytes.len(), "name": s.doc.name}))
}
/// Cache only warm display geometry. A save never waits for an unevaluated mesh.
@@ -267,7 +310,10 @@ fn recover(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "doc.recover";
let dir = recovery_dir(p, cmd)?;
let id = str_(p, "id").ok_or_else(|| bad(cmd, "`id` is required (see doc.recovery_list)"))?;
let (doc, entry) = crate::recovery::load(&dir, id)?;
let (mut doc, entry) = crate::recovery::load(&dir, id)?;
if let Some(stem) = entry.path.as_deref().and_then(|path| default_name_for(&doc.name, path)) {
doc.name = stem;
}
*s = Session::new(doc);
s.path = entry.path.clone();
s.mark_unsaved();
@@ -420,6 +466,67 @@ mod tests {
assert!(saved_preview(&editing).is_none());
}
fn name_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-name-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn save_as_names_an_untitled_design_after_its_file() {
let dir = name_dir("untitled");
let path = dir.join("bracket.solidcraft");
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
assert_eq!(s.doc.name, "Untitled");
let r = s.execute("file.save_as", &json!({"path":path})).unwrap();
assert_eq!(r["name"], "bracket");
assert_eq!(s.doc.name, "bracket");
assert!(!s.is_dirty(), "taking the file's name is part of the save");
assert_eq!(solidcraft_io::read_design(&std::fs::read(&path).unwrap()).unwrap().name, "bracket");
// Undoing the box does not bring the old name back.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc.name, "bracket");
// Saving under another file keeps the name it now has.
s.execute("file.save_as", &json!({"path":dir.join("copy.solidcraft")})).unwrap();
assert_eq!(s.doc.name, "bracket");
// A failed save leaves the name alone.
let mut failed = Session::default();
assert!(failed.execute("file.save_as", &json!({"path":dir.join("missing-dir").join("plate.solidcraft")})).is_err());
assert_eq!(failed.doc.name, "Untitled");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn save_as_keeps_a_name_the_user_chose() {
let dir = name_dir("named");
let mut s = Session::default();
s.execute("file.new", &json!({"name":"Gearbox lid"})).unwrap();
s.execute("file.save_as", &json!({"path":dir.join("lid-v2.solidcraft")})).unwrap();
assert_eq!(s.doc.name, "Gearbox lid");
let mut reopened = Session::default();
reopened.execute("doc.open", &json!({"path":dir.join("lid-v2.solidcraft")})).unwrap();
assert_eq!(reopened.doc.name, "Gearbox lid");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn opening_an_untitled_file_shows_its_file_name() {
let dir = name_dir("open");
for (stored, file) in [("Untitled", "hinge.solidcraft"), ("", "spacer.json")] {
let path = dir.join(file);
let bytes = solidcraft_io::write_design(&Document::new(stored));
std::fs::write(&path, bytes).unwrap();
let mut s = Session::default();
let r = s.execute("doc.open", &json!({"path":path})).unwrap();
let stem = file.split('.').next().unwrap();
assert_eq!(s.doc.name, stem);
assert_eq!(r["name"], stem);
assert!(!s.is_dirty(), "showing the file's name is not a change to save");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn design_save_formats_reopen_and_preserve_atomic_backups() {
let dir = std::env::temp_dir().join(format!("solidcraft-container-{}", std::process::id()));
+19
View File
@@ -208,6 +208,25 @@ fn patterns_of_a_blind_hole_drill_every_copy() {
assert!((c - 2.0 * one).abs() < 1e-3 * one, "{c} vs 2 × {one}");
}
/// Feature patterns and mirrors reference features by name; a rename follows (#401).
#[test]
fn renaming_a_source_feature_follows_pattern_and_mirror_references() {
let mut s = plate_with_hole();
run(&mut s, "construct.plane.offset", json!({"base": "YZ", "offset": 0, "name": "Mirror plane"}));
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Hole1"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 20, "name": "Copies"}));
run(&mut s, "solid.mirror", json!({"features": ["Hole1"], "plane": "Mirror plane", "name": "Mirrored"}));
let renamed = run(&mut s, "timeline.rename", json!({"feature": "Hole1", "name": "Single hole"}));
assert_eq!(renamed["name"], "Single hole", "{renamed}");
run(&mut s, "timeline.rename", json!({"feature": "Mirror plane", "name": "Renamed plane"}));
for feature in ["Copies", "Mirrored"] {
let inspected = run(&mut s, "timeline.inspect", json!({"feature": feature}));
assert!(inspected["error"].is_null(), "{feature}: {inspected}");
assert_eq!(inspected["definition"]["features"], json!(["Single hole"]), "{inspected}");
}
let inspected = run(&mut s, "timeline.inspect", json!({"feature": "Mirrored"}));
assert_eq!(inspected["definition"]["plane"]["name"], "Renamed plane", "{inspected}");
}
/// A plate with a `count1` × `count2` feature pattern of one through-hole cut: its volume and how
/// long the pattern took. It runs on its own thread so a hang fails the test instead of CI.
fn hole_pattern(count1: usize, count2: usize) -> (f64, std::time::Duration) {
+37 -3
View File
@@ -2,9 +2,9 @@
//! the flat pattern (outline, bend lines, bend table) and its DXF export.
use serde_json::{Value, json};
use solidcraft_doc::FeatureKind;
use solidcraft_doc::expr::Kind;
use solidcraft_doc::sheet::{SheetBody, SheetRule};
use solidcraft_doc::{FeatureKind, FlangeEdge};
use solidcraft_geom::{Vec2, Vec3};
use super::CommandSpec;
@@ -15,7 +15,8 @@ use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sheet.flange", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params(
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \
edge: edges: [[x,y,z] on top/bottom edges] + height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
edge: edges: [[x,y,z] on top/bottom edges, or {point: [x,y,z], angle?, height?} for an edge with its own angle and height] + \
height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs); name?, body_name? (the new body)",
),
CommandSpec::new("sheet.hem", "Hem", hem)
@@ -61,6 +62,39 @@ fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
.collect()
}
/// The `edges` of an edge flange: points `[x, y, z]` on sheet edges, or `{point, angle?, height?}`
/// for an edge with its own angle and height (expressions, checked like the feature's).
fn flange_edges(s: &Session, p: &Value, cmd: &str) -> Result<Vec<FlangeEdge>> {
const KEYS: [&str; 3] = ["point", "angle", "height"];
let list = "`edges` must list points [x, y, z] on sheet edges, or {point, angle?, height?}";
let a = p.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, list))?;
if a.is_empty() || a.len() > 1000 {
return Err(bad(cmd, "`edges` must list 1…1000 points"));
}
let mut edges = Vec::with_capacity(a.len());
for (i, v) in a.iter().enumerate() {
// A bare point, as before: [x, y, z] or {x, y, z}.
let Some(o) = v.as_object().filter(|o| KEYS.iter().any(|k| o.contains_key(*k))) else {
edges.push(FlangeEdge::at(vec3(v).ok_or_else(|| bad(cmd, format!("edges[{i}]: {list}")))?));
continue;
};
if let Some(k) = o.keys().find(|k| !KEYS.contains(&k.as_str())) {
return Err(bad(cmd, format!("edges[{i}]: unknown key `{k}` (an edge takes point, angle and height)")));
}
let point = o.get("point").and_then(vec3).ok_or_else(|| bad(cmd, format!("edges[{i}]: `point` must be [x, y, z]")))?;
let own = |k: &str, kind: Kind| -> Result<Option<String>> {
if o.get(k).is_none_or(Value::is_null) {
return Ok(None);
}
let e = expr(v, k).ok_or_else(|| bad(cmd, format!("edges[{i}]: `{k}` must be a number or an expression")))?;
check_expr(s, &e, kind, cmd, &format!("edges[{i}].{k}"))?;
Ok(Some(e))
};
edges.push(FlangeEdge { point, angle: own("angle", Kind::Angle)?, height: own("height", Kind::Length)? });
}
Ok(edges)
}
fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
match str_(p, "rule") {
Some(r) if s.doc.sheet.rules.iter().any(|x| x.name == r) || r == SheetRule::steel().name => Ok(Some(r.to_string())),
@@ -106,7 +140,7 @@ fn flange(s: &mut Session, p: &Value) -> Result<Value> {
)
}
"edge" => {
let edges = points(p, "edges", cmd)?;
let edges = flange_edges(s, p, cmd)?;
let height = req_expr(cmd, p, "height")?;
check_expr(s, &height, Kind::Length, cmd, "height")?;
let angle = expr(p, "angle").unwrap_or_else(|| "90 deg".into());
+505
View File
@@ -709,6 +709,212 @@ fn fold_keeps_the_larger_side_either_way() {
}
}
/// A 100 x 60 plate with a 90° flange up from its y = 0 edge (steel: 2.5 thick, R = T, K 0.44).
/// The flange's inner face is the plane y = 2.5 and its outer face y = 0; it stands from z = 5.
fn plate_with_flange(s: &mut Session) {
base(s, 100.0, 60.0);
run(s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
}
/// A circle sketch on `plane`, finished.
fn circle_sketch(s: &mut Session, name: &str, plane: Value, center: [f64; 2], diameter: f64) -> Value {
let made = run(s, "sketch.create", json!({"plane": plane, "name": name}));
run(s, "sketch.circle.center", json!({"center": center, "diameter": diameter}));
run(s, "sketch.finish", json!({}));
made
}
fn cut_through(s: &mut Session, sketch: &str) {
run(s, "solid.extrude", json!({"sketch": sketch, "through_all": true, "direction": "negative", "operation": "cut"}));
}
fn contains(s: &Session, p: [f64; 3]) -> bool {
s.model.state().bodies.first().is_some_and(|b| b.body.contains(solidcraft_geom::Vec3::new(p[0], p[1], p[2])))
}
#[test]
fn a_sketch_on_a_flange_face_attaches_to_its_panel() {
// Sketch coordinates on a sheet face are the flat pattern's: the flange panel starts at
// y = 5 − BA and runs on toward −y, so a hole 7 mm up the face is at y = 5 − BA − 7.
let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5);
let hole = PI * 1.8 * 1.8 * 2.5;
let mut s = Session::default();
plate_with_flange(&mut s);
let v0 = volume(&mut s);
let made = circle_sketch(&mut s, "Holes", json!({"face": [50, 2.5, 12]}), [30.0, 5.0 - ba - 7.0], 3.6);
assert_eq!(made["attachment"], "sheet_face");
// The sketch keeps the face's name: the body, the panel (its flange) and the side of the sheet.
let sketch = s.doc.find_feature("Holes").cloned().unwrap();
let solidcraft_doc::FeatureKind::Sketch { plane, .. } = &sketch.kind else { panic!("{:?}", sketch.kind) };
let name = solidcraft_doc::sheet::plane_sheet_face(plane).unwrap_or_default();
assert!(name.starts_with("sheet:Body1:F") && name.ends_with(":top"), "{name}");
// A cut through the flange from that sketch is a round hole 7 mm up the face, at x = 30.
run(&mut s, "solid.extrude", json!({"sketch": "Holes", "distance": 2.5, "direction": "negative", "operation": "cut"}));
assert!((v0 - volume(&mut s) - hole).abs() < 0.1, "{}", v0 - volume(&mut s));
assert!(!contains(&s, [30.0, 1.25, 12.0]) && contains(&s, [30.0, 1.25, 14.5]) && contains(&s, [33.0, 1.25, 12.0]));
let f = flat(&mut s);
let cut = f["cutouts"][0].as_array().cloned().unwrap_or_default();
assert!(!cut.is_empty(), "{f}");
for p in &cut {
let (x, y) = (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN));
assert!(((x - 30.0).hypot(y - (5.0 - ba - 7.0)) - 1.8).abs() < 1e-3, "({x}, {y})");
}
// `fixed: true` still freezes the picked plane instead of attaching to the panel.
let frozen = circle_sketch(&mut s, "Frozen", json!({"face": [50, 2.5, 12], "fixed": true}), [0.0, 0.0], 1.0);
assert_eq!(frozen["attachment"], "fixed");
}
/// The thickness of a sheet comes from a parameter: the flange's face moves with it, and a
/// sketch frozen on the old plane cuts a blind pocket in the thicker sheet. The attached
/// sketch cuts the same hole as one drawn on the thicker sheet in the first place.
#[test]
fn a_sketch_on_a_sheet_face_follows_the_sheet_when_its_thickness_changes() {
let build = |t: f64, then: Option<f64>| {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "T", "expression": format!("{t} mm")}));
run(&mut s, "sheet.manage_rules", json!({"name": "AL", "thickness": "T", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, t]], "height": 20}));
// The hole is drawn 10 mm up the 2 mm flange, in the flat pattern: its bend ends at
// y = T + R = 3 and the bend allowance is (R + T/2)·π/2 = π. The coordinates stay as they are.
let flat_y = 3.0 - PI - 10.0;
circle_sketch(&mut s, "Holes", json!({"face": [50, t, 12]}), [30.0, flat_y], 6.0);
run(&mut s, "sketch.create", json!({"plane": {"face": [10, 30, t]}, "offset": "5 mm", "name": "Above"}));
run(&mut s, "sketch.finish", json!({}));
let v0 = volume(&mut s);
cut_through(&mut s, "Holes");
if let Some(t2) = then {
let r = run(&mut s, "parameters.change", json!({"name": "T", "expression": format!("{t2} mm")}));
assert_eq!(r["errors"], json!([]), "{r}");
}
(s, v0)
};
let (mut grown, _) = build(2.0, Some(3.0));
let (mut direct, v_direct) = build(3.0, None);
let (vg, vd) = (volume(&mut grown), volume(&mut direct));
// The hole is the cylinder Ø6 × 3 through the thicker sheet, as if drawn there at first.
assert!((v_direct - vd - PI * 9.0 * 3.0).abs() < 0.1, "{}", v_direct - vd);
assert!(rel(vg, vd) < 1e-9, "{vg} {vd}");
let (fg, fd) = (flat(&mut grown), flat(&mut direct));
assert_eq!(fg["pockets"], json!([]), "a blind pocket instead of the hole: {fg}");
assert_eq!(fg["cutouts"], fd["cutouts"]);
// The thicker flange's panel starts at flat y = 4 − 3.927 and z = T + R = 4, so the hole is
// 10.215 mm up it: through the whole 3 mm.
assert!(!contains(&grown, [30.0, 1.5, 14.2]) && !contains(&grown, [30.0, 0.2, 14.2]) && contains(&grown, [30.0, 1.5, 19.0]));
// The sketch offset from the base's top face moved up with it: 3 + 5.
let plane = run(&mut grown, "sketch.inspect", json!({"sketch": "Above"}))["plane"]["origin"][2].as_f64().unwrap_or(f64::NAN);
assert!((plane - 8.0).abs() < 1e-9, "{plane}");
}
#[test]
fn sketches_on_the_top_and_bottom_faces_of_a_sheet_survive_unfold_refold_and_reopen() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
// The top face's coordinates are the base sketch's; the bottom face's mirror y, as seen from below.
circle_sketch(&mut s, "Top", json!({"face": [30, 20, 2.5]}), [30.0, 20.0], 4.0);
circle_sketch(&mut s, "Bottom", json!({"face": [60, 20, 0]}), [60.0, -20.0], 4.0);
cut_through(&mut s, "Top");
cut_through(&mut s, "Bottom");
let want = 15000.0 - 2.0 * PI * 4.0 * 2.5;
assert!((volume(&mut s) - want).abs() < 0.1, "{}", volume(&mut s));
let holes = |f: &Value| {
let mut centres: Vec<(f64, f64)> = f["cutouts"]
.as_array()
.cloned()
.unwrap_or_default()
.iter()
.map(|c| {
let pts: Vec<(f64, f64)> = c
.as_array()
.cloned()
.unwrap_or_default()
.iter()
.map(|p| (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN)))
.collect();
let n = pts.len().max(1) as f64;
(pts.iter().map(|p| p.0).sum::<f64>() / n, pts.iter().map(|p| p.1).sum::<f64>() / n)
})
.collect();
centres.sort_by(|a, b| a.0.total_cmp(&b.0));
centres
};
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
if let Some(command) = command {
run(&mut s, command, json!({}));
}
let centres = holes(&flat(&mut s));
assert_eq!(centres.len(), 2, "{centres:?}");
assert!((centres[0].0 - 30.0).abs() < 0.01 && (centres[0].1 - 20.0).abs() < 0.01, "{centres:?}");
assert!((centres[1].0 - 60.0).abs() < 0.01 && (centres[1].1 - 20.0).abs() < 0.01, "{centres:?}");
assert!((volume(&mut s) - want).abs() < 0.1);
}
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!((volume(&mut reopened) - want).abs() < 0.1);
assert_eq!(holes(&flat(&mut reopened)), holes(&flat(&mut s)));
}
#[test]
fn a_sketch_made_on_the_unfolded_sheet_lands_on_the_flange_after_refolding() {
let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5);
let mut s = Session::default();
plate_with_flange(&mut s);
let v0 = volume(&mut s);
run(&mut s, "sheet.unfold", json!({}));
// Unfolded, the flange lies in the sheet's plane: the one flat face covers it too.
let y = 5.0 - ba - 7.0;
circle_sketch(&mut s, "Holes", json!({"face": [30, y, 2.5]}), [30.0, y], 3.6);
cut_through(&mut s, "Holes");
run(&mut s, "sheet.refold", json!({}));
assert!((v0 - volume(&mut s) - PI * 1.8 * 1.8 * 2.5).abs() < 0.1, "{}", v0 - volume(&mut s));
assert!(!contains(&s, [30.0, 1.25, 12.0]) && contains(&s, [30.0, 1.25, 14.5]));
}
#[test]
fn a_sketch_on_a_sheet_face_reports_a_lost_attachment_instead_of_moving_to_another_face() {
let mut s = Session::default();
plate_with_flange(&mut s);
circle_sketch(&mut s, "Holes", json!({"face": [50, 2.5, 12]}), [30.0, -8.0], 3.6);
let id = s.doc.find_feature("Holes").map(|f| f.id).unwrap_or(0);
let status = |s: &Session| s.placed_document().sketch_face_attachment(id, &s.model);
assert!(matches!(status(&s), solidcraft_doc::FaceAttachmentStatus::Sheet { plane: Some(_) }));
// With the flange suppressed there is no such face; the base's top face is not a substitute.
run(&mut s, "timeline.suppress", json!({"feature": "EdgeFlange1", "suppressed": true}));
let error = run(&mut s, "timeline.inspect", json!({"feature": "Holes"}))["error"].as_str().unwrap_or_default().to_string();
assert!(error.contains("lost face attachment") && error.contains("reattach"), "{error}");
let solidcraft_doc::FaceAttachmentStatus::Lost { message, repair_plane } = status(&s) else { panic!("{:?}", status(&s)) };
assert!(message.contains("sheet face") && repair_plane.is_some(), "{message}");
// Redefine Sketch Plane repairs it on another face, here the base's top (its sketch keeps its geometry).
let repaired = run(&mut s, "sketch.redefine", json!({"sketch": "Holes", "plane": {"face": [10, 30, 2.5]}}));
assert_eq!(repaired["attachment"], "sheet_face");
assert!(run(&mut s, "timeline.inspect", json!({"feature": "Holes"}))["error"].is_null());
assert!(matches!(status(&s), solidcraft_doc::FaceAttachmentStatus::Sheet { .. }));
// The flange is back, and a sketch attached to it again is healthy.
run(&mut s, "timeline.suppress", json!({"feature": "EdgeFlange1", "suppressed": false}));
let again = run(&mut s, "sketch.redefine", json!({"sketch": "Holes", "plane": {"face": [50, 2.5, 12]}}));
assert_eq!(again["attachment"], "sheet_face");
assert!(run(&mut s, "timeline.inspect", json!({"feature": "Holes"}))["error"].is_null());
assert!(matches!(status(&s), solidcraft_doc::FaceAttachmentStatus::Sheet { .. }));
}
#[test]
fn renaming_a_sheet_body_keeps_its_sketches_on_their_faces() {
let mut s = Session::default();
plate_with_flange(&mut s);
let v0 = volume(&mut s);
circle_sketch(&mut s, "Holes", json!({"face": [50, 2.5, 12]}), [30.0, -8.0], 3.6);
run(&mut s, "sketch.create", json!({"plane": {"face": [10, 30, 2.5]}, "offset": "5 mm", "name": "Above"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "body.rename", json!({"body": "Body1", "name": "Bracket"}));
for (sketch, face) in [("Holes", "sheet:Bracket:F"), ("Above", "sheet:Bracket:base:top")] {
let feature = s.doc.find_feature(sketch).cloned().unwrap();
let solidcraft_doc::FeatureKind::Sketch { plane, .. } = &feature.kind else { panic!("{:?}", feature.kind) };
assert!(solidcraft_doc::sheet::plane_sheet_face(plane).is_some_and(|name| name.starts_with(face)), "{sketch}: {:?}", feature.kind);
assert!(run(&mut s, "timeline.inspect", json!({"feature": sketch}))["error"].is_null(), "{sketch}");
}
cut_through(&mut s, "Holes");
assert!((v0 - volume(&mut s) - PI * 1.8 * 1.8 * 2.5).abs() < 0.1, "{}", v0 - volume(&mut s));
}
/// A tray shrunk by a parameter change unfolds like the same tray built at that size. The
/// sketch solved again leaves its corners ~1e-13 mm off, and the flat pattern's planar
/// arrangement used to drop a bend zone through a gap that small: "planar region arrangement
@@ -1865,3 +2071,302 @@ fn corners_left_open_are_reported_on_the_flange() {
run(&mut s, "sheet.refold", json!({}));
assert!(volume(&mut s) > 0.0);
}
/// 2 mm aluminium (R 1, K 0.5, gap 0.5) and a 120 x 90 base flange.
fn alu_base(s: &mut Session) {
run(
s,
"sheet.manage_rules",
json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": "0.5 mm", "active": true}),
);
base(s, 120.0, 90.0);
}
/// The base's four edges (bottom, right, top, left) in one feature: 75° and 38.6 high from the
/// feature, then 90°, 105° at 30 high and 120° at 25 high from the edges themselves.
fn four_angles_in_one_feature() -> Session {
let mut s = Session::default();
alu_base(&mut s);
let edges = json!([
[60, 0, 2],
{"point": [120, 45, 2], "angle": "90 deg"},
{"point": [60, 90, 2], "angle": 105, "height": "30 mm"},
{"point": {"x": 0, "y": 45, "z": 2}, "angle": "120 deg", "height": 25}
]);
run(&mut s, "sheet.flange", json!({"edges": edges, "height": 38.6, "angle": "75 deg"}));
s
}
/// The flat outline's points.
fn outline_points(f: &Value) -> Vec<(f64, f64)> {
let loops = f["outline"].as_array().cloned().unwrap_or_default();
loops
.iter()
.flat_map(|l| l.as_array().cloned().unwrap_or_default())
.map(|q| (q[0].as_f64().unwrap_or(f64::NAN), q[1].as_f64().unwrap_or(f64::NAN)))
.collect()
}
/// Two point lists are the same set of points, to 1e-6.
fn same_points(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
a.len() == b.len() && a.iter().all(|p| b.iter().any(|q| (p.0 - q.0).abs() < 1e-6 && (p.1 - q.1).abs() < 1e-6))
}
fn bend_angles(s: &mut Session) -> Vec<f64> {
flat(s)["bends"].as_array().cloned().unwrap_or_default().iter().map(|b| b["angle_deg"].as_f64().unwrap_or(f64::NAN).round()).collect()
}
fn no_errors_or_warnings(s: &Session) -> bool {
s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none())
}
/// The flanges of [`four_angles_in_one_feature`] (bottom, right, top, left): angles in degrees
/// and heights.
const FOUR_ANGLES: [f64; 4] = [75.0, 90.0, 105.0, 120.0];
const FOUR_HEIGHTS: [f64; 4] = [38.6, 38.6, 30.0, 25.0];
/// That tray's panels in the flat pattern by hand (T 2, R 1, K 0.5, gap g 0.5; the derivation
/// is with the doc tests). Folded, a flange at θ whose neighbour at a corner is at φ ends
/// x(w) = ((g/2)·√(2 − 2·cosθ·cosφ) + T·(1 − cosθ·cosφ) − w·sinθ·cosφ) / sinφ along its edge from
/// that corner, w up its outer face from the virtual sharp. In the flat that point lies
/// s0 − BA − (w − s0) into the base from the edge, with s0 = (R + T)·tan(θ/2) the setback and
/// BA = θ·(R + K·T) the bend allowance; the panel spans w = s0 … the flange's height.
fn hand_panels() -> Vec<Vec<(f64, f64)>> {
// Each edge's start corner, its direction, the direction into the base, and its length.
let frames: [((f64, f64), (f64, f64), (f64, f64), f64); 4] = [
((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), 120.0),
((120.0, 0.0), (0.0, 1.0), (-1.0, 0.0), 90.0),
((120.0, 90.0), (-1.0, 0.0), (0.0, -1.0), 120.0),
((0.0, 90.0), (0.0, -1.0), (1.0, 0.0), 90.0),
];
(0..4)
.map(|i| {
let (o, d, v, len) = frames[i];
let (th, h) = (FOUR_ANGLES[i].to_radians(), FOUR_HEIGHTS[i]);
let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0);
let x_end = |phi: f64, w: f64| {
let c = th.cos() * phi.cos();
(0.25 * (2.0 - 2.0 * c).sqrt() + 2.0 * (1.0 - c) - w * th.sin() * phi.cos()) / phi.sin()
};
let (prev, next) = (FOUR_ANGLES[(i + 3) % 4].to_radians(), FOUR_ANGLES[(i + 1) % 4].to_radians());
let at = |u: f64, w: f64| {
let into = s0 - ba - (w - s0);
(o.0 + d.0 * u + v.0 * into, o.1 + d.1 * u + v.1 * into)
};
vec![at(x_end(prev, s0), s0), at(x_end(prev, h), h), at(len - x_end(next, h), h), at(len - x_end(next, s0), s0)]
})
.collect()
}
/// A polygon's area.
fn polygon_area(p: &[(f64, f64)]) -> f64 {
let n = p.len();
(0..n).map(|i| p[i].0 * p[(i + 1) % n].1 - p[(i + 1) % n].0 * p[i].1).sum::<f64>().abs() / 2.0
}
#[test]
fn edge_flanges_take_their_own_angle_and_height_per_edge() {
let mut one = four_angles_in_one_feature();
assert!(no_errors_or_warnings(&one), "{:?}", one.model.results);
let f1 = flat(&mut one);
// The bends: each edge's own angle, as long as the base's edge between its neighbours' bend
// start lines (each s0 in from the original edge).
assert_eq!(bend_angles(&mut one), FOUR_ANGLES);
let s0 = FOUR_ANGLES.map(|a| 3.0 * (a.to_radians() / 2.0).tan());
let ba = FOUR_ANGLES.map(|a| a.to_radians() * 2.0);
let (w, h) = (120.0 - s0[3] - s0[1], 90.0 - s0[0] - s0[2]);
let lengths: Vec<f64> = f1["bends"].as_array().cloned().unwrap_or_default().iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect();
assert_eq!(lengths.len(), 4, "{f1}");
for (got, want) in lengths.iter().zip([w, h, w, h]) {
assert!((got - want).abs() < 1e-6, "{lengths:?} vs {w} and {h}");
}
// The flat pattern is one outline through every hand-derived panel corner.
let (outline, panels) = (outline_points(&f1), hand_panels());
assert_eq!(f1["outline"].as_array().map(Vec::len), Some(1), "{f1}");
for (i, panel) in panels.iter().enumerate() {
for &(x, y) in panel {
assert!(outline.iter().any(|p| (p.0 - x).abs() < 1e-6 && (p.1 - y).abs() < 1e-6), "flange {i}: ({x}, {y}) not in {outline:?}");
}
}
// It holds the base, the four bend zones and the four panels, nothing more or less, and its
// size is the panels' extent.
let area = w * h + (ba[0] + ba[2]) * w + (ba[1] + ba[3]) * h + panels.iter().map(|p| polygon_area(p)).sum::<f64>();
assert!(rel(polygon_area(&outline), area) < 1e-9, "{} vs {area}", polygon_area(&outline));
let all = panels.concat();
let span = |k: fn(&(f64, f64)) -> f64| all.iter().map(k).fold(f64::MIN, f64::max) - all.iter().map(k).fold(f64::MAX, f64::min);
for (k, want) in [span(|p| p.0), span(|p| p.1), 2.0].into_iter().enumerate() {
assert!((f1["flat_size_mm"][k].as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{}: {want}", f1["flat_size_mm"]);
}
// Folded, the part holds that material (K = 0.5 keeps the volume).
let v1 = volume(&mut one);
assert!(rel(v1, area * 2.0) < 1e-3, "{v1} vs {}", area * 2.0);
// The DXF: the outline in straight lines, and four bend lines.
let dxf = run(&mut one, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
assert_eq!(dxf.matches("0\nLINE\n").count(), outline.len() + 4, "{} outline points", outline.len());
// Unfold and refold keep it.
run(&mut one, "sheet.unfold", json!({}));
assert!(same_points(&outline_points(&flat(&mut one)), &outline_points(&f1)));
run(&mut one, "sheet.refold", json!({}));
assert!(rel(volume(&mut one), v1) < 1e-9);
// Saved, an edge with its own values is an object and one without is the point it always was.
let text = one.doc.to_json();
let saved: Value = serde_json::from_str(&text).unwrap();
let flange = saved["features"]
.as_array()
.and_then(|f| f.iter().find(|f| f["type"] == "sheet_flange" && f["edges"].as_array().is_some_and(|e| e.len() == 4)));
assert_eq!(
flange.map(|f| f["edges"].clone()),
Some(json!([
[60.0, 0.0, 2.0],
{"point": [120.0, 45.0, 2.0], "angle": "90 deg"},
{"point": [60.0, 90.0, 2.0], "angle": "105", "height": "30 mm"},
{"point": [0.0, 45.0, 2.0], "angle": "120 deg", "height": "25"}
])),
"{saved}"
);
// Reopened, it is the same part.
let mut again = Session::new(solidcraft_doc::Document::from_json(&text).unwrap());
assert!(no_errors_or_warnings(&again), "{:?}", again.model.results);
assert!(rel(volume(&mut again), v1) < 1e-9);
}
#[test]
fn per_edge_values_are_expressions_and_feature_inputs() {
let build = |angle: &str, height: &str| {
let mut s = Session::default();
alu_base(&mut s);
run(&mut s, "parameters.add", json!({"name": "wall_angle", "expression": "105 deg"}));
run(&mut s, "parameters.add", json!({"name": "wall_height", "expression": "30 mm"}));
let edges = json!([[60, 0, 2], {"point": [120, 45, 2], "angle": "90 deg"}, {"point": [60, 90, 2], "angle": angle, "height": height}]);
run(&mut s, "sheet.flange", json!({"edges": edges, "height": 38.6, "angle": "75 deg"}));
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
s
};
let part = |s: &mut Session| (volume(s), outline_points(&flat(s)));
let mut s = build("wall_angle", "wall_height");
// The feature's inputs: its own, then each edge's, in the order of the edges.
let labels: Vec<String> =
s.doc.features.last().map(|f| f.kind.inputs().into_iter().map(|(l, e, _)| format!("{l}={e}")).collect()).unwrap_or_default();
assert_eq!(labels, ["Height=38.6", "Angle=75 deg", "Edge angle=90 deg", "Edge angle=wall_angle", "Edge height=wall_height"]);
// Changing the user parameters moves that flange.
run(&mut s, "parameters.change", json!({"name": "wall_angle", "expression": "110 deg"}));
run(&mut s, "parameters.change", json!({"name": "wall_height", "expression": "34 mm"}));
let (v, outline) = part(&mut build("110 deg", "34 mm"));
assert!(rel(volume(&mut s), v) < 1e-9, "{} vs {v}", volume(&mut s));
assert!(same_points(&outline_points(&flat(&mut s)), &outline));
// So does the feature's own input for an edge, by its parameter name (the last two).
let names = s.doc.features.last().map(|f| f.param_names.clone()).unwrap_or_default();
assert_eq!(names.len(), 5, "{names:?}");
run(&mut s, "parameters.change", json!({"name": names[3], "expression": "100 deg"}));
run(&mut s, "parameters.change", json!({"name": names[4], "expression": "36 mm"}));
let (v, outline) = part(&mut build("100 deg", "36 mm"));
assert!(rel(volume(&mut s), v) < 1e-9, "{} vs {v}", volume(&mut s));
assert!(same_points(&outline_points(&flat(&mut s)), &outline));
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
}
#[test]
fn bad_per_edge_input_is_refused_and_plain_edges_stay_plain() {
let mut s = Session::default();
alu_base(&mut s);
let err = |s: &mut Session, edges: Value| {
s.execute("sheet.flange", &json!({"edges": edges, "height": 38.6, "angle": "90 deg"})).err().map(|e| e.to_string()).unwrap_or_default()
};
// The wrong kind of value, or no expression at all: the edge and the key are named.
let e = err(&mut s, json!([[60, 0, 2], {"point": [120, 45, 2], "angle": "10 mm"}]));
assert!(e.contains("edges[1].angle"), "{e}");
let e = err(&mut s, json!([{"point": [60, 0, 2], "height": "30 deg"}]));
assert!(e.contains("edges[0].height"), "{e}");
let e = err(&mut s, json!([{"point": [60, 0, 2], "angle": "nope"}]));
assert!(e.contains("edges[0].angle"), "{e}");
let e = err(&mut s, json!([{"point": [60, 0, 2], "angle": [1, 2]}]));
assert!(e.contains("edges[0]") && e.contains("`angle` must be a number or an expression"), "{e}");
// An unknown key, a point that is none, an angle without `point`, something that is no edge.
let e = err(&mut s, json!([{"point": [60, 0, 2], "twist": 3}]));
assert!(e.contains("edges[0]") && e.contains("unknown key `twist`"), "{e}");
let e = err(&mut s, json!([{"point": [60, 0], "angle": "60 deg"}]));
assert!(e.contains("edges[0]") && e.contains("`point` must be [x, y, z]"), "{e}");
let e = err(&mut s, json!([{"x": 60, "y": 0, "z": 2, "angle": "60 deg"}]));
assert!(e.contains("edges[0]") && e.contains("unknown key"), "{e}");
let e = err(&mut s, json!([{"angle": "60 deg"}]));
assert!(e.contains("edges[0]") && e.contains("`point` must be [x, y, z]"), "{e}");
let e = err(&mut s, json!(["edge"]));
assert!(e.contains("edges[0]"), "{e}");
let e = err(&mut s, json!([]));
assert!(e.contains("1…1000"), "{e}");
// Evaluated: an angle outside 0…180° and a height under the bend's, each naming its edge.
let e = err(&mut s, json!([[60, 0, 2], {"point": [120, 45, 2], "angle": "200 deg"}]));
assert!(e.contains("edge 2") && e.contains("angle must be between 0 and 180"), "{e}");
let e = err(&mut s, json!([{"point": [60, 0, 2], "height": "2 mm"}, [120, 45, 2]]));
assert!(e.contains("edge 1") && e.contains("height must be at least"), "{e}");
// None of it added a feature.
assert_eq!(s.doc.features.len(), 2, "{:?}", s.doc.features.iter().map(|f| f.name.clone()).collect::<Vec<_>>());
// Plain points, `{x, y, z}` points and `{point}` without values are saved as the points they were.
run(&mut s, "sheet.flange", json!({"edges": [[60, 0, 2], {"x": 120, "y": 45, "z": 2}, {"point": [60, 90, 2]}], "height": 20, "angle": "90 deg"}));
let edges = serde_json::to_value(s.doc.features.last().map(|f| f.kind.clone())).unwrap()["edges"].clone();
assert_eq!(edges, json!([[60.0, 0.0, 2.0], [120.0, 45.0, 2.0], [60.0, 90.0, 2.0]]));
}
#[test]
fn editing_a_flange_keeps_each_edges_own_values_with_its_edge() {
let mut s = four_angles_in_one_feature();
let id = s.doc.features.last().map(|f| f.id).unwrap_or(0);
// Give the first edge 80° and take the 90° off the second.
let edges = json!([
{"point": [60, 0, 2], "angle": "80 deg"},
[120, 45, 2],
{"point": [60, 90, 2], "angle": "105 deg", "height": "30 mm"},
{"point": [0, 45, 2], "angle": "120 deg", "height": "25 mm"}
]);
run(&mut s, "timeline.edit", json!({"feature": id, "set": {"edges": edges}}));
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
assert_eq!(bend_angles(&mut s), [80.0, 75.0, 105.0, 120.0]);
let kind = |s: &Session| serde_json::to_value(s.doc.features.last().map(|f| f.kind.clone())).unwrap();
assert_eq!(kind(&s)["edges"][1], json!([120.0, 45.0, 2.0]), "no value of its own: a plain point again");
let (v, outline) = (volume(&mut s), outline_points(&flat(&mut s)));
// The same edges in another order are the same part: each value follows its edge.
let edges = json!([
{"point": [0, 45, 2], "angle": "120 deg", "height": "25 mm"},
{"point": [60, 90, 2], "angle": "105 deg", "height": "30 mm"},
{"point": [60, 0, 2], "angle": "80 deg"},
[120, 45, 2]
]);
run(&mut s, "timeline.edit", json!({"feature": id, "set": {"edges": edges}}));
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
assert_eq!(bend_angles(&mut s), [120.0, 105.0, 80.0, 75.0]);
assert!(rel(volume(&mut s), v) < 1e-9, "{} vs {v}", volume(&mut s));
assert!(same_points(&outline_points(&flat(&mut s)), &outline));
// A bad edit is refused and leaves the feature as it was.
let before = kind(&s);
assert!(s.execute("timeline.edit", &json!({"feature": id, "set": {"edges": [{"point": [60, 0, 2], "angle": "80 deg", "colour": 1}]}})).is_err());
assert!(s.execute("timeline.edit", &json!({"feature": id, "set": {"edges": [{"point": [60, 0, 2], "angle": "250 deg"}]}})).is_err());
assert_eq!(kind(&s), before);
assert_eq!(bend_angles(&mut s), [120.0, 105.0, 80.0, 75.0]);
}
#[test]
fn designs_without_per_edge_values_are_read_and_written_as_before() {
let mut s = Session::default();
alu_base(&mut s);
run(&mut s, "sheet.flange", json!({"edges": [[60, 0, 2], [120, 45, 2], [60, 90, 2], [0, 45, 2]], "height": 38.6, "angle": "72 deg"}));
let v = volume(&mut s);
let text = s.doc.to_json();
let mut design: Value = serde_json::from_str(&text).unwrap();
let flange = design["features"].as_array().and_then(|f| f.iter().position(|f| f["type"] == "sheet_flange")).unwrap();
assert_eq!(design["features"][flange]["edges"], json!([[60.0, 0.0, 2.0], [120.0, 45.0, 2.0], [60.0, 90.0, 2.0], [0.0, 45.0, 2.0]]));
let mut same = Session::new(solidcraft_doc::Document::from_json(&text).unwrap());
assert!(rel(volume(&mut same), v) < 1e-12);
// A design file whose last edge carries its own angle is the part the command makes.
design["features"][flange]["edges"][3] = json!({"point": [0.0, 45.0, 2.0], "angle": "100 deg"});
let mut opened = Session::new(solidcraft_doc::Document::from_json(&design.to_string()).unwrap());
assert!(no_errors_or_warnings(&opened), "{:?}", opened.model.results);
let mut made = Session::default();
alu_base(&mut made);
let edges = json!([[60, 0, 2], [120, 45, 2], [60, 90, 2], {"point": [0, 45, 2], "angle": "100 deg"}]);
run(&mut made, "sheet.flange", json!({"edges": edges, "height": 38.6, "angle": "72 deg"}));
let (vo, vm) = (volume(&mut opened), volume(&mut made));
assert!(rel(vo, vm) < 1e-9 && rel(vo, v) > 1e-4, "{vo} vs {vm} vs {v}");
// A key this build doesn't know is refused, not dropped.
design["features"][flange]["edges"][3] = json!({"point": [0.0, 45.0, 2.0], "twist": "3 deg"});
assert!(solidcraft_doc::Document::from_json(&design.to_string()).is_err());
}
+48 -17
View File
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
.at("SOLID", "CREATE")
.icon("sketch")
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source, fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"),
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source (a flat face of a sheet metal body attaches to its panel instead, in the flat pattern's coordinates), fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"),
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"),
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("sketch.line", "Line", draw_line)
@@ -117,12 +117,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "CONSTRAINTS")
.icon("c_coincident")
.enabled(in_sketch)
.params("a, b: points, a point and a curve, or two distinct lines (joins their closest endpoints)"),
.params("a, b: points, a point and a curve or sketch axis (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created), or two distinct lines (joins their closest endpoints)"),
CommandSpec::new("sketch.constraint.tangent", "Tangent", c_tangent)
.at("SKETCH", "CONSTRAINTS")
.icon("c_tangent")
.enabled(in_sketch)
.params("a, b: curves"),
.params("a, b: curves or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.equal", "Equal", c_equal)
.at("SKETCH", "CONSTRAINTS")
.icon("c_equal")
@@ -132,12 +132,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "CONSTRAINTS")
.icon("c_parallel")
.enabled(in_sketch)
.params("a, b: lines"),
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.perpendicular", "Perpendicular", c_perpendicular)
.at("SKETCH", "CONSTRAINTS")
.icon("c_perpendicular")
.enabled(in_sketch)
.params("a, b: lines"),
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.fix", "Fix/UnFix", c_fix)
.at("SKETCH", "CONSTRAINTS")
.icon("c_fix")
@@ -157,12 +157,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "CONSTRAINTS")
.icon("c_collinear")
.enabled(in_sketch)
.params("a, b: lines"),
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.symmetry", "Symmetry", c_symmetry)
.at("SKETCH", "CONSTRAINTS")
.icon("c_symmetry")
.enabled(in_sketch)
.params("a, b: points, line: symmetry line"),
.params("a, b: two points or two curves (lines mirror their ends, circles/arcs their centres); line: symmetry line id, or x / y for the sketch's own axes (a curve with that id wins; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.construction", "Normal/Construction", construction)
.at("SKETCH", "CREATE")
.icon("construction")
@@ -454,6 +454,22 @@ pub(super) fn curve_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) -
sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown sketch curve `{id}`")))
}
/// A line a constraint refers to: a curve id, or `x` / `y` for the sketch's own axes (a curve
/// with that id wins). An axis becomes the sketch's linked construction line on it
/// ([`Sketch::sketch_axis_line`]), added on first use and reported in `created`.
pub(super) fn line_or_axis_ref(sk: &mut Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result<usize> {
let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, format!("`{what}` must be a curve id, or x / y for a sketch axis")))?;
curve_or_axis(sk, id)?.ok_or_else(|| bad(cmd, format!("unknown sketch curve `{id}` (x and y name the sketch's own axes)")))
}
/// Curve `id`, or the line of sketch axis `id` (see [`line_or_axis_ref`]); `None` when neither.
fn curve_or_axis(sk: &mut Sketch, id: &str) -> Result<Option<usize>> {
match sk.curve_index(id) {
Some(c) => Ok(Some(c)),
None => Ok(sk.sketch_axis_line(id)?),
}
}
pub(super) fn point_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result<usize> {
let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, format!("`{what}` must be a point reference")))?;
sk.resolve_point(id).ok_or_else(|| bad(cmd, format!("unknown sketch point `{id}`")))
@@ -646,6 +662,8 @@ fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneRef, interna
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
FeatureKind::Sketch { plane, .. } => Some(if face::anchor(plane).is_some() {
"exact_face"
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
"sheet_face"
} else if internal_body.is_some() {
"internal_legacy_face"
} else {
@@ -1154,7 +1172,7 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
// ---------------------------------------------------------------------------------------------
// Constraints
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&Sketch) -> Result<ConstraintKind>) -> Result<Value> {
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch) -> Result<ConstraintKind>) -> Result<Value> {
let mut before = None;
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let k = f(sk)?;
@@ -1179,7 +1197,7 @@ pub(super) enum Ent {
}
fn ent(sk: &Sketch, r: &str) -> Option<Ent> {
sk.resolve_point(r).map(Ent::Point).or_else(|| sk.curve_index(r).map(Ent::Curve))
sk.resolve_point(r).map(Ent::Point).or_else(|| sk.curve_index(r).or_else(|| sk.sketch_axis_curve(r)).map(Ent::Curve))
}
/// The entities of a two-entity constraint in the order they were picked (`a` then `b`, or
@@ -1372,15 +1390,18 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
}
let aref = p.get("a").and_then(Value::as_str).unwrap_or_default();
let bref = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?;
// A curve picked first and a point second: the point goes onto the curve.
if let (None, Some(c), Some(q)) = (sk.resolve_point(aref), sk.curve_index(aref), sk.resolve_point(bref)) {
// A curve (or sketch axis) picked first and a point second: the point goes onto it.
if sk.resolve_point(aref).is_none()
&& let Some(q) = sk.resolve_point(bref)
&& let Some(c) = curve_or_axis(sk, aref)?
{
return Ok(ConstraintKind::PointOnCurve { p: q, c });
}
let a = point_ref(sk, p.get("a"), cmd, "a")?;
if let Some(b) = sk.resolve_point(bref) {
return Ok(ConstraintKind::Coincident { p: a, q: b });
}
let c = sk.curve_index(bref).ok_or_else(|| bad(cmd, format!("unknown entity `{bref}`")))?;
let c = curve_or_axis(sk, bref)?.ok_or_else(|| bad(cmd, format!("unknown entity `{bref}`")))?;
Ok(ConstraintKind::PointOnCurve { p: a, c })
})
}
@@ -1420,20 +1441,27 @@ fn two_curves(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> {
Ok((curve_ref(sk, p.get("a"), cmd, "a")?, curve_ref(sk, p.get("b"), cmd, "b")?))
}
/// Two curves where either may be a sketch axis (`x` / `y`, see [`line_or_axis_ref`]).
fn two_curves_or_axes(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> {
Ok((line_or_axis_ref(sk, p.get("a"), cmd, "a")?, line_or_axis_ref(sk, p.get("b"), cmd, "b")?))
}
fn c_tangent(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.tangent", |sk| two_curves(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b }))
constrain(s, p, "sketch.constraint.tangent", |sk| {
two_curves_or_axes(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b })
})
}
fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.equal", |sk| two_curves(sk, p, "sketch.constraint.equal").map(|(a, b)| ConstraintKind::Equal { a, b }))
}
fn c_parallel(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.parallel", |sk| {
two_curves(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b })
two_curves_or_axes(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b })
})
}
fn c_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.perpendicular", |sk| {
two_curves(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })
two_curves_or_axes(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })
})
}
fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1443,7 +1471,7 @@ fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
}
fn c_collinear(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.collinear", |sk| {
two_curves(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b })
two_curves_or_axes(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b })
})
}
fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1455,7 +1483,7 @@ fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.symmetry";
let (ids, info) = edit(s, p, cmd, true, |sk, _| {
let l = curve_ref(sk, p.get("line"), cmd, "line")?;
let l = line_or_axis_ref(sk, p.get("line"), cmd, "line")?;
let ra = p.get("a").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`a` must be a point or curve"))?;
let rb = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?;
let rep = solve(&mut sk.clone());
@@ -2119,6 +2147,9 @@ mod rect_tests;
#[path = "sketch_ux_tests.rs"]
mod ux_tests;
#[cfg(test)]
#[path = "sketch_axis_ref_tests.rs"]
mod axis_ref_tests;
#[cfg(test)]
#[path = "sketch_drag_live_tests.rs"]
mod drag_live_tests;
@@ -0,0 +1,238 @@
//! Constraints that take a line accept `x` / `y` for the sketch's own axes (issue #394): the
//! axis becomes one linked construction line on it, shared by every constraint on that axis,
//! reported in `created`, and left out of profiles. A real curve with that id wins.
use serde_json::{Value, json};
use solidcraft_geom::{Vec2, Vec3};
use solidcraft_sketch::{CurveKind, LinkSource, Sketch};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn sketch(s: &Session) -> Sketch {
s.doc.sketch(s.active_sketch.unwrap()).unwrap().clone()
}
fn on(plane: Value) -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({ "plane": plane }));
s
}
fn pt(sk: &Sketch, r: &str) -> Vec2 {
sk.point(sk.resolve_point(r).unwrap()).unwrap()
}
fn strs(v: &Value) -> Vec<String> {
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
/// The sketch's axis lines: (axis name, curve id).
fn axis_lines(sk: &Sketch) -> Vec<(String, String)> {
sk.links
.iter()
.filter_map(|l| match &l.source {
LinkSource::SketchAxis { name } => Some((name.clone(), l.id.clone())),
_ => None,
})
.flat_map(|(name, link)| sk.link_curves(&link).into_iter().map(move |c| (name.clone(), sk.curves[c].id.clone())))
.collect()
}
fn assert_axis_line(sk: &Sketch, id: &str, dir: Vec2) {
let c = sk.curve_index(id).unwrap();
let curve = &sk.curves[c];
assert!(curve.construction, "axis line {id} is construction");
assert!(sk.is_linked_curve(c), "axis line {id} is linked");
let CurveKind::Line { a, b } = curve.kind else { panic!("axis line {id} is a line") };
for p in [sk.point(a).unwrap(), sk.point(b).unwrap()] {
assert!(p.cross(dir).abs() < 1e-9, "{id} end {p:?} lies on the axis {dir:?}");
}
}
#[test]
fn issue_394_symmetry_about_the_sketch_y_axis() {
let mut s = on(json!("XY"));
run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]}));
let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"}));
assert!(r["sketch"]["solved"].as_bool().unwrap(), "{r}");
let sk = sketch(&s);
let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end"));
assert!((start.x + end.x).abs() < 1e-7 && (start.y - end.y).abs() < 1e-7, "mirrored about y: {start:?} {end:?}");
assert!((start.x - -4.0).abs() < 1e-7, "the first entity stays: {start:?}");
let axes = axis_lines(&sk);
assert_eq!(axes.len(), 1, "{axes:?}");
let (name, line) = &axes[0];
assert_eq!(name, "y");
assert_axis_line(&sk, line, Vec2::Y);
assert_eq!(strs(&r["created"]["curves"]), vec![line.clone()], "the axis line is reported in created: {r}");
assert_eq!(strs(&r["created"]["constraints"]).len(), 1, "{r}");
// A second constraint on the same axis reuses its line.
run(&mut s, "sketch.line", json!({"points": [[-3, 5], [8, 5]], "ids": ["b"]}));
let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "b.start", "b": "b.end", "line": "y"}));
assert!(strs(&r["created"]["curves"]).is_empty(), "{r}");
assert_eq!(axis_lines(&sketch(&s)).len(), 1);
}
#[test]
fn a_real_curve_named_like_an_axis_wins() {
let mut s = on(json!("XY"));
run(&mut s, "sketch.line", json!({"points": [[5, -20], [5, 20]], "ids": ["y"]}));
run(&mut s, "sketch.constraint.fix", json!({"entity": "y"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [12, 0]], "ids": ["a"]}));
run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"}));
let sk = sketch(&s);
assert!(axis_lines(&sk).is_empty(), "no axis line when a curve is called y");
let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end"));
assert!((start.x + end.x - 10.0).abs() < 1e-7, "mirrored about x = 5: {start:?} {end:?}");
}
#[test]
fn a_rectangle_centred_on_the_origin_keeps_one_profile() {
let mut s = on(json!("XY"));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [2, 1], "p1": [12, 7]}));
let sk = sketch(&s);
let lines = strs(&r["curves"]);
let dir = |id: &String| {
let CurveKind::Line { a, b } = sk.curves[sk.curve_index(id).unwrap()].kind else { panic!() };
sk.point(b).unwrap() - sk.point(a).unwrap()
};
let horizontal = lines.iter().find(|l| dir(l).y.abs() < 1e-9).unwrap().clone();
let vertical = lines.iter().find(|l| dir(l).x.abs() < 1e-9).unwrap().clone();
run(&mut s, "sketch.constraint.symmetry", json!({"a": format!("{horizontal}.start"), "b": format!("{horizontal}.end"), "line": "y"}));
run(&mut s, "sketch.constraint.symmetry", json!({"a": format!("{vertical}.start"), "b": format!("{vertical}.end"), "line": "x"}));
let sk = sketch(&s);
let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY);
let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
for l in &lines {
for end in ["start", "end"] {
let p = pt(&sk, &format!("{l}.{end}"));
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
}
}
assert!((lo.x + hi.x).abs() < 1e-7 && (lo.y + hi.y).abs() < 1e-7, "centred on the origin: {lo:?} {hi:?}");
assert_eq!(axis_lines(&sk).len(), 2);
// The axis lines cross the rectangle but are construction: still one profile.
assert_eq!(run(&mut s, "sketch.solve", json!({}))["profiles"], json!(1));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
}
#[test]
fn sketch_axes_on_a_plane_off_the_world_axes() {
// A plane whose own axes are none of the world axes: x along world Y, y along world Z,
// origin away from the world origin.
let origin = Vec3::new(5.0, 7.0, 9.0);
let mut s = on(json!({"origin": [5, 7, 9], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}));
run(&mut s, "sketch.line", json!({"points": [[3, -6], [3, 2]], "ids": ["a"]}));
let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "x"}));
assert!(r["sketch"]["solved"].as_bool().unwrap(), "{r}");
let sk = sketch(&s);
let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end"));
assert!((start.y + end.y).abs() < 1e-7 && (start.x - end.x).abs() < 1e-7, "mirrored about the sketch x axis: {start:?} {end:?}");
let axes = axis_lines(&sk);
assert_eq!(axes.len(), 1);
assert_axis_line(&sk, &axes[0].1, Vec2::X);
// After a recompute the axis line is still the sketch's x axis, in world space too.
run(&mut s, "sketch.finish", json!({}));
let st = s.model.state();
let id = s.doc.find_feature("Sketch1").map(|f| f.id).unwrap();
let ss = st.sketch(id).unwrap();
let c = ss.sketch.curve_index(&axes[0].1).unwrap();
let CurveKind::Line { a, b } = ss.sketch.curves[c].kind else { panic!() };
for p in [a, b] {
let w = ss.plane.to_world(ss.sketch.point(p).unwrap()) - origin;
assert!(w.x.abs() < 1e-9 && w.z.abs() < 1e-9, "on the plane's x axis (world Y through its origin): {w:?}");
}
}
#[test]
fn other_line_constraints_take_sketch_axes() {
let mut s = on(json!("XZ"));
run(&mut s, "sketch.line", json!({"points": [[1, 1], [6, 3]], "ids": ["p"]}));
run(&mut s, "sketch.constraint.parallel", json!({"a": "p", "b": "x"}));
let sk = sketch(&s);
assert!((pt(&sk, "p.start").y - pt(&sk, "p.end").y).abs() < 1e-7, "parallel to x");
run(&mut s, "sketch.line", json!({"points": [[-2, 1], [-1, 6]], "ids": ["q"]}));
run(&mut s, "sketch.constraint.perpendicular", json!({"a": "x", "b": "q"}));
let sk = sketch(&s);
assert!((pt(&sk, "q.start").x - pt(&sk, "q.end").x).abs() < 1e-7, "perpendicular to x");
run(&mut s, "sketch.line", json!({"points": [[2, -5], [4, -8]], "ids": ["r"]}));
run(&mut s, "sketch.constraint.collinear", json!({"a": "r", "b": "y"}));
let sk = sketch(&s);
assert!(pt(&sk, "r.start").x.abs() < 1e-7 && pt(&sk, "r.end").x.abs() < 1e-7, "on the y axis");
run(&mut s, "sketch.circle.center", json!({"center": [10, 4], "radius": 2, "id": "c"}));
run(&mut s, "sketch.constraint.tangent", json!({"a": "c", "b": "x"}));
let sk = sketch(&s);
let c = sk.curve_index("c").unwrap();
let CurveKind::Circle { c: centre, r } = sk.curves[c].kind else { panic!() };
assert!((sk.point(centre).unwrap().y.abs() - r).abs() < 1e-7, "tangent to x");
// A point onto an axis, either pick order.
run(&mut s, "sketch.point", json!({"point": [7, 9], "id": "u"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "u", "b": "x"}));
assert!(pt(&sketch(&s), "u").y.abs() < 1e-7);
run(&mut s, "sketch.point", json!({"point": [7, 9], "id": "v"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "y", "b": "v"}));
assert!(pt(&sketch(&s), "v").x.abs() < 1e-7);
// Still one line per axis.
let mut names: Vec<String> = axis_lines(&sketch(&s)).into_iter().map(|(n, _)| n).collect();
names.sort();
assert_eq!(names, ["x", "y"]);
}
#[test]
fn the_axis_survives_save_and_load() {
let mut s = on(json!("YZ"));
run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]}));
run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"}));
run(&mut s, "sketch.finish", json!({}));
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let id = doc.find_feature("Sketch1").map(|f| f.id).unwrap();
let sk = doc.sketch(id).unwrap();
assert_eq!(axis_lines(sk).len(), 1);
assert_eq!(sk.links.iter().map(|l| &l.source).collect::<Vec<_>>(), [&LinkSource::SketchAxis { name: "y".into() }]);
}
#[test]
fn bad_line_references_are_errors_and_change_nothing() {
let mut s = on(json!("XY"));
run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]}));
run(&mut s, "sketch.line", json!({"points": [[0, 3], [9, 4]], "ids": ["b"]}));
let before = s.doc.to_json();
let hostile =
[json!("Y"), json!("X"), json!("z"), json!(""), json!("x.start"), json!(" y"), json!(5), json!(null), json!(["y"]), json!({"axis": "y"})];
for line in &hostile {
assert!(s.execute("sketch.constraint.symmetry", &json!({"a": "a.start", "b": "a.end", "line": line})).is_err(), "symmetry line {line}");
for cmd in ["sketch.constraint.parallel", "sketch.constraint.perpendicular", "sketch.constraint.collinear", "sketch.constraint.tangent"] {
assert!(s.execute(cmd, &json!({"a": "b", "b": line})).is_err(), "{cmd} b {line}");
}
assert!(s.execute("sketch.constraint.coincident", &json!({"a": "b.start", "b": line})).is_err(), "coincident b {line}");
}
// Constraints that do not take an axis still refuse x / y, and leave no axis line behind.
for cmd in ["sketch.constraint.equal", "sketch.constraint.concentric"] {
assert!(s.execute(cmd, &json!({"a": "b", "b": "x"})).is_err(), "{cmd}");
}
assert!(s.execute("sketch.constraint.midpoint", &json!({"point": "b.start", "line": "y"})).is_err());
assert!(s.execute("sketch.constraint.horizontal_vertical", &json!({"line": "x"})).is_err());
assert_eq!(s.doc.to_json(), before, "failed commands change nothing");
// A constraint the sketch refuses rolls the axis line back with it.
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "b", "mode": "horizontal"}));
let before_redundant = s.doc.to_json();
assert!(s.execute("sketch.constraint.parallel", &json!({"a": "b", "b": "x"})).is_err(), "already horizontal");
assert_eq!(s.doc.to_json(), before_redundant);
assert!(axis_lines(&sketch(&s)).is_empty());
}
+36
View File
@@ -79,6 +79,7 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
by_component.entry(doc.model_body_component(body)).or_default().push(body);
}
let mut chosen = None;
let mut sheet_face: Option<(String, Plane)> = None;
let mut work = state.bodies.len().saturating_add(doc.occurrences.len());
for (component, path, placement) in placements {
let Some(bodies) = by_component.get(&component) else {
@@ -104,6 +105,22 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
let inverse = mat_inverse(&frame).ok_or_else(|| bad(cmd, "invalid selected face frame"))?;
let local_point = apply_point(&inverse, point);
let tolerance = (body.body.size() * 1e-4).max(1e-5);
// A flat face of a sheet metal body attaches to its panel: the exact plane comes from
// the panel's flat region and fold, and the sketch keeps the face's name to follow it.
if !fixed
&& !internal
&& let Some(sheet) = state.sheets.iter().find(|sheet| sheet.body == body.name)
&& let Some((face, plane)) = sheet.panel_face_at(local_point, tolerance * 10.0)
{
if chosen.is_some() || sheet_face.is_some() {
return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index"));
}
let relative = mat_mul(&author_inverse, &frame);
let plane = Plane::new(apply_point(&relative, plane.origin), apply_vector(&relative, plane.x), apply_vector(&relative, plane.y))
.ok_or_else(|| bad(cmd, "invalid sheet face plane"))?;
sheet_face = Some((solidcraft_doc::sheet::sheet_face_name(&body.name, &face), plane));
continue;
}
let faces = body.body.faces(tolerance)?;
let mesh = body.body.tessellate(tolerance)?;
work = work.saturating_add(mesh.triangles.len().saturating_mul(if requested_index.is_some() { 1 } else { faces.len() }));
@@ -138,6 +155,12 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
}
}
}
if let Some((name, plane)) = sheet_face {
if chosen.is_some() {
return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index"));
}
return Ok(PlaneRef::Face { plane, at: apply_point(&author_inverse, point), name: Some(name), anchor: None });
}
let (body, index, path, plane) =
chosen.ok_or_else(|| bad(cmd, "the selected planar face no longer exists; choose another face or an explicit fixed plane"))?;
if fixed {
@@ -160,6 +183,19 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
}
None
}
Err(solidcraft_doc::DocError::Invalid(message))
if matches!(
message.as_str(),
"this face has no exact planar source; choose a fixed plane instead"
| "this face has no supported construction certificate; choose a fixed plane instead"
) =>
{
return Err(bad(
cmd,
"this face can't keep an attachment: it was merged from more than one feature's faces or reshaped by an edit. \
Pick another face, or use a fixed plane here (fixed:true), which won't follow later changes to the face",
));
}
Err(error) => return Err(bad(cmd, format!("{error}; choose a supported exact face or explicitly use fixed:true"))),
};
Ok(PlaneRef::Face { plane, at, name, anchor })
+2 -1
View File
@@ -76,7 +76,8 @@ fn explicit_rebind_preserves_geometry_dimensions_context_and_one_undo() {
fn unsupported_face_has_explicit_fixed_alternative_and_no_live_projection() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[4,4,10],"operation":"join"}));
// The tops of two joined boxes merge into one face that no single construction owns.
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[10,0,0],"operation":"join"}));
let mut pick = top_pick(&s, "Base");
let before = s.doc.to_json();
let error = s.execute("sketch.create", &json!({"plane":pick})).unwrap_err();
@@ -0,0 +1,144 @@
//! Sketches on construction planes built from the model (perpendicular to a plane through a
//! sketch line, through three points, a midplane…): Project, Intersect, Include 3D Geometry and
//! Text resolve the sketch's plane against the model before the sketch, as evaluation does, and
//! the links follow when a parameter moves the plane.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// Sketch `A` on YZ with the line `s` from the origin to (run 100, rise `rise`), and the plane
/// `P` through that line, square to YZ. The plane's origin is the line's start and its x axis
/// runs along the line.
fn sloped_plane(s: &mut Session) {
run(s, "parameters.add", json!({"name": "rise", "expression": "60 mm"}));
run(s, "sketch.create", json!({"plane": "YZ", "name": "A"}));
run(s, "sketch.line", json!({"points": [[0, 0], [100, 60]], "ids": ["s"]}));
run(s, "sketch.dimension", json!({"entities": ["s.start", "s.end"], "type": "horizontal", "value": "100 mm"}));
run(s, "sketch.dimension", json!({"entities": ["s.start", "s.end"], "type": "vertical", "value": "rise"}));
run(s, "sketch.finish", json!({}));
run(s, "construct.plane.perpendicular", json!({"plane": "YZ", "line": {"sketch": "A", "curve": "s"}, "name": "P"}));
}
/// World position of point `point` of sketch `sketch`.
fn world_point(s: &Session, sketch: &str, point: &str) -> Vec3 {
let id = s.doc.find_feature(sketch).unwrap().id;
let st = s.model.state();
let ss = st.sketch(id).unwrap();
let i = ss.sketch.resolve_point(point).unwrap_or_else(|| panic!("no point {point} in {sketch}"));
ss.plane.to_world(ss.sketch.point(i).unwrap())
}
fn no_trouble(s: &Session) {
for r in &s.model.results {
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
}
}
#[test]
fn project_onto_a_plane_built_from_a_sketch_line_follows_the_parameter() {
let mut s = Session::default();
sloped_plane(&mut s);
run(&mut s, "sketch.create", json!({"plane": "P", "name": "B"}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "A", "point": "s.end"}, {"sketch": "A", "curve": "s"}]}));
let point = r["links"][0]["points"][0].as_str().unwrap().to_string();
let curve = r["links"][1]["curves"][0].as_str().unwrap().to_string();
// Geometry drawn against the projection keeps working: a circle on the projected point.
run(&mut s, "sketch.circle.center", json!({"center": point, "radius": 4}));
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
let len = (100.0f64 * 100.0 + 60.0 * 60.0).sqrt();
let end = world_point(&s, "A", "s.end");
assert!(world_point(&s, "B", &point).dist(end) < 1e-9, "{:?} {end:?}", world_point(&s, "B", &point));
let ends = [world_point(&s, "B", &format!("{curve}.start")), world_point(&s, "B", &format!("{curve}.end"))];
assert!(ends.iter().any(|q| q.dist(Vec3::ZERO) < 1e-9) && ends.iter().any(|q| q.dist(end) < 1e-9), "{ends:?}");
{
let st = s.model.state();
let b = st.sketch(s.doc.find_feature("B").unwrap().id).unwrap();
let q = b.sketch.point(b.sketch.resolve_point(&point).unwrap()).unwrap();
assert!((q.x - len).abs() < 1e-9 && q.y.abs() < 1e-9, "the line's end lies on the plane's x axis: {q:?}");
}
// The parameter tilts the plane; the projection and the circle on it follow, in the file too.
run(&mut s, "parameters.change", json!({"name": "rise", "expression": "25 mm"}));
let check = |s: &Session| {
no_trouble(s);
let end = world_point(s, "A", "s.end");
assert!((end.z - 25.0).abs() < 1e-9 && (end.y - 100.0).abs() < 1e-9, "{end:?}");
assert!(world_point(s, "B", &point).dist(end) < 1e-9);
assert!(world_point(s, "B", "c1.center").dist(end) < 1e-9);
};
check(&s);
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
// Editing the sketch again after the change refreshes its links on the moved plane.
run(&mut s, "sketch.edit", json!({"sketch": "B"}));
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
check(&s);
}
#[test]
fn text_on_a_plane_built_from_a_sketch_line() {
let mut s = Session::default();
sloped_plane(&mut s);
run(&mut s, "sketch.create", json!({"plane": "P", "name": "B"}));
let r = run(&mut s, "sketch.text", json!({"text": "AB", "at": [10, 10], "height": 5}));
assert!(!r["curves"].as_array().unwrap().is_empty(), "{r}");
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
let st = s.model.state();
let b = st.sketch(s.doc.find_feature("B").unwrap().id).unwrap();
assert!(!b.profiles.is_empty());
}
#[test]
fn intersect_and_include_on_planes_built_from_the_model() {
let mut s = Session::default();
sloped_plane(&mut s);
run(&mut s, "solid.box", json!({"corner": [-10, 20, -5], "length": 20, "width": 30, "height": 80, "body_name": "Post"}));
// The plane through three sketch points and the midplane of two faces are built from the model too.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "T"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 30]], "ids": ["u", "v"]}));
run(&mut s, "sketch.finish", json!({}));
run(
&mut s,
"construct.plane.three_points",
json!({"a": {"sketch": "T", "point": "u.start"}, "b": {"sketch": "T", "point": "u.end"}, "c": {"sketch": "T", "point": "v.end"}, "name": "Three"}),
);
run(&mut s, "construct.plane.midplane", json!({"a": {"face": [0, 20, 30]}, "b": {"face": [0, 50, 30]}, "name": "Mid"}));
run(&mut s, "sketch.create", json!({"plane": "P", "name": "B"}));
// The slope cuts the post: a closed section of four lines.
let r = run(&mut s, "sketch.intersect", json!({"refs": [{"body": "Post"}]}));
assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}");
// An edge of the post, brought onto the slope.
let r = run(&mut s, "sketch.include_3d", json!({"refs": [{"edge": [-10, 20, 30], "body": "Post"}]}));
assert!(r["links"][0]["link"].is_string(), "{r}");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "Three", "name": "C"}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "T", "point": "v.end"}]}));
let p = r["links"][0]["points"][0].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
assert!(world_point(&s, "C", &p).dist(Vec3::new(40.0, 30.0, 0.0)) < 1e-9);
run(&mut s, "sketch.create", json!({"plane": "Mid", "name": "D"}));
let r = run(&mut s, "sketch.intersect", json!({"refs": [{"body": "Post"}]}));
assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}");
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
// The midplane sits half way between the post's two faces (y = 35).
let st = s.model.state();
let d = st.sketch(s.doc.find_feature("D").unwrap().id).unwrap();
assert!(d.sketch.points.iter().any(|q| q.link.is_some()));
for q in d.sketch.points.iter().filter(|q| q.link.is_some()) {
assert!((d.plane.to_world(q.pos).y - 35.0).abs() < 1e-9);
}
}
+35 -23
View File
@@ -118,15 +118,36 @@ fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
Err(bad(cmd, format!("cannot project {v}")))
}
/// The plane of a sketch feature.
fn sketch_plane(doc: &Document, id: u64) -> Result<Plane> {
/// The plane of a sketch feature, resolved against `st`: the model before the sketch as its
/// component sees it, which is what evaluation resolves it against. A construction plane built
/// from the model (through a sketch line, three points, between two faces…) exists only there.
fn sketch_plane(doc: &Document, id: u64, st: &solidcraft_doc::ModelState) -> Result<Plane> {
let (vals, _) = doc.param_values();
match doc.feature(id).map(|f| &f.kind) {
Some(FeatureKind::Sketch { plane, .. }) => Ok(doc.resolve_plane(&vals, plane, 0)?),
Some(FeatureKind::Sketch { plane, .. }) => Ok(doc.resolve_plane_in(&vals, plane, 0, Some(st))?),
_ => Err(EngineError::Other(format!("feature {id} is not a sketch"))),
}
}
/// The model before sketch `id` in the frame its links and its plane are resolved in: the
/// attached face's frame for a sketch on a face, else its component's.
fn state_for_sketch<'a>(
s: &Session,
doc: &Document,
id: u64,
st: &'a solidcraft_doc::ModelState,
) -> std::result::Result<std::borrow::Cow<'a, solidcraft_doc::ModelState>, solidcraft_doc::DocError> {
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
_ => None,
});
match attachment {
Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(doc, st, anchor, comp),
None => Ok(solidcraft_doc::state_in_frame_at(doc, st, comp, owner_frame(s, doc, comp))),
}
}
/// Keep candidate feature edits while overlaying uncommitted occurrence placements.
fn placed_candidate<'a>(s: &Session, doc: &'a Document) -> std::borrow::Cow<'a, Document> {
if s.pending_moves.is_empty() {
@@ -152,21 +173,16 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) ->
return Vec::new();
}
let doc = placed_candidate(s, doc);
let Ok(plane) = sketch_plane(&doc, id) else { return Vec::new() };
let (vals, _) = doc.param_values();
let st = s.model.state_before(id);
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
_ => None,
});
let st = match attachment {
Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp),
None => Ok(solidcraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))),
};
let Ok(st) = st else {
let Ok(st) = state_for_sketch(s, &doc, id, &st) else {
return vec!["lost face attachment; reattach explicitly".into()];
};
let Ok(plane) = sketch_plane(&doc, id, &st) else { return Vec::new() };
match attachment {
Some(anchor) => solidcraft_doc::project::refresh_face_anchor_links(&doc, &vals, &st, &plane, sk, anchor),
None => solidcraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk),
@@ -176,20 +192,11 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) ->
/// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id.
pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, kind: LinkKind, src: LinkSource) -> Result<String> {
let doc = placed_candidate(s, doc);
let plane = sketch_plane(&doc, id)?;
let (vals, _) = doc.param_values();
let st = s.model.state_before(id);
// The model as the sketch's component sees it.
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
_ => None,
});
let st = match attachment {
Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp),
None => Ok(solidcraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))),
};
let st = st?;
let st = state_for_sketch(s, &doc, id, &st)?;
let plane = sketch_plane(&doc, id, &st)?;
let r = resolve(&doc, &vals, &st, &plane, kind, &src)?;
let name = if let LinkSource::Face { body, at } = &r.source {
st.body(body).and_then(|body| {
@@ -454,7 +461,8 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
sk.points.get(pi).map(|q| q.id.clone()).unwrap_or_default()
} else if let Some(at) = edge {
let l = add_link(s, d, id, sk, LinkKind::Project, LinkSource::Edge { body, at }).map_err(|e| bad(cmd, format!("edge: {e}")))?;
let plane = sketch_plane(d, id)?;
let before = s.model.state_before(id);
let plane = sketch_plane(d, id, &*state_for_sketch(s, d, id, &before)?)?;
let q = plane.to_local(at);
let ci = sk.link_curves(&l).first().copied().ok_or_else(|| bad(cmd, "the edge projects to a point"))?;
let pi = sk.add_point(q, None)?;
@@ -490,10 +498,10 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
/// edits, but cannot provide the construction certificate required by public attachments.
pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result<String> {
let mut doc = (*s.doc).clone();
let plane = sketch_plane(&doc, id)?;
let raw = s.model.state_before(id);
let component = doc.feature(id).map(|feature| feature.component).unwrap_or(0);
let state = solidcraft_doc::state_in_frame_at(&doc, &raw, component, owner_frame(s, &doc, component));
let plane = sketch_plane(&doc, id, &state)?;
let resolved = solidcraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?;
let face_name = state.body(body).and_then(|body| solidcraft_doc::naming::face_names(body).get(index).cloned());
let mut sk = doc.sketch(id)?.clone();
@@ -538,3 +546,7 @@ pub(super) fn auto_project_face(s: &mut Session, id: u64, _at: Vec3) -> Result<O
#[cfg(test)]
#[path = "sketch_project_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "sketch_model_plane_tests.rs"]
mod model_plane_tests;
+133
View File
@@ -0,0 +1,133 @@
//! Issue #489: a sketch or an offset plane on the top face of an extrude that was itself
//! sketched on a face. Sketching a profile over the whole face draws curves on top of the face's
//! projected boundary; the extrude's faces must still carry an exact, certified planar source.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use crate::Session;
use serde_json::{Value, json};
use solidcraft_doc::FaceAttachmentStatus;
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command} {params}: {e}"))
}
fn profile(s: &mut Session, circle: bool, full: bool) {
if circle {
run(s, "sketch.circle.center", json!({"center": [20, 20], "diameter": if full { 40 } else { 16 }}));
} else if full {
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
} else {
run(s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [30, 30]}));
}
}
/// A 40 mm base (square or round) 10 mm tall, then a second extrude 10 mm up from a sketch on
/// its top face (or on a plane at that height), joined or as a new body.
fn two_extrudes(circle: bool, full: bool, on_face: bool, operation: &str) -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
profile(&mut s, circle, true);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "First"}));
let plane = if on_face { json!({"face": [20, 20, 10]}) } else { json!("XY") };
let mut params = json!({"plane": plane, "name": "Upper"});
if !on_face {
params["offset"] = json!(10);
}
run(&mut s, "sketch.create", params);
profile(&mut s, circle, full);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": operation, "name": "Second"}));
s
}
fn healthy(s: &Session, sketch: &str) -> solidcraft_geom::Plane {
let id = s.doc.find_feature(sketch).map(|f| f.id).unwrap();
match s.placed_document().sketch_face_attachment(id, &s.model) {
FaceAttachmentStatus::Healthy { plane, .. } => plane,
other => panic!("{sketch}: {other:?}"),
}
}
fn no_errors(s: &Session) {
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
}
#[test]
fn sketch_and_offset_plane_on_the_top_face_of_a_face_sketched_extrude() {
for operation in ["join", "new"] {
for circle in [false, true] {
for full in [true, false] {
for on_face in [true, false] {
let case = format!("{operation} circle={circle} full={full} on_face={on_face}");
let mut s = two_extrudes(circle, full, on_face, operation);
let top = [20., 20., 20.];
s.execute("sketch.create", &json!({"plane": {"face": top}, "name": "Top"})).unwrap_or_else(|e| panic!("{case}: {e}"));
run(&mut s, "sketch.finish", json!({}));
let plane = healthy(&s, "Top");
assert!((plane.origin.z - 20.).abs() < 1e-9 && (plane.normal().z - 1.).abs() < 1e-9, "{case}: {plane:?}");
s.execute("construct.plane.offset", &json!({"base": {"face": top}, "offset": 5, "name": "Above"}))
.unwrap_or_else(|e| panic!("{case}: {e}"));
// The second extrude's flat side is an exact source too, unless a full-face
// Join merged it with the first extrude's side: that face belongs to neither,
// so it offers the explicit fixed plane with a readable reason.
if !circle {
let side = if full { [40., 20., 15.] } else { [30., 20., 15.] };
let params = json!({"plane": {"face": side}, "name": "Side"});
if full && operation == "join" {
let error = s.execute("sketch.create", &params).unwrap_err().to_string();
assert!(error.contains("merged") && error.contains("fixed:true"), "{case}: {error}");
run(&mut s, "sketch.create", json!({"plane": {"face": side, "fixed": true}, "name": "Side"}));
} else {
s.execute("sketch.create", &params).unwrap_or_else(|e| panic!("{case} side: {e}"));
assert!((healthy(&s, "Side").normal().x - 1.).abs() < 1e-9, "{case}");
}
run(&mut s, "sketch.finish", json!({}));
}
s.refresh();
no_errors(&s);
}
}
}
}
}
/// Three extrudes, each sketched over the whole top face of the one before; editing the first
/// one's height moves every later sketch with its face.
#[test]
fn three_stacked_extrudes_follow_an_edit_of_the_first() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "E1"}));
for (k, top) in [(2, 10.), (3, 20.)] {
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 20, top]}, "name": format!("S{k}")}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": "join", "name": format!("E{k}")}));
}
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 20, 30]}, "name": "S4"}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 20], "diameter": 10}));
run(&mut s, "sketch.finish", json!({}));
s.refresh();
no_errors(&s);
assert_eq!(s.model.state().bodies.len(), 1);
for (name, z) in [("S2", 10.), ("S3", 20.), ("S4", 30.)] {
assert!((healthy(&s, name).origin.z - z).abs() < 1e-9, "{name}");
}
run(&mut s, "timeline.edit", json!({"feature": "E1", "set": {"extent": {"distance": "25"}}}));
s.refresh();
no_errors(&s);
for (name, z) in [("S2", 25.), ("S3", 35.), ("S4", 45.)] {
assert!((healthy(&s, name).origin.z - z).abs() < 1e-9, "{name} after edit");
}
let volume: f64 = s.model.state().bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum();
assert!((volume - 40. * 40. * 45.).abs() < 1e-6, "{volume}");
// Saved and reopened, every attachment still resolves.
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
reopened.refresh();
no_errors(&reopened);
assert!((healthy(&reopened, "S4").origin.z - 45.).abs() < 1e-9);
}
+21 -2
View File
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", patch)
.at("SURFACE", "CREATE")
.icon("patch")
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges) and body?; name?, body_name?"),
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; name?, body_name?"),
CommandSpec::new("surface.stitch", "Stitch", stitch)
.at("SURFACE", "MODIFY")
.icon("stitch")
@@ -129,7 +129,8 @@ mod tests {
solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume
}
/// A rectangle patched, trimmed by a plane and thickened; a box's faces stitched back.
/// A rectangle patched, trimmed by a plane and thickened; a box's open top patched; a rim
/// that is not planar patched.
#[test]
fn patch_trim_thicken_and_stitch() {
let mut s = Session::default();
@@ -148,6 +149,24 @@ mod tests {
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cup"}));
run(&mut s, "surface.patch", json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup", "body_name": "Lid"}));
assert!(s.world_state().body("Lid").unwrap().body.is_surface());
// A rim that is not planar: a box with its top cut away by a sideways cylinder.
run(&mut s, "solid.box", json!({"length": 28, "width": 28, "height": 20, "corner": [-14, -14, 0], "body_name": "Saddle"}));
run(
&mut s,
"solid.cylinder",
json!({"radius": 40, "height": 120, "base": [0, -60, 50], "axis": "Y", "operation": "cut", "targets": ["Saddle"]}),
);
let z = |x: f64| 50.0 - (1600.0 - x * x).sqrt();
let rim = json!([[14, 0, z(14.0)], [0, 14, z(0.0)], [-14, 0, z(-14.0)], [0, -14, z(0.0)]]);
run(&mut s, "surface.patch", json!({"edges": rim, "body": "Saddle", "body_name": "Cover"}));
let st = s.world_state();
let cover = &st.body("Cover").unwrap().body;
assert!(cover.is_surface() && cover.face_count() == 1);
// The cover is a smooth surface on the rim, close to the cylinder that cut it (its area
// within 1% of the cylinder's inside the rim).
let area = solidcraft_kernel::measure(cover).unwrap().area;
let want = 28.0 * 40.0 * 2.0 * (14.0f64 / 40.0).asin();
assert!((area - want).abs() < want * 1e-2, "{area} vs {want}");
assert!(s.execute("surface.thicken", &json!({"bodies": ["Nope"], "thickness": 1})).is_err());
assert!(s.execute("surface.trim", &json!({"body": "Sheet"})).is_err());
}
+37 -9
View File
@@ -331,6 +331,38 @@ fn revolve_and_primitives() {
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), PI * 125.0 * 5.0 / 2.0) < 5e-4);
}
/// A spherical shell (a half ring between two arcs, both radial lines on the axis) revolved on a
/// sketch plane that does not pass through the origin: the body is the shell, as it is on XY.
/// Before, built in place, it measured a third too large (truck tessellates the degenerate faces
/// on the axis reliably only with the axis through the origin).
#[test]
fn revolve_a_spherical_shell_off_the_origin() {
for (plane, z) in [(json!("XY"), 0.0), (json!({"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}), 10.0)] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": plane, "name": "Ring"}));
run(&mut s, "sketch.line", json!({"points": [[-40, 0], [40, 0]]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [40, 0], "end": [-40, 0]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [18.5, 0], "end": [-18.5, 0]}));
run(&mut s, "sketch.finish", json!({}));
// The half ring: the larger of the two regions above the axis.
let si = run(&mut s, "sketch.inspect", json!({"sketch": "Ring"}));
let ring = si["profiles"]
.as_array()
.unwrap()
.iter()
.max_by(|a, b| a["area_mm2"].as_f64().unwrap().total_cmp(&b["area_mm2"].as_f64().unwrap()))
.unwrap()["index"]
.clone();
run(&mut s, "solid.revolve", json!({"sketch": "Ring", "profiles": [ring], "axis": "l1", "body_name": "Shell"}));
let m = run(&mut s, "inspect.measure", json!({"bodies": ["Shell"]}));
let want = 4.0 / 3.0 * PI * (40.0f64.powi(3) - 18.5f64.powi(3));
let v = m["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(v, want) < 5e-4, "plane at z {z}: {v} vs {want}");
let b = &m["bodies"][0]["bbox"];
assert!((b["min"][2].as_f64().unwrap() - (z - 40.0)).abs() < 1e-6 && (b["max"][2].as_f64().unwrap() - (z + 40.0)).abs() < 1e-6, "{b}");
}
}
#[test]
fn sketch_tools_and_constraints() {
let mut s = Session::default();
@@ -1882,7 +1914,8 @@ mod naming {
let mut legacy = Session::new(solidcraft_doc::Document::from_json(&old.to_json()).unwrap());
assert_eq!(attached(&legacy), Some(10.));
assert!((top(&mut legacy) - 15.).abs() < 1e-6);
// The upstream join consumes the certified body; new attachments cannot follow its substitute roof.
// An upstream join that trims the certified face: the attachment stays on what is left
// of it and never follows the substitute roof (see face_tests for a face covered whole).
for target in [&mut s, &mut legacy] {
run(target, "timeline.roll_to", json!({"feature": "Block"}));
run(target, "sketch.create", json!({"plane": "XY", "name": "B"}));
@@ -1903,19 +1936,14 @@ mod naming {
};
assert!(anchor.is_none(), "opening old face references must not infer a strict attachment");
let failed = |s: &Session, name: &str| s.model.results.iter().find(|r| r.id == s.doc.find_feature(name).unwrap().id).unwrap().error.is_some();
assert!(failed(&s, "Top"));
assert!(failed(&s, "Peg"), "lost sketch cannot supply downstream geometry");
assert_eq!(attached(&s), None);
assert!(!failed(&s, "Top"));
assert!(!failed(&s, "Peg"));
assert_eq!(attached(&s), Some(10.), "the strip of Block's top left beside the boss is still that face");
assert!((top(&mut s) - 20.0).abs() < 1e-6, "{}", top(&mut s));
run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true}));
assert!(!failed(&s, "Top"));
assert!(!failed(&s, "Peg"));
assert_eq!(attached(&s), Some(10.));
assert!((top(&mut s) - 15.).abs() < 1e-6);
run(&mut s, "edit.undo", json!({}));
assert!(failed(&s, "Top"));
assert!(failed(&s, "Peg"));
assert_eq!(attached(&s), None);
}
#[test]
+111 -67
View File
@@ -154,6 +154,18 @@ pub fn check_knots(knots: &[f64]) -> SplineResult<()> {
/// A knot vector shorter than two knots gives zeros.
pub fn local_basis(knots: &[f64], u: f64, side: Side, n: usize) -> Vec<f64> {
let mut out = vec![0.0; n + 1];
let ders = local_basis_array(knots, u, side, n);
for (o, d) in out.iter_mut().zip(ders) {
*o = d;
}
out
}
/// [`local_basis`] into a fixed array (no allocation): element `k` is the `k`-th derivative,
/// for `k ≤ min(n, MAX_SPLINE_DEGREE)`, the rest zero.
pub(crate) fn local_basis_array(knots: &[f64], u: f64, side: Side, n: usize) -> [f64; MAX_SPLINE_DEGREE + 1] {
const W: usize = MAX_SPLINE_DEGREE + 1;
let mut out = [0.0; W];
let Some(p) = knots.len().checked_sub(2) else { return out };
if p > MAX_SPLINE_DEGREE || !u.is_finite() {
return out;
@@ -163,7 +175,6 @@ pub fn local_basis(knots: &[f64], u: f64, side: Side, n: usize) -> Vec<f64> {
// table[k][j]: k-th derivative of N_{j,q} for the current degree q (j = 0..=p−q), updated
// in place: k descending (level k reads level k−1 of the previous degree), j ascending
// (N_{j,q} reads N_{j+1,q−1}, not yet overwritten). Derivatives above p are zero.
const W: usize = MAX_SPLINE_DEGREE + 1;
let levels = n.min(p);
let mut table = [[0.0f64; W]; W];
for (j, slot) in table[0].iter_mut().enumerate().take(p + 1) {
@@ -286,15 +297,16 @@ pub fn find_span(knots: &[f64], p: usize, n: usize, u: f64) -> Option<usize> {
if u < lo || u > hi {
return None;
}
// The last non-empty span in [p, n−1] that starts at or before u.
let mut k = p;
for i in p..n {
if let (Some(&a), Some(&b)) = (knots.get(i), knots.get(i + 1))
&& a <= u
&& a < b
{
k = i;
}
// The last non-empty span in [p, n−1] that starts at or before u: by bisection (the knots
// are sorted), and at the domain end the last non-empty span.
let mut k = if u >= hi {
n - 1
} else {
let below = knots.get(p..n)?.partition_point(|&x| x <= u);
p + below.checked_sub(1)?
};
while k > p && !(knots.get(k)? < knots.get(k + 1)?) {
k -= 1;
}
Some(k)
}
@@ -331,16 +343,62 @@ pub fn insert_knot(p: usize, knots: &[f64], ctrl: &[HPoint], u: f64) -> SplineRe
Ok((nk, q))
}
/// The binomial coefficient `(n k)` (zero for `k > n`).
pub(crate) fn binomial(n: usize, k: usize) -> f64 {
if k > n {
return 0.0;
}
(0..k).fold(1.0, |c, m| c * (n - m) as f64 / (m + 1) as f64)
}
/// Bernstein polynomial `Bᵢ,ₚ(x)`.
pub fn bernstein(p: usize, i: usize, x: f64) -> f64 {
if i > p {
return 0.0;
}
let mut c = 1.0;
for m in 0..i {
c = c * (p - m) as f64 / (m + 1) as f64;
binomial(p, i) * x.powi(i as i32) * (1.0 - x).powi((p - i) as i32)
}
/// One row of a B-spline (degree `p`, knots, homogeneous control points) clamped on its domain
/// `[knots[p], knots[n]]`: each domain end inserted until it repeats `p` times (§5.2), then the
/// basis functions that vanish on the domain dropped (a basis function's last span does not
/// depend on its first knot, so that knot moves to the domain end). The curve on the domain is
/// unchanged.
pub(crate) fn clamp_row(p: usize, knots: &[f64], row: &[HPoint]) -> SplineResult<(Vec<f64>, Vec<HPoint>)> {
let n = row.len();
let (Some(&a), Some(&b)) = (knots.get(p), knots.get(n)) else { return invalid("too few knots") };
let (mut knots, mut row) = (knots.to_vec(), row.to_vec());
for (at_end, x) in [(false, a), (true, b)] {
let mult = |k: &[f64]| k.iter().filter(|&&y| y == x).count();
let mut guard = 0;
while mult(&knots) < p {
guard += 1;
if guard > MAX_SPLINE_DEGREE + 1 {
return invalid("clamping did not converge");
}
(knots, row) = insert_knot(p, &knots, &row, x)?;
}
let n = row.len();
let (first, last) = if at_end {
// Functions i with i ≥ (first index of x) start at or past the end.
(0, knots.iter().position(|&y| y == x).unwrap_or(n).min(n))
} else {
// Functions i with i + p + 1 ≤ (last index of x) end at or before the start.
(knots.iter().rposition(|&y| y == x).unwrap_or(p).saturating_sub(p), n)
};
if last <= first + p {
return invalid("too few control points remain after clamping");
}
let mut nk: Vec<f64> = knots.get(first..last + p + 1).map(<[f64]>::to_vec).unwrap_or_default();
if at_end {
nk.last_mut().into_iter().for_each(|y| *y = x);
} else {
nk.first_mut().into_iter().for_each(|y| *y = x);
}
row = row.get(first..last).map(<[HPoint]>::to_vec).unwrap_or_default();
knots = nk;
}
c * x.powi(i as i32) * (1.0 - x).powi((p - i) as i32)
Ok((knots, row))
}
/// A Bézier curve's point at `x ∈ [0, 1]` (de Casteljau, NURBS Book §1.4).
@@ -543,66 +601,37 @@ impl NurbsSurface {
}
/// The same surface on the same domain with clamped knot vectors (`p+1` equal knots at
/// each end, so the boundary curves are given by the boundary rows of control points):
/// insert each domain end until it has multiplicity `p`, then drop the basis functions
/// that vanish on the domain (NURBS Book §5.2 knot insertion; a basis function's last span
/// does not depend on its first knot, so that knot can be moved to the domain end).
/// each end, so the boundary curves are given by the boundary rows of control points): each
/// row of control points clamped in turn ([`clamp_row`]).
pub fn clamped(&self) -> SplineResult<NurbsSurface> {
let mut s = self.clone();
for dir in [Dir::S, Dir::T] {
let d = dir.pick(0, 1);
for d in 0..2 {
let p = s.degree[d];
for end in 0..2 {
let a = s.domain()[d][end];
while s.knots[d].iter().filter(|&&x| x == a).count() < p {
s = s.insert_knot(dir, a)?;
}
s = s.trim_end(d, end, a)?;
let [nu, nv] = s.count;
let (lines, len) = if d == 0 { (nv, nu) } else { (nu, nv) };
let mut knots = Vec::new();
let mut rows = Vec::with_capacity(lines);
for line in 0..lines {
let row: Vec<HPoint> = (0..len).map(|k| if d == 0 { s.point(k, line) } else { s.point(line, k) }).collect();
let (k, r) = clamp_row(p, &s.knots[d], &row)?;
knots = k;
rows.push(r);
}
let new_len = rows.first().map_or(0, Vec::len);
let mut ctrl = Vec::with_capacity(lines * new_len);
if d == 0 {
rows.iter().for_each(|r| ctrl.extend_from_slice(r));
} else {
for j in 0..new_len {
ctrl.extend(rows.iter().map(|r| r.get(j).copied().unwrap_or(HPoint::ZERO)));
}
}
let mut all = s.knots.clone();
all[d] = knots;
s = NurbsSurface::from_homogeneous(s.degree, all, ctrl)?;
}
Ok(s)
}
/// Drop the basis functions that vanish on the domain past the knot `a` (multiplicity ≥ p)
/// at the start (`end == 0`) or the end of direction `d`.
fn trim_end(&self, d: usize, end: usize, a: f64) -> SplineResult<NurbsSurface> {
let p = self.degree[d];
let kn = &self.knots[d];
let n = self.count[d];
let (first, last) = match end {
0 => {
let last_a = kn.iter().rposition(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
// Functions i with i + p + 1 ≤ last_a vanish on [a, …).
(last_a.saturating_sub(p), n)
}
_ => {
let first_a = kn.iter().position(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
// Functions i with i ≥ first_a vanish on (…, a].
(0, first_a.min(n))
}
};
if last <= first + p {
return invalid("clamp: too few control points remain");
}
let mut knots = self.knots.clone();
let mut nk: Vec<f64> = kn.get(first..last + p + 1).map(<[f64]>::to_vec).unwrap_or_default();
match end {
0 => nk.first_mut().into_iter().for_each(|x| *x = a),
_ => nk.last_mut().into_iter().for_each(|x| *x = a),
}
knots[d] = nk;
let [nu, nv] = self.count;
let mut ctrl = Vec::new();
for j in 0..nv {
for i in 0..nu {
let k = if d == 0 { i } else { j };
if k >= first && k < last {
ctrl.push(self.point(i, j));
}
}
}
NurbsSurface::from_homogeneous(self.degree, knots, ctrl)
}
}
#[cfg(test)]
@@ -666,6 +695,21 @@ pub(crate) mod tests {
assert!(worst < 1e-5, "derivative error {worst}");
}
#[test]
fn span_search_finds_the_last_non_empty_span_at_or_before_u() {
let p = 3;
let knots = [0.0, 0.0, 0.0, 0.0, 0.3, 0.3, 0.5, 0.9, 0.9, 0.9, 1.0, 1.0, 1.0, 1.0];
let n = knots.len() - p - 1;
for k in 0..=1000 {
let u = k as f64 / 1000.0;
// The plain search: the last span [knots[i], knots[i+1]) with knots[i] ≤ u.
let want = (p..n).filter(|&i| knots[i] <= u && knots[i] < knots[i + 1]).max().unwrap();
assert_eq!(find_span(&knots, p, n, u), Some(want), "u = {u}");
}
assert_eq!(find_span(&knots, p, n, 1.5), None);
assert_eq!(find_span(&knots, p, n, f64::NAN), None);
}
#[test]
fn single_function_knot_insertion_is_exact() {
let mut rng = Lcg(7);
+742
View File
@@ -0,0 +1,742 @@
//! Filling a closed loop of curves with one surface: a non-rational bicubic B-spline height
//! field over the loop's best-fit plane, fitted to the loop by least squares with a smoothness
//! term. A loop that cannot be seen as the boundary of a height field over that plane (it
//! crosses itself, folds back or runs along the plane's normal when seen along that normal) is
//! refused, never filled with wrong geometry.
//!
//! References: Piegl & Tiller, *The NURBS Book* (2nd ed.) — §9.4.1 and §9.4.3 (least squares
//! approximation: minimise the sum of squared residuals, eq. 9.62, through the normal
//! equations, eq. 9.65), §3.4 (tensor product B-spline surfaces), §3.3 (derivative of a
//! B-spline curve, eq. 3.4: control values at the knot averages reproduce a linear function),
//! A2.1 (span search).
use crate::Vec3;
use crate::bspline::{HPoint, NurbsSurface, Side, SplineError, SplineResult, find_span, invalid, local_basis_array};
/// Most loop points a fill takes.
pub const MAX_LOOP_POINTS: usize = 8192;
/// Most spans of the height field in each direction.
pub const MAX_FILL_SPANS: usize = 32;
/// Fewest spans of the height field in each direction (the first fit tried).
const MIN_FILL_SPANS: usize = 4;
/// The smoothness energy's terms: `(order in u, order in v, weight)` of `∫∫ (∂ h)²`.
const ENERGY: [(usize, usize, f64); 3] = [(2, 0, 1.0), (1, 1, 2.0), (0, 2, 1.0)];
/// Four-point Gauss–Legendre nodes and weights on `[−1, 1]` (exact to degree 7).
const GAUSS4: [(f64, f64); 4] = [
(-0.861_136_311_594_052_6, 0.347_854_845_137_453_8),
(-0.339_981_043_584_856_3, 0.652_145_154_862_546_2),
(0.339_981_043_584_856_3, 0.652_145_154_862_546_2),
(0.861_136_311_594_052_6, 0.347_854_845_137_453_8),
];
/// The smoothness term's weight per fitted point (see [`fill_loop`]).
const SMOOTHNESS: f64 = 1e-7;
/// A loop step shorter than this fraction of its length in space, seen along the normal, runs
/// along the normal (steeper than about 87° from the plane).
const MIN_STEP_SLOPE: f64 = 0.05;
/// Consecutive steps closer to reversed than this cosine fold the loop back on itself.
const FOLD_COS: f64 = -0.999;
/// A loop's fill: the surface (`S(u, v) = o + u·x + v·y + h(u, v)·n` over the plane with normal
/// `n`, so `∂S/∂u × ∂S/∂v` points along `n`), the plane's frame, whether the loop runs counter-
/// clockwise about it, the largest distance from a sampled loop point to the surface along `n`,
/// and how many points of the loop were evaluated.
#[derive(Clone, Debug, PartialEq)]
pub struct LoopFill {
pub surface: NurbsSurface,
/// The plane's origin and its axes `x`, `y` (the surface's parameter directions).
pub origin: Vec3,
pub axes: [Vec3; 2],
pub normal: Vec3,
pub counter_clockwise: bool,
pub deviation: f64,
pub evaluations: usize,
}
/// Fill the closed loop of `sides` curves (side `k` given by `point(k, t)`, `t` from 0 at its
/// start to 1 at its end, which is where side `k+1` starts) with one surface passing within
/// `tol` of the loop.
///
/// - Sampling: each side at `t = i/s` (fitted) and halfway between (checked), `s` from 16
/// doubling while the fit needs denser points, at most [`MAX_LOOP_POINTS`] points in all. The
/// sample parameters are the same run either way, so the fill does not depend on where the
/// loop starts or which way it runs.
/// - The plane: through the fitted points' centroid, normal by Newell's method, turned so that
/// its largest component is positive; its first axis points to the fitted point farthest from
/// the centroid in the plane (the highest of points as far). So the plane and its axes, and with them the grid and the fill,
/// move with the loop (a reflected axis gives the same grid).
/// - Refused ("not supported yet"): a loop that, seen along the normal, has a step much shorter
/// than in space (it runs along the normal), turns straight back, or crosses or touches
/// itself; such a loop does not bound a height field over the plane.
/// - The height field: a bicubic B-spline with uniform knots over the projected loop's box
/// (widened by 1%); its control points sit at the knot averages in the plane, so its surface
/// parameters are plane coordinates, and their heights are the unknowns. The heights minimise
/// `Σ (h(uₖ, vₖ) − hₖ)² + λ·A · ∫∫ h_uu² + 2·h_uv² + h_vv²` over the fitted points — least
/// squares (eq. 9.62, normal equations eq. 9.65) plus the thin-plate bending energy over the
/// box, `A` the area of one grid cell (so each cell's energy counts like a squared second
/// difference of heights, and the balance is the same at any size). The energy decides the
/// inside, where there are no points; it is zero only for planes, so a plane through the
/// loop is reproduced exactly, and any other rim gets the least-bending surface on it, which
/// is free to turn at the rim (it is not the surface that made the rim). It does not depend
/// on the grid's directions. `λ = 10⁻⁷ ·` (fitted points) `/` (control points): just enough
/// to fix the inside, small enough not to pull the surface off the loop.
/// - Grids of 4, 8, 16, 32 spans across the box's longer side are tried, with as many across
/// the shorter side as keep the cells about square; a grid whose cells are under twice the
/// fitted points' largest step in a direction (cells between points would be left to the
/// energy alone) waits for denser sampling. The first grid within `tol` of every sampled
/// point (measured along the normal, which bounds the distance) is the fill; otherwise the
/// sampling doubles, and when that runs out, "not supported yet" with the closest deviation
/// reached.
pub fn fill_loop(sides: usize, point: &mut dyn FnMut(usize, f64) -> Option<Vec3>, tol: f64) -> SplineResult<LoopFill> {
if sides == 0 || sides.saturating_mul(16) > MAX_LOOP_POINTS / 2 {
return Err(SplineError::TooLarge(format!("a loop of {sides} sides (at most {})", MAX_LOOP_POINTS / 32)));
}
if !(tol.is_finite() && tol > 0.0) {
return invalid("the fill tolerance must be positive");
}
let mut evaluations = 0usize;
let mut eval = |k: usize, t: f64| -> SplineResult<Vec3> {
evaluations += 1;
point(k, t).filter(|p| p.is_finite()).ok_or_else(|| SplineError::Invalid(format!("side {k} has no finite point at {t}")))
};
let mut per = 16usize;
// Fitted points: side by side, per side at i/per (i < per).
let mut fit: Vec<Vec3> = Vec::with_capacity(sides * per);
for k in 0..sides {
for i in 0..per {
fit.push(eval(k, i as f64 / per as f64)?);
}
}
let mut deviation = f64::INFINITY;
loop {
let mut check = Vec::with_capacity(fit.len());
for k in 0..sides {
for i in 0..per {
check.push(eval(k, (i as f64 + 0.5) / per as f64)?);
}
}
// The loop in order: fitted and checked points alternating.
let all: Vec<Vec3> = fit.iter().zip(&check).flat_map(|(a, b)| [*a, *b]).collect();
match fit_once(&fit, &all, tol)? {
Fit::Done(mut fill) => {
fill.evaluations = evaluations;
return Ok(*fill);
}
Fit::Denser(dev) => deviation = deviation.min(dev),
Fit::Exhausted(dev) => {
deviation = deviation.min(dev);
break;
}
}
if all.len() * 2 > MAX_LOOP_POINTS {
break;
}
fit = all;
per *= 2;
}
Err(SplineError::Unsupported(format!("filling a loop this wavy with one surface (it stays {deviation:e} off the loop, tolerance {tol:e})")))
}
/// What one round of [`fill_loop`] gave.
enum Fit {
Done(Box<LoopFill>),
/// Within tolerance only with cells finer than the points allow: sample denser.
Denser(f64),
/// The finest grid tried is not within tolerance.
Exhausted(f64),
}
/// One round of [`fill_loop`]: fit the points `fit`, check every point of `all` (the loop in
/// order, `fit` among them).
fn fit_once(fit: &[Vec3], all: &[Vec3], tol: f64) -> SplineResult<Fit> {
let m = fit.len();
let o = fit.iter().fold(Vec3::ZERO, |a, p| a + *p) / m as f64;
let at = |k: usize| fit.get(k % m).copied().unwrap_or(Vec3::ZERO);
let area = (0..m).fold(Vec3::ZERO, |a, k| a + (at(k) - o).cross(at(k + 1) - o));
let Some(normal) = area.normalized() else { return invalid("the loop encloses no area") };
let largest = [normal.x, normal.y, normal.z].into_iter().fold(0.0f64, |a, c| if c.abs() > a.abs() { c } else { a });
let counter_clockwise = largest > 0.0;
let n = if counter_clockwise { normal } else { normal * -1.0 };
// The first axis towards the fitted point farthest from the centroid in the plane: the axes,
// the grid and so the fill move with the loop.
let flat = |p: &Vec3| {
let d = *p - o;
d - n * d.dot(n)
};
// Points as far as each other (within rounding) are told apart by their height, and points
// alike in both by their coordinates: the same point is chosen however the loop is given,
// and as the loop moves, but for ties its symmetry makes.
let key = |p: &Vec3| (flat(p).len(), (*p - o).dot(n));
let reach = fit.iter().map(|p| key(p).0).fold(0.0, f64::max);
let near = 1e-9 * reach;
let far = fit.iter().fold(None::<&Vec3>, |best, p| match best {
None => Some(p),
Some(b) => {
let ((db, hb), (dp, hp)) = (key(b), key(p));
let better = if (dp - db).abs() > near {
dp > db
} else if (hp - hb).abs() > near {
hp > hb
} else {
(p.x, p.y, p.z) > (b.x, b.y, b.z)
};
Some(if better { p } else { b })
}
});
let far = far.map(flat).unwrap_or(Vec3::ZERO);
let x = far.normalized().ok_or_else(|| SplineError::Invalid("the loop has no extent in its plane".into()))?;
let y = n.cross(x);
let project = |pts: &[Vec3]| -> (Vec<[f64; 2]>, Vec<f64>) {
(pts.iter().map(|p| [(*p - o).dot(x), (*p - o).dot(y)]).collect(), pts.iter().map(|p| (*p - o).dot(n)).collect())
};
let (uv, h) = project(all);
check_projectable(all, &uv)?;
let (fuv, fh) = project(fit);
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
for q in &uv {
for d in 0..2 {
lo[d] = lo[d].min(q[d]);
hi[d] = hi[d].max(q[d]);
}
}
for d in 0..2 {
let pad = 0.01 * (hi[d] - lo[d]);
lo[d] -= pad;
hi[d] += pad;
}
if !(lo[0] < hi[0] && lo[1] < hi[1] && (hi[0] - lo[0]).is_finite() && (hi[1] - lo[1]).is_finite()) {
return invalid("the loop's extent is not finite");
}
// Spans per direction: `level` across the longer side of the box, as many across the
// shorter as keep the cells about square (at least one). A grid finer than the fitted
// points can hold in a direction (cells under twice their largest step along it) is tried
// only after denser sampling; the coarsest is always tried.
let extent = [hi[0] - lo[0], hi[1] - lo[1]];
let mut step = [0.0f64; 2];
for k in 0..m {
let (p, q) = (fuv.get(k).copied().unwrap_or([0.0; 2]), fuv.get((k + 1) % m).copied().unwrap_or([0.0; 2]));
for d in 0..2 {
step[d] = step[d].max((q[d] - p[d]).abs());
}
}
let longer = extent[0].max(extent[1]);
let mut deviation = f64::INFINITY;
let mut level = MIN_FILL_SPANS;
while level <= MAX_FILL_SPANS {
let spans = [0, 1].map(|d| ((extent[d] / longer * level as f64).ceil() as usize).clamp(1, level));
let holds = (0..2).all(|d| extent[d] / spans[d] as f64 >= 2.0 * step[d]);
if !holds && level > MIN_FILL_SPANS {
return Ok(Fit::Denser(deviation));
}
let grid = Grid::new(spans, lo, hi);
let heights = grid.fit(&fuv, &fh)?;
let dev = uv.iter().zip(&h).map(|(q, hk)| (grid.height(&heights, *q) - hk).abs()).fold(0.0, f64::max);
if !dev.is_finite() {
return invalid("the fit is not finite");
}
deviation = deviation.min(dev);
if dev <= tol && holds {
let surface = grid.surface(&heights, o, x, y, n)?;
let fill = LoopFill { surface, origin: o, axes: [x, y], normal: n, counter_clockwise, deviation: dev, evaluations: 0 };
return Ok(Fit::Done(Box::new(fill)));
}
if !holds {
return Ok(Fit::Denser(deviation));
}
level *= 2;
}
Ok(Fit::Exhausted(deviation))
}
/// Refuse a loop that does not bound a height field over its plane (see [`fill_loop`]).
fn check_projectable(points: &[Vec3], uv: &[[f64; 2]]) -> SplineResult<()> {
let m = uv.len();
let q = |k: usize| uv.get(k % m).copied().unwrap_or([0.0; 2]);
let p = |k: usize| points.get(k % m).copied().unwrap_or(Vec3::ZERO);
let step = |k: usize| {
let (a, b) = (q(k), q(k + 1));
[b[0] - a[0], b[1] - a[1]]
};
for k in 0..m {
let d = step(k);
let flat = d[0].hypot(d[1]);
let space = p(k).dist(p(k + 1));
if space > 0.0 && flat < MIN_STEP_SLOPE * space {
return Err(SplineError::Unsupported("filling a loop that runs along its plane's normal".into()));
}
let e = step(k + 1);
let fe = e[0].hypot(e[1]);
if flat > 0.0 && fe > 0.0 && (d[0] * e[0] + d[1] * e[1]) < FOLD_COS * flat * fe {
return Err(SplineError::Unsupported("filling a loop that folds back on itself".into()));
}
}
// Non-adjacent segments must not meet: a sweep over the segments' u extents.
let mut segs: Vec<(f64, f64, usize)> = (0..m)
.map(|k| {
let (a, b) = (q(k), q(k + 1));
(a[0].min(b[0]), a[0].max(b[0]), k)
})
.collect();
segs.sort_by(|a, b| a.0.total_cmp(&b.0));
for (i, &(_, hi, k)) in segs.iter().enumerate() {
for &(lo2, _, l) in segs.get(i + 1..).into_iter().flatten() {
if lo2 > hi {
break;
}
let adjacent = (k + 1) % m == l || (l + 1) % m == k;
if !adjacent && segments_meet(q(k), q(k + 1), q(l), q(l + 1)) {
return Err(SplineError::Unsupported("filling a loop that crosses itself seen along its plane's normal".into()));
}
}
}
Ok(())
}
/// Whether the closed segments `ab` and `cd` share a point.
fn segments_meet(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
let orient = |p: [f64; 2], q: [f64; 2], r: [f64; 2]| (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
let within =
|p: [f64; 2], q: [f64; 2], r: [f64; 2]| r[0] >= p[0].min(q[0]) && r[0] <= p[0].max(q[0]) && r[1] >= p[1].min(q[1]) && r[1] <= p[1].max(q[1]);
let (d1, d2, d3, d4) = (orient(c, d, a), orient(c, d, b), orient(a, b, c), orient(a, b, d));
if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) {
return true;
}
(d1 == 0.0 && within(c, d, a)) || (d2 == 0.0 && within(c, d, b)) || (d3 == 0.0 && within(a, b, c)) || (d4 == 0.0 && within(a, b, d))
}
/// A bicubic height field's grid: uniform clamped knots over `[lo, hi]` in each direction,
/// `count` control points along each.
struct Grid {
knots: [Vec<f64>; 2],
count: [usize; 2],
}
impl LoopFill {
/// The surface's point over (or under) `p`, along the normal.
pub fn point_over(&self, p: Vec3) -> SplineResult<Vec3> {
let d = p - self.origin;
let [x, y] = self.axes;
self.surface.eval(d.dot(x), d.dot(y))
}
}
impl Grid {
fn new(spans: [usize; 2], lo: [f64; 2], hi: [f64; 2]) -> Grid {
let knots = [0, 1].map(|d| {
let (a, b, s) = (lo[d], hi[d], spans[d]);
let mut k = vec![a; 4];
k.extend((1..s).map(|i| a + (b - a) * i as f64 / s as f64));
k.extend([b; 4]);
k
});
Grid { knots, count: spans.map(|s| s + 3) }
}
/// The length of one span in direction `d`.
fn cell(&self, d: usize) -> f64 {
let (Some(k), Some(&c)) = (self.knots.get(d), self.count.get(d)) else { return 0.0 };
(k.last().copied().unwrap_or(0.0) - k.first().copied().unwrap_or(0.0)) / (c - 3) as f64
}
/// The knot average `(kᵢ₊₁ + kᵢ₊₂ + kᵢ₊₃)/3` of control point `i` in direction `d`.
fn average(&self, d: usize, i: usize) -> f64 {
self.knots.get(d).map_or(0.0, |k| (1..=3).map(|t| k.get(i + t).copied().unwrap_or(0.0)).sum::<f64>() / 3.0)
}
/// The four non-zero basis functions' `r`-th derivatives and the first index, at `x` in
/// direction `d`.
fn basis_derivative(&self, d: usize, x: f64, r: usize) -> Option<(usize, [f64; 4])> {
let kn = self.knots.get(d)?;
let x = x.clamp(*kn.first()?, *kn.last()?);
let span = find_span(kn, 3, *self.count.get(d)?, x)?;
let side = if x >= *kn.last()? { Side::Left } else { Side::Right };
let first = span.checked_sub(3)?;
let mut out = [0.0; 4];
for (a, o) in out.iter_mut().enumerate() {
*o = kn.get(first + a..first + a + 5).map_or(0.0, |l| local_basis_array(l, x, side, r).get(r).copied().unwrap_or(0.0));
}
Some((first, out))
}
/// The height at `q` of the net `heights` (i fastest).
fn height(&self, heights: &[f64], q: [f64; 2]) -> f64 {
let (Some((i0, bu)), Some((j0, bv))) = (self.basis_derivative(0, q[0], 0), self.basis_derivative(1, q[1], 0)) else { return f64::NAN };
let mut s = 0.0;
for (b, wv) in bv.iter().enumerate() {
for (a, wu) in bu.iter().enumerate() {
s += wu * wv * heights.get((j0 + b) * self.count[0] + i0 + a).copied().unwrap_or(f64::NAN);
}
}
s
}
/// The control heights fitting the points (see [`fill_loop`]): the normal equations, banded
/// (unknowns i fastest, so coupled unknowns are at most `3·count[0] + 3` apart), solved by
/// Cholesky factorisation.
fn fit(&self, uv: &[[f64; 2]], h: &[f64]) -> SplineResult<Vec<f64>> {
let [cu, cv] = self.count;
let n = cu * cv;
let mut band = Band::new(n, 3 * cu + 3);
let mut rhs = vec![0.0; n];
let mut fitted = 0usize;
for (q, hk) in uv.iter().zip(h) {
let (Some((i0, bu)), Some((j0, bv))) = (self.basis_derivative(0, q[0], 0), self.basis_derivative(1, q[1], 0)) else {
return invalid("a loop point is off the grid");
};
let mut terms = [(0usize, 0.0f64); 16];
for (t, slot) in terms.iter_mut().enumerate() {
let (a, b) = (t % 4, t / 4);
*slot = ((j0 + b) * cu + i0 + a, bu.get(a).copied().unwrap_or(0.0) * bv.get(b).copied().unwrap_or(0.0));
}
for &(r, wr) in &terms {
if let Some(slot) = rhs.get_mut(r) {
*slot += wr * hk;
}
for &(s, ws) in &terms {
band.add(r, s, wr * ws);
}
}
fitted += 1;
}
// The thin-plate energy ∫∫ h_uu² + 2·h_uv² + h_vv² over the grid's box, times the area of
// one cell: per cell that is the squared second difference of the heights, so it weighs
// against the data term (squared heights) alike at any size. Each term is a product of
// one-direction Gram matrices of the basis functions' derivatives.
let lambda = SMOOTHNESS * fitted as f64 / n as f64 * self.cell(0) * self.cell(1);
let gram = |d: usize, r: usize| -> Vec<f64> {
let c = self.count.get(d).copied().unwrap_or(0);
let mut g = vec![0.0; c * c];
let Some(k) = self.knots.get(d) else { return g };
for w in k.windows(2) {
let &[a, b] = w else { continue };
if !(b > a) {
continue;
}
for (gx, gw) in GAUSS4 {
let x = 0.5 * (a + b) + 0.5 * (b - a) * gx;
let Some((first, vals)) = self.basis_derivative(d, x, r) else { continue };
for (i, vi) in vals.iter().enumerate() {
for (j, vj) in vals.iter().enumerate() {
if let Some(slot) = g.get_mut((first + i) * c + first + j) {
*slot += 0.5 * (b - a) * gw * vi * vj;
}
}
}
}
}
g
};
let grams: [[Vec<f64>; 3]; 2] = [0, 1].map(|d| [0, 1, 2].map(|r| gram(d, r)));
let g = |d: usize, r: usize, i: usize, k: usize| {
let c = self.count.get(d).copied().unwrap_or(0);
grams.get(d).and_then(|m| m.get(r)).and_then(|m| m.get(i * c + k)).copied().unwrap_or(0.0)
};
for j in 0..cv {
for i in 0..cu {
let r = j * cu + i;
for l in j.saturating_sub(3)..(j + 4).min(cv) {
for k in i.saturating_sub(3)..(i + 4).min(cu) {
let e: f64 = ENERGY.iter().map(|&(ru, rv, w)| w * g(0, ru, i, k) * g(1, rv, j, l)).sum();
band.add(r, l * cu + k, lambda * e);
}
}
}
}
band.solve(rhs)
}
/// The surface of the net `heights` over the plane `o`, `x`, `y`, `n`: control point `(i, j)`
/// at the knot averages `(ξᵢ, ηⱼ)` in the plane, `heights[j·count[0] + i]` along `n`.
fn surface(&self, heights: &[f64], o: Vec3, x: Vec3, y: Vec3, n: Vec3) -> SplineResult<NurbsSurface> {
let [cu, cv] = self.count;
let mut ctrl = Vec::with_capacity(cu * cv);
for j in 0..cv {
for i in 0..cu {
let hij = heights.get(j * cu + i).copied().unwrap_or(f64::NAN);
ctrl.push(HPoint::weighted(o + x * self.average(0, i) + y * self.average(1, j) + n * hij, 1.0));
}
}
NurbsSurface::from_homogeneous([3, 3], self.knots.clone(), ctrl)
}
}
/// A symmetric banded matrix: the upper band, row `r` holding columns `r ..= r + bw`.
struct Band {
n: usize,
bw: usize,
a: Vec<f64>,
}
impl Band {
fn new(n: usize, bw: usize) -> Band {
Band { n, bw, a: vec![0.0; n * (bw + 1)] }
}
fn index(&self, r: usize, s: usize) -> Option<usize> {
let (r, s) = if r <= s { (r, s) } else { (s, r) };
(s - r <= self.bw && s < self.n).then_some(r * (self.bw + 1) + (s - r))
}
fn get(&self, r: usize, s: usize) -> f64 {
self.index(r, s).and_then(|i| self.a.get(i)).copied().unwrap_or(0.0)
}
fn set(&mut self, r: usize, s: usize, v: f64) {
if let Some(x) = self.index(r, s).and_then(|i| self.a.get_mut(i)) {
*x = v;
}
}
/// Add `v` at `(r, s)` with `r ≤ s` (the lower half is the same by symmetry: callers adding
/// a full outer product reach each stored entry once through `r ≤ s`).
fn add(&mut self, r: usize, s: usize, v: f64) {
if r <= s
&& let Some(x) = self.index(r, s).and_then(|i| self.a.get_mut(i))
{
*x += v;
}
}
/// Solve `A·x = b` by banded Cholesky factorisation `A = Uᵀ·U`; an error when `A` is not
/// positive definite.
fn solve(mut self, mut b: Vec<f64>) -> SplineResult<Vec<f64>> {
let (n, bw) = (self.n, self.bw);
for r in 0..n {
let mut d = self.get(r, r);
for k in r.saturating_sub(bw)..r {
let u = self.get(k, r);
d -= u * u;
}
if !(d > 0.0) {
return invalid("the fit's equations are singular");
}
let d = d.sqrt();
self.set(r, r, d);
for s in r + 1..(r + bw + 1).min(n) {
let mut v = self.get(r, s);
for k in s.saturating_sub(bw)..r {
v -= self.get(k, r) * self.get(k, s);
}
self.set(r, s, v / d);
}
}
// Uᵀ·y = b, then U·x = y.
for r in 0..n {
let mut v = b.get(r).copied().unwrap_or(0.0);
for k in r.saturating_sub(bw)..r {
v -= self.get(k, r) * b.get(k).copied().unwrap_or(0.0);
}
if let Some(x) = b.get_mut(r) {
*x = v / self.get(r, r);
}
}
for r in (0..n).rev() {
let mut v = b.get(r).copied().unwrap_or(0.0);
for s in r + 1..(r + bw + 1).min(n) {
v -= self.get(r, s) * b.get(s).copied().unwrap_or(0.0);
}
if let Some(x) = b.get_mut(r) {
*x = v / self.get(r, r);
}
}
if b.iter().any(|x| !x.is_finite()) {
return invalid("the fit is not finite");
}
Ok(b)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The closed polygon through `corners` in space, side `k` from corner `k` to `k+1`.
fn polygon3(corners: &[Vec3]) -> impl FnMut(usize, f64) -> Option<Vec3> + '_ {
move |k, t| {
let n = corners.len();
Some(corners.get(k)?.lerp(*corners.get((k + 1) % n)?, t))
}
}
/// The polygon `corners` in the plane, lifted by `z(x, y)` all along.
fn lifted<'a>(corners: &'a [[f64; 2]], z: &'a dyn Fn(f64, f64) -> f64) -> impl FnMut(usize, f64) -> Option<Vec3> + 'a {
move |k, t| {
let n = corners.len();
let (a, b) = (corners.get(k)?, corners.get((k + 1) % n)?);
let (x, y) = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t);
Some(Vec3::new(x, y, z(x, y)))
}
}
fn ring(n: usize, r: f64) -> Vec<[f64; 2]> {
(0..n).map(|k| k as f64 * std::f64::consts::TAU / n as f64).map(|a| [r * a.cos(), r * a.sin()]).collect()
}
/// The farthest the surface is from `z` over the polygon's inside (sampled on a grid).
fn off_inside(f: &LoopFill, corners: &[[f64; 2]], z: &dyn Fn(f64, f64) -> f64) -> f64 {
let inside = |x: f64, y: f64| {
let n = corners.len();
let mut w = false;
for k in 0..n {
let (a, b) = (corners[k], corners[(k + 1) % n]);
if (a[1] > y) != (b[1] > y) && x < a[0] + (y - a[1]) / (b[1] - a[1]) * (b[0] - a[0]) {
w = !w;
}
}
w
};
let s = &f.surface;
let [du, dv] = s.domain();
let mut worst = 0.0f64;
for i in 0..=40 {
for j in 0..=40 {
let (u, v) = ((du[0] + (du[1] - du[0]) * i as f64 / 40.0).min(du[1]), (dv[0] + (dv[1] - dv[0]) * j as f64 / 40.0).min(dv[1]));
let p = s.eval(u, v).unwrap();
if inside(p.x, p.y) {
worst = worst.max((p.z - z(p.x, p.y)).abs());
}
}
}
worst
}
#[test]
fn planes_are_reproduced_and_rims_of_quadrics_are_filled_closely() {
// A tilted plane over a hexagon: the smoothness energy vanishes on it, so the fill is it.
let hex = ring(6, 10.0);
let plane = |x: f64, y: f64| 0.3 * x - 0.2 * y + 1.0;
let f = fill_loop(6, &mut lifted(&hex, &plane), 1e-6).unwrap();
let inside = off_inside(&f, &hex, &plane);
eprintln!("plane: deviation {:e}, inside off by {inside:e}, {} evaluations", f.deviation, f.evaluations);
assert!(f.deviation <= 1e-6 && inside < 1e-9);
// A saddle and a dome: the least-bending fill through the rim is not the quadric (only
// planes are reproduced), but stays near it (the plate is free to turn at the rim).
let saddle = |x: f64, y: f64| 0.05 * x * y;
let dome = |x: f64, y: f64| 3.0 - 0.02 * (x * x + y * y);
for (name, z, bound) in [("saddle", &saddle as &dyn Fn(f64, f64) -> f64, 0.1), ("dome", &dome, 1.0)] {
let f = fill_loop(6, &mut lifted(&hex, z), 2e-5).unwrap();
let inside = off_inside(&f, &hex, z);
eprintln!("{name}: deviation {:e}, inside off by {inside:e}", f.deviation);
assert!(f.deviation <= 2e-5 && inside < bound, "{name}: {inside}");
}
}
#[test]
fn concave_loops_fill_and_the_result_does_not_depend_on_where_the_loop_starts() {
// A dart, an arrow-head pentagon and a plus, on a cylinder.
let z = |x: f64, _: f64| 50.0 - (1600.0 - x * x).sqrt();
let dart = vec![[0.0, 0.0], [20.0, -10.0], [8.0, 0.0], [20.0, 10.0]];
let arrow = vec![[0.0, 0.0], [10.0, -8.0], [10.0, -3.0], [20.0, 0.0], [10.0, 3.0]];
let plus = vec![
[-3.0, -9.0],
[3.0, -9.0],
[3.0, -3.0],
[9.0, -3.0],
[9.0, 3.0],
[3.0, 3.0],
[3.0, 9.0],
[-3.0, 9.0],
[-3.0, 3.0],
[-9.0, 3.0],
[-9.0, -3.0],
[-3.0, -3.0],
];
let flat_corner = vec![[-10.0, -10.0], [0.0, -10.0], [10.0, -10.0], [10.0, 10.0], [-10.0, 10.0]];
for (name, c) in [("dart", dart), ("arrow", arrow), ("plus", plus), ("flat corner", flat_corner)] {
let n = c.len();
let f = fill_loop(n, &mut lifted(&c, &z), 1e-4).unwrap();
let inside = off_inside(&f, &c, &z);
eprintln!("{name}: deviation {:e}, inside off the cylinder by {inside:e}, {} evaluations", f.deviation, f.evaluations);
// The least-bending fill, not the cylinder: within a few tenths of a millimetre of it.
assert!(f.deviation <= 1e-4 && inside < 0.2, "{name}: {inside}");
// The same loop from another corner and the other way round.
let mut back = c.clone();
back.rotate_left(2);
back.reverse();
let g = fill_loop(n, &mut lifted(&back, &z), 1e-4).unwrap();
assert_ne!(f.counter_clockwise, g.counter_clockwise);
for (x, y) in [(4.0, 0.0), (6.0, 1.0), (5.0, -1.0)] {
let p = Vec3::new(x, y, z(x, y));
let gap = f.point_over(p).unwrap().dist(g.point_over(p).unwrap());
assert!(gap < 1e-7, "{name}: {gap}");
}
}
}
#[test]
fn the_fill_is_the_same_at_any_size_and_place() {
// The arrow on the cylinder, scaled by 100 and by 1/100, turned and moved: the same fill,
// scaled and moved alike.
let z = |x: f64, _: f64| 50.0 - (1600.0 - x * x).sqrt();
let arrow = [[0.0, 0.0], [10.0, -8.0], [10.0, -3.0], [20.0, 0.0], [10.0, 3.0]];
let f = fill_loop(5, &mut lifted(&arrow, &z), 1e-4).unwrap();
let axis = Vec3::new(1.0, -2.0, 0.5).normalized().unwrap();
let shift = Vec3::new(30.0, -12.0, 7.0);
for scale in [100.0, 0.01] {
let place = |p: Vec3| {
// Scaled, turned by 0.9 about `axis` (Rodrigues), moved.
let p = p * scale;
let (c, s) = (0.9f64.cos(), 0.9f64.sin());
p * c + axis.cross(p) * s + axis * (axis.dot(p) * (1.0 - c)) + shift * scale
};
let mut moved = |k: usize, t: f64| lifted(&arrow, &z)(k, t).map(place);
let g = fill_loop(5, &mut moved, 1e-4 * scale).unwrap();
let mut worst = 0.0f64;
for (x, y) in [(4.0, 0.0), (6.0, 1.0), (5.0, -1.0), (9.0, -2.0), (12.0, 0.5)] {
let p = Vec3::new(x, y, z(x, y));
worst = worst.max(g.point_over(place(p)).unwrap().dist(place(f.point_over(p).unwrap())) / scale);
}
eprintln!("arrow scaled by {scale}, turned and moved: {} evaluations, surfaces apart by {worst:e} (relative)", g.evaluations);
assert!(worst < 1e-7, "scale {scale}: {worst}");
}
}
#[test]
fn a_long_narrow_slot_fills() {
// A 200 × 1 mm slot on a cylinder of radius 300 running along it.
let z = |x: f64, _: f64| 300.0 - (90_000.0 - (x - 100.0) * (x - 100.0)).sqrt();
let slot = [[0.0, 0.0], [200.0, 0.0], [200.0, 1.0], [0.0, 1.0]];
let f = fill_loop(4, &mut lifted(&slot, &z), 2e-4).unwrap();
let inside = off_inside(&f, &slot, &z);
eprintln!("slot: deviation {:e}, inside off the cylinder by {inside:e}, {} evaluations", f.deviation, f.evaluations);
assert!(f.deviation <= 2e-4 && inside < 1e-2);
}
#[test]
fn loops_that_do_not_bound_a_height_field_are_refused() {
let unsupported = |r: SplineResult<LoopFill>| matches!(r, Err(SplineError::Unsupported(_)));
let flat = |_: f64, _: f64| 0.0;
// A figure eight (lobes of different sizes).
let eight = [[0.0, 0.0], [10.0, 10.0], [10.0, 0.0], [0.0, 6.0]];
assert!(unsupported(fill_loop(4, &mut lifted(&eight, &flat), 1e-4)));
// A loop with a vertical spike in it (up along the normal and down again).
let spike = [
Vec3::ZERO,
Vec3::new(10.0, 0.0, 0.0),
Vec3::new(10.0, 10.0, 0.0),
Vec3::new(5.0, 10.0, 0.0),
Vec3::new(5.0, 10.0, 3.0),
Vec3::new(5.0, 10.0, 0.0),
Vec3::new(0.0, 10.0, 0.0),
];
assert!(unsupported(fill_loop(7, &mut polygon3(&spike), 1e-4)));
// A loop that runs back over itself.
let back = [[0.0, 0.0], [10.0, 0.0], [4.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
assert!(unsupported(fill_loop(5, &mut lifted(&back, &flat), 1e-4)));
// A loop too wavy for one surface.
let wavy = |x: f64, y: f64| (x * 9.0).sin() * (y * 7.0).cos();
assert!(unsupported(fill_loop(64, &mut lifted(&ring(64, 10.0), &wavy), 1e-6)));
}
#[test]
fn hostile_loops_are_refused() {
let flat = |_: f64, _: f64| 0.0;
let five = ring(5, 10.0);
assert!(fill_loop(0, &mut lifted(&five, &flat), 1e-4).is_err());
assert!(fill_loop(5, &mut |_, _| Some(Vec3::X), 1e-4).is_err(), "no area");
assert!(fill_loop(5, &mut |_, _| None, 1e-4).is_err());
assert!(fill_loop(5, &mut |k, t| Some(Vec3::new(k as f64, t, f64::NAN)), 1e-4).is_err());
assert!(fill_loop(5, &mut lifted(&five, &flat), 0.0).is_err() && fill_loop(5, &mut lifted(&five, &flat), f64::NAN).is_err());
assert!(fill_loop(10_000, &mut lifted(&five, &flat), 1e-4).is_err());
let huge = |x: f64, y: f64| (x * y) * 1e300;
let _ = fill_loop(5, &mut lifted(&five, &huge), 1e-4);
let tiny: Vec<[f64; 2]> = five.iter().map(|p| [p[0] * 1e-300, p[1] * 1e-300]).collect();
let _ = fill_loop(5, &mut lifted(&tiny, &flat), 1e-4);
let big: Vec<[f64; 2]> = five.iter().map(|p| [p[0] * 1e300, p[1] * 1e300]).collect();
let _ = fill_loop(5, &mut lifted(&big, &flat), 1e-4);
}
}
+3 -2
View File
@@ -1,7 +1,7 @@
//! Geometry foundation for SolidCraft: 2D/3D vectors, sketch planes, axis-aligned boxes,
//! triangle meshes with their measures (volume, area, centroid), and freeform surface maths
//! (B-spline basis and NURBS surfaces in [`bspline`], T-splines in [`tspline`]). Units are
//! millimetres.
//! (B-spline basis and NURBS surfaces in [`bspline`], one-surface fills of closed loops in
//! [`fill`], T-splines in [`tspline`]). Units are millimetres.
//!
//! This crate has no knowledge of sketches, documents or the B-rep kernel; every other crate
//! builds on it.
@@ -10,6 +10,7 @@
pub mod bspline;
pub mod env;
pub mod fill;
mod mesh;
mod plane;
mod profile;
+136 -13
View File
@@ -1,7 +1,7 @@
//! Versioned design packages. Geometry is rebuilt from authoritative inputs; no
//! evaluated cache is trusted here. All archive access stays in memory, never on disk.
//! evaluated cache is trusted here. Entries are never extracted to filesystem paths.
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read, Write};
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
@@ -219,18 +219,25 @@ fn decode_mesh(bytes: &[u8], mesh: &mut MeshData) -> Result<()> {
validate_mesh(mesh)
}
struct PackageReader<'a> {
zip: zip::ZipArchive<Cursor<&'a [u8]>>,
struct PackageReader<R: Read + Seek> {
zip: zip::ZipArchive<R>,
blobs: BTreeMap<String, Vec<u8>>,
hydrated: usize,
}
impl<'a> PackageReader<'a> {
fn new(bytes: &'a [u8]) -> Result<Self> {
impl<R: Read + Seek> PackageReader<R> {
fn new(mut source: R) -> Result<Self> {
let length = source.seek(SeekFrom::End(0)).map_err(invalid)?;
if length > MAX_DESIGN_BYTES as u64 {
return Err(invalid("compressed file too large"));
}
let tail_length = length.min(65_557) as usize;
source.seek(SeekFrom::Start(length - tail_length as u64)).map_err(invalid)?;
let mut tail = vec![0; tail_length];
source.read_exact(&mut tail).map_err(invalid)?;
// zip's name index can coalesce duplicate central-directory entries. Our
// bounded format does not need ZIP64/multiple disks: verify the declared
// entry count as well so duplicate names cannot silently select a payload.
let tail = bytes.get(bytes.len().saturating_sub(65_557)..).ok_or_else(|| invalid("missing ZIP directory"))?;
let eocd = tail.windows(4).rposition(|v| v == b"PK\x05\x06").ok_or_else(|| invalid("missing ZIP directory"))?;
let end = tail.get(eocd..).filter(|v| v.len() >= 22).ok_or_else(|| invalid("truncated ZIP directory"))?;
let u16_at = |offset| -> Result<u16> {
@@ -241,7 +248,7 @@ impl<'a> PackageReader<'a> {
if declared > MAX_ENTRIES || u16_at(4)? != 0 || u16_at(6)? != 0 || u16_at(8)? as usize != declared || end.len() != 22 + u16_at(20)? as usize {
return Err(invalid("too many entries or unsupported ZIP directory"));
}
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(invalid)?;
let mut zip = zip::ZipArchive::new(source).map_err(invalid)?;
if u16_at(4)? != 0
|| u16_at(6)? != 0
|| u16_at(8)? as usize != zip.len()
@@ -342,11 +349,10 @@ pub fn read_design_preview(bytes: &[u8]) -> Option<DesignPreview> {
if bytes.len() > MAX_DESIGN_BYTES || !is_design_container(bytes) {
return None;
}
read_preview(bytes).ok()
DesignContainerReader::new(Cursor::new(bytes)).ok()?.preview()
}
fn read_preview(bytes: &[u8]) -> Result<DesignPreview> {
let mut reader = PackageReader::new(bytes)?;
fn read_preview<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<DesignPreview> {
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
if !matches!(container.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
return Err(invalid("unsupported container version"));
@@ -398,8 +404,38 @@ fn read_preview(bytes: &[u8]) -> Result<DesignPreview> {
Ok(preview)
}
/// A bounded container reader over an immutable, seekable source.
///
/// Opening inspects the directory, not the source blobs. `preview` reads only the
/// optional display entries and document metadata; `read_document` then resolves
/// and validates authoritative blobs. Keep the source unchanged for this reader's
/// lifetime. This type does not turn untrusted cached data into editable geometry.
pub struct DesignContainerReader<R: Read + Seek> {
reader: PackageReader<R>,
}
impl<R: Read + Seek> DesignContainerReader<R> {
/// Open a container (not plain JSON), enforcing the same budgets as byte IO.
pub fn new(source: R) -> Result<Self> {
Ok(Self { reader: PackageReader::new(source)? })
}
/// Optional display-only data. Corruption or incompatibility is a cache miss.
pub fn preview(&mut self) -> Option<DesignPreview> {
read_preview(&mut self.reader).ok()
}
/// Consume the reader, resolving every required blob before returning a document.
pub fn read_document(mut self) -> Result<Document> {
read_document(&mut self.reader)
}
}
pub(super) fn read(bytes: &[u8]) -> Result<Document> {
let mut reader = PackageReader::new(bytes)?;
DesignContainerReader::new(Cursor::new(bytes))?.read_document()
}
fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Document> {
let version: Value = serde_json::from_slice(&reader.part("container.json")?).map_err(invalid)?;
if !matches!(version.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
return Err(invalid("unsupported container version"));
@@ -450,6 +486,85 @@ mod tests {
use super::*;
use solidcraft_geom::Vec2;
struct CountingReader {
source: Cursor<Vec<u8>>,
count: std::rc::Rc<std::cell::Cell<usize>>,
}
impl Read for CountingReader {
fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
let count = self.source.read(bytes)?;
self.count.set(self.count.get() + count);
Ok(count)
}
}
impl Seek for CountingReader {
fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
self.source.seek(position)
}
}
#[test]
fn seekable_reader_rejects_oversized_source_before_reading() {
struct Oversized;
impl Read for Oversized {
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
panic!("oversized input must be rejected before any allocation or read");
}
}
impl Seek for Oversized {
fn seek(&mut self, _: SeekFrom) -> std::io::Result<u64> {
Ok(MAX_DESIGN_BYTES as u64 + 1)
}
}
assert!(DesignContainerReader::new(Oversized).is_err());
}
#[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);
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone(), components: Vec::new() }, None).unwrap();
let preview = DesignPreview { meshes: vec![mesh()] };
let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
let count = std::rc::Rc::new(std::cell::Cell::new(0));
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
// 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());
}
#[test]
fn seekable_reader_validates_required_blobs_after_preview() {
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;
let mut reader = DesignContainerReader::new(Cursor::new(bytes)).unwrap();
assert!(reader.preview().is_some());
assert!(reader.read_document().is_err());
}
#[test]
fn seekable_reader_without_preview_preserves_authoritative_document() {
let doc = document();
let bytes = write_design_container(&doc).unwrap();
let mut source = Cursor::new(bytes);
source.set_position(17); // Constructor starts from the whole source, not its cursor.
let mut reader = DesignContainerReader::new(source).unwrap();
assert!(reader.preview().is_none());
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
assert!(DesignContainerReader::new(Cursor::new(vec![0u8; 1])).is_err());
}
fn mesh() -> MeshData {
MeshData {
name: "Precise mesh".into(),
@@ -676,7 +791,7 @@ mod tests {
let mut entries = parts(&bytes);
let mut metadata: Value = serde_json::from_slice(&entries["document.json"]).unwrap();
let reference = metadata["features"][0].clone();
let mut reader = PackageReader::new(&bytes).unwrap();
let mut reader = PackageReader::new(Cursor::new(&bytes)).unwrap();
reader.hydrated = MAX_EXPANDED;
assert!(reader.blob(&reference, "step_blob", "step").unwrap_err().to_string().contains("resolved payloads too large"));
for name in ["../escape.step", "blobs/not-a-hash.step", "blobs/00.png"] {
@@ -696,6 +811,10 @@ mod tests {
std::panic::catch_unwind(|| {
let _ = crate::read_design(&bytes[..end]);
let _ = read_design_preview(&bytes[..end]);
if let Ok(mut reader) = DesignContainerReader::new(Cursor::new(&bytes[..end])) {
let _ = reader.preview();
let _ = reader.read_document();
}
})
.is_ok(),
"truncated at {end}"
@@ -713,6 +832,10 @@ mod tests {
std::panic::catch_unwind(|| {
let _ = crate::read_design(&changed);
let _ = read_design_preview(&changed);
if let Ok(mut reader) = DesignContainerReader::new(Cursor::new(&changed)) {
let _ = reader.preview();
let _ = reader.read_document();
}
})
.is_ok()
);
+2 -2
View File
@@ -10,8 +10,8 @@ mod design;
mod design_metadata;
mod dxf;
pub use design::{
DesignCompression, DesignPreview, is_design_container, read_design_preview, write_design_container, write_design_container_with_compression,
write_design_container_with_preview,
DesignCompression, DesignContainerReader, DesignPreview, is_design_container, read_design_preview, write_design_container,
write_design_container_with_compression, write_design_container_with_preview,
};
mod legacy_dir;
pub mod safe_write;
+9 -2
View File
@@ -634,8 +634,10 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
done = Some(x);
break;
}
// Reported: the first refusal, unless a later one is a size a smaller radius may
// fit (the others may be refused only until that edge is blended).
Err(e) => {
if first_err.is_none() {
if first_err.is_none() || (matches!(e, KernelError::TooLarge(_)) && !matches!(first_err, Some(KernelError::TooLarge(_)))) {
first_err = Some(e);
}
}
@@ -909,7 +911,12 @@ pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
// Corners the local blends refuse (an earlier round at an edge's end): radius per edge.
blend(body, edges, radius, Shape::Round, "fillet").or_else(|e| {
let sel: Vec<(Vec3, f64)> = edges.iter().map(|p| (*p, radius)).collect();
crate::blend_corner::fillet_radii(body, &sel).map_err(|_| e)
// The local blend's refusal, unless only the corner build found the radius too large.
crate::blend_corner::fillet_radii(body, &sel).map_err(|e2| match (&e, e2) {
(KernelError::TooLarge(_), _) => e,
(_, e2 @ KernelError::TooLarge(_)) => e2,
_ => e,
})
})
}
+3 -3
View File
@@ -20,7 +20,7 @@ use std::collections::{HashMap, HashSet};
use solidcraft_geom::Vec3;
use truck_modeling::{self as mt, builder};
use crate::blend::{plane_normal, unsupported, vtx};
use crate::blend::{plane_normal, too_large, unsupported, vtx};
use crate::body::{Body, Solid, p3, v3};
use crate::{KernelError, Result, guard};
@@ -331,7 +331,7 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
let check_extension = |q: Vec3, fi: usize| -> Result<Vec3> {
let face = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
if !crate::blend::extension_fits(face, v, q, size * 1e-7) {
return Err(unsupported("a fillet larger than the faces next to it"));
return Err(too_large("a fillet larger than the faces next to it"));
}
Ok(q)
};
@@ -465,7 +465,7 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
let (pa, pb) = (vtx(&a), vtx(&b));
let orig = vtx(e.absolute_back()) - vtx(e.absolute_front());
if (pb - pa).dot(orig) <= size * 1e-9 * orig.len() {
return Err(unsupported("a fillet larger than the faces next to it"));
return Err(too_large("a fillet larger than the faces next to it"));
}
let x = builder::line(&a, &b);
lines.insert((e.id(), f), x.clone());
+80 -10
View File
@@ -42,13 +42,54 @@ pub struct Body {
#[derive(Clone, Debug, PartialEq)]
pub enum FaceSource {
ExtrudeSide { segment: solidcraft_geom::Seg2, plane: Option<solidcraft_geom::Plane>, coalesced: bool },
CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String, plane: Option<solidcraft_geom::Plane> },
ExtrudeCap { high: bool, region: solidcraft_geom::Region2, plane: solidcraft_geom::Plane },
CertifiedExtrudeCap { producer: u64, sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>>, plane: solidcraft_geom::Plane },
Box { producer: Option<u64>, axis: u8, high: bool, plane: solidcraft_geom::Plane },
ShellPlanar { producer: Option<u64>, source: Box<FaceSource>, inner: bool, plane: solidcraft_geom::Plane },
Imported { item: u64, face: u64, payload_sha256: Option<String>, producer: Option<u64> },
ExtrudeSide {
segment: solidcraft_geom::Seg2,
plane: Option<solidcraft_geom::Plane>,
coalesced: bool,
},
CertifiedExtrudeSide {
producer: u64,
sketch: u64,
curve: String,
plane: Option<solidcraft_geom::Plane>,
},
ExtrudeCap {
high: bool,
region: solidcraft_geom::Region2,
plane: solidcraft_geom::Plane,
},
CertifiedExtrudeCap {
producer: u64,
sketch: u64,
high: bool,
outer: Vec<String>,
holes: Vec<Vec<String>>,
plane: solidcraft_geom::Plane,
},
Box {
producer: Option<u64>,
axis: u8,
high: bool,
plane: solidcraft_geom::Plane,
},
/// A cylinder primitive's cap: `high` at the far end (its `top`), else at the base.
CylinderCap {
producer: u64,
high: bool,
plane: solidcraft_geom::Plane,
},
ShellPlanar {
producer: Option<u64>,
source: Box<FaceSource>,
inner: bool,
plane: solidcraft_geom::Plane,
},
Imported {
item: u64,
face: u64,
payload_sha256: Option<String>,
producer: Option<u64>,
},
}
pub type CapSignature = (Vec<String>, Vec<Vec<String>>);
@@ -58,9 +99,11 @@ impl FaceSource {
fn plane_mut(&mut self) -> Option<&mut solidcraft_geom::Plane> {
match self {
Self::ExtrudeSide { plane, .. } | Self::CertifiedExtrudeSide { plane, .. } => plane.as_mut(),
Self::ExtrudeCap { plane, .. } | Self::CertifiedExtrudeCap { plane, .. } | Self::Box { plane, .. } | Self::ShellPlanar { plane, .. } => {
Some(plane)
}
Self::ExtrudeCap { plane, .. }
| Self::CertifiedExtrudeCap { plane, .. }
| Self::Box { plane, .. }
| Self::CylinderCap { plane, .. }
| Self::ShellPlanar { plane, .. } => Some(plane),
Self::Imported { .. } => None,
}
}
@@ -504,6 +547,7 @@ impl Body {
FaceSource::ExtrudeCap { plane, .. }
| FaceSource::CertifiedExtrudeCap { plane, .. }
| FaceSource::Box { plane, .. }
| FaceSource::CylinderCap { plane, .. }
| FaceSource::ShellPlanar { plane, .. } => Some(*plane),
FaceSource::Imported { .. } => self.planar_face(index),
}
@@ -529,6 +573,32 @@ impl Body {
.flatten()
}
/// Certify the two caps of a body built by [`crate::cylinder`]. Its wall stays uncertified.
pub fn with_cylinder_certificate(mut self, producer: u64) -> Result<Self> {
if producer == 0 {
return Err(KernelError::Invalid("invalid cylinder producer".into()));
}
let Some(sources) = &self.face_sources else {
return Err(KernelError::Failed("cylinder construction source".into()));
};
let mut sources = sources.as_ref().clone();
let mut ends = [false; 2];
for source in sources.iter_mut() {
if let Some(FaceSource::ExtrudeCap { high, plane, .. }) = source {
let end = ends.get_mut(usize::from(*high)).ok_or_else(|| KernelError::Failed("cylinder cap role".into()))?;
if std::mem::replace(end, true) {
return Err(KernelError::Failed("duplicate cylinder cap role".into()));
}
*source = Some(FaceSource::CylinderCap { producer, high: *high, plane: *plane });
}
}
if ends != [true; 2] {
return Err(KernelError::Failed("cylinder cap role count".into()));
}
self.face_sources = Some(std::sync::Arc::new(sources));
Ok(self)
}
pub fn with_box_certificate(mut self, producer: u64) -> Result<Self> {
if producer == 0 {
return Err(KernelError::Invalid("invalid box producer".into()));
+3 -1
View File
@@ -115,7 +115,9 @@ fn curved_internal_void_and_disconnected_material_keep_analytic_volume() {
}
let imported = step_import(&step_export(&[&body], "curved nested cavities").unwrap()).unwrap();
assert!(imported.warnings.is_empty(), "{:?}", imported.warnings);
let imported: Vec<_> = imported.bodies.into_iter().flat_map(|b| b.body.lumps().unwrap()).collect();
// The two pieces are written as two solids (each with its own void), so they come back as
// two bodies.
let imported: Vec<_> = imported.bodies.into_iter().map(|b| b.body).collect();
assert_eq!(imported.len(), 2);
let imported_meshes: Vec<_> = imported.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect();
for (p, want) in [(Vec3::ZERO, 0), (Vec3::X * 3.0, 1), (Vec3::X * 7.0, 0), (Vec3::X * 15.0, 1)] {
+25 -2
View File
@@ -341,7 +341,15 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
_ => nl,
}
};
let given = numbered_region(r)?.map_loops(|l| reseam(snap_loop(l)));
// Adjacent profiles revolved together leave the axis edge split where they met: one
// edge again, so the builders for a profile with an edge on the axis take it (swept
// pieces of the axis are zero-area faces that booleans fail on).
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
let axis_run = |s: &Seg2, t: &Seg2| {
let (a, b, c) = (s.start(), s.end(), t.end());
on_axis(a) && on_axis(b) && on_axis(c) && (b - a).dot(c - b) > 0.0
};
let given = numbered_region(r)?.map_loops(|l| reseam(snap_loop(l).merged_lines(axis_run)));
let r = &given.region();
// A half disc on the axis revolved fully is a sphere: build it without poles. Every
// patch is swept from the half disc's arcs.
@@ -384,9 +392,24 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
out.push(b);
continue;
}
let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?;
// Edges on the axis (a spherical shell's two radial lines, say) sweep into degenerate faces
// there, which truck only tessellates reliably with the axis through the world origin. Off
// it, the body measured too large and booleans with it failed or removed the wrong side.
// Build such a profile there and move it into place, as `revolve_touching_axis` does.
let axis_edges = r.outer.segs.iter().filter(|s| matches!(**s, Seg2::Line { a, b } if on_axis(a) && on_axis(b))).count();
let shift = if axis_edges > 0 { o } else { Vec3::ZERO };
let (profile, f) = if shift.len() > 0.0 {
let local = Plane { origin: plane.origin - shift, ..*plane };
let profile = region_wires(&local, &given.oriented())?;
let f = attach_profile(&wires_of(&profile))?;
(profile, f)
} else {
(profile, f)
};
let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o - shift), v3(axis), mt::Rad(angle))))?;
let closed = angle.abs() >= std::f64::consts::TAU;
let history = guard("revolve history", || Ok(record::swept_face(&solid, &f, &seeds(&profile), closed)))?;
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
out.push(record::attach(Body::new(solid)?, &[], history, Default::default())?);
}
Ok(out)
+2 -2
View File
@@ -300,14 +300,14 @@ fn trim_curved_seam(seam: &mt::Edge, a: Option<mt::Vertex>, b: Option<mt::Vertex
.unwrap_or(end);
let t = curve.search_nearest_parameter(v.point(), hint, 100).ok_or_else(|| unsupported("the blend is larger than the neighbouring faces"))?;
if !(t.is_finite() && from_p3(mt::ParametricCurve::subs(&curve, t)).dist(from_p3(v.point())) <= size * 1e-5) {
return Err(unsupported("the blend is larger than the neighbouring faces"));
return Err(too_large("the blend is larger than the neighbouring faces"));
}
Ok(t)
};
let (ta, tb) = (at(&a, lo)?, at(&b, hi)?);
let margin = (hi - lo) * 1e-9;
if ta < lo - margin || tb > hi + margin || tb - ta < (hi - lo) * 1e-6 {
return Err(unsupported("the blend is larger than the neighbouring faces"));
return Err(too_large("the blend is larger than the neighbouring faces"));
}
if tb < hi {
let _ = curve.cut(tb);
+28
View File
@@ -299,3 +299,31 @@ fn closed_curved_seam_is_not_supported() {
assert!(err.to_string().contains("not supported yet: a closed curved edge"), "{err}");
}
}
#[test]
fn rolling_ball_past_the_end_seam_is_too_large_and_reduces() {
// The crossing cylinders of the test above with the small one's centre inside the large
// one: a round of 3 would reach past the end of the cap arc it trims, so it is too large
// (a smaller one fits), and the largest that fits is the analytic round over the full height
// (checked on its surface: its added volume is too small beside the body's to measure).
let (r1, r2, d, h) = (17.0, 9.5, 12.0, 10.0);
let (c1, c2) = (Vec3::ZERO, Vec3::new(d, 0.0, 0.0));
let body = boolean(&cylinder(c1, Vec3::Z, r1, h).unwrap(), &cylinder(c2, Vec3::Z, r2, h).unwrap(), BoolOp::Union).unwrap().unwrap();
let along = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
let corner = Vec3::new(along, (r1 * r1 - along * along).sqrt(), 0.0);
let at = [corner + Vec3::Z * (h / 2.0)];
let e = fillet(&body, &at, 3.0).unwrap_err();
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
let (r, blend) = crate::fillet_max(&body, &at, 3.0).unwrap();
assert!(r > 1.5 && r < 3.0, "{r}");
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
let g = gap(c1, r1, c2, r2, corner, r);
let surfaces: Vec<_> = blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
let mut worst: f64 = 0.0;
for z in [0.0, 0.25, 0.5, 0.75, 1.0] {
for p in &g.arc {
worst = worst.max(nearest_blend(&surfaces, *p + Vec3::Z * (h * z)).0);
}
}
assert!(worst < 1e-3, "blend surface is {worst} mm off the analytic round at r {r}");
}
+527
View File
@@ -0,0 +1,527 @@
//! Filling a closed loop of a body's edges that is not planar with one face: a B-spline height
//! field over the loop's best-fit plane (`solidcraft_geom::fill::fill_loop`), bounded by the
//! body's own edges, whole. Loops that do not bound a height field over a plane are refused.
use solidcraft_geom::Vec3;
use solidcraft_geom::bspline::SplineError;
use truck_modeling as mt;
use crate::body::{Body, from_p3};
use crate::plane_fit::EdgeSampler;
use crate::record::{Rebuild, rebuilt_body};
use crate::{KernelError, Result};
/// A surface body of one face filling the closed loop `chain` of `body`'s edges (each edge
/// oriented along the loop, each ending where the next starts), within [`face_tolerance`] of
/// every edge. Its history is over `[body]`: the face is new, its edges the body's own. Call it
/// under [`crate::guard`].
pub(crate) fn fill_loop(body: &Body, chain: &[mt::Edge]) -> Result<Body> {
let tol = face_tolerance(body);
let mut sides: Vec<(EdgeSampler, Vec3)> = Vec::with_capacity(chain.len());
for e in chain {
let sampler = EdgeSampler::new(e).ok_or_else(|| KernelError::Invalid("an edge has no parameter range".into()))?;
sides.push((sampler, from_p3(e.front().point())));
}
let mut point = |k: usize, t: f64| -> Option<Vec3> {
let (sampler, start) = sides.get_mut(k)?;
// The loop's corners are its vertices (an imported edge's curve may end a little off).
if t <= 0.0 { Some(*start) } else { sampler.at(t) }
};
let fill = solidcraft_geom::fill::fill_loop(chain.len(), &mut point, tol).map_err(|e| match e {
SplineError::Unsupported(m) => KernelError::Invalid(format!("not supported yet: {m}")),
other => KernelError::Invalid(format!("cannot fill the loop: {other}")),
})?;
// The wire runs counter-clockwise about the surface's normal.
let wire: mt::Wire =
if fill.counter_clockwise { chain.to_vec().into() } else { chain.iter().rev().map(mt::Edge::inverse).collect::<Vec<_>>().into() };
let face = mt::Face::try_new(vec![wire], crate::spline_conv::surface(&fill.surface)?).map_err(|e| KernelError::Failed(format!("fill: {e}")))?;
let mut rec = Rebuild::default();
rec.made(&face, Vec::new());
Ok(rebuilt_body(crate::surfaces::surface_body(vec![face]), &[&body.solid], &rec))
}
/// How far a loop's edges may be from a plane for its patch to be that plane: `10⁻⁷` of the
/// body's size, at least the linear tolerance (`10⁻⁶` mm) — the body's own "same point"
/// tolerance, since a planar face is exact and other operations treat it as containing its
/// edges to that tolerance. A fitted face gets [`face_tolerance`], ten times looser.
pub(crate) fn planarity_tolerance(body: &Body) -> f64 {
(body.size() * 1e-7).max(solidcraft_geom::LINEAR_TOL)
}
/// How far the fill's surface may be from the loop's edges: `10⁻⁶` of the body's size, at least
/// `10⁻⁵` mm — ten times [`planarity_tolerance`]. Truck meshes a face from its edges' own
/// polylines (shared with the neighbouring faces), so the mesh stays watertight whatever the
/// gap: measured on a five-sided rim, fills whose edges were up to `4.5·10⁻⁴` mm off the surface
/// meshed cleanly and stitched back into closed solids. Ten times the linear tolerance because a
/// fitted surface approaches curved edges like a cubic spline, so each further factor of ten
/// costs about twice the spans each way, and imported edges may already lie `10⁻⁵` mm or more off
/// their vertices; two orders below what meshing tolerates. Later operations must take the face
/// to contain its edges within this tolerance, not the linear one: the face's edges are the
/// body's exact edges, the surface between them is the fit.
pub(crate) fn face_tolerance(body: &Body) -> f64 {
(body.size() * 1e-6).max(1e-5)
}
#[cfg(test)]
mod tests {
use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve, ParametricSurface};
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, from_p3, p3};
use crate::boolean_record::tests::{Reach, checked};
use crate::history::{Change, EdgeOrigin};
use crate::{BoolOp, boolean, copy_faces, cylinder, extrude, measure, patch_edges, stitch};
/// The cutting cylinder: axis along y at x = 0, z = 50, radius 40, so the top of a prism
/// within 20 of the axis and 20 high is cut away everywhere (to between 10 and 15.4 mm).
fn cut_height(x: f64) -> f64 {
50.0 - (1600.0 - x * x).sqrt()
}
/// A prism on the polygon `corners`, 20 high, its top cut by the cylinder; the cut solid, the
/// open cup without its top, and a point on each rim edge (the edges' midpoints in plan).
/// `inside` is a point inside the polygon.
fn cup(corners: &[Vec2], inside: Vec2) -> (Body, Body, Vec<Vec3>) {
let prism = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(corners), holes: vec![] }], 0.0, 20.0).unwrap().pop().unwrap();
let tool = cylinder(Vec3::new(0.0, -60.0, 50.0), Vec3::Y, 40.0, 120.0).unwrap();
let solid = boolean(&prism, &tool, BoolOp::Cut).unwrap().unwrap();
let n = corners.len();
let mut keep = vec![inside.extend(0.0)];
let mut rim = Vec::new();
for k in 0..n {
let m = corners[k].lerp(corners[(k + 1) % n], 0.5);
keep.push(m.extend(5.0));
rim.push(m.extend(cut_height(m.x)));
}
let open = copy_faces(&solid, &keep).unwrap();
assert!(open.is_surface());
(solid, open, rim)
}
fn regular(n: usize, phase: f64) -> Vec<Vec2> {
(0..n).map(|k| Vec2::from_angle(phase + k as f64 * std::f64::consts::TAU / n as f64) * 20.0).collect()
}
/// The distance from `p` to the surface: the nearest of a grid of samples, then Gauss–Newton
/// within the domain.
fn to_surface(s: &mt::Surface, p: mt::Point3) -> f64 {
let (Some((u0, u1)), Some((v0, v1))) = s.try_range_tuple() else { panic!("a bounded surface") };
let n = 80;
let mut best = (f64::MAX, u0, v0);
for i in 0..=n {
for j in 0..=n {
let (u, v) = (u0 + (u1 - u0) * i as f64 / n as f64, v0 + (v1 - v0) * j as f64 / n as f64);
let d = s.subs(u, v).distance(p);
if d < best.0 {
best = (d, u, v);
}
}
}
let (_, mut u, mut v) = best;
for _ in 0..50 {
let (q, a, b) = (s.subs(u, v), s.uder(u, v), s.vder(u, v));
let r = q - p;
let (aa, ab, bb) = (a.dot(a), a.dot(b), b.dot(b));
let det = aa * bb - ab * ab;
if det.abs() < 1e-300 {
break;
}
let (ga, gb) = (a.dot(r), b.dot(r));
u = (u - (bb * ga - ab * gb) / det).clamp(u0, u1);
v = (v - (aa * gb - ab * ga) / det).clamp(v0, v1);
}
s.subs(u, v).distance(p)
}
/// The farthest point along the face's edges from its surface.
fn edges_off_surface(face: &mt::Face) -> f64 {
let s = face.surface();
let mut worst = 0.0f64;
for e in face.boundaries().iter().flat_map(|w| w.edge_iter()) {
let c = e.curve();
let (t0, t1) = c.range_tuple();
for k in 0..=24 {
worst = worst.max(to_surface(&s, c.subs(t0 + (t1 - t0) * k as f64 / 24.0)));
}
}
worst
}
/// The fill's mesh: every normal finite, and no triangle folded over (all face the same way
/// as the mesh's total area vector, by a clear margin).
fn mesh_is_clean(fill: &Body) -> f64 {
let m = fill.tessellate(0.01).unwrap();
assert!(!m.triangles.is_empty());
assert!(m.normals.iter().all(|n| n.is_finite() && n.len() > 0.5), "a mesh normal is not finite");
let tri = |t: &[u32; 3]| m.tri(t).unwrap();
let total = m.triangles.iter().map(|t| {
let [a, b, c] = tri(t);
(b - a).cross(c - a)
});
let total = total.fold(Vec3::ZERO, |s, v| s + v);
let n = total.normalized().unwrap();
for t in &m.triangles {
let [a, b, c] = tri(t);
let v = (b - a).cross(c - a);
if v.len() > 1e-12 {
assert!(v.dot(n) > 0.0, "a folded triangle");
}
}
measure(fill).unwrap().area
}
/// Fill the rim, check the face, stitch it back: (the fill, the closed solid's volume).
fn fill_rim(open: &Body, rim: &[Vec3]) -> (Body, f64) {
let fill = patch_edges(open, rim).unwrap();
assert!(fill.is_surface());
assert_eq!(fill.face_count(), 1, "one face");
let face = fill.solid.face_iter().next().unwrap().clone();
assert!(!matches!(face.surface(), mt::Surface::Plane(_)));
// Its edges are the open body's own, by identity, and nothing else.
let own: Vec<mt::EdgeID> = open.solid.edge_iter().map(|e| e.id()).collect();
assert!(fill.solid.edge_iter().all(|e| own.contains(&e.id())), "an edge that is not the body's");
let h = checked(&fill, &[open], 0.05, Reach { made: 0.0, moved: 0.0 }, &|_| true);
assert!(h.faces.iter().all(|f| f.change == Change::Generated && f.origins.is_empty()));
assert!(h.edges.iter().all(|e| matches!(e.origins.as_slice(), [EdgeOrigin::Edge { operand: 0, .. }])));
let off = edges_off_surface(&face);
let tol = super::face_tolerance(open);
assert!(off <= tol, "an edge is {off} off the face (tolerance {tol})");
mesh_is_clean(&fill);
let closed = stitch(&[open, &fill], 0.0).unwrap();
assert!(!closed.is_surface(), "the fill closes the cup");
(fill, measure(&closed).unwrap().volume)
}
/// The fill's height over the point `(x, y)`: Newton's method on the surface's `x`, `y` from
/// the nearest of a grid of samples.
fn height_over(s: &mt::Surface, x: f64, y: f64, start: &mut Option<(f64, f64)>) -> Option<f64> {
let (Some((u0, u1)), Some((v0, v1))) = s.try_range_tuple() else { return None };
let (mut u, mut v) = start.unwrap_or(((u0 + u1) / 2.0, (v0 + v1) / 2.0));
for _ in 0..30 {
let (q, a, b) = (s.subs(u, v), s.uder(u, v), s.vder(u, v));
let (rx, ry) = (q.x - x, q.y - y);
let det = a.x * b.y - a.y * b.x;
if det.abs() < 1e-300 {
return None;
}
u = (u - (b.y * rx - b.x * ry) / det).clamp(u0, u1);
v = (v - (a.x * ry - a.y * rx) / det).clamp(v0, v1);
}
let q = s.subs(u, v);
*start = Some((u, v));
((q.x - x).hypot(q.y - y) < 1e-9).then_some(q.z)
}
/// The volume the cut prism gains with the fill for its top: the fill's height above the
/// cylinder integrated over the polygon (midpoint rule on a 300 × 300 grid; the integrand
/// vanishes on the rim, so cells across it add little error).
fn gained(fill: &Body, corners: &[Vec2]) -> f64 {
let s = fill.solid.face_iter().next().unwrap().surface();
let (lo, hi) = corners.iter().fold((Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN)), |(lo, hi), p| {
(Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)))
});
let n = 300;
let (dx, dy) = ((hi.x - lo.x) / n as f64, (hi.y - lo.y) / n as f64);
let inside = |x: f64, y: f64| {
let mut w = false;
for k in 0..corners.len() {
let (a, b) = (corners[k], corners[(k + 1) % corners.len()]);
if (a.y > y) != (b.y > y) && x < a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x) {
w = !w;
}
}
w
};
let mut sum = 0.0;
let mut start = None;
for i in 0..n {
let x = lo.x + (i as f64 + 0.5) * dx;
for j in 0..n {
let y = lo.y + (j as f64 + 0.5) * dy;
if inside(x, y) {
let h = height_over(&s, x, y, &mut start).expect("the fill covers the polygon");
sum += (h - cut_height(x)) * dx * dy;
}
}
}
sum
}
/// The closed solid's volume against the cut prism's plus what the fill adds over the
/// cylinder (integrated independently, [`gained`]); they agree to the integration's error
/// (0.1% of what the fill adds) plus the error of measuring both solids on meshes
/// (`5·10⁻⁵` of the volume).
fn check_volume(name: &str, solid: &Body, fill: &Body, corners: &[Vec2], got: f64) {
let prism = measure(solid).unwrap().volume;
let extra = gained(fill, corners);
let want = prism + extra;
let bound = 1e-3 * extra.abs() + 5e-5 * want;
eprintln!("{name}: closed volume {got:.4}, cut prism {prism:.4} + fill above the cylinder {extra:.4} = {want:.4} (bound ±{bound:.4})");
assert!((got - want).abs() <= bound, "{name}: {got} vs {want}");
}
#[test]
fn regular_rims_of_three_to_eight_sides_fill_with_one_face_and_close_the_cup() {
for n in 3..=8 {
let corners = regular(n, 0.3);
let (solid, open, rim) = cup(&corners, Vec2::ZERO);
let (fill, got) = fill_rim(&open, &rim);
assert_eq!(fill.edge_count(), n, "the rim's own edges");
check_volume(&format!("{n} sides"), &solid, &fill, &corners, got);
}
}
#[test]
fn concave_rims_fill_with_one_face() {
let v = |x: f64, y: f64| Vec2::new(x, y);
let shapes = [
("dart", vec![v(-10.0, 0.0), v(15.0, -12.0), v(3.0, 0.0), v(15.0, 12.0)], v(0.0, 0.0)),
("arrow", vec![v(-12.0, 0.0), v(0.0, -10.0), v(0.0, -4.0), v(14.0, 0.0), v(0.0, 4.0)], v(0.0, 0.0)),
(
"plus",
vec![
v(-4.0, -14.0),
v(4.0, -14.0),
v(4.0, -4.0),
v(14.0, -4.0),
v(14.0, 4.0),
v(4.0, 4.0),
v(4.0, 14.0),
v(-4.0, 14.0),
v(-4.0, 4.0),
v(-14.0, 4.0),
v(-14.0, -4.0),
v(-4.0, -4.0),
],
v(0.0, 0.0),
),
];
for (name, corners, inside) in shapes {
let (solid, open, rim) = cup(&corners, inside);
let (fill, got) = fill_rim(&open, &rim);
assert_eq!(fill.edge_count(), corners.len(), "{name}: the rim's own edges");
check_volume(name, &solid, &fill, &corners, got);
}
}
#[test]
fn a_flat_corner_between_two_edges_is_kept() {
// A rim of five straight edges, two of them in line (a 180° corner between them), the
// four true corners at different heights; walls hang below it.
let p = |x: f64, y: f64, z: f64| mt::Vertex::new(mt::Point3::new(x, y, z));
let v = [p(-14.0, -14.0, 12.0), p(0.0, -14.0, 12.0), p(14.0, -14.0, 12.0), p(14.0, 14.0, 15.0), p(-14.0, 14.0, 10.0)];
let line = |a: &mt::Vertex, b: &mt::Vertex| mt::Edge::new(a, b, mt::Curve::Line(mt::Line(a.point(), b.point())));
let wire: mt::Wire = (0..5).map(|k| line(&v[k], &v[(k + 1) % 5])).collect::<Vec<_>>().into();
let low = mt::builder::translated(&wire, mt::Vector3::new(0.0, 0.0, -10.0));
let walls = mt::builder::try_wire_homotopy(&wire, &low).unwrap();
let body = crate::surfaces::surface_body(walls.face_iter().cloned().collect());
let rim: Vec<Vec3> = (0..5).map(|k| from_p3(v[k].point()).lerp(from_p3(v[(k + 1) % 5].point()), 0.5)).collect();
let fill = patch_edges(&body, &rim).unwrap();
assert_eq!((fill.face_count(), fill.edge_count()), (1, 5), "one face on all five edges");
let face = fill.solid.face_iter().next().unwrap().clone();
assert!(edges_off_surface(&face) <= super::face_tolerance(&body));
let area = mesh_is_clean(&fill);
eprintln!("flat corner: one face of area {area}");
assert!(area > 28.0 * 28.0 * 0.99 && area < 28.0 * 28.0 * 1.05, "{area}");
}
#[test]
fn the_fill_does_not_depend_on_pick_order_or_where_the_body_is() {
let (_, open, rim) = cup(&regular(5, 0.3), Vec2::ZERO);
let (a, _) = fill_rim(&open, &rim);
let mut shuffled = rim.clone();
shuffled.rotate_left(3);
shuffled.swap(0, 1);
let (b, _) = fill_rim(&open, &shuffled);
let (sa, sb) = (a.solid.face_iter().next().unwrap().surface(), b.solid.face_iter().next().unwrap().surface());
let (Some((u0, u1)), Some((v0, v1))) = sa.try_range_tuple() else { panic!() };
let mut gap = 0.0f64;
for (x, y) in [(0.2, 0.3), (0.5, 0.5), (0.7, 0.9)] {
let (u, v) = (u0 + (u1 - u0) * x, v0 + (v1 - v0) * y);
gap = gap.max(sa.subs(u, v).distance(sb.subs(u, v)));
}
assert!(gap < 1e-7, "pick order moved the surface by {gap}");
let (area_a, area_b) = (mesh_is_clean(&a), mesh_is_clean(&b));
assert!((area_a - area_b).abs() < area_a * 1e-9);
// Moved and turned: the same area.
let moved = crate::transform(&open, Vec3::new(130.0, -70.0, 40.0), Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0), 0.7).unwrap();
let ax = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
let turn = |p: Vec3| {
// Rodrigues' rotation by 0.7 about `ax`, then the shift.
let (c, s) = (0.7f64.cos(), 0.7f64.sin());
p * c + ax.cross(p) * s + ax * (ax.dot(p) * (1.0 - c)) + Vec3::new(130.0, -70.0, 40.0)
};
let moved_rim: Vec<Vec3> = rim.iter().map(|p| turn(*p)).collect();
let (c, _) = fill_rim(&moved, &moved_rim);
let area_c = mesh_is_clean(&c);
eprintln!("pick order: surfaces within {gap:e}; areas {area_a} / {area_b} / moved {area_c}");
assert!((area_a - area_c).abs() < area_a * 1e-6, "{area_a} vs moved {area_c}");
}
#[test]
fn a_saddle_rim_of_two_intersection_curves_fills() {
// A round post with its top cut by the sideways cylinder: the rim is two intersection
// curves; the fill is one face on them.
let post = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
let tool = cylinder(Vec3::new(0.0, -60.0, 50.0), Vec3::Y, 40.0, 120.0).unwrap();
let solid = boolean(&post, &tool, BoolOp::Cut).unwrap().unwrap();
let sides: Vec<Vec3> = (0..8).map(|k| Vec2::from_angle(k as f64 * 0.8).extend(0.0) * 10.0 + Vec3::new(0.0, 0.0, 5.0)).collect();
let open = copy_faces(&solid, &[&[Vec3::ZERO][..], &sides].concat()).unwrap();
let rim: Vec<Vec3> = [-0.98, 2.18]
.map(|a: f64| {
let (x, y) = (10.0 * a.cos(), 10.0 * a.sin());
Vec3::new(x, y, cut_height(x))
})
.to_vec();
let (fill, got) = fill_rim(&open, &rim);
let circle: Vec<Vec2> = (0..720).map(|k| Vec2::from_angle(k as f64 * std::f64::consts::TAU / 720.0) * 10.0).collect();
check_volume("saddle rim", &solid, &fill, &circle, got);
}
#[test]
fn imported_edges_whose_vertices_are_off_their_curves_fill() {
// The cup's vertices moved 2·10⁻⁵ mm off their curves, as a file might give them.
let corners = regular(5, 0.3);
let (solid, open, rim) = cup(&corners, Vec2::ZERO);
let shell = open.solid.boundaries()[0].mapped(
|p: &mt::Point3| *p + mt::Vector3::new(2e-5, 0.0, 0.0),
|c: &mt::Curve| c.clone(),
|s: &mt::Surface| s.clone(),
);
let imported = crate::surfaces::surface_body(shell.face_iter().cloned().collect());
let (fill, got) = fill_rim(&imported, &rim);
check_volume("imported", &solid, &fill, &corners, got);
}
#[test]
fn a_spline_edge_bulging_out_of_plane_is_never_given_a_flat_face() {
// A flat rectangle of edges but for one cubic edge with a control point raised 1.5 mm: no
// plane can be certified. A one-span bump is followed by the fitted face; a narrow one
// (64 spans, about 1 mm wide, its third derivative jumping at every knot) may be beyond
// a 32 × 32 grid, and then it is refused.
for (spans, raised, must_fill) in [(1, 1, true), (64, 30, false)] {
let count = spans + 3;
let pts: Vec<mt::Point3> =
(0..count).map(|i| mt::Point3::new(20.0 * i as f64 / (count - 1) as f64, 0.0, if i == raised { 1.5 } else { 0.0 })).collect();
let spline = mt::BSplineCurve::new(mt::KnotVec::uniform_knot(3, spans), pts);
let v: Vec<mt::Vertex> =
[(0.0, 0.0), (20.0, 0.0), (20.0, 10.0), (0.0, 10.0)].iter().map(|&(x, y)| mt::Vertex::new(mt::Point3::new(x, y, 0.0))).collect();
let line = |a: &mt::Vertex, b: &mt::Vertex| mt::Edge::new(a, b, mt::Curve::Line(mt::Line(a.point(), b.point())));
let bottom = mt::Edge::new(&v[0], &v[1], mt::Curve::BSplineCurve(spline.clone()));
let wire: mt::Wire = vec![bottom, line(&v[1], &v[2]), line(&v[2], &v[3]), line(&v[3], &v[0])].into();
let top = mt::builder::translated(&wire, mt::Vector3::new(0.0, 0.0, -5.0));
let walls = mt::builder::try_wire_homotopy(&wire, &top).unwrap();
let body = crate::surfaces::surface_body(walls.face_iter().cloned().collect());
let peak = from_p3(spline.subs((raised as f64 + 1.0) / (count + 1) as f64));
let rim = [from_p3(spline.subs(0.5)), Vec3::new(20.0, 5.0, 0.0), Vec3::new(10.0, 10.0, 0.0), Vec3::new(0.0, 5.0, 0.0)];
match patch_edges(&body, &rim) {
Ok(fill) => {
let face = fill.solid.face_iter().next().unwrap().clone();
assert!(!matches!(face.surface(), mt::Surface::Plane(_)), "a plane would miss the bulge");
let (off, tol) = (edges_off_surface(&face), super::face_tolerance(&body));
eprintln!("{spans}-span bump: filled, edges within {off:e} of the face");
assert!(off <= tol && to_surface(&face.surface(), p3(peak)) <= tol);
mesh_is_clean(&fill);
}
Err(e) => {
eprintln!("{spans}-span bump: {e}");
assert!(!must_fill && e.to_string().contains("not supported yet"), "{e}");
}
}
}
}
/// A point on each edge that bounds one face of `body` (its free edges).
fn free_edge_points(body: &Body) -> Vec<Vec3> {
let mut uses: std::collections::HashMap<mt::EdgeID, (usize, mt::Edge)> = std::collections::HashMap::new();
for f in body.solid.face_iter() {
for e in f.boundaries().iter().flat_map(|w| w.edge_iter()) {
uses.entry(e.id()).or_insert((0, e.clone())).0 += 1;
}
}
uses.values()
.filter(|(n, _)| *n == 1)
.map(|(_, e)| {
let c = e.curve();
let (t0, t1) = c.range_tuple();
from_p3(c.subs(0.5 * (t0 + t1)))
})
.collect()
}
#[test]
fn a_loop_that_does_not_bound_a_height_field_is_refused() {
// Half a round post's side: two straight edges up and two half circles, whose plane
// stands on the post's axis; the half circles run along its normal at their ends.
let post = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
let halves = crate::split_by_plane(&post, &Plane::from_normal(Vec3::ZERO, Vec3::X).unwrap()).unwrap();
let half = halves.into_iter().find(|b| measure(b).unwrap().bbox.min.x > -1e-6).unwrap();
let at: Vec<Vec3> =
(-4..=4).map(|k| Vec2::from_angle(k as f64 * 0.35).extend(10.0) * 1.0).map(|p| Vec3::new(p.x * 10.0, p.y * 10.0, 10.0)).collect();
let side = copy_faces(&half, &at).unwrap();
let rim = free_edge_points(&side);
let err = patch_edges(&side, &rim).unwrap_err().to_string();
eprintln!("half a post's side: {err}");
assert!(err.contains("not supported yet"), "{err}");
}
#[test]
fn planar_loops_of_intersection_curves_are_one_planar_face() {
// A round post cut by planes tilted every way, gently to steeply: each rim is an ellipse
// of intersection curves (split where the post's side has seams) lying on the cutting
// plane, so the patch is one planar face, and the post closes again.
for tilt in [Vec3::new(0.0, -0.4, 1.0), Vec3::new(0.7, -0.9, 1.0), Vec3::new(1.3, 0.2, 1.0)] {
let post = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 30.0).unwrap();
let n = tilt.normalized().unwrap();
let plane = Plane::from_normal(Vec3::new(0.0, 0.0, 15.0), n).unwrap();
let pieces = crate::split_by_plane(&post, &plane).unwrap();
let solid = pieces.into_iter().find(|b| measure(b).unwrap().bbox.min.z < 0.5).unwrap();
let height = |x: f64, y: f64| 15.0 - (tilt.x * x + tilt.y * y) / tilt.z;
let sides: Vec<Vec3> = (0..8).map(|k| Vec2::from_angle(k as f64 * 0.8).extend(0.0) * 10.0 + Vec3::new(0.0, 0.0, 1.0)).collect();
let open = copy_faces(&solid, &[&[Vec3::ZERO][..], &sides].concat()).unwrap();
let rim: Vec<Vec3> = (0..12)
.map(|k| {
let a = k as f64 * std::f64::consts::TAU / 12.0 + 0.2;
let (x, y) = (10.0 * a.cos(), 10.0 * a.sin());
Vec3::new(x, y, height(x, y))
})
.collect();
let lid = patch_edges(&open, &rim).unwrap();
assert_eq!(lid.face_count(), 1);
let edges = lid.edge_count();
assert!(matches!(lid.solid.face_iter().next().unwrap().surface(), mt::Surface::Plane(_)), "tilt {tilt:?}");
let closed = stitch(&[&open, &lid], 0.0).unwrap();
assert!(!closed.is_surface());
let (want, got) = (measure(&solid).unwrap().volume, measure(&closed).unwrap().volume);
eprintln!("rim cut at {tilt:?}: {edges} intersection curves, planar face, volume {got} against {want}");
assert!((got - want).abs() < want * 1e-6, "{got} vs {want}");
}
}
#[test]
fn planar_loops_are_still_one_planar_face() {
let b = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let open = copy_faces(
&b,
&[Vec3::new(5.0, 5.0, 0.0), Vec3::new(5.0, 0.0, 5.0), Vec3::new(10.0, 5.0, 5.0), Vec3::new(5.0, 10.0, 5.0), Vec3::new(0.0, 5.0, 5.0)],
)
.unwrap();
let rim = [Vec3::new(5.0, 0.0, 10.0), Vec3::new(10.0, 5.0, 10.0), Vec3::new(5.0, 10.0, 10.0), Vec3::new(0.0, 5.0, 10.0)];
let lid = patch_edges(&open, &rim).unwrap();
assert_eq!(lid.face_count(), 1);
assert!(matches!(lid.solid.face_iter().next().unwrap().surface(), mt::Surface::Plane(_)));
let closed = stitch(&[&open, &lid], 0.0).unwrap();
assert!((measure(&closed).unwrap().volume - 1000.0).abs() < 1e-9);
}
#[test]
fn open_partial_or_hostile_picks_are_refused() {
let (_, open, rim) = cup(&regular(5, 0.3), Vec2::ZERO);
assert!(patch_edges(&open, &rim[..4]).is_err(), "a loop with a missing edge");
assert!(patch_edges(&open, &[]).is_err());
assert!(patch_edges(&open, &[Vec3::new(f64::NAN, 0.0, 0.0)]).is_err());
assert!(patch_edges(&open, &[Vec3::new(1e300, 0.0, 0.0)]).is_err());
assert!(patch_edges(&open, &vec![rim[0]; 20_000]).is_err());
}
}
+3
View File
@@ -26,6 +26,7 @@ mod delete_face;
mod emboss;
#[cfg(test)]
mod face_source_tests;
mod fill;
mod freeform;
pub mod fuzz;
mod heal;
@@ -45,6 +46,7 @@ mod modify_history_tests;
mod numbered;
mod offset;
mod ops;
mod plane_fit;
mod polybool;
mod polyhedron;
mod prism;
@@ -54,6 +56,7 @@ pub mod provenance;
mod record;
mod sew;
mod smooth_edges;
mod spline_conv;
mod splitface;
mod step;
mod step_in;
+29
View File
@@ -125,6 +125,35 @@ impl NumberedLoop {
out
}
/// Each run of lines that `joins` says continue one another as a single line, over what
/// they all ran over. (Adjacent profiles merged into one region keep a vertex where each
/// shared edge met the outline.)
pub(crate) fn merged_lines(&self, joins: impl Fn(&Seg2, &Seg2) -> bool) -> NumberedLoop {
let n = self.lp.segs.len();
let join = |i: usize, j: usize| match (self.lp.segs.get(i), self.lp.segs.get(j)) {
(Some(s @ Seg2::Line { .. }), Some(t @ Seg2::Line { .. })) => joins(s, t),
_ => false,
};
// Start where a run starts (a loop of nothing but one run stays as it is).
let Some(k) = (0..n).find(|&i| !join((i + n - 1) % n, i)) else { return self.clone() };
let mut out = NumberedLoop { lp: Loop2 { segs: Vec::new() }, from: Vec::new() };
for j in 0..n {
let i = (k + j) % n;
let (Some(s), from) = (self.lp.segs.get(i).copied(), self.numbers(i)) else { continue };
if j > 0
&& join((i + n - 1) % n, i)
&& let (Some(Seg2::Line { b, .. }), Some(prev)) = (out.lp.segs.last_mut(), out.from.last_mut())
{
*b = s.end();
*prev = union([prev.as_slice(), from]);
continue;
}
out.lp.segs.push(s);
out.from.push(from.to_vec());
}
out
}
/// Split segment `k` in half, both halves over what it ran over.
fn split_half(&mut self, k: usize) {
if k >= self.lp.segs.len() || k >= self.from.len() {
+91 -24
View File
@@ -757,6 +757,56 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
))
})
.collect();
// Across the collapsed rounds next to edge `e` (its faces `fs`): a round is a strip, crossed
// to its side that shares no vertex with the edge, through further collapsed rounds.
let opposite_side = |e: &mt::Edge, fs: &[usize]| -> Vec<usize> {
let mut out: Vec<usize> = Vec::new();
let mut seen: Vec<usize> = Vec::new();
let mut queue: Vec<(usize, [mt::VertexID; 2])> =
fs.iter().copied().filter(|i| is_collapsed(*i)).map(|i| (i, [e.front().id(), e.back().id()])).collect();
while let Some((cf, avoid)) = queue.pop() {
if seen.contains(&cf) {
continue;
}
seen.push(cf);
let Some(face) = faces.get(cf) else { continue };
for side in face.edge_iter() {
if avoid.contains(&side.front().id()) || avoid.contains(&side.back().id()) {
continue;
}
for nb in efaces.get(&side.id()).into_iter().flatten().copied() {
if nb == cf || fs.contains(&nb) {
continue;
}
if is_collapsed(nb) {
queue.push((nb, [side.front().id(), side.back().id()]));
} else if !out.contains(&nb) {
out.push(nb);
}
}
}
}
out
};
// For a point on a collapsed round touching the faces `at`: the faces it keeps (`at`'s
// own, not collapsed), and the faces along the rounds it is on, nearest `p` first.
let across = |at: &[usize], p: Vec3| -> (Vec<usize>, Vec<usize>) {
let own: Vec<usize> = at.iter().copied().filter(|i| !is_collapsed(*i)).collect();
let mut cand: Vec<(f64, usize)> = expand(at)
.into_iter()
.filter(|nb| !own.contains(nb))
.map(|nb| {
let d = edge_pts
.iter()
.filter(|(fs, _)| fs.contains(&nb) && fs.iter().any(|i| is_collapsed(*i)))
.flat_map(|(_, q)| q.iter().map(|x| x.dist(p)))
.fold(f64::INFINITY, f64::min);
(d, nb)
})
.collect();
cand.sort_by(|x, y| x.0.total_cmp(&y.0));
(own, cand.into_iter().map(|(_, nb)| nb).collect())
};
let mut newpos: HashMap<mt::VertexID, Vec3> = HashMap::new();
// Corners whose moved faces meet at several points (see `split_corner`).
let mut splits: HashMap<mt::VertexID, Vec<(Vec3, Vec<usize>)>> = HashMap::new();
@@ -766,22 +816,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
let fs: Vec<usize> = if at.iter().any(|i| is_collapsed(*i)) {
// On a collapsed round: its own faces, then the faces along the rounds it is on,
// nearest first, until they fix a point.
let group = expand(at);
let mut own: Vec<usize> = at.iter().copied().filter(|i| !is_collapsed(*i)).collect();
let mut cand: Vec<(f64, usize)> = group
.iter()
.copied()
.filter(|nb| !own.contains(nb))
.map(|nb| {
let d = edge_pts
.iter()
.filter(|(fs, _)| fs.contains(&nb) && fs.iter().any(|i| is_collapsed(*i)))
.flat_map(|(_, q)| q.iter().map(|x| x.dist(p)))
.fold(f64::INFINITY, f64::min);
(d, nb)
})
.collect();
cand.sort_by(|x, y| x.0.total_cmp(&y.0));
let (mut own, cand) = across(at, p);
let rank = |ids: &[usize]| -> usize {
let mut basis: Vec<Vec3> = Vec::new();
for i in ids {
@@ -796,7 +831,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
}
basis.len()
};
for (_, c) in cand {
for c in cand {
if rank(&own) >= 3 {
break;
}
@@ -904,7 +939,8 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
let mut edges: HashMap<mt::EdgeID, (mt::Edge, bool)> = HashMap::new();
// Moved faces and edges; an edge where neighbours of a collapsed face meet is new.
let mut rec = crate::record::Rebuild::default();
let mut merged: HashMap<(mt::VertexID, mt::VertexID), mt::Edge> = HashMap::new();
// New edges along collapsed rounds: ends and points along them, for their twins.
let mut merged: Vec<(mt::VertexID, mt::VertexID, Vec<Vec3>, mt::Edge)> = Vec::new();
for e in b.solid.edge_iter() {
if edges.contains_key(&e.id()) {
continue;
@@ -921,13 +957,44 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
continue;
}
if touches {
// Along a collapsed round: a straight edge, shared with its twin from the
// round's other side.
let ne = match merged.get(&(a.id(), c.id())).or_else(|| merged.get(&(c.id(), a.id()))) {
Some(x) => x.clone(),
// Along a collapsed round: where the edge's own face meets the face across the
// round, between the edge's moved ends, through the edge's middle moved onto
// both: a line (planes, or a plane along a cylinder's axis) or an arc (a plane
// square to a coaxial cylinder); faces that meet in another curve are refused.
// Shared with its twin from the round's other side: the new edge with the same
// ends through the same middle (two arcs of one circle have the same ends).
use mt::{BoundedCurve, ParametricCurve};
let fs = efaces.get(&e.id()).ok_or_else(|| fail("edge faces"))?;
let cv = e.curve();
let (t0, t1) = cv.range_tuple();
let mid = from_p3(cv.subs((t0 + t1) / 2.0));
// The face across: the strip's far side (`opposite_side`), the nearest of them
// when several; the nearest along the rounds when the strip has none.
let (mut meet, cand) = across(fs, mid);
let far = opposite_side(&e, fs);
meet.extend(cand.iter().find(|c| far.contains(c)).or(cand.first()));
if meet.len() < 2 {
return Err(fail("an edge along a collapsed round with no face across it"));
}
let (o, s, n) = pick(&meet);
let m = move_point(mid, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("faces that don't meet along a collapsed round"))?;
let near = size * 1e-5;
let twin = merged.iter().find(|(x, y, pts, _)| {
((*x == a.id() && *y == c.id()) || (*x == c.id() && *y == a.id()))
&& pts.windows(2).any(|w| w.first().zip(w.get(1)).is_some_and(|(p, q)| m.dist_to_segment(*p, *q) < near))
});
let ne = match twin {
Some((_, _, _, x)) => x.clone(),
None => {
let x = builder::line(a, c);
merged.insert((a.id(), c.id()), x.clone());
let (pa, pc) = (from_p3(a.point()), from_p3(c.point()));
let x = if m.dist_to_segment(pa, pc) < near { builder::line(a, c) } else { builder::circle_arc(a, c, p3(m)) };
let xc = x.curve();
let (u0, u1) = xc.range_tuple();
let pts: Vec<Vec3> = (0..=16).map(|j| from_p3(xc.subs(u0 + (u1 - u0) * j as f64 / 16.0))).collect();
if !pts.iter().all(|p| n.iter().all(|sf| sf.dist(*p).abs() < near)) {
return Err(fail("not supported yet: faces meeting along a collapsed round in a curve other than a line or circle"));
}
merged.push((a.id(), c.id(), pts, x.clone()));
x
}
};
+4 -2
View File
@@ -248,8 +248,10 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
};
}
r = r.map(|result| result.map(|body| body.inherit_regularization_protection(a).inherit_regularization_protection(b)));
if matches!(op, BoolOp::Cut | BoolOp::Union) {
r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, op == BoolOp::Cut)).transpose());
match op {
BoolOp::Cut => r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, true)).transpose()),
BoolOp::Union => r = r.and_then(|result| result.map(|body| body.with_preserved_union_planar_sources(a, b)).transpose()),
BoolOp::Intersect => {}
}
if r.is_ok() {
merge(diagnostics);
+68 -17
View File
@@ -30,6 +30,7 @@ fn base_plane(source: &FaceSource, actual: &Plane) -> Option<Plane> {
FaceSource::CertifiedExtrudeSide { plane: Some(p), .. }
| FaceSource::CertifiedExtrudeCap { plane: p, .. }
| FaceSource::Box { producer: Some(_), plane: p, .. }
| FaceSource::CylinderCap { plane: p, .. }
| FaceSource::ShellPlanar { producer: Some(_), plane: p, .. } => Some(*p),
FaceSource::Imported { producer: Some(_), payload_sha256: Some(_), .. } => Some(*actual),
_ => None,
@@ -41,6 +42,7 @@ pub(super) fn certified_plane(source: &FaceSource) -> Option<Plane> {
FaceSource::CertifiedExtrudeSide { plane: Some(p), .. }
| FaceSource::CertifiedExtrudeCap { plane: p, .. }
| FaceSource::Box { producer: Some(_), plane: p, .. }
| FaceSource::CylinderCap { plane: p, .. }
| FaceSource::ShellPlanar { producer: Some(_), plane: p, .. } => Some(*p),
_ => None,
}
@@ -93,7 +95,7 @@ pub(super) fn descriptor_cost(source: &FaceSource) -> Option<usize> {
}
cost = cost.checked_add(count.checked_mul(128)?)?;
}
FaceSource::ExtrudeSide { .. } | FaceSource::Imported { .. } | FaceSource::Box { .. } => {}
FaceSource::ExtrudeSide { .. } | FaceSource::Imported { .. } | FaceSource::Box { .. } | FaceSource::CylinderCap { .. } => {}
_ => return None,
}
Some(cost)
@@ -102,13 +104,13 @@ pub(super) fn descriptor_cost(source: &FaceSource) -> Option<usize> {
impl Body {
/// Preserve uniquely certified unchanged planar construction supports.
pub fn with_preserved_planar_sources(self, input: &Body) -> Result<Self> {
self.map_planar_sources(input, None, None, &[], false)
self.map_planar_sources(&[input], None, None, &[], false)
}
/// Generate distinct outer/inner roles from the exact original supports of
/// an inside shell. Generated rims and curved walls are intentionally absent.
pub(crate) fn with_shell_planar_sources(self, input: &Body, thickness: f64, open: &[Vec3]) -> Result<Self> {
self.map_planar_sources(input, Some(thickness), None, open, false)
self.map_planar_sources(&[input], Some(thickness), None, open, false)
}
/// Retain exact shell roles, and construction roles for Cut, only on unchanged
@@ -118,19 +120,38 @@ impl Body {
if !input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, constructed))) {
return Ok(self_);
}
self_.map_planar_sources(input, None, Some(tool), &[], constructed)
self_.map_planar_sources(&[input], None, Some(tool), &[], constructed)
}
fn map_planar_sources(mut self, input: &Body, shell: Option<f64>, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result<Self> {
self.regularization_protected |= input.regularization_protected;
let Some(sources) = input.face_sources() else { return Ok(self) };
if input.solid.face_iter().count() > 16_384
/// Retain exact construction and shell roles through a Union from both operands, only on
/// unchanged supports. Coplanar supports of the two operands carry distinct identities, so
/// a face merged from both stays without a role.
pub(crate) fn with_preserved_union_planar_sources(self, a: &Body, b: &Body) -> Result<Self> {
let self_ = self.inherit_regularization_protection(a).inherit_regularization_protection(b);
if ![a, b].iter().any(|input| input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, true)))) {
return Ok(self_);
}
self_.map_planar_sources(&[a, b], None, None, &[], true)
}
fn map_planar_sources(mut self, inputs: &[&Body], shell: Option<f64>, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result<Self> {
// Every boolean (a Cut's tool, or a Union's second operand) leaves the result with
// faces whose support no input role describes.
let boolean = blocker.is_some() || inputs.len() > 1;
for input in inputs {
self.regularization_protected |= input.regularization_protected;
}
if inputs.iter().all(|input| input.face_sources().is_none()) {
return Ok(self);
}
if inputs.len() > 2
|| inputs.iter().any(|input| input.solid.face_iter().count() > 16_384)
|| self.solid.face_iter().count() > 16_384
|| blocker.is_some_and(|b| b.solid.face_iter().count() > 16_384)
{
return Err(KernelError::Invalid("planar source face budget exceeded".into()));
}
if sources.len() != input.solid.face_iter().count() {
if inputs.iter().any(|input| input.face_sources().is_some_and(|sources| sources.len() != input.solid.face_iter().count())) {
return Err(KernelError::Invalid("planar source alignment mismatch".into()));
}
if shell.is_some_and(|t| !t.is_finite() || t <= 0.) {
@@ -139,20 +160,22 @@ impl Body {
if open.len() > 256 {
return Ok(self);
}
let opening_tolerance = if shell.is_some() { input.size() * 1e-4 + 1e-6 } else { 0. };
let opening_tolerance = if shell.is_some() { inputs.iter().map(|input| input.size()).fold(0., f64::max) * 1e-4 + 1e-6 } else { 0. };
let mapped = guard("exact planar support lineage", || {
let competing: Vec<_> = blocker.into_iter().flat_map(|b| b.solid.face_iter()).filter_map(actual_plane).collect();
let mut candidates = Vec::new();
let mut unknown_supports = Vec::new();
for (face, source) in input.solid.face_iter().zip(sources) {
let faces = inputs.iter().flat_map(|input| {
let sources = input.face_sources().unwrap_or_default();
input.solid.face_iter().enumerate().map(move |(index, face)| (face, sources.get(index).and_then(Option::as_ref)))
});
for (face, source) in faces {
let Some(actual) = actual_plane(face) else { continue };
let Some(source) = source.as_ref() else {
let Some(source) = source else {
unknown_supports.push(actual);
continue;
};
if (blocker.is_some() && !boolean_source(source, constructed))
|| (shell.is_some() && matches!(source, FaceSource::ShellPlanar { .. }))
{
if (boolean && !boolean_source(source, constructed)) || (shell.is_some() && matches!(source, FaceSource::ShellPlanar { .. })) {
unknown_supports.push(actual);
continue;
}
@@ -207,7 +230,7 @@ impl Body {
.face_sources()
.and_then(|sources| sources.get(index))
.and_then(|s| s.as_ref())
.filter(|s| (blocker.is_some() || self.face_was_coalesced(index)) && !boolean_source(s, constructed));
.filter(|s| (boolean || self.face_was_coalesced(index)) && !boolean_source(s, constructed));
let keep = existing.and_then(|s| {
descriptor_cost(s).and_then(|cost| {
metadata.checked_sub(cost).map(|remaining| {
@@ -251,7 +274,7 @@ impl Body {
});
}
}
if found.is_none() && (blocker.is_some() || self.face_was_coalesced(index)) {
if found.is_none() && (boolean || self.face_was_coalesced(index)) {
found = self
.face_sources()
.and_then(|sources| sources.get(index))
@@ -428,6 +451,34 @@ mod tests {
assert!((copy.tessellate(0.01).unwrap().measure().volume - mesh.measure().volume).abs() < 1e-6);
}
#[test]
fn union_keeps_unchanged_supports_of_both_operands_and_drops_shared_ones() {
use crate::{BoolOp, boolean};
let a = box_solid(Vec3::ZERO, Vec3::new(20., 20., 10.)).unwrap().with_box_certificate(1).unwrap();
// A taller box overlapping one side: the bottoms share z = 0, the tops do not.
let b = box_solid(Vec3::new(10., 5., 0.), Vec3::new(30., 15., 15.)).unwrap().with_box_certificate(2).unwrap();
let joined = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
let sources = joined.face_sources().unwrap();
let mut seen = Vec::new();
for (i, face) in joined.solid.face_iter().enumerate() {
let plane = actual_plane(face).unwrap();
let source = sources[i].as_ref();
let height = plane.origin.z;
if plane.normal().z > 0.999 && (height - 10.).abs() < 1e-9 {
assert!(matches!(source, Some(FaceSource::Box { producer: Some(1), axis: 2, high: true, .. })), "{source:?}");
seen.push("a top");
} else if plane.normal().z > 0.999 && (height - 15.).abs() < 1e-9 {
assert!(matches!(source, Some(FaceSource::Box { producer: Some(2), axis: 2, high: true, .. })), "{source:?}");
seen.push("b top");
} else if plane.normal().z < -0.999 {
assert!(source.is_none(), "a bottom merged from both operands has no single identity: {source:?}");
seen.push("bottom");
}
}
for want in ["a top", "b top", "bottom"] {
assert!(seen.contains(&want), "{want} missing from {seen:?}");
}
}
#[test]
fn coincident_competing_source_planes_and_tool_planes_stay_unknown() {
let a = box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(1).unwrap();
let b = box_solid(Vec3::new(20., 0., 0.), Vec3::new(30., 10., 10.)).unwrap().with_box_certificate(2).unwrap();
+133
View File
@@ -0,0 +1,133 @@
//! Planes fitted to points around a loop (Newell's method), for planar faces of polygons read
//! from files and for patches filling a planar loop of edges, and points sampled along edges
//! (shared with the fill of a loop that is not planar).
use mt::{BoundedCurve, EuclideanSpace, InnerSpace, MetricSpace, ParametricCurve};
use solidcraft_geom::Vec3;
use truck_modeling as mt;
use crate::body::{from_p3, p3};
use crate::{KernelError, Result};
/// The plane through points round a loop: its normal by Newell's method (turning with the
/// loop), through their centroid; `None` for fewer than three points or no area.
pub(crate) fn plane_through(pts: &[mt::Point3]) -> Option<mt::Plane> {
let n = pts.len();
if n < 3 {
return None;
}
let mut nrm = mt::Vector3::new(0.0, 0.0, 0.0);
let mut c = mt::Vector3::new(0.0, 0.0, 0.0);
for i in 0..n {
let (a, b) = (pts.get(i)?, pts.get((i + 1) % n)?);
nrm.x += (a.y - b.y) * (a.z + b.z);
nrm.y += (a.z - b.z) * (a.x + b.x);
nrm.z += (a.x - b.x) * (a.y + b.y);
c += a.to_vec();
}
let len = nrm.magnitude();
if !(len > 1e-12) {
return None;
}
let z = nrm / len;
let o = mt::Point3::from_vec(c / n as f64);
let a = if z.x.abs() < 0.9 { mt::Vector3::unit_x() } else { mt::Vector3::unit_y() };
let x = (a - z * a.dot(z)).normalize();
let y = z.cross(x);
Some(mt::Plane::new(o, o + x, o + y))
}
/// The point of `c` at `t` (`None` where an intersection curve cannot find it).
fn curve_point(c: &mt::Curve, t: f64) -> Option<Vec3> {
let p = match c {
mt::Curve::IntersectionCurve(ic) => ic.search_triple(t)?.0,
other => other.subs(t),
};
let p = from_p3(p);
p.is_finite().then_some(p)
}
/// Points along an edge, in its own direction, at `s ∈ [0, 1]` of its parameter range. An
/// intersection curve's point is searched from the surface parameters of the point found before
/// when that one is near (an eighth of the range), and kept only when it lies near the curve's
/// leader (a seeded search can slide to another branch of the surfaces' intersection); else the
/// unseeded search decides. `None` where no point is found.
pub(crate) struct EdgeSampler {
curve: mt::Curve,
range: (f64, f64),
hint: Option<(f64, mt::Point2, mt::Point2)>,
}
impl EdgeSampler {
pub(crate) fn new(edge: &mt::Edge) -> Option<EdgeSampler> {
let curve = edge.oriented_curve();
let range = curve.range_tuple();
(range.0.is_finite() && range.1.is_finite() && range.0 < range.1).then_some(EdgeSampler { curve, range, hint: None })
}
pub(crate) fn at(&mut self, s: f64) -> Option<Vec3> {
let (t0, t1) = self.range;
let t = if s >= 1.0 { t1 } else { t0 + (t1 - t0) * s };
let mt::Curve::IntersectionCurve(ic) = &self.curve else { return curve_point(&self.curve, t) };
let leader = ic.leader();
let (p, d) = (leader.subs(t), leader.der(t));
let near = |q: mt::Point3| q.distance(p) <= 4.0 * ic.tolerance() + 1e-6;
let seeded = self
.hint
.filter(|(tp, _, _)| (t - tp).abs() <= (t1 - t0) / 8.0)
.and_then(|(_, a, b)| mt::double_projection(ic.surface0(), Some((a.x, a.y)), ic.surface1(), Some((b.x, b.y)), p, d, 100))
.filter(|f| near(f.0));
let found = seeded.or_else(|| mt::double_projection(ic.surface0(), None, ic.surface1(), None, p, d, 100))?;
self.hint = Some((t, found.1, found.2));
let q = from_p3(found.0);
q.is_finite().then_some(q)
}
}
/// The planar face bounded by the closed loop `chain` (each edge oriented along it) when its
/// edges are certainly in one plane, `None` otherwise (also when an edge cannot be sampled).
/// The plane is fitted to points along the edges ([`plane_through`], the normal turning with the
/// loop); the edges are certified within `tol` of it conservatively: a line by its ends, a
/// B-spline or NURBS curve with positive weights by its control points (the curve lies in their
/// convex hull), an intersection curve only when one of its two surfaces is a plane that the
/// loop's points lie on and the fitted plane agrees with over the loop (the curve lies in that
/// plane). Samples alone are never trusted: a spline can bulge out of the plane between them.
pub(crate) fn planar_face(chain: &[mt::Edge], tol: f64) -> Result<Option<mt::Face>> {
const SAMPLES: usize = 16;
let mut pts = Vec::with_capacity(chain.len() * SAMPLES);
for e in chain {
let Some(mut sampler) = EdgeSampler::new(e) else { return Ok(None) };
for k in 0..SAMPLES {
let Some(p) = sampler.at(k as f64 / SAMPLES as f64) else { return Ok(None) };
pts.push(p3(p));
}
}
let Some(plane) = plane_through(&pts) else { return Ok(None) };
let (o, n) = (plane.origin(), plane.normal());
let off = |p: mt::Point3, o: mt::Point3, n: mt::Vector3| (p - o).dot(n).abs();
let on_plane = |p: mt::Point3| off(p, o, n) <= tol;
if !pts.iter().all(|p| on_plane(*p)) {
return Ok(None);
}
// A support plane `q` of an intersection curve holds the loop when the loop's points lie on
// it and their feet on the fitted plane do too.
let holds = |q: &mt::Plane| {
let (qo, qn) = (q.origin(), q.normal());
pts.iter().all(|p| off(*p, qo, qn) <= tol && off(*p - n * (*p - o).dot(n), qo, qn) <= tol)
};
for e in chain {
let certified = match e.curve() {
mt::Curve::Line(l) => on_plane(l.0) && on_plane(l.1),
mt::Curve::BSplineCurve(b) => b.control_points().iter().all(|p| on_plane(*p)),
mt::Curve::NurbsCurve(c) => {
c.non_rationalized().control_points().iter().all(|h| h.w > 0.0 && on_plane(mt::Point3::new(h.x / h.w, h.y / h.w, h.z / h.w)))
}
mt::Curve::IntersectionCurve(ic) => [ic.surface0(), ic.surface1()].into_iter().any(|s| matches!(s, mt::Surface::Plane(q) if holds(q))),
};
if !certified {
return Ok(None);
}
}
let wire: mt::Wire = chain.to_vec().into();
Ok(Some(mt::Face::try_new(vec![wire], mt::Surface::Plane(plane)).map_err(|e| KernelError::Failed(format!("patch: {e}")))?))
}
+94 -1
View File
@@ -460,7 +460,14 @@ fn sew_impl(faces: &[FaceSpec], touching: bool, sources: Option<&[Option<crate::
})
.collect::<Result<_>>()?;
let face = match &f.surface {
SurfSpec::Plane => builder::try_attach_plane(&wires).map_err(|e| KernelError::Failed(format!("sew: face {fi}: {e}")))?,
SurfSpec::Plane => match builder::try_attach_plane(&wires) {
Ok(face) => face,
// truck's fit loses thin faces away from the origin: see `plane_about_the_mean`.
Err(e) => {
let plane = plane_about_the_mean(&wires).ok_or_else(|| KernelError::Failed(format!("sew: face {fi}: {e}")))?;
mt::Face::try_new(wires, mt::Surface::Plane(plane)).map_err(|e| KernelError::Failed(format!("sew: face {fi}: {e}")))?
}
},
SurfSpec::Revolved { a, b, axis_origin, axis, angle } => {
let k = axis.normalized().ok_or_else(|| KernelError::Invalid("sew: axis".into()))?;
let (va, vb) = (builder::vertex(p3(*a)), builder::vertex(p3(*b)));
@@ -558,6 +565,35 @@ fn curved(mut surf: mt::Surface, wires: Vec<mt::Wire>, probe: Option<(Vec3, Vec3
mt::Face::try_new(wires, surf).map_err(|e| KernelError::Failed(format!("sew: face {fi}: {e}")))
}
/// The plane of a boundary that truck's fit (`builder::try_attach_plane`) refused: truck's fit
/// again, on the boundary moved so that the mean of its control points is the origin, and the
/// plane moved back. None when truck refuses that one too (the boundary is not flat within
/// truck's 1e-6, or has no area).
///
/// truck sums the cross products of consecutive control points about a centre, and takes the
/// points' sum for that centre (it divides by the number of wires, not of points): eight times as
/// far from the origin as a rectangle of four lines. The sum is the same about any centre; its
/// rounding is not. It grows with the square of the centre's distance, and on a thin face, whose
/// normal is short, turns the normal far enough for truck's own flatness test to fail: truck
/// finds a 0.00013 x 30 mm face 360 mm from the origin 2e-6 mm out of the plane it fitted. With
/// the centre at the origin the same face is 1e-14 mm out at most.
fn plane_about_the_mean(wires: &[mt::Wire]) -> Option<mt::Plane> {
let (mut sum, mut count) = (Vec3::ZERO, 0usize);
for p in wires.iter().flat_map(|w| w.edge_iter()).flat_map(|e| e.oriented_curve().lift_up().control_points().clone()) {
sum += Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w);
count += 1;
}
let mean = sum * (1.0 / count.max(1) as f64);
if !mean.is_finite() {
return None;
}
let moved: Vec<mt::Wire> = wires.iter().map(|w| builder::transformed(w, mt::Matrix4::from_translation(v3(-mean)))).collect();
match builder::try_attach_plane(&moved).ok()?.surface() {
mt::Surface::Plane(plane) => Some(mt::Transformed::transformed(&plane, mt::Matrix4::from_translation(v3(mean)))),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -734,4 +770,61 @@ mod tests {
let e = sew(&bulged_box(bulge(0.0), whole)).unwrap_err().to_string();
assert!(e.contains("ruled chains differ"), "{e}");
}
#[test]
fn thin_faces_away_from_the_origin_get_their_planes() {
// Slabs 30 x 2 mm and 0.00013 to 0.1 mm thick, away from the origin and turned off the
// axes. truck's plane fit sums the cross products of a boundary's points about a centre
// and takes the points' sum for it, eight times as far from the origin as a rectangle;
// on the thin faces its normal then comes out so far off that truck finds a
// 0.00013 x 30 mm face at (-300, -200, 5) 2e-6 mm out of its own plane and refuses it:
// `cannot attach a plane to a wire that is not on one plane`. Nine of these 45 slabs
// have such a face. (Thinner slabs meet another limit, at the origin too: a face
// 0.00003 mm wide and askew to the axes is sewn but gets no triangles.)
let turned = |about_z: f64, about_x: f64| {
let (sz, cz) = about_z.sin_cos();
let (sx, cx) = about_x.sin_cos();
// The x, y and z axes turned about x, then about z.
(Vec3::new(cz, sz, 0.0), Vec3::new(-sz * cx, cz * cx, sx), Vec3::new(sz * sx, -cz * sx, cx))
};
// A box on the corner `o` with the edges `u`, `v` and `w` (right-handed).
let skew_box = |o: Vec3, u: Vec3, v: Vec3, w: Vec3| {
let p = |x: f64, y: f64, z: f64| o + u * x + v * y + w * z;
let (a, b, c, d) = (p(0., 0., 0.), p(1., 0., 0.), p(1., 1., 0.), p(0., 1., 0.));
let (e, f, g, h) = (p(0., 0., 1.), p(1., 0., 1.), p(1., 1., 1.), p(0., 1., 1.));
vec![quad(a, d, c, b), quad(e, f, g, h), quad(a, b, f, e), quad(b, c, g, f), quad(c, d, h, g), quad(d, a, e, h)]
};
for o in [Vec3::new(-300.0, -200.0, 5.0), Vec3::new(2500.0, -1800.0, 40.0), Vec3::ZERO] {
for width in [1.3e-4, 1e-3, 0.1] {
for (about_z, about_x) in [(0.0, 0.0), (0.0, 0.45), (0.9, 0.0), (-0.65, 0.45), (2.2, 1.1)] {
let tag = format!("{o:?}, {width} wide, turned {about_z} and {about_x}");
let (u, v, w) = turned(about_z, about_x);
let body = sew(&skew_box(o, u * width, v * 2.0, w * 30.0)).unwrap_or_else(|e| panic!("{tag}: {e}"));
assert!(body.validity().is_empty() && body.shell_count() == 1 && body.face_count() == 6, "{tag}: {:?}", body.validity());
// (The measure sums about the origin: it is good to about 1e-14 of the
// distance cubed, which three metres out is more than the thinnest slabs hold.)
let (got, want) = (crate::measure(&body).unwrap().volume, width * 60.0);
assert!((got - want).abs() < 1e-5 * want + 1e-14 * o.len().powi(3) + 1e-9, "{tag}: {got} vs {want}");
}
}
}
// A boundary that leaves its plane is refused as before: one corner of a face lifted by
// a thousandth of a millimetre, on a cube and on a sliver.
for size in [Vec3::new(10.0, 10.0, 10.0), Vec3::new(10.0, 2e-4, 10.0)] {
let mut warped = box_faces(Vec3::new(500.0, 300.0, 0.0), size);
if let EdgeSpec::Line { b, .. } = &mut warped[1].loops[0][1] {
b.z += 1e-3;
}
if let EdgeSpec::Line { a, .. } = &mut warped[1].loops[0][2] {
a.z += 1e-3;
}
let e = sew(&warped).unwrap_err().to_string();
assert!(e.contains("face 1") && e.contains("not on one plane"), "{e}");
}
// So is one with no area.
let mut flat = box_faces(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0));
let p = Vec3::new(0.0, 0.0, 10.0);
flat[1] = quad(p, p + Vec3::X * 3.0, p + Vec3::X * 7.0, p + Vec3::X * 10.0);
assert!(sew(&flat).unwrap_err().to_string().contains("not on one plane"));
}
}
+36
View File
@@ -0,0 +1,36 @@
//! geom's B-spline curves and surfaces as truck's NURBS geometry, weights kept (homogeneous
//! control points carried over as they are).
use solidcraft_geom::bspline::{HPoint, NurbsSurface};
use truck_modeling as mt;
use crate::{KernelError, Result};
fn fail(m: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("spline geometry: {m}"))
}
/// A homogeneous control point as truck's.
pub(crate) fn h4(h: HPoint) -> mt::Vector4 {
mt::Vector4::new(h.x, h.y, h.z, h.w)
}
/// A NURBS curve of knots and homogeneous control points.
pub(crate) fn nurbs_curve(knots: mt::KnotVec, ctrl: &[HPoint]) -> Result<mt::Curve> {
let c = mt::BSplineCurve::try_new(knots, ctrl.iter().copied().map(h4).collect()).map_err(fail)?;
Ok(mt::Curve::NurbsCurve(mt::NurbsCurve::new(c)))
}
/// A NURBS surface of knots and homogeneous control points (`rows[i][j]`: i along u).
pub(crate) fn nurbs_surface(knots: (mt::KnotVec, mt::KnotVec), rows: Vec<Vec<mt::Vector4>>) -> Result<mt::Surface> {
let s = mt::BSplineSurface::try_new(knots, rows).map_err(fail)?;
Ok(mt::Surface::NurbsSurface(mt::NurbsSurface::new(s)))
}
/// geom's tensor surface as truck's.
pub(crate) fn surface(s: &NurbsSurface) -> Result<mt::Surface> {
let [nu, nv] = s.count();
let [ku, kv] = s.knots();
let rows: Vec<Vec<mt::Vector4>> = (0..nu).map(|i| (0..nv).map(|j| h4(s.point(i, j))).collect()).collect();
nurbs_surface((mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone())), rows)
}
+2 -26
View File
@@ -4,7 +4,7 @@
use std::collections::HashMap;
use mt::{BoundedCurve, EuclideanSpace, InnerSpace, MetricSpace, ParametricCurve};
use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve};
use truck_modeling as mt;
use super::Ctx;
@@ -244,7 +244,7 @@ impl Builder {
})
.collect();
let surface = if e.name() == "FACE" {
plane_through(&extent).ok_or("polygonal face without a plane")?
crate::plane_fit::plane_through(&extent).map(mt::Surface::Plane).ok_or("polygonal face without a plane")?
} else {
let sid = rid(p.get(2), "face geometry")?;
let sense = p.get(3).and_then(Param::as_bool).unwrap_or(true);
@@ -344,30 +344,6 @@ impl Builder {
}
}
/// Plane through a polygon (Newell's normal).
fn plane_through(pts: &[mt::Point3]) -> Option<mt::Surface> {
let n = pts.len();
let mut nrm = mt::Vector3::new(0.0, 0.0, 0.0);
let mut c = mt::Vector3::new(0.0, 0.0, 0.0);
for i in 0..n {
let (a, b) = (pts.get(i)?, pts.get((i + 1) % n)?);
nrm.x += (a.y - b.y) * (a.z + b.z);
nrm.y += (a.z - b.z) * (a.x + b.x);
nrm.z += (a.x - b.x) * (a.y + b.y);
c += a.to_homogeneous().truncate();
}
let len = nrm.magnitude();
if !(len > 1e-12) || n == 0 {
return None;
}
let z = nrm / len;
let o = mt::Point3::from_vec(c / n as f64);
let a = if z.x.abs() < 0.9 { mt::Vector3::unit_x() } else { mt::Vector3::unit_y() };
let x = (a - z * a.dot(z)).normalize();
let y = z.cross(x);
Some(mt::Surface::Plane(mt::Plane::new(o, o + x, o + y)))
}
/// Read a solid-like representation item: MANIFOLD_SOLID_BREP, BREP_WITH_VOIDS, FACETED_BREP,
/// or a SHELL_BASED_SURFACE_MODEL (closed shells give solids, open ones open bodies).
pub(crate) fn read_item(cx: &Ctx, id: u64) -> R<BrepOut> {
+82 -19
View File
@@ -1214,6 +1214,39 @@ fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap<u64, V
Ok((ex, merged, paint))
}
/// The separate pieces of a body for export, each keeping its face colours: a body of several
/// lumps (a plate and the islands a cut left inside its holes, such as letter counters) is
/// several solids, never one solid whose other pieces are written as voids. A body in one
/// piece, or one whose pieces cannot be told apart, is itself.
fn export_lumps(b: &Body) -> Vec<Body> {
if b.mesh.is_some() || b.solid.boundaries().len() < 2 {
return vec![b.clone()];
}
let lumps = match b.lumps() {
Ok(l) if l.len() > 1 => l,
_ => return vec![b.clone()],
};
let index: HashMap<_, usize> = b.solid.face_iter().enumerate().map(|(i, f)| (f.id(), i)).collect();
lumps
.into_iter()
.map(|lump| {
let paint = b.paint().map(|p| crate::body::Paint {
opacity: p.opacity,
faces: lump
.solid
.face_iter()
.enumerate()
.filter_map(|(k, f)| {
let i = index.get(&f.id())?;
p.faces.iter().find(|fp| fp.face == *i).map(|fp| crate::body::FacePaint { face: k, ..fp.clone() })
})
.collect(),
});
lump.with_color(b.color()).with_split_lines(b.split_lines().cloned()).with_paint(paint)
})
.collect()
}
/// Faces in the exchange's shells (fewer after merging).
fn shell_face_count(ex: &p21::Exchange) -> usize {
ex.entities
@@ -1366,26 +1399,28 @@ fn products_text(products: &[ExportProduct], root: usize, header: &StepHeader, m
if !p.bodies.is_empty() {
let mut solids = Vec::new();
for b in &p.bodies {
let (ex, merged, face_paint) = brep_exchange(b.body, merge)?;
o.merged = merged;
o.faces_out.clear();
let mut roots: Vec<u64> =
ex.entities.iter().filter(|(_, e)| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS")).map(|(i, _)| *i).collect();
roots.sort();
for r in roots {
let id = o.copy_solid(&ex, r, &b.name).map_err(|e| KernelError::Failed(format!("STEP export: {e}")))?;
solids.push(id);
let opacity = b.body.paint().map(|p| p.opacity).unwrap_or(1.0);
if let Some(c) = b.color {
styled.push(style(&mut o, id, c, opacity));
for lump in export_lumps(b.body) {
let (ex, merged, face_paint) = brep_exchange(&lump, merge)?;
o.merged = merged;
o.faces_out.clear();
let mut roots: Vec<u64> =
ex.entities.iter().filter(|(_, e)| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS")).map(|(i, _)| *i).collect();
roots.sort();
for r in roots {
let id = o.copy_solid(&ex, r, &b.name).map_err(|e| KernelError::Failed(format!("STEP export: {e}")))?;
solids.push(id);
let opacity = lump.paint().map(|p| p.opacity).unwrap_or(1.0);
if let Some(c) = b.color {
styled.push(style(&mut o, id, c, opacity));
}
}
// Faces with colours of their own.
let mut painted: Vec<(u64, ([f32; 3], f32))> =
face_paint.into_iter().filter_map(|(f, look)| Some((*o.faces_out.get(&f)?, look))).collect();
painted.sort_by_key(|(f, _)| *f);
for (f, (c, op)) in painted {
styled.push(style(&mut o, f, c, op));
}
}
// Faces with colours of their own.
let mut painted: Vec<(u64, ([f32; 3], f32))> =
face_paint.into_iter().filter_map(|(f, look)| Some((*o.faces_out.get(&f)?, look))).collect();
painted.sort_by_key(|(f, _)| *f);
for (f, (c, op)) in painted {
styled.push(style(&mut o, f, c, op));
}
}
let list = solids.iter().chain(std::iter::once(&origin)).map(|i| format!("#{i}")).collect::<Vec<_>>().join(",");
@@ -1484,6 +1519,34 @@ pub fn step_export_bodies(bodies: &[ExportBody], header: &StepHeader) -> Result<
mod tests {
use super::*;
#[test]
fn islands_are_solids_of_their_own_not_voids() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let square = |a: f64, b: f64| Loop2::polygon(&[Vec2::new(a, a), Vec2::new(b, a), Vec2::new(b, b), Vec2::new(a, b)]);
// A plate with a square hole, and an island inside the hole (a letter's counter).
let plate = Region2 { outer: square(0.0, 30.0), holes: vec![square(10.0, 20.0).reversed()] };
let island = Region2 { outer: square(12.0, 18.0), holes: vec![] };
let body = crate::extrude_planar_regions(&Plane::XY, &[plate, island], 0.0, 2.0).unwrap();
assert_eq!(body.shell_count(), 2);
let text = crate::step_export(&[&body], "test").unwrap();
assert_eq!(text.matches("MANIFOLD_SOLID_BREP(").count(), 2, "{text}");
assert_eq!(text.matches("BREP_WITH_VOIDS").count(), 0);
let imp = crate::step_import(&text).unwrap();
let v: f64 = imp.bodies.iter().map(|b| crate::measure(&b.body).unwrap().volume).sum();
assert!((v - (800.0 + 36.0) * 2.0).abs() < 1e-6, "{v}");
// A real void stays a void.
let hollow = crate::boolean(
&crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(),
&crate::box_solid(Vec3::new(3.0, 3.0, 3.0), Vec3::new(7.0, 7.0, 7.0)).unwrap(),
crate::BoolOp::Cut,
)
.unwrap()
.unwrap();
assert_eq!(hollow.shell_count(), 2);
let text = crate::step_export(&[&hollow], "test").unwrap();
assert_eq!(text.matches("BREP_WITH_VOIDS").count(), 1, "{text}");
}
#[test]
fn numbers_and_strings() {
assert_eq!(real(1.0), "1.0");
+42 -32
View File
@@ -1,5 +1,6 @@
//! Surface bodies (open shells) and the operations that turn them into solids: Patch (a planar
//! face filling a closed loop of edges or a sketch region), Stitch (surfaces sharing edges sewn
//! Surface bodies (open shells) and the operations that turn them into solids: Patch (a face
//! filling a closed loop of edges — planar, or B-spline patches for a loop that is not — or a
//! planar sketch region), Stitch (surfaces sharing edges sewn
//! into one body, a solid when they close), Trim (a surface cut by a tool, one side kept),
//! Thicken (a surface offset along its normals into a solid) and copying faces out of a body.
@@ -76,13 +77,31 @@ pub(crate) fn edge_near(body: &Body, p: Vec3) -> Option<mt::Edge> {
best.filter(|(d, _)| *d <= body.size() * 0.05 + 1e-6).map(|(_, e)| e)
}
/// A planar surface filling a closed loop of a body's edges (each picked by a point on it). Its
/// history is over `[body]`: the face is new, its edges the body's.
/// A surface filling a closed loop of a body's edges (each picked by a point on it): one face on
/// the body's own edges, planar when the loop is certainly planar ([`crate::plane_fit`]), else a
/// B-spline height field over the loop's plane ([`crate::fill`]; loops that do not bound one are
/// refused). Its history is over `[body]`: the face is new, its edges the body's own.
pub fn patch_edges(body: &Body, at: &[Vec3]) -> Result<Body> {
body.require_brep("patch")?;
if at.is_empty() || at.len() > 10_000 {
return Err(KernelError::Invalid("pick the edges of a closed loop".into()));
}
guard("patch", || {
let chain = picked_loop(body, at)?;
let Some(face) = crate::plane_fit::planar_face(&chain, crate::fill::planarity_tolerance(body))? else {
// Not certainly planar: a fitted surface.
return crate::fill::fill_loop(body, &chain);
};
// A new face on the body's own edges.
let mut rec = Rebuild::default();
rec.made(&face, Vec::new());
Ok(rebuilt_body(surface_body(vec![face]), &[&body.solid], &rec))
})
}
/// The edges of `body` picked by points on them, chained into one closed loop (each turned to
/// run along it). Call it under [`guard`]: sampling an intersection curve can fail in truck.
pub(crate) fn picked_loop(body: &Body, at: &[Vec3]) -> Result<Vec<mt::Edge>> {
let mut edges: Vec<mt::Edge> = Vec::new();
for p in at {
let e = edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?;
@@ -90,34 +109,25 @@ pub fn patch_edges(body: &Body, at: &[Vec3]) -> Result<Body> {
edges.push(e);
}
}
guard("patch", || {
// Chain the edges into one loop (turning them round as needed).
let mut pool = edges;
let first = pool.pop().ok_or_else(|| KernelError::Invalid("no edges".into()))?;
let start = first.front().id();
let mut at_v = first.back().clone();
let mut chain = vec![first];
while at_v.id() != start {
let k = pool
.iter()
.position(|e| e.front().id() == at_v.id() || e.back().id() == at_v.id())
.ok_or_else(|| KernelError::Invalid("the edges do not form a closed loop".into()))?;
let e = pool.remove(k);
let e = if e.front().id() == at_v.id() { e } else { e.inverse() };
at_v = e.back().clone();
chain.push(e);
}
if !pool.is_empty() {
return Err(KernelError::Invalid("the edges form more than one loop".into()));
}
let wire: mt::Wire = chain.into();
let face = mt::builder::try_attach_plane(&[wire])
.map_err(|_| KernelError::Invalid("not supported yet: patching a loop that is not planar".into()))?;
// A new face on the body's own edges.
let mut rec = Rebuild::default();
rec.made(&face, Vec::new());
Ok(rebuilt_body(surface_body(vec![face]), &[&body.solid], &rec))
})
let mut pool = edges;
let first = pool.pop().ok_or_else(|| KernelError::Invalid("no edges".into()))?;
let start = first.front().id();
let mut at_v = first.back().clone();
let mut chain = vec![first];
while at_v.id() != start {
let k = pool
.iter()
.position(|e| e.front().id() == at_v.id() || e.back().id() == at_v.id())
.ok_or_else(|| KernelError::Invalid("the edges do not form a closed loop".into()))?;
let e = pool.remove(k);
let e = if e.front().id() == at_v.id() { e } else { e.inverse() };
at_v = e.back().clone();
chain.push(e);
}
if !pool.is_empty() {
return Err(KernelError::Invalid("the edges form more than one loop".into()));
}
Ok(chain)
}
/// Faces of a body copied out as a surface body (faces picked by points on them). Its history is
+75
View File
@@ -125,6 +125,32 @@ fn revolve_washer_and_sphere() {
assert!(rel(ms.volume, 4.0 / 3.0 * PI * 1000.0) < 1e-3, "{}", ms.volume);
}
#[test]
fn revolve_adjacent_profiles_on_the_axis() {
// Two rectangles on the axis, revolved together: their merged outline keeps a vertex
// where the shared edge met the axis. The swept axis pieces used to be zero-area faces,
// and cutting the body's flat end out of another failed.
let r = Region2 {
outer: Loop2::polygon(&[
Vec2::new(0.0, 0.0),
Vec2::new(30.0, 0.0),
Vec2::new(60.0, 0.0),
Vec2::new(60.0, 20.0),
Vec2::new(30.0, 20.0),
Vec2::new(0.0, 20.0),
]),
holes: vec![],
};
let b = revolve(&Plane::XZ, &[r], Vec2::ZERO, Vec2::X, 2.0 * PI).unwrap().pop().unwrap();
assert!(rel(measure(&b).unwrap().volume, PI * 400.0 * 60.0) < 5e-4);
let faces = b.faces(1e-3).unwrap();
assert!(faces.iter().all(|f| f.area > 1e-6), "{:?}", faces.iter().map(|f| f.area).collect::<Vec<_>>());
let disc = cylinder(Vec3::new(-1.0, 0.0, 0.0), Vec3::X, 40.0, 3.0).unwrap();
let cut = boolean(&disc, &b, BoolOp::Cut).unwrap().unwrap();
let want = PI * 1600.0 * 3.0 - PI * 400.0 * 2.0;
assert!(rel(measure(&cut).unwrap().volume, want) < 1e-3, "{} vs {want}", measure(&cut).unwrap().volume);
}
#[test]
fn chamfer_and_step() {
let b = box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap();
@@ -356,6 +382,29 @@ fn fillet_max_finds_the_largest_radius_that_works() {
assert!(fillet_max(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).is_none());
}
/// A corner's rounds too large for its faces are refused as too large, so a fit can reduce
/// them, also where only the corner build finds it (the edge-by-edge blend failing otherwise).
#[test]
fn corner_rounds_larger_than_their_faces_are_too_large() {
let (l, eps) = (20.0, 1e-9);
let b = box_solid(Vec3::ZERO, Vec3::new(l, l, l)).unwrap();
let corner = [Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(0.0, 0.0, 10.0)];
// As wide as the faces: the first edge's round uses up its neighbour, then the edge-by-edge
// blend can't find it; the corner build is what says the radius is too large.
let e = fillet(&b, &corner, l).unwrap_err();
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
let (r, body) = fillet_max(&b, &corner, l).unwrap();
assert!((r - 19.99).abs() < eps, "{r}");
assert!(body.validity().is_empty(), "{:?}", body.validity());
// Three rounds along the edges past the corner cube, and the cube less its sphere octant.
let removed = 3.0 * (1.0 - PI / 4.0) * r * r * (l - r) + r * r * r * (1.0 - PI / 6.0);
assert!(rel(measure(&body).unwrap().volume, l * l * l - removed) < 2e-4);
// Different radii per edge, one wider than its faces.
let sel: Vec<(Vec3, f64)> = corner.iter().copied().zip([21.0, 5.0, 5.0]).collect();
let e = crate::blend_corner::fillet_radii(&b, &sel).unwrap_err();
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
}
#[test]
fn tapered_extrudes() {
// 40 x 40 square, 30 up, 10° outward: a frustum.
@@ -2123,3 +2172,29 @@ fn shell_open_beside_a_round_as_large_as_the_wall_is_refused() {
let f = fillet(&b, &loop_, 1.5).unwrap();
assert!(shell(&f, &bottom, 2.0).unwrap().validity().is_empty());
}
/// A disc whose circular edge is rounded smaller than the wall (#319): moved in by the
/// thickness the round collapses to a sharp edge, where the floor meets the side along a
/// circle (two arcs with the same ends), not a straight line.
#[test]
fn shell_of_a_disc_rounded_smaller_than_the_wall() {
use std::f64::consts::PI;
let (rd, h, r) = (15.0, 5.0, 1.5);
let disc = cylinder(Vec3::new(0.0, 0.0, -h), Vec3::Z, rd, h).unwrap();
// The round takes a corner of area r²(1 − π/4) whose centroid is r(10 − 3π)/(3(4 − π))
// in from the rim, swept round the axis (Pappus).
let corner = r * r * (1.0 - PI / 4.0) * 2.0 * PI * (rd - r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
let (top, bottom) = ([Vec3::ZERO], [Vec3::new(0.0, 0.0, -h)]);
for (edge_z, open) in [(-h, &top), (-h, &bottom), (0.0, &bottom)] {
let rounded = fillet(&disc, &[Vec3::new(0.0, rd, edge_z), Vec3::new(0.0, -rd, edge_z)], r).unwrap();
let v0 = PI * rd * rd * h - corner;
for t in [1.6, 2.0] {
let s = shell(&rounded, open, t).unwrap_or_else(|e| panic!("round at z {edge_z}, t {t}: {e}"));
assert!(s.validity().is_empty(), "round at z {edge_z}, t {t}: {:?}", s.validity());
// The cavity: a plain cylinder the thickness in from the side and the closed end.
let want = v0 - PI * (rd - t).powi(2) * (h - t);
let v = measure(&s).unwrap().volume;
assert!(rel(v, want) < 1e-3, "round at z {edge_z}, t {t}: {v} vs {want}");
}
}
}
+3 -17
View File
@@ -11,6 +11,7 @@ use solidcraft_geom::tspline::TSpline;
use truck_modeling as mt;
use crate::body::Body;
use crate::spline_conv::{h4, nurbs_curve, nurbs_surface};
use crate::{KernelError, Result, guard};
/// A T-spline as a surface body (an open shell of NURBS faces whose normals follow
@@ -34,25 +35,11 @@ fn fail(m: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("t-spline sheet: {m}"))
}
fn h4(h: HPoint) -> mt::Vector4 {
mt::Vector4::new(h.x, h.y, h.z, h.w)
}
fn vertex(h: HPoint) -> Result<mt::Vertex> {
let p = h.project().ok_or_else(|| fail("a corner has no position"))?;
Ok(mt::builder::vertex(crate::body::p3(p)))
}
fn nurbs_curve(knots: mt::KnotVec, ctrl: &[HPoint]) -> Result<mt::Curve> {
let c = mt::BSplineCurve::try_new(knots, ctrl.iter().copied().map(h4).collect()).map_err(fail)?;
Ok(mt::Curve::NurbsCurve(mt::NurbsCurve::new(c)))
}
fn surface(knots: (mt::KnotVec, mt::KnotVec), rows: Vec<Vec<mt::Vector4>>) -> Result<mt::Surface> {
let s = mt::BSplineSurface::try_new(knots, rows).map_err(fail)?;
Ok(mt::Surface::NurbsSurface(mt::NurbsSurface::new(s)))
}
/// One face from a clamped tensor surface, bounded by its four boundary curves.
fn tensor_face(n: &NurbsSurface) -> Result<mt::Face> {
let [nu, nv] = n.count();
@@ -69,8 +56,7 @@ fn tensor_face(n: &NurbsSurface) -> Result<mt::Face> {
let top = edge(v3, v2, nurbs_curve(ku.clone(), &row(nv - 1))?)?;
let left = edge(v0, v3, nurbs_curve(kv.clone(), &column(0))?)?;
let wire: mt::Wire = vec![bottom, right, top.inverse(), left.inverse()].into();
let rows: Vec<Vec<mt::Vector4>> = (0..nu).map(|i| (0..nv).map(|j| h4(n.point(i, j))).collect()).collect();
mt::Face::try_new(vec![wire], surface((ku, kv), rows)?).map_err(fail)
mt::Face::try_new(vec![wire], crate::spline_conv::surface(n)?).map_err(fail)
}
/// A parameter point, exact (knot values are shared, so equal points have equal bits).
@@ -148,7 +134,7 @@ fn patch_faces(patches: &[BezierPatch]) -> Result<Vec<mt::Face>> {
}
let [ps, pt] = p.degree;
let rows: Vec<Vec<mt::Vector4>> = (0..=ps).map(|i| (0..=pt).map(|j| h4(p.point(i, j))).collect()).collect();
let surf = surface((mt::KnotVec::bezier_knot(ps), mt::KnotVec::bezier_knot(pt)), rows)?;
let surf = nurbs_surface((mt::KnotVec::bezier_knot(ps), mt::KnotVec::bezier_knot(pt)), rows)?;
faces.push(mt::Face::try_new(vec![wire.into()], surf).map_err(fail)?);
}
Ok(faces)
+1 -1
View File
@@ -42,7 +42,7 @@ pub use curves::{
pub use decompose::Budget;
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, sketch_axis_geom};
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, MAX_CONSTRAINTS, MAX_CURVES, MAX_POINTS, SPoint, Sketch, SketchError, SketchView};
pub use offset::OffsetChain;
pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRoleKind, OffsetTopology};
+45
View File
@@ -55,6 +55,9 @@ pub enum LinkSource {
Origin,
/// An origin axis (`X`, `Y`, `Z`).
Axis { name: String },
/// One of the sketch's own axes (`x` or `y`): the line through the sketch origin along it,
/// whatever plane the sketch is on (see [`sketch_axis_geom`]).
SketchAxis { name: String },
/// An origin plane (`XY`, `XZ`, `YZ`) or a construction plane (by name): its trace on the
/// sketch plane.
Plane { name: String },
@@ -90,6 +93,7 @@ impl LinkSource {
LinkSource::SketchPoint { point, .. } => format!("sketch point {point}"),
LinkSource::Origin => "origin".into(),
LinkSource::Axis { name } => format!("{name} axis"),
LinkSource::SketchAxis { name } => format!("sketch {name} axis"),
LinkSource::Plane { name } => format!("{name} plane"),
LinkSource::OnSurface { curve, .. } => format!("{curve} on a face"),
LinkSource::Intersection { a, b } => format!("intersection of {} and {}", a.describe(), b.describe()),
@@ -100,6 +104,19 @@ impl LinkSource {
}
}
/// Half length of the line a sketch axis link produces.
pub const SKETCH_AXIS_HALF: f64 = 100.0;
/// The geometry of sketch axis `name` (`x` or `y`, sketch coordinates); `None` for any other name.
pub fn sketch_axis_geom(name: &str) -> Option<LinkGeom> {
let u = match name {
"x" => Vec2::X,
"y" => Vec2::Y,
_ => return None,
};
Some(LinkGeom::Line(Vec2::ZERO - u * SKETCH_AXIS_HALF, u * SKETCH_AXIS_HALF))
}
/// One link and its state.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Link {
@@ -417,6 +434,34 @@ impl Sketch {
self.curves.get(ci).and_then(|c| c.link.as_deref()).and_then(|l| self.link(l)).is_some_and(|l| l.lost)
}
/// The line of sketch axis `name` (`x` or `y`) when the sketch already has one (see
/// [`Sketch::sketch_axis_line`]).
pub fn sketch_axis_curve(&self, name: &str) -> Option<usize> {
self.links
.iter()
.filter(|l| matches!(&l.source, LinkSource::SketchAxis { name: n } if n == name))
.find_map(|l| self.link_curves(&l.id).into_iter().find(|c| matches!(self.curves.get(*c).map(|c| &c.kind), Some(CurveKind::Line { .. }))))
}
/// The line of sketch axis `name` (`x` or `y`) that constraints can refer to: a linked
/// construction line on the axis (it stays there and is left out of profiles), shared by
/// every reference to that axis and added on first use. `None` when `name` is not a sketch
/// axis.
pub fn sketch_axis_line(&mut self, name: &str) -> Result<Option<usize>> {
let Some(geom) = sketch_axis_geom(name) else { return Ok(None) };
if let Some(c) = self.sketch_axis_curve(name) {
return Ok(Some(c));
}
let id = self.add_link(LinkKind::Project, LinkSource::SketchAxis { name: name.into() }, &[geom])?;
let curves = self.link_curves(&id);
for c in &curves {
if let Some(cu) = self.curves.get_mut(*c) {
cu.construction = true;
}
}
curves.first().copied().map(Some).ok_or_else(|| SketchError::Invalid(format!("sketch {name} axis")))
}
/// Add linked geometry. Returns the link id; the new curve and point ids are
/// [`Sketch::link_curves`] / [`Sketch::link_points`] of it.
pub fn add_link(&mut self, kind: LinkKind, source: LinkSource, geom: &[LinkGeom]) -> Result<String> {
+12
View File
@@ -164,6 +164,8 @@ pub struct MenuState {
pub confirm: Option<crate::delete::Pending>,
/// The Properties window: title and measurements.
pub props: Option<(String, Value)>,
/// The viewport menu opened over something: it offers Select Other.
pub over_geometry: bool,
}
#[derive(Clone, Debug, PartialEq)]
@@ -346,6 +348,11 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
v.push(act("ui.repeat", &format!("Repeat {label}"), "redo"));
v.push(Item::sep());
}
// Everything under the cursor, for what lies beneath something else.
if app.menu.over_geometry {
v.push(act("ui.selectOther", "Select Other…", "").key("Hold click"));
v.push(Item::sep());
}
if app.session.active_sketch.is_some() {
v.extend(sketch_entity_items(app));
v.push(Item::sep());
@@ -726,6 +733,9 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
app.start(&id);
}
}
"ui.selectOther" => {
crate::select_other::open(app, at);
}
"ui.delete" if item.params.is_some() => crate::delete::request(app, p),
"ui.delete" => delete(app),
"ui.finishSketch" => app.finish_sketch(),
@@ -1161,6 +1171,8 @@ fn commit_rename(app: &mut SolveApp, r: &Rename) {
/// Open the viewport menu at a screen position. A right-click on something not selected
/// selects it first, so the menu is about what is under the cursor.
pub fn open(app: &mut SolveApp, at: Pos2) {
app.menu.over_geometry = app.viewport.hover.is_some();
crate::select_other::close(app);
if app.session.active_sketch.is_none()
&& let Some(h) = app.viewport.hover.clone()
&& let Some((_, sel)) = crate::viewport::candidate(app, &[h])
+1
View File
@@ -501,6 +501,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
json!({"diagnostics": crate::help::diagnostics(app), "version": crate::help::VERSION, "commit": crate::help::COMMIT, "built": crate::help::BUILD_DATE}),
),
},
"ui.selectOther" => wrap(crate::select_other::control(app, p)),
"ui.menuPick" => {
let Some(key) = s("item") else { return err("missing `item` (id or label)") };
wrap(crate::context_menu::pick(app, key))
+1 -1
View File
@@ -2321,7 +2321,7 @@ pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> {
_ => return Err("Select an origin plane, construction plane or planar body face".into()),
};
let before = app.cam;
app.run("sketch.create", json!({ "plane": plane }))?;
app.run("sketch.create", json!({ "plane": plane })).map_err(|error| crate::friendly_error("sketch.create", &error))?;
app.pre_sketch_cam = Some(before);
look_at_sketch(app);
// On a face: look at the picked point, not the plane's origin (a corner of the face),
+81 -4
View File
@@ -46,6 +46,9 @@ pub enum Sm {
distance: String,
reverse: bool,
rule: usize,
/// Edge flanges: the edges with an angle and/or a height of their own (the picked point,
/// angle, height; an empty text means the dialog's), from the rows per edge.
own: Vec<(Vec3, String, String)>,
},
Hem {
length: String,
@@ -125,6 +128,7 @@ fn new_flange(ty: usize) -> Sm {
distance: "20 mm".into(),
reverse: false,
rule: 0,
own: Vec::new(),
}
}
@@ -188,7 +192,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
let t = Tokens::get();
let mut enter = false;
match k {
Sm::Flange { ty, height, angle, radius, position, flip, distance, reverse, rule } => {
Sm::Flange { ty, height, angle, radius, position, flip, distance, reverse, rule, own } => {
*ty = flange_type(inputs);
row_label(ui, "Type");
ui.label(FLANGE_TYPES.get(*ty).copied().unwrap_or(""));
@@ -209,6 +213,34 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
crate::params_dialog::complete(ui, &r, radius);
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.end_row();
// With several edges, a row for each: an angle and a height of its own, left
// empty for the values above (shown as the hints).
let points = edge_point_list(inputs);
if points.len() > 1 || own.iter().any(|o| !(o.1.trim().is_empty() && o.2.trim().is_empty())) {
row_label(ui, "Per Edge");
ui.label(RichText::new("angle, height").weak()).on_hover_text("An edge's own angle and height. Empty: the values above.");
ui.end_row();
let w = ((ui.spacing().text_edit_width - ui.spacing().item_spacing.x) / 2.0).max(40.0);
for (n, p) in points.iter().enumerate() {
let slot = own_slot(own, *p);
let Some((_, a, h)) = own.get_mut(slot) else { continue };
row_label(ui, &format!("Edge {}", n + 1));
ui.horizontal(|ui| {
for (text, hint, what, id) in
[(a, angle.as_str(), "angle", "sm_edge_angle"), (h, height.as_str(), "height", "sm_edge_height")]
{
let r = ui
.add(egui::TextEdit::singleline(text).hint_text(hint).desired_width(w).id_salt((id, n)))
.on_hover_text(format!("The {what} of the edge picked at ({:.1}, {:.1}, {:.1})", p.x, p.y, p.z));
// For scenarios and the control channel: `dialog:Edge 2 angle`.
crate::scenario::publish_handle(&format!("dialog:Edge {} {what}", n + 1), r.rect.center());
crate::params_dialog::complete(ui, &r, text);
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
}
});
ui.end_row();
}
}
}
2 => {
row_label(ui, "Distance");
@@ -419,14 +451,48 @@ fn edge_points(inputs: &[SelInput]) -> Vec<Value> {
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect()
}
/// The picked edges' points.
fn edge_point_list(inputs: &[SelInput]) -> Vec<Vec3> {
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(*point) } else { None }).collect()
}
/// Where the own angle and height of the edge picked at `p` are kept (an empty entry on first use).
fn own_slot(own: &mut Vec<(Vec3, String, String)>, p: Vec3) -> usize {
own.iter().position(|o| o.0.dist(p) < 1e-9).unwrap_or_else(|| {
own.push((p, String::new(), String::new()));
own.len() - 1
})
}
/// The edges for `sheet.flange`: a plain point, or `{point, angle?, height?}` for an edge with
/// an angle and/or a height of its own.
fn flange_edges(inputs: &[SelInput], own: &[(Vec3, String, String)]) -> Vec<Value> {
edge_point_list(inputs)
.into_iter()
.map(|p| {
let (a, h) = own.iter().find(|o| o.0.dist(p) < 1e-9).map(|o| (o.1.trim(), o.2.trim())).unwrap_or(("", ""));
if a.is_empty() && h.is_empty() {
return pt(p);
}
let mut e = json!({"point": pt(p)});
for (k, v) in [("angle", a), ("height", h)] {
if !v.is_empty() {
e[k] = json!(v);
}
}
e
})
.collect()
}
/// The commands the dialog's OK runs (`extra`: parameters kept from the feature being edited).
pub fn commands(app: &SolveApp, k: &Sm, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
let s = &app.session;
let (cmd, mut p): (&str, Value) = match k {
Sm::Flange { height, angle, radius, position, flip, distance, reverse, rule, .. } => {
Sm::Flange { height, angle, radius, position, flip, distance, reverse, rule, own, .. } => {
let mut p = match flange_type(inputs) {
1 => {
let mut p = json!({"type": "edge", "edges": edge_points(inputs), "height": height, "angle": angle,
let mut p = json!({"type": "edge", "edges": flange_edges(inputs, own), "height": height, "angle": angle,
"position": POSITIONS.get(*position).copied().unwrap_or("inside")});
if !radius.trim().is_empty() {
p["radius"] = json!(radius);
@@ -572,6 +638,16 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelI
if let Some(b) = body {
extra.insert("body".into(), json!(b));
}
// The edges, and the angle and height some have of their own (kept by the point the
// edge's selection carries).
let (mut picked, mut own) = (Vec::new(), Vec::new());
for e in edges {
let Some(sel) = edge_sel(s, e.point) else { continue };
if let (Sel::Edge { point, .. }, true) = (&sel, e.angle.is_some() || e.height.is_some()) {
own.push((*point, e.angle.clone().unwrap_or_default(), e.height.clone().unwrap_or_default()));
}
picked.push(sel);
}
let f = Sm::Flange {
ty: 1,
height: height.clone(),
@@ -582,8 +658,9 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelI
distance: "20 mm".into(),
reverse: false,
rule: 0,
own,
};
(f, edges.iter().filter_map(|p| edge_sel(s, *p)).collect())
(f, picked)
}
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
if let Some(b) = body {
+62
View File
@@ -318,3 +318,65 @@ fn pocket_depth_labels_follow_inches_without_changing_flat_data() {
assert_eq!(super::pocket_label(&app, &json!({"side":"top","depth_mm":25.4*0.1234})).as_deref(), Some("Top pocket: 0.1234 in deep"));
assert!(super::pocket_label(&app, &json!({"side":"top","depth_mm":-1})).is_none());
}
/// Edges with an angle and/or a height of their own: the texts of the dialog's rows per edge
/// become `{point, angle?, height?}` edges while the others stay plain points; Edit Feature
/// brings them back and rebuilds the same flanges.
#[test]
fn edge_flanges_can_have_an_angle_and_a_height_of_their_own() {
let mut app = plate();
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
let edges: Vec<Sel> = [[50.0, 0.0, 2.5], [50.0, 60.0, 2.5], [0.0, 30.0, 2.5], [100.0, 30.0, 2.5]]
.iter()
.filter_map(|p| edge_sel(&app.session, Vec3::new(p[0], p[1], p[2])))
.collect();
assert_eq!(edges.len(), 4);
let point = |i: usize| if let Sel::Edge { point, .. } = &edges[i] { *point } else { Vec3::ZERO };
app.session.selection = edges.clone();
app.start("sheet.flange");
let mut d = app.dialog.clone().unwrap();
if let Kind::Sheet(Sm::Flange { height, angle, own, .. }) = &mut d.kind {
*height = "20 mm".into();
*angle = "75 deg".into();
// The second edge: its own angle; the fourth: both; the third: texts left blank.
own.push((point(1), "100 deg".into(), String::new()));
own.push((point(3), "90 deg".into(), "15 mm".into()));
own.push((point(2), " ".into(), String::new()));
}
let c = apply_commands(&app, &d).unwrap();
assert_eq!(
c[0].1["edges"],
json!([pt(point(0)), {"point": pt(point(1)), "angle": "100 deg"}, pt(point(2)), {"point": pt(point(3)), "angle": "90 deg", "height": "15 mm"}]),
"no text or a blank one: a plain point"
);
run_all(&mut app, c);
let bends = app.session.execute("sheet.flat_pattern", &json!({})).unwrap()["bends"].clone();
let angles: Vec<f64> = bends.as_array().unwrap().iter().map(|b| b["angle_deg"].as_f64().unwrap().round()).collect();
assert_eq!(angles, [75.0, 100.0, 75.0, 90.0], "{bends}");
assert!(app.session.model.results.iter().all(|r| r.error.is_none()), "{:?}", app.session.model.results);
// Edit Feature: the edges and their own values come back, and OK rebuilds the same part.
let f = app.session.doc.features.last().unwrap().clone();
let mut e = edit_dialog(&app, f.id, None).unwrap();
assert_eq!(e.inputs[0].items.len(), 4);
let Kind::Sheet(Sm::Flange { own, .. }) = &e.kind else { panic!("not a flange dialog") };
assert_eq!(own.len(), 2, "{own:?}");
assert!(own.iter().any(|o| o.0.dist(point(1)) < 1e-9 && o.1 == "100 deg" && o.2.is_empty()), "{own:?}");
assert!(own.iter().any(|o| o.0.dist(point(3)) < 1e-9 && o.1 == "90 deg" && o.2 == "15 mm"), "{own:?}");
let volume = |app: &mut SolveApp| app.session.execute("inspect.measure", &json!({})).unwrap()["total"]["volume_mm3"].as_f64().unwrap();
let before = volume(&mut app);
let c = apply_commands(&app, &e).unwrap();
assert_eq!(c[0].0, "timeline.redefine");
assert_eq!(c[0].1["params"]["edges"][1], json!({"point": pt(point(1)), "angle": "100 deg"}));
assert_eq!(c[0].1["params"]["edges"][3]["height"], "15 mm");
run_all(&mut app, c);
let after = volume(&mut app);
assert!((after - before).abs() < 1e-6 * before, "{after} vs {before}");
// Taking edges out of the selection leaves each value with its edge.
e.inputs[0].items.remove(2);
e.inputs[0].items.remove(0);
let c = apply_commands(&app, &e).unwrap();
assert_eq!(
c[0].1["params"]["edges"],
json!([{"point": pt(point(1)), "angle": "100 deg"}, {"point": pt(point(3)), "angle": "90 deg", "height": "15 mm"}])
);
}
+7
View File
@@ -165,6 +165,13 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
}
});
}
ui.label(
RichText::new(
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there.",
)
.size(11.0)
.color(t.text_dim),
);
ui.separator();
let q = app.keymap.filter.to_lowercase();
egui::ScrollArea::both().max_height(330.0).auto_shrink([false, true]).show(ui, |ui| {
+1
View File
@@ -79,6 +79,7 @@ mod revolve_edit_tests;
#[cfg(test)]
mod revolve_manipulator_tests;
pub mod scenario;
pub mod select_other;
pub mod selection;
#[cfg(test)]
mod shell_face_sketch_ui_tests;
+33 -7
View File
@@ -414,7 +414,9 @@ pub fn zoom_sign(app: &SolveApp) -> f64 {
}
/// Orbit by pixel deltas: around the view's centre, or, with "orbit around the cursor", around
/// the model point that was under the cursor when the drag began (kept still on screen).
/// the model point that was under the cursor when the drag began (kept still on screen). A drag
/// that begins over empty space turns around the centre of the visible geometry instead, so the
/// model never swings around a far-off point. The pivot is chosen once, when the drag begins.
pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Option<Pos2>) {
if !app.preferences.orbit_cursor {
app.cam.orbit(dx, dy);
@@ -438,7 +440,8 @@ pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Opti
}
}
/// The model point under a screen position, or the point at the view's depth.
/// The model point under a screen position. Over empty space: the centre of the visible bodies
/// and sketches (what Fit frames), or the view's centre when nothing is visible.
fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 {
use crate::viewport::Hit;
for h in crate::viewport::pick(app, proj, at) {
@@ -448,10 +451,8 @@ fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 {
_ => {}
}
}
let (o, d) = proj.ray(at);
let n = app.cam.back();
let den = d.dot(n);
if den.abs() > 1e-9 { o + d * ((app.cam.target - o).dot(n) / den) } else { app.cam.target }
let b = solidcraft_engine::view::bounds(&app.session);
if b.is_empty() { app.cam.target } else { b.center() }
}
/// Units per millimetre and the unit's name.
@@ -815,7 +816,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
});
ui.end_row();
let snap_help = if app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" };
note(ui, &format!("{}{snap_help}; the wheel zooms", nav_preset(&app.preferences.nav).help));
note(ui, &format!("{}; Ctrl+left drag on empty space orbits{snap_help}; the wheel zooms", nav_preset(&app.preferences.nav).help));
ui.label("Context menu");
ui.checkbox(&mut app.preferences.radial_context_menu, "Radial context menu");
ui.end_row();
@@ -1319,6 +1320,31 @@ mod tests {
assert!(s.distance(at) < 2.0, "{s:?}");
}
/// A drag that starts over empty space turns around the visible geometry's centre, not a
/// point far off to the side; with nothing visible it turns around the view's centre.
#[test]
fn orbiting_from_empty_space_turns_around_the_visible_geometry() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.run("solid.box", json!({"length": 40, "width": 40, "height": 40})).unwrap();
app.preferences.orbit_cursor = true;
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
app.fit_view();
let corner = egui::pos2(10.0, 10.0);
let proj = crate::viewport::projection(&app, rect);
let hits = crate::viewport::pick(&app, &proj, corner);
use crate::viewport::Hit;
assert!(!hits.iter().any(|h| matches!(h, Hit::Face { .. } | Hit::Edge { .. } | Hit::Vertex { .. })), "the corner is empty space");
let centre = solidcraft_engine::view::bounds(&app.session).center();
orbit(&mut app, rect, 40.0, 25.0, Some(corner));
assert_eq!(app.viewport.orbit_pivot, Some(centre));
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.preferences.orbit_cursor = true;
app.cam.target = solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0);
orbit(&mut app, rect, 40.0, 25.0, Some(corner));
assert_eq!(app.viewport.orbit_pivot, Some(solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0)));
}
/// The Y up preference turns the view: the camera's up axis follows it, startup included.
#[test]
fn up_axis_preference_turns_the_view() {
+25
View File
@@ -709,6 +709,31 @@ fn surface_dialogs_make_their_commands() {
assert!((v - 30.0 * 20.0 * 2.0).abs() < 1.0, "{v}");
}
#[test]
fn patch_dialog_fills_a_loop_of_edges_that_is_not_planar() {
// A box with its top cut away by a sideways cylinder: its rim is a closed loop of four
// curved edges, not in a plane. Picking them in the Patch dialog fills it with one face.
let mut app = app_with(json!([
{"command": "solid.box", "params": {"length": 28, "width": 28, "height": 20, "corner": [-14, -14, 0], "body_name": "Saddle"}},
{"command": "solid.cylinder", "params": {"radius": 40, "height": 120, "base": [0, -60, 50], "axis": "Y", "operation": "cut", "targets": ["Saddle"]}}
]));
let z = |x: f64| 50.0 - (1600.0 - x * x).sqrt();
let rim = [Vec3::new(14.0, 0.0, z(14.0)), Vec3::new(0.0, 14.0, z(0.0)), Vec3::new(-14.0, 0.0, z(-14.0)), Vec3::new(0.0, -14.0, z(0.0))];
let mut d = start(&mut app, vec![], "surface.patch");
d.inputs[0].items = rim.iter().enumerate().map(|(i, p)| Sel::Edge { body: "Saddle".into(), index: i, point: *p }).collect();
let cmds = apply_commands(&app, &d).unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].0, "surface.patch");
app.session.execute(&cmds[0].0, &cmds[0].1).unwrap();
let st = app.session.model.state();
let fill = st.bodies.iter().find(|b| b.name != "Saddle").unwrap();
assert!(fill.body.is_surface() && fill.body.face_count() == 1);
// One smooth face over the rim: its area is close to the cylinder's inside the rim.
let area = fill.mesh().measure().area;
let want = 28.0 * 40.0 * 2.0 * (14.0f64 / 40.0).asin();
assert!((area - want).abs() < want * 1e-2, "{area} vs {want}");
}
#[test]
fn split_body_by_a_curved_face_sends_it_as_a_tool() {
let mut app = app_with(json!([
+23 -1
View File
@@ -276,7 +276,13 @@ fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> B
fn as_new_body(cmds: &[(String, Value)]) -> Option<(Vec<(String, Value)>, String)> {
let mut out = cmds.to_vec();
let mut changed: Option<String> = None;
for (_, p) in &mut out {
for (command, p) in &mut out {
let inner = p.get("command").and_then(Value::as_str).map(str::to_string);
// A combine's operation is between bodies that already exist: it has no new-body form
// (and no tool body of its own to show).
if inner.as_deref().unwrap_or(command.as_str()) == "solid.combine" {
continue;
}
let p = if p.get("command").is_some() { p.get_mut("params")? } else { p };
if let Some(op) = p.get_mut("operation")
&& let Some(o) = op.as_str().filter(|o| *o != "new").map(str::to_string)
@@ -455,6 +461,22 @@ mod tests {
assert_eq!(pv.error.as_deref(), Some("current invalid expression"), "current errors still prevent invalid Apply");
}
/// Combine previews its result (cut, join or intersect between existing bodies) without an
/// error, so its dialog's OK is enabled. Before, the preview also tried the command as a new
/// body, which a combine has no form of, and always reported "operation must be join, cut or
/// intersect".
#[test]
fn combine_previews_its_result_without_an_error() {
let mut s = solidcraft_engine::Session::default();
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "Cube"})).unwrap();
s.execute("solid.cylinder", &json!({"base": [10, 10, -5], "diameter": 6, "height": 30, "body_name": "Pin"})).unwrap();
for op in ["cut", "join", "intersect"] {
let cmd = ("solid.combine".to_string(), json!({"target": "Cube", "tools": ["Pin"], "operation": op, "keep_tools": false}));
let built = compute(&s, &[cmd], colors()).unwrap_or_else(|e| panic!("{op}: {e}"));
assert!(built.error.is_none(), "{op}: {:?}", built.error);
}
}
fn count(bytes: &[u8], stride: usize, col: [u8; 4]) -> usize {
bytes.chunks(stride).filter(|v| v.get(24..28) == Some(&col[..])).count()
}
+11
View File
@@ -7,6 +7,7 @@
//! - `{"widget": "Revolute", "near"?: "Type"}`: click a dialog field, choice or button by its
//! text (with `near`: the one in that label's row)
//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`,
//! `{"hold": AT, "frames"?}` (press and hold still: Select Other),
//! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}`
//! or `{"sketch": [x,y]}` (the sketch being edited)
//! - `{"queued": "engine.execute", "params": {…}, "min_frames"?: n, "max_frames"?: n}`: a request sent the way an
@@ -378,6 +379,16 @@ impl Harness {
let [x, y] = found.ok_or_else(|| format!("no widget `{text}` on screen"))?;
self.call("ui.move", json!({"x": x, "y": y}));
self.call("ui.click", json!({"x": x, "y": y}))
} else if let Some(at) = s.get("hold") {
// Press and hold still (Select Other): `frames` with the button down (default 40,
// about 0.67 s), then let go.
let [x, y] = self.at(at)?;
self.call("ui.move", json!({"x": x, "y": y}));
let frames = s.get("frames").and_then(Value::as_u64).unwrap_or(40);
self.call(
"ui.drag",
json!({"x0": x, "y0": y, "x1": x, "y1": y, "steps": 1, "hold": frames, "shift": flag("shift"), "ctrl": flag("ctrl")}),
)
} else if let Some(at) = s.get("move") {
let [x, y] = self.at(at)?;
self.call("ui.move", json!({"x": x, "y": y}))
+526
View File
@@ -0,0 +1,526 @@
//! Select Other: everything under the cursor, for picking what lies under something else (two
//! lines drawn on top of each other, coincident points, an edge in front of a face, overlapping
//! bodies).
//!
//! - Holding the left button still (or a finger, on touch screens) for [`HOLD_SECS`], or the
//! viewport menu's "Select Other…", opens a list at the cursor of every pickable thing there,
//! in the selection filter's and the open dialog's terms: sketch points and curves first, then
//! vertices, edges, faces, bodies, profiles, planes and axes, each kind nearest first.
//! - Hovering a row lights its entity up in the viewport; clicking it selects it (Shift/Ctrl add,
//! as clicks do) or, with a dialog open, gives it to the dialog's active selection input.
//! Esc or a click elsewhere closes the list.
//! - Clicking again on the same spot with nothing changed selects the next thing in that list
//! (click cycling). The second click of a double-click keeps its own actions.
//!
//! The control channel's `ui.selectOther` opens, describes and picks from the list.
use egui::{Align2, FontId, Pos2, Rect, Sense, pos2, vec2};
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::sketch::CurveKind;
use std::hash::{Hash, Hasher};
use crate::SolveApp;
use crate::theme::Tokens;
use crate::viewport::{Hit, Proj};
/// How long the left button stays down, without moving, before the list opens.
pub const HOLD_SECS: f64 = 0.5;
/// How far (screen points) a repeat click may be from the last one and still cycle.
const CYCLE_SLOP: f32 = 4.0;
/// One row of the list.
#[derive(Clone, Debug)]
pub struct Row {
pub hit: Hit,
pub sel: Sel,
pub label: String,
pub icon: &'static str,
}
/// The open list.
#[derive(Clone, Debug)]
pub struct Popup {
pub at: Pos2,
pub rows: Vec<Row>,
/// The row under the pointer (lit in the viewport).
pub hovered: Option<usize>,
/// The model and dialog it was made for: it closes when they change.
revision: u64,
dialog: bool,
/// The browser's body highlight is ours to clear.
lit_body: bool,
}
/// The last plain click, for cycling.
#[derive(Clone, Debug)]
struct Cycle {
at: Pos2,
chosen: Sel,
selection: Vec<Sel>,
key: u64,
}
#[derive(Default)]
pub struct State {
pub popup: Option<Popup>,
/// A left press that may become a hold: where and when.
hold: Option<(Pos2, f64)>,
/// The current press belongs to Select Other (it opened or closed the list): the viewport
/// doesn't also click, drag or box-select with it.
held: bool,
cycle: Option<Cycle>,
}
// ---------------------------------------------------------------------------------------------
// What is under the cursor
// ---------------------------------------------------------------------------------------------
/// Every pickable thing under `pos`, ordered by kind (the sketch being edited first), then depth.
pub fn rows(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Row> {
let hits = crate::viewport::pick_all(app, proj, pos);
let mut found = crate::viewport::candidates(app, &hits);
let dialog = app.dialog.as_ref().is_some_and(|d| d.wants_picks());
// A body behind each face (body picking already turns faces into bodies).
if !dialog && !app.ui.pick_bodies {
for h in &hits {
if let Hit::Face { body, .. } = h {
found.push((h.clone(), Sel::Body { name: body.clone() }));
}
}
}
let mut out: Vec<(Hit, Sel)> = Vec::new();
for (h, s) in found {
if !out.iter().any(|(_, x)| crate::selection::same_item(x, &s)) {
out.push((h, s));
}
}
let sketching = app.session.active_sketch.is_some();
out.sort_by_key(|(_, s)| rank(s, sketching));
out.into_iter()
.map(|(hit, sel)| {
let label = label(app, &sel);
Row { icon: icon(app, &sel), hit, sel, label }
})
.collect()
}
/// Sort key: in a sketch its own points and curves come first; then by kind.
fn rank(s: &Sel, sketching: bool) -> (u8, u8) {
let kind = match s {
Sel::SketchPoint { .. } | Sel::ExternalSketchPoint { .. } => 0,
Sel::SketchCurve { .. } | Sel::ExternalSketchCurve { .. } => 1,
Sel::SketchConstraint { .. } => 2,
Sel::Vertex { .. } => 3,
Sel::Edge { .. } => 4,
Sel::Face { .. } | Sel::Feature { .. } => 5,
Sel::Body { .. } | Sel::Occurrence { .. } => 6,
Sel::Profile { .. } => 7,
Sel::Plane { .. } => 8,
Sel::Axis { .. } => 9,
};
let own = matches!(s, Sel::SketchPoint { .. } | Sel::SketchCurve { .. });
(if sketching && !own { 1 } else { 0 }, kind)
}
/// The number at the end of an id (`l3` → 3).
fn id_number(id: &str) -> Option<u64> {
let digits: String = id.chars().rev().take_while(char::is_ascii_digit).collect::<Vec<_>>().into_iter().rev().collect();
digits.parse().ok()
}
fn curve_kind_name(k: &CurveKind) -> &'static str {
match k {
CurveKind::Line { .. } => "Line",
CurveKind::Circle { .. } => "Circle",
CurveKind::Arc { .. } => "Arc",
CurveKind::Ellipse { .. } => "Ellipse",
CurveKind::Spline { .. } => "Spline",
CurveKind::Conic { .. } => "Conic",
}
}
fn sketch_name(app: &SolveApp, sketch: u64) -> String {
app.session.doc.feature(sketch).map(|f| f.name.clone()).unwrap_or_else(|| format!("Sketch {sketch}"))
}
/// "Line 3" (its kind and number), with what kind of geometry it is when that matters.
fn curve_label(sk: &solidcraft_engine::sketch::Sketch, id: &str) -> String {
if let Some((i, c)) = sk.curves.iter().enumerate().find(|(_, c)| c.id == id) {
let kind = curve_kind_name(&c.kind);
let n = id_number(id).unwrap_or_else(|| sk.curves.iter().take(i + 1).filter(|x| curve_kind_name(&x.kind) == kind).count() as u64);
let mut s = format!("{kind} {n}");
if c.link.is_some() {
s.push_str(" (projected)");
} else if c.construction {
s.push_str(" (construction)");
}
return s;
}
if sk.wires.iter().any(|w| w.id == id) {
return format!("3D Curve {}", id_number(id).unwrap_or(0));
}
id.to_string()
}
/// "Point (arc 2 centre)": the role it plays in the first curve that uses it.
fn point_label(sk: &solidcraft_engine::sketch::Sketch, id: &str) -> String {
let Some(pi) = sk.points.iter().position(|p| p.id == id) else { return id.to_string() };
let role = sk.curves.iter().find_map(|c| {
let name = format!("{} {}", curve_kind_name(&c.kind).to_lowercase(), id_number(&c.id).unwrap_or(0));
let (start, end) = if c.reversed { ("end", "start") } else { ("start", "end") };
let role = match &c.kind {
CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. } | CurveKind::Conic { a, b, .. } if *a == pi => Some(start.to_string()),
CurveKind::Line { b, .. } | CurveKind::Arc { b, .. } | CurveKind::Conic { b, .. } if *b == pi => Some(end.to_string()),
CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } | CurveKind::Ellipse { c, .. } if *c == pi => Some("centre".to_string()),
CurveKind::Ellipse { m, .. } if *m == pi => Some("major axis end".to_string()),
CurveKind::Conic { apex, .. } if *apex == pi => Some("apex".to_string()),
CurveKind::Spline { pts, .. } => pts.iter().position(|p| *p == pi).map(|k| format!("point {}", k + 1)),
_ => None,
}?;
Some(format!("{name} {role}"))
});
let fixed = if sk.point_locked(pi) { ", fixed" } else { "" };
match role {
Some(r) => format!("Point ({r}{fixed})"),
None if fixed.is_empty() => format!("Point {}", id_number(id).unwrap_or(pi as u64 + 1)),
None => format!("Point {} (fixed)", id_number(id).unwrap_or(pi as u64 + 1)),
}
}
/// A row's name: "Line 3", "Point (arc 2 centre)", "Edge 4 · Body1", "XY Plane"…
pub fn label(app: &SolveApp, sel: &Sel) -> String {
let st = app.session.world_state();
let in_sketch =
|sketch: u64, f: &dyn Fn(&solidcraft_engine::sketch::Sketch) -> String| st.sketch(sketch).map(|ss| f(&ss.sketch)).unwrap_or_default();
match sel {
Sel::SketchPoint { id } => app.session.active_sketch.map(|s| in_sketch(s, &|sk| point_label(sk, id))).unwrap_or_else(|| id.clone()),
Sel::SketchCurve { id } => app.session.active_sketch.map(|s| in_sketch(s, &|sk| curve_label(sk, id))).unwrap_or_else(|| id.clone()),
Sel::ExternalSketchPoint { sketch, id } => format!("{} · {}", in_sketch(*sketch, &|sk| point_label(sk, id)), sketch_name(app, *sketch)),
Sel::ExternalSketchCurve { sketch, id } => format!("{} · {}", in_sketch(*sketch, &|sk| curve_label(sk, id)), sketch_name(app, *sketch)),
Sel::SketchConstraint { id } => format!("Constraint {id}"),
Sel::Vertex { body, .. } => format!("Vertex · {body}"),
Sel::Edge { body, index, .. } => format!("Edge {} · {body}", index + 1),
Sel::Face { body, index, .. } => format!("Face {} · {body}", index + 1),
Sel::Body { name } => format!("Body · {name}"),
Sel::Occurrence { path } => format!("Occurrence {path:?}"),
Sel::Feature { id } => app.session.doc.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| format!("Feature {id}")),
Sel::Profile { sketch, index } => format!("Profile {} · {}", index + 1, sketch_name(app, *sketch)),
Sel::Plane { name } if matches!(name.as_str(), "XY" | "XZ" | "YZ") => format!("{name} Plane"),
Sel::Axis { name } if matches!(name.as_str(), "X" | "Y" | "Z") => format!("{name} Axis"),
Sel::Plane { name } | Sel::Axis { name } => name.clone(),
}
}
fn icon(app: &SolveApp, sel: &Sel) -> &'static str {
let st = app.session.world_state();
let curve = |sketch: Option<u64>, id: &str| {
let kind = sketch.and_then(|s| st.sketch(s)).and_then(|ss| ss.sketch.curves.iter().find(|c| c.id == id).map(|c| c.kind.clone()));
match kind {
Some(CurveKind::Circle { .. }) => "circle",
Some(CurveKind::Arc { .. }) => "arc_tangent",
Some(CurveKind::Ellipse { .. }) => "ellipse",
Some(CurveKind::Spline { .. }) => "spline",
Some(CurveKind::Conic { .. }) => "conic",
_ => "line",
}
};
match sel {
Sel::SketchPoint { .. } | Sel::ExternalSketchPoint { .. } | Sel::Vertex { .. } => "point",
Sel::SketchCurve { id } => curve(app.session.active_sketch, id),
Sel::ExternalSketchCurve { sketch, id } => curve(Some(*sketch), id),
Sel::SketchConstraint { .. } => "dimension",
Sel::Edge { .. } => "fillet",
Sel::Face { .. } | Sel::Feature { .. } => "presspull",
Sel::Body { .. } => "body",
Sel::Occurrence { .. } => "component",
Sel::Profile { .. } => "sketch",
Sel::Plane { .. } => "plane",
Sel::Axis { .. } => "axis",
}
}
// ---------------------------------------------------------------------------------------------
// Opening, picking, closing
// ---------------------------------------------------------------------------------------------
fn projection_now(app: &SolveApp) -> Option<Proj> {
app.viewport.rect.map(|r| crate::viewport::projection(app, r))
}
/// Open the list at a screen point; false (with a status message) when nothing is there.
pub fn open(app: &mut SolveApp, at: Pos2) -> bool {
let Some(proj) = projection_now(app) else { return false };
open_with(app, &proj, at)
}
fn open_with(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool {
let rows = rows(app, proj, at);
if rows.is_empty() {
app.status = Some(("Nothing to select here".into(), crate::now_ms(), false));
return false;
}
close(app);
app.viewport.select_other.popup =
Some(Popup { at, rows, hovered: None, revision: app.session.revision, dialog: app.dialog.is_some(), lit_body: false });
true
}
pub fn close(app: &mut SolveApp) {
if let Some(p) = app.viewport.select_other.popup.take()
&& p.lit_body
{
app.viewport.hover_bodies.clear();
}
}
pub fn is_open(app: &SolveApp) -> bool {
app.viewport.select_other.popup.is_some()
}
/// Pick row `i` of the open list (`add`: Shift/Ctrl, add to the selection) and close it.
pub fn choose(app: &mut SolveApp, i: usize, add: bool) -> Result<Row, String> {
let Some(p) = app.viewport.select_other.popup.as_ref() else { return Err("Select Other is not open".into()) };
let at = p.at;
let row = p.rows.get(i).cloned().ok_or_else(|| format!("no row {i} (the list has {})", p.rows.len()))?;
close(app);
apply(app, &row, add);
remember(app, at, if add { None } else { Some(row.sel.clone()) });
Ok(row)
}
/// Select a row's entity: into the dialog's active input, or as a click does.
fn apply(app: &mut SolveApp, row: &Row, add: bool) {
if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) {
crate::viewport::dialog_pick(app, &row.hit, row.sel.clone());
} else {
crate::viewport::select(app, Some(row.sel.clone()), add);
}
}
/// The open list as JSON (for `ui.selectOther`).
pub fn describe(app: &SolveApp) -> Value {
match &app.viewport.select_other.popup {
None => json!({ "open": false }),
Some(p) => json!({
"open": true,
"at": [p.at.x, p.at.y],
"hovered": p.hovered,
"rows": p.rows.iter().map(|r| json!({ "label": r.label, "icon": r.icon, "sel": r.sel })).collect::<Vec<_>>(),
}),
}
}
/// The control channel's `ui.selectOther`: `{x, y}` opens the list there, `{pick: i, add?}`
/// picks a row, `{hover: i}` lights one up, `{close: true}` closes it; it answers with the list.
pub fn control(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
if p.get("close").and_then(Value::as_bool) == Some(true) {
close(app);
return Ok(describe(app));
}
if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
if !(x.is_finite() && y.is_finite()) {
return Err("x and y must be numbers".into());
}
if app.viewport.rect.is_none() {
return Err("no viewport".into());
}
open(app, pos2(x as f32, y as f32));
return Ok(describe(app));
}
if let Some(i) = p.get("hover").and_then(Value::as_u64) {
let Some(pop) = app.viewport.select_other.popup.as_mut() else { return Err("Select Other is not open".into()) };
pop.hovered = Some(i as usize).filter(|i| *i < pop.rows.len());
hover_override(app);
return Ok(describe(app));
}
if let Some(i) = p.get("pick").and_then(Value::as_u64) {
let row = choose(app, i as usize, p.get("add").and_then(Value::as_bool).unwrap_or(false))?;
return Ok(json!({ "picked": row.label, "sel": row.sel, "selection": app.session.selection }));
}
Ok(describe(app))
}
// ---------------------------------------------------------------------------------------------
// Press and hold, and click cycling (called by the viewport)
// ---------------------------------------------------------------------------------------------
/// Follow a left press held still in the viewport; open the list when it has been held long
/// enough. True while the press belongs to Select Other (the viewport must not also click,
/// drag or box-select with it).
#[allow(clippy::too_many_arguments)]
pub fn track_hold(
app: &mut SolveApp,
ctx: &egui::Context,
proj: &Proj,
inside: bool,
pressed: bool,
down: bool,
hover: Option<Pos2>,
navigating: bool,
) -> bool {
let consumed = app.viewport.select_other.held;
let now = ctx.input(|i| i.time);
let free = app.tool.is_none()
&& app.viewport.context_menu.is_none()
&& !is_open(app)
&& app.dialog.as_ref().is_none_or(|d| d.wants_picks())
&& app.viewport.point_drag.is_none();
if pressed && inside && free && !navigating {
app.viewport.select_other.hold = hover.map(|p| (p, now));
}
let mut opened = false;
if let Some((p0, t0)) = app.viewport.select_other.hold {
let slop = ctx.options(|o| o.input_options.max_click_dist);
if !down || navigating || !free || hover.is_some_and(|p| p.distance(p0) > slop) {
app.viewport.select_other.hold = None;
} else if now - t0 >= HOLD_SECS {
app.viewport.select_other.hold = None;
opened = open_with(app, proj, p0);
app.viewport.select_other.held = true;
} else {
ctx.request_repaint_after(std::time::Duration::from_millis(30));
}
}
if !down {
app.viewport.select_other.held = false;
}
consumed || opened
}
/// While the list is open, the hovered row is what the viewport lights up.
pub fn hover_override(app: &mut SolveApp) {
let Some(p) = app.viewport.select_other.popup.as_mut() else { return };
let row = p.hovered.and_then(|i| p.rows.get(i));
let body = match row.map(|r| &r.sel) {
Some(Sel::Body { name }) => Some(name.clone()),
_ => None,
};
app.viewport.hover = if body.is_some() { None } else { row.map(|r| r.hit.clone()) };
match body {
Some(b) => {
app.viewport.hover_bodies = vec![b];
p.lit_body = true;
}
None if p.lit_body => {
app.viewport.hover_bodies.clear();
p.lit_body = false;
}
None => {}
}
}
/// What a repeat click must find unchanged: the model, the view, the sketch and the filter.
fn cycle_key(app: &SolveApp) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
app.session.revision.hash(&mut h);
for v in [app.cam.yaw, app.cam.pitch, app.cam.roll, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] {
v.to_bits().hash(&mut h);
}
app.session.active_sketch.hash(&mut h);
app.ui.pick_off.hash(&mut h);
app.ui.pick_bodies.hash(&mut h);
app.dialog.is_some().hash(&mut h);
h.finish()
}
fn remember(app: &mut SolveApp, at: Pos2, chosen: Option<Sel>) {
app.viewport.select_other.cycle = chosen.map(|chosen| Cycle { at, chosen, selection: app.session.selection.clone(), key: cycle_key(app) });
}
/// A plain click selected `sel` at `at`: a repeat click there may cycle from it.
pub fn note_click(app: &mut SolveApp, at: Pos2, sel: Option<Sel>) {
remember(app, at, sel);
}
/// A plain click at `at`: when it repeats the last one (same spot, nothing changed since), select
/// the next thing under the cursor and return true.
pub fn cycle_click(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool {
let Some(c) = app.viewport.select_other.cycle.clone() else { return false };
if c.at.distance(at) > CYCLE_SLOP || c.key != cycle_key(app) || c.selection != app.session.selection {
return false;
}
let rows = rows(app, proj, at);
if rows.len() < 2 {
return false;
}
let next = rows.iter().position(|r| crate::selection::same_item(&r.sel, &c.chosen)).map_or(0, |k| (k + 1) % rows.len());
let Some(row) = rows.get(next).cloned() else { return false };
apply(app, &row, false);
app.status = Some((format!("{} ({} of {})", row.label, next + 1, rows.len()), crate::now_ms(), false));
remember(app, c.at, Some(row.sel));
true
}
// ---------------------------------------------------------------------------------------------
// Drawing
// ---------------------------------------------------------------------------------------------
/// Draw the open list; handle its hover, clicks, and a press elsewhere (which closes it).
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let Some(p) = app.viewport.select_other.popup.clone() else { return };
if p.revision != app.session.revision || p.dialog != app.dialog.is_some() || app.tool.is_some() {
close(app);
return;
}
let t = Tokens::get();
let mut hovered = None;
let mut picked = None;
let w = crate::context_menu::LIST_W - 10.0;
let resp = crate::frame::area(egui::Id::new("sc_select_other"))
.fixed_pos(p.at + vec2(12.0, 12.0))
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
crate::context_menu::menu_frame(ui, |ui| {
let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover());
ui.painter().text(
pos2(r.left() + 8.0, r.center().y),
Align2::LEFT_CENTER,
crate::i18n::tr_label("Select Other").to_uppercase(),
FontId::proportional(10.5),
t.text_dim,
);
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
for (i, row) in p.rows.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click());
crate::scenario::publish_handle(&format!("select_other:{i}"), r.center());
if resp.hovered() {
hovered = Some(i);
ui.painter().rect_filled(r.shrink2(vec2(2.0, 1.0)), 4.0, t.hover);
}
crate::icons::paint(
ui.painter(),
Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(16.0, 16.0)),
row.icon,
t.icon,
t.icon_fill,
t.accent,
);
ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, &row.label, FontId::proportional(13.0), t.text);
if resp.clicked() {
picked = Some(i);
}
}
});
});
});
if let Some(pop) = app.viewport.select_other.popup.as_mut()
&& pop.hovered != hovered
{
pop.hovered = hovered;
ctx.request_repaint();
}
let (pressed, pointer, mods) = ctx.input(|i| (i.pointer.any_pressed(), i.pointer.interact_pos(), i.modifiers));
if let Some(i) = picked {
let _ = choose(app, i, mods.shift || mods.ctrl || mods.command);
} else if pressed && !pointer.is_some_and(|q| resp.response.rect.contains(q)) {
// A press elsewhere only closes the list.
close(app);
app.viewport.select_other.held = true;
}
}
#[cfg(test)]
#[path = "select_other_tests.rs"]
mod tests;
+172
View File
@@ -0,0 +1,172 @@
//! Select Other: press and hold, the viewport menu's entry, click cycling and dialog inputs.
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use crate::scenario::Harness;
/// A sketch with two lines drawn on top of each other (l1, then l2), off the origin axes.
fn two_lines() -> (Harness, Value) {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
for _ in 0..2 {
h.app.run("sketch.line", json!({"points":[[0,10],[20,10]],"infer":false,"connect":false})).unwrap();
}
h.app.cam.distance = 60.0;
h.settle();
let at = h.call("ui.at", json!({"sketch":[10,10]}))["result"].clone();
(h, at)
}
fn labels(h: &Harness) -> Vec<String> {
crate::select_other::describe(&h.app)["rows"].as_array().into_iter().flatten().filter_map(|r| r["label"].as_str().map(String::from)).collect()
}
fn curves(h: &Harness) -> Vec<String> {
h.app.session.selection.iter().filter_map(|s| if let Sel::SketchCurve { id } = s { Some(id.clone()) } else { None }).collect()
}
#[test]
fn holding_still_lists_both_coincident_lines_and_picks_the_one_underneath() {
let (mut h, at) = two_lines();
let before = h.app.session.doc.to_json();
h.step(&json!({"hold": at})).unwrap();
assert!(crate::select_other::is_open(&h.app), "a held press opens Select Other");
let rows = labels(&h);
assert!(rows.len() >= 2, "{rows:?}");
assert_eq!(&rows[..2], ["Line 1", "Line 2"], "the sketch's own curves come first");
// Letting go of the hold neither selected nor dragged anything.
assert!(h.app.session.selection.is_empty(), "{:?}", h.app.session.selection);
assert_eq!(h.app.session.doc.to_json(), before);
// Hovering a row lights it up in the viewport.
h.step(&json!({"move": {"handle": "select_other:1"}})).unwrap();
assert!(matches!(&h.app.viewport.hover, Some(crate::viewport::Hit::SketchCurve { id, .. }) if id == "l2"), "{:?}", h.app.viewport.hover);
h.step(&json!({"click": {"handle": "select_other:1"}})).unwrap();
assert!(!crate::select_other::is_open(&h.app));
assert_eq!(curves(&h), ["l2"]);
// Shift-picking from the list adds, like a Shift-click.
h.step(&json!({"hold": at})).unwrap();
h.step(&json!({"click": {"handle": "select_other:0"}, "shift": true})).unwrap();
let mut got = curves(&h);
got.sort();
assert_eq!(got, ["l1", "l2"]);
}
#[test]
fn esc_and_a_click_elsewhere_close_the_list_without_selecting() {
let (mut h, at) = two_lines();
h.step(&json!({"hold": at})).unwrap();
assert!(crate::select_other::is_open(&h.app));
h.step(&json!({"key": "Escape"})).unwrap();
assert!(!crate::select_other::is_open(&h.app));
assert!(h.app.session.active_sketch.is_some(), "Esc only closed the list");
h.step(&json!({"hold": at})).unwrap();
h.step(&json!({"click": [at[0].as_f64().unwrap() + 200.0, at[1].as_f64().unwrap() + 150.0]})).unwrap();
assert!(!crate::select_other::is_open(&h.app));
assert!(h.app.session.selection.is_empty(), "{:?}", h.app.session.selection);
}
#[test]
fn clicking_again_on_the_same_spot_selects_the_next_thing_under_it() {
let (mut h, at) = two_lines();
h.step(&json!({"click": at})).unwrap();
assert_eq!(curves(&h), ["l1"]);
// A deliberate second click (not part of a double-click).
h.frames(30);
h.step(&json!({"click": at})).unwrap();
assert_eq!(curves(&h), ["l2"]);
h.frames(30);
h.step(&json!({"click": at})).unwrap();
assert_eq!(curves(&h), ["l1"], "it wraps around");
// A click somewhere else starts over.
let x = at[0].as_f64().unwrap();
let y = at[1].as_f64().unwrap();
h.frames(30);
h.step(&json!({"click": [x + 40.0, y]})).unwrap();
h.frames(30);
h.step(&json!({"click": at})).unwrap();
assert_eq!(curves(&h), ["l1"]);
}
#[test]
fn a_double_click_does_not_cycle() {
let (mut h, at) = two_lines();
h.step(&json!({"click": at, "double": true})).unwrap();
// The double-click's chain starts from the line under the cursor.
assert!(curves(&h).contains(&"l1".to_string()), "{:?}", h.app.session.selection);
}
#[test]
fn the_viewport_menu_offers_select_other() {
let (mut h, at) = two_lines();
h.step(&json!({"click": at, "button": "right"})).unwrap();
let menu = h.call("ui.menu", json!({}))["result"].clone();
let items: Vec<&str> = menu["items"].as_array().into_iter().flatten().filter_map(|i| i["id"].as_str()).collect();
assert!(items.contains(&"ui.selectOther"), "{items:?}");
let r = h.call("ui.menuPick", json!({"item": "ui.selectOther"}));
assert_eq!(r["ok"], true, "{r}");
assert!(labels(&h).starts_with(&["Line 1".to_string(), "Line 2".to_string()]), "{:?}", labels(&h));
// The control channel picks rows too.
let r = h.call("ui.selectOther", json!({"pick": 1}));
assert_eq!(r["ok"], true, "{r}");
assert_eq!(curves(&h), ["l2"]);
// Over empty space there is nothing to offer.
h.step(&json!({"click": [40, 400], "button": "right"})).unwrap();
let menu = h.call("ui.menu", json!({}))["result"].clone();
assert!(!menu["items"].as_array().into_iter().flatten().any(|i| i["id"] == "ui.selectOther"), "{menu}");
}
#[test]
fn overlapping_bodies_list_both_and_the_hidden_one_can_be_picked() {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Inner"})).unwrap();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[5,5,5],"body_name":"Outer"})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.call("ui.view", json!({"view":"fit"}));
h.step(&json!({"hold": {"world": [7, 7, 15]}})).unwrap();
let rows = labels(&h);
assert!(rows.iter().any(|r| r == "Body · Outer") && rows.iter().any(|r| r == "Body · Inner"), "{rows:?}");
let faces = rows.iter().filter(|r| r.starts_with("Face ")).count();
assert_eq!(faces, 2, "the nearest face of each body: {rows:?}");
let i = rows.iter().position(|r| r == "Body · Inner").unwrap();
h.call("ui.selectOther", json!({"pick": i}));
assert_eq!(h.app.session.selection, [Sel::Body { name: "Inner".into() }]);
}
#[test]
fn a_dialog_selection_input_takes_its_pick_from_the_list() {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})).unwrap();
h.call("ui.view", json!({"view":"home"}));
h.call("ui.view", json!({"view":"fit"}));
h.step(&json!({"start": "solid.fillet"})).unwrap();
h.frames(2);
h.step(&json!({"hold": {"world": [5, 0, 10]}})).unwrap();
let rows = crate::select_other::describe(&h.app)["rows"].clone();
let sels: Vec<&Value> = rows.as_array().into_iter().flatten().map(|r| &r["sel"]).collect();
assert!(!sels.is_empty(), "{rows}");
// The edge in front, then the face behind it; no bodies, which the fillet doesn't take.
assert_eq!(sels[0]["type"], "edge", "{rows}");
assert!(sels.iter().all(|s| s["type"] == "edge" || s["type"] == "face"), "only what the fillet's input accepts: {rows}");
h.call("ui.selectOther", json!({"pick": 0}));
let d = h.app.dialog.as_ref().expect("the fillet dialog stays open");
assert!(d.inputs.iter().any(|i| i.items.iter().any(|x| matches!(x, Sel::Edge { .. }))), "{:?}", d.inputs);
assert!(h.app.session.selection.is_empty(), "the pick went to the dialog, not the selection");
}
#[test]
fn rows_name_points_by_their_curves() {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.circle.center", json!({"center":[10,10],"radius":3})).unwrap();
h.app.cam.distance = 60.0;
h.settle();
let at = h.call("ui.at", json!({"sketch":[10,10]}))["result"].clone();
h.call("ui.selectOther", json!({"x": at[0], "y": at[1]}));
let rows = labels(&h);
assert!(rows.iter().any(|r| r.starts_with("Point (") && r.contains("centre")), "{rows:?}");
}
@@ -167,6 +167,10 @@ pub fn panel(app: &mut SolveApp, ctx: &egui::Context, at: Pos2) {
ui.label("Legacy face attachment. Rebinding to a certified face creates an exact attachment.");
*plane
}
FaceAttachmentStatus::Sheet { plane } => {
ui.label("Attached to a flat face of a sheet metal body; it follows that panel when the sheet changes.");
*plane
}
FaceAttachmentStatus::Fixed { plane } => {
ui.label("Sketch plane has no body-face attachment.");
*plane
@@ -115,10 +115,16 @@ fn lost_attachment_ghost_rebind_preview_cancel_apply_and_undo_preserve_sketch()
let repaired = h.app.session.doc.to_json();
let path = h.dir.join("rebound-face.solidcraft");
h.app.run("file.save", json!({"path":path})).unwrap();
// Saving the untitled design names it after its file; everything else is exact.
let named = |doc: &str| {
let mut doc = solidcraft_engine::doc::Document::from_json(doc).unwrap();
doc.name = "rebound-face".into();
doc.to_json()
};
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before);
assert_eq!(h.app.session.doc.to_json(), named(&before));
h.app.run("doc.open", json!({"path":path})).unwrap();
assert_eq!(h.app.session.doc.to_json(), repaired);
assert_eq!(h.app.session.doc.to_json(), named(&repaired));
assert_eq!(raw_sketch(&h, sketch), raw);
assert!(h.app.session.model.result(sketch).unwrap().error.is_none());
}
@@ -324,3 +330,43 @@ fn nested_anchor_saved_fixed_conversion_preserves_rotated_second_author_plane()
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before);
}
/// Issue #489: extrude, sketch a rectangle over the whole top face (its corners snap to the
/// face's projected boundary), extrude it up as a Join, then pick the new top face for a sketch.
#[test]
fn picked_top_face_of_a_stacked_join_extrude_takes_a_sketch() {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,40]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("solid.extrude", json!({"distance":10,"name":"First"})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.call("ui.view", json!({"view":"fit"}));
h.settle();
let pick_face = |h: &mut Harness, z: f64| {
h.step(&json!({"start":"sketch.create"})).unwrap();
h.step(&json!({"click":{"world":[20,20,z]}})).unwrap();
h.settle();
let error = h.app.dialog.as_ref().and_then(|d| d.error.clone());
let sketch = h.app.session.active_sketch.unwrap_or_else(|| panic!("top face at z={z} must take a sketch: {error:?}"));
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(_), .. }, .. } = &h.app.session.doc.feature(sketch).unwrap().kind else {
panic!("the top face at z={z} must give a certified attachment");
};
sketch
};
pick_face(&mut h, 10.);
h.step(&json!({"start":"sketch.rectangle.two_point"})).unwrap();
h.step(&json!({"click":{"world":[0,0,10]}})).unwrap();
h.step(&json!({"click":{"world":[40,40,10]}})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
h.app.finish_sketch();
h.app.run("solid.extrude", json!({"distance":10,"operation":"join","name":"Second"})).unwrap();
assert_eq!(h.app.session.model.state().bodies.len(), 1);
h.settle();
let top = pick_face(&mut h, 20.);
let FaceAttachmentStatus::Healthy { plane, .. } = h.app.session.placed_document().sketch_face_attachment(top, &h.app.session.model) else {
panic!("attachment must be healthy");
};
assert!((plane.origin.z - 20.).abs() < 1e-9);
}
+4 -4
View File
@@ -191,8 +191,8 @@ impl Tokens {
subheading_bg: Color32::from_rgb(234, 237, 242),
profile_fill: Color32::from_rgba_unmultiplied_const(255, 196, 120, 70),
glyph_bg: Color32::WHITE,
glyph_edge: Color32::from_rgb(90, 150, 90),
glyph_text: Color32::from_rgb(40, 110, 40),
glyph_edge: Color32::from_rgb(190, 110, 100),
glyph_text: Color32::from_rgb(150, 40, 35),
overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235),
rubber_band: Color32::from_rgb(30, 120, 230),
app_bar_text: Color32::from_rgb(230, 234, 242),
@@ -247,8 +247,8 @@ impl Tokens {
t.heading_bg = Color32::from_rgb(38, 44, 55);
t.subheading_bg = Color32::from_rgb(48, 55, 68);
t.glyph_bg = Color32::from_rgb(44, 50, 60);
t.glyph_edge = Color32::from_rgb(96, 170, 110);
t.glyph_text = Color32::from_rgb(150, 220, 160);
t.glyph_edge = Color32::from_rgb(200, 130, 120);
t.glyph_text = Color32::from_rgb(240, 165, 155);
t.overlay = Color32::from_rgba_unmultiplied_const(34, 38, 46, 240);
t.rubber_band = Color32::from_rgb(92, 160, 255);
t.timeline_item = Color32::from_rgb(52, 58, 70);
+82 -16
View File
@@ -8,6 +8,19 @@ use crate::{SolveApp, icons};
pub const TABS: &[&str] = &["SOLID", "SURFACE", "MESH", "SHEET METAL", "PLASTIC", "UTILITIES"];
/// The accent stroke colour for a panel's icons: constraint commands use the same red as the
/// constraint glyphs they draw in the sketch (`glyph_text`), sketch tools otherwise keep
/// `sketch_accent`, and every other tab keeps the plain `accent`.
fn panel_accent(t: Tokens, tab: &str, panel: &str) -> Color32 {
if panel == "CONSTRAINTS" {
t.glyph_text
} else if tab == "SKETCH" {
t.sketch_accent
} else {
t.accent
}
}
/// Panels shown on each tab (in order). Commands come from the registry by (tab, panel).
pub fn panels(tab: &str) -> &'static [&'static str] {
if let Some(p) = crate::workspace::panels(tab) {
@@ -30,7 +43,6 @@ pub fn promoted(tab: &str, panel: &str) -> usize {
match (tab, panel) {
("SOLID", "CREATE") => 4,
("SOLID", "MODIFY") => 4,
("SKETCH", "CREATE") => 6,
("SKETCH", "CONSTRAINTS") => 6,
(_, "FINISH SKETCH") => 1,
_ => 2,
@@ -573,12 +585,6 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
let free_x = bx + drawn as f32 * 40.0;
let free = Rect::from_min_size(pos2(free_x, top + 2.0), vec2((px + width - 4.0 - free_x).max(8.0), 38.0));
let fresp = ui.interact(free, ui.id().with(("panel_free", panel)), if collapsed { Sense::click() } else { Sense::hover() });
if fresp.dnd_hover_payload::<ToolbarDrag>().is_some() {
painter.line_segment([pos2(free.left(), free.top() + 2.0), pos2(free.left(), free.bottom() - 2.0)], Stroke::new(2.0, t.accent));
}
if let Some(d) = fresp.dnd_release_payload::<ToolbarDrag>() {
drop = Some((d.id, *panel, None));
}
let mut opened = false;
if collapsed {
// One button: the panel's first command's icon over a caret; it opens the
@@ -589,7 +595,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
painter.rect_filled(br, 4.0, t.hover);
}
if let Some(c) = shown.first().or(cmds.first()) {
let acc = if tab == "SKETCH" { t.sketch_accent } else { t.accent };
let acc = panel_accent(t, &tab, panel);
icons::paint(&painter, Rect::from_center_size(br.center() - vec2(0.0, 3.0), vec2(26.0, 26.0)), c.icon, t.icon, t.icon_fill, acc);
}
caret(&painter, pos2(br.center().x, br.bottom() - 3.0), t.text_dim);
@@ -628,11 +634,8 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
} else if resp.hovered() && info.enabled {
painter.rect_filled(br, 4.0, t.hover);
}
let (ink, fill, acc) = if info.enabled {
(t.icon, t.icon_fill, if tab == "SKETCH" { t.sketch_accent } else { t.accent })
} else {
(t.border, t.panel_header, t.border)
};
let (ink, fill, acc) =
if info.enabled { (t.icon, t.icon_fill, panel_accent(t, &tab, panel)) } else { (t.border, t.panel_header, t.border) };
let icon = match c.id {
"sketch.finish" => "finish",
"config.table" => "configuration_table",
@@ -692,6 +695,20 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
if lresp.clicked() {
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_panel_open"), popup_id));
}
// Labels, gaps and margins append; actual buttons keep their insert-before target.
// Respect the current layer so a dropdown covering the panel is not a drop target.
let panel_rect = Rect::from_min_max(pos2(px, top + 2.0), pos2(px + width, r.bottom()));
let over_button = ui
.input(|input| input.pointer.hover_pos())
.is_some_and(|p| (0..drawn).any(|i| Rect::from_min_size(pos2(bx + i as f32 * 40.0, top + 2.0), vec2(38.0, 38.0)).contains(p)));
if !over_button && ui.rect_contains_pointer(panel_rect) && egui::DragAndDrop::payload::<ToolbarDrag>(ui.ctx()).is_some() {
painter.line_segment([pos2(px + width - 4.0, top + 4.0), pos2(px + width - 4.0, top + 38.0)], Stroke::new(2.0, t.accent));
if ui.input(|input| input.pointer.any_released())
&& let Some(d) = egui::DragAndDrop::take_payload::<ToolbarDrag>(ui.ctx())
{
drop = Some((d.id, *panel, None));
}
}
let open = ui.ctx().data(|d| d.get_temp::<egui::Id>(egui::Id::new("sc_panel_open"))) == Some(popup_id);
if open {
let cmds = if collapsed { menu_commands(&cmds, &shown) } else { cmds.clone() };
@@ -869,8 +886,14 @@ fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solidcraft_engine
p.rect_filled(rect, 3.0, t.hover);
crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center());
}
let (ink, fill, acc, text) =
if info.enabled { (t.icon, t.icon_fill, t.accent, t.text) } else { (t.border, t.panel_header, t.border, t.text_dim) };
// Every other row keeps the plain accent it always has; only a constraint command's row
// switches to the constraint red, to match its button on the ribbon.
let (ink, fill, acc, text) = if info.enabled {
let acc = if c.panel == "CONSTRAINTS" { t.glyph_text } else { t.accent };
(t.icon, t.icon_fill, acc, t.text)
} else {
(t.border, t.panel_header, t.border, t.text_dim)
};
let ir = Rect::from_center_size(pos2(rect.left() + 6.0 + 9.0, rect.center().y), vec2(18.0, 18.0));
icons::paint(p, ir, c.icon, ink, fill, acc);
p.text(pos2(ir.right() + 8.0, rect.center().y), Align2::LEFT_CENTER, &label, FontId::proportional(13.0), text);
@@ -1022,7 +1045,9 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
match k {
Key::Escape => {
app.esc_handled = true;
if app.tool.is_some() {
if crate::select_other::is_open(app) {
crate::select_other::close(app);
} else if app.tool.is_some() {
crate::tools::finish(app);
} else if app.dialog.is_some() {
crate::dialogs::cancel(app);
@@ -1320,3 +1345,44 @@ mod dropdown_tests {
assert!(menu.contains(*at), "the focused last row is out of view: {at:?} in {menu:?}");
}
}
#[cfg(test)]
mod panel_drop_tests {
use super::*;
#[test]
fn panel_label_gap_and_margins_append_while_buttons_insert_before() {
for target in ["label", "gap", "left margin", "right margin", "button"] {
let mut h = crate::scenario::Harness::new();
h.step(&json!({"click":{"handle":"panel:SOLID:CREATE"}})).unwrap();
let label = crate::scenario::handle_at("panel:SOLID:CREATE").unwrap();
let destination = match target {
"gap" => label - vec2(0.0, 10.0),
"left margin" => pos2(label.x - panel_width(4) / 2.0 + 1.0, label.y - 30.0),
"right margin" => pos2(label.x + panel_width(4) / 2.0 - 1.0, label.y - 30.0),
"button" => crate::scenario::handle_at("button:solid.extrude").unwrap(),
_ => label,
};
h.step(&json!({"drag":[{"handle":"dropdown:solid.cylinder"}, [destination.x,destination.y]],"steps":12})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
let order = if target == "button" {
json!(["button:sketch.create", "button:solid.cylinder", "button:solid.extrude", "button:solid.revolve", "button:solid.sweep"])
} else {
json!(["button:sketch.create", "button:solid.extrude", "button:solid.revolve", "button:solid.sweep", "button:solid.cylinder"])
};
h.step(&json!({"expect":{"handle_order":order,"tool":null,"dialog":null,"errors":0}})).unwrap();
assert!(h.app.session.doc.features.is_empty(), "{target}: pinning must not run the command");
h.step(&json!({"restart":"close"})).unwrap();
h.step(&json!({"expect":{"handle_order":order}})).unwrap();
}
}
#[test]
fn panel_label_drop_moves_a_button_between_panels() {
let mut h = crate::scenario::Harness::new();
h.step(&json!({"drag":[{"handle":"button:solid.extrude"},{"handle":"panel:SOLID:MODIFY"}],"steps":12})).unwrap();
h.step(&json!({"expect":{"handle_order":["button:solid.fillet","button:solid.chamfer","button:solid.scale","button:solid.extrude"],"tool":null,"dialog":null,"errors":0}})).unwrap();
h.step(&json!({"restart":"close"})).unwrap();
h.step(&json!({"expect":{"handle_order":["button:solid.scale","button:solid.extrude"]}})).unwrap();
}
}
+255 -84
View File
@@ -82,6 +82,8 @@ pub struct ViewportState {
pub build_ms: f64,
/// The right-click menu, open at this screen position.
pub context_menu: Option<Pos2>,
/// Select Other: its list, a press being held, and click cycling.
pub select_other: crate::select_other::State,
/// A sketch point or curve being dragged (its id, the undo depth when the drag began: the
/// whole drag is one undo step, and for a curve where it was last under the cursor).
pub point_drag: Option<(String, usize, Option<Vec2>)>,
@@ -720,7 +722,68 @@ pub fn pick_3d(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
pick_with_sketch_points(app, proj, pos, true)
}
/// Everything under `pos` (Select Other): like [`pick`], but every sketch point, sketch curve,
/// vertex, edge, axis and profile near the cursor, and the nearest face of every body along the
/// ray, instead of the nearest of each kind. Each kind is nearest first.
pub fn pick_all(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
pick_hits(app, proj, pos, false, true)
}
fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool) -> Vec<Hit> {
pick_hits(app, proj, pos, other_sketch_points, false)
}
/// The same thing picked twice (one row per target in [`pick_all`]).
pub fn same_target(a: &Hit, b: &Hit) -> bool {
match (a, b) {
(Hit::SketchPoint { sketch: s, id: i, .. }, Hit::SketchPoint { sketch: t, id: j, .. })
| (Hit::SketchCurve { sketch: s, id: i, .. }, Hit::SketchCurve { sketch: t, id: j, .. }) => s == t && i == j,
(Hit::Vertex { body: a, point: p }, Hit::Vertex { body: b, point: q }) => a == b && p.dist(*q) < 1e-9,
(Hit::Edge { body: a, index: i, .. }, Hit::Edge { body: b, index: j, .. })
| (Hit::Face { body: a, index: i, .. }, Hit::Face { body: b, index: j, .. }) => a == b && i == j,
(Hit::Profile { sketch: s, index: i }, Hit::Profile { sketch: t, index: j }) => s == t && i == j,
(Hit::Plane { name: a, .. }, Hit::Plane { name: b, .. }) | (Hit::Axis { name: a }, Hit::Axis { name: b }) => a == b,
_ => false,
}
}
/// The candidates of one kind: the nearest only, or with `all` every target (nearest first).
struct Near<T> {
all: bool,
v: Vec<(T, Hit)>,
}
impl<T: PartialOrd + Copy> Near<T> {
fn new(all: bool) -> Self {
Near { all, v: Vec::new() }
}
/// Whether a candidate at `d` would be kept.
fn wants(&self, d: T) -> bool {
self.all || self.v.first().is_none_or(|(b, _)| d < *b)
}
fn offer(&mut self, d: T, h: Hit) {
if !self.all {
if self.wants(d) {
self.v = vec![(d, h)];
}
return;
}
match self.v.iter_mut().find(|(_, x)| same_target(x, &h)) {
Some(e) if d < e.0 => *e = (d, h),
Some(_) => {}
None => self.v.push((d, h)),
}
}
fn sorted(mut self) -> Vec<(T, Hit)> {
self.v.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
self.v
}
fn hits(self) -> impl Iterator<Item = Hit> {
self.sorted().into_iter().map(|x| x.1)
}
}
fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, all: bool) -> Vec<Hit> {
let s = &app.session;
let st = s.world_state();
let bounds = body_bounds(app, &st);
@@ -733,23 +796,21 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
let external_points =
wants_points || other_sketch_points || (app.dialog.is_none() && app.tool.as_ref().is_none_or(|tool| tool.cmd == "sketch.project"));
let point_sketches: Vec<u64> = if external_points { visible_sketches(app) } else { s.active_sketch.into_iter().collect() };
let mut best: Option<(f32, Hit)> = None;
let mut best = Near::new(all);
for ss in point_sketches.iter().filter_map(|sid| st.sketch(*sid)) {
let sid = ss.feature;
for p in &ss.sketch.points {
if let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) {
let dd = sp.distance(pos) - if s.active_sketch == Some(sid) { 2.0 } else { 0.0 };
if dd < 8.0 && best.as_ref().is_none_or(|(b, _)| dd < *b) {
best = Some((dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos }));
if dd < 8.0 && best.wants(dd) {
best.offer(dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos });
}
}
}
}
if let Some((_, h)) = best {
hits.push(h);
}
hits.extend(best.hits());
// Sketch curves (the active sketch wins ties).
let mut bestc: Option<(f32, Hit)> = None;
let mut bestc = Near::new(all);
for sid in visible_sketches(app) {
let Some(ss) = st.sketch(sid) else { continue };
let bonus = if s.active_sketch == Some(sid) { 2.0 } else { 0.0 };
@@ -762,8 +823,8 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
let pts: Vec<Pos2> = part.iter().filter_map(|q| proj.to_screen(*q)).collect();
for w in pts.windows(2) {
let dd = seg_dist(pos, w[0], w[1]) - bonus;
if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) {
bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: c.id.clone(), straight }));
if dd < 6.0 && bestc.wants(dd) {
bestc.offer(dd, Hit::SketchCurve { sketch: sid, id: c.id.clone(), straight });
}
}
}
@@ -773,17 +834,16 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
let pts: Vec<Pos2> = w.pts.iter().filter_map(|q| proj.to_screen(*q)).collect();
for seg in pts.windows(2) {
let dd = seg_dist(pos, seg[0], seg[1]) - bonus;
if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) {
bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: w.id.clone(), straight: false }));
if dd < 6.0 && bestc.wants(dd) {
bestc.offer(dd, Hit::SketchCurve { sketch: sid, id: w.id.clone(), straight: false });
}
}
}
}
if let Some((_, h)) = bestc {
hits.push(h);
}
// Nearest face along the ray (hides what is behind it).
hits.extend(bestc.hits());
// Nearest face along the ray (hides what is behind it); with `all`, every body's nearest.
let mut bestf: Option<(f64, Hit)> = None;
let mut faces = Near::new(all);
for b in &st.bodies {
if !app.session.body_visible(&b.name) {
continue;
@@ -793,23 +853,27 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
let pad = bb.diagonal() * 1e-6 + 1e-9;
match ray_box(o, d, &bb, pad) {
None => continue,
Some(t0) if bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => continue,
Some(t0) if !all && bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => continue,
_ => {}
}
}
let m = b.mesh();
if let Some((t, ti)) = m.raycast(o, d)
&& bestf.as_ref().is_none_or(|(bt, _)| t < *bt)
{
if let Some((t, ti)) = m.raycast(o, d) {
let fi = m.tri_face.get(ti).copied().unwrap_or(0) as usize;
bestf = Some((t, Hit::Face { body: b.name.clone(), index: fi, point: o + d * t }));
let hit = Hit::Face { body: b.name.clone(), index: fi, point: o + d * t };
if bestf.as_ref().is_none_or(|(bt, _)| t < *bt) {
bestf = Some((t, hit.clone()));
}
if all {
faces.offer(t, hit);
}
}
}
let slack = scene_radius(app) * 0.02;
let visible = |p: Vec3| bestf.as_ref().is_none_or(|(t, _)| (p - o).dot(d) <= *t + slack);
// Vertices (edge end points) and edges.
let mut bestv: Option<(f32, Hit)> = None;
let mut beste: Option<(f32, Hit)> = None;
let mut bestv = Near::new(all);
let mut beste = Near::new(all);
for b in &st.bodies {
if !app.session.body_visible(&b.name) {
continue;
@@ -828,35 +892,35 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
for v in [e.first(), e.last()].into_iter().flatten() {
if let Some(sp) = proj.to_screen(*v) {
let dd = sp.distance(pos);
if dd < 7.0 && visible(*v) && bestv.as_ref().is_none_or(|(bd, _)| dd < *bd) {
bestv = Some((dd, Hit::Vertex { body: b.name.clone(), point: *v }));
if dd < 7.0 && visible(*v) && bestv.wants(dd) {
bestv.offer(dd, Hit::Vertex { body: b.name.clone(), point: *v });
}
}
}
let pts: Vec<Pos2> = e.iter().filter_map(|q| proj.to_screen(*q)).collect();
for (k, w) in pts.windows(2).enumerate() {
let dd = seg_dist(pos, w[0], w[1]);
if dd < 6.0 && beste.as_ref().is_none_or(|(bd, _)| dd < *bd) {
if dd < 6.0 && beste.wants(dd) {
let mid = polyline_mid(e);
// Visible where the cursor is (not just at the middle).
let at = e.get(k).copied().unwrap_or(mid);
if visible(at) || visible(mid) {
beste = Some((dd, Hit::Edge { body: b.name.clone(), index: ei, mid }));
beste.offer(dd, Hit::Edge { body: b.name.clone(), index: ei, mid });
}
}
}
}
}
hits.extend(bestv.map(|x| x.1));
hits.extend(beste.map(|x| x.1));
hits.extend(bestv.hits());
hits.extend(beste.hits());
// Origin axes.
let size = origin_size(app.cam.half_height());
let mut besta: Option<(f32, Hit)> = None;
let mut besta = Near::new(all);
for (name, dir) in origin_axes(app) {
if let (Some(a), Some(b)) = (proj.to_screen(Vec3::ZERO), proj.to_screen(dir * (size * AXIS_LEN))) {
let dd = seg_dist(pos, a, b);
if dd < 5.0 && besta.as_ref().is_none_or(|(bd, _)| dd < *bd) {
besta = Some((dd, Hit::Axis { name: name.into() }));
if dd < 5.0 && besta.wants(dd) {
besta.offer(dd, Hit::Axis { name: name.into() });
}
}
}
@@ -864,15 +928,16 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
for (name, a, b) in construction_axis_segments(app) {
if let (Some(a), Some(b)) = (proj.to_screen(a), proj.to_screen(b)) {
let dd = seg_dist(pos, a, b);
if dd < 5.0 && besta.as_ref().is_none_or(|(bd, _)| dd < *bd) {
besta = Some((dd, Hit::Axis { name }));
if dd < 5.0 && besta.wants(dd) {
besta.offer(dd, Hit::Axis { name });
}
}
}
hits.extend(besta.map(|x| x.1));
hits.extend(besta.hits());
// Surfaces by depth: profiles (slightly preferred over a face they lie on), faces, planes.
let mut surf: Vec<(f64, Hit)> = Vec::new();
let mut bestp: Option<(f64, f64, Hit)> = None;
let mut profiles_all: Vec<(f64, f64, Hit)> = Vec::new();
for sid in visible_sketches(app) {
let Some(ss) = st.sketch(sid) else { continue };
let n = ss.plane.normal();
@@ -888,15 +953,24 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
// Show Profile off: the sketch being edited offers no profiles.
let profiles = if ss.sketch.view.hide_profile && s.active_sketch == Some(sid) { &[][..] } else { &ss.profiles[..] };
for (pi, p) in profiles.iter().enumerate() {
if p.region.contains(lp) && bestp.as_ref().is_none_or(|(_, a, _)| p.area < *a) {
if !p.region.contains(lp) {
continue;
}
if all {
profiles_all.push((t, p.area, Hit::Profile { sketch: sid, index: pi }));
} else if bestp.as_ref().is_none_or(|(_, a, _)| p.area < *a) {
bestp = Some((t, p.area, Hit::Profile { sketch: sid, index: pi }));
}
}
}
if let Some((t, _, h)) = bestp {
// Smallest profile first.
profiles_all.sort_by(|a, b| a.1.total_cmp(&b.1));
for (t, _, h) in bestp.into_iter().chain(profiles_all) {
surf.push((t - slack, h));
}
if let Some(f) = bestf {
if all {
surf.extend(faces.sorted());
} else if let Some(f) = bestf {
surf.push(f);
}
// Planes give way to a face or profile at the same depth (a floor on the XZ plane).
@@ -1103,27 +1177,57 @@ pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> {
if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) {
return hits.iter().find_map(|h| d.candidate(&app.session, h).map(|s| (h.clone(), s)));
}
hits.iter().filter(|h| pick_allowed(app, h)).find_map(|h| hit_candidate(app, h))
}
/// Every hit a click could pick, in order, each with its selection (Select Other).
pub fn candidates(app: &SolveApp, hits: &[Hit]) -> Vec<(Hit, Sel)> {
if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) {
return hits.iter().filter_map(|h| d.candidate(&app.session, h).map(|s| (h.clone(), s))).collect();
}
hits.iter().filter(|h| pick_allowed(app, h)).filter_map(|h| hit_candidate(app, h)).collect()
}
/// What the selection filter lets a click pick with no dialog.
fn pick_allowed(app: &SolveApp, h: &Hit) -> bool {
let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k);
hits.iter()
.filter(|h| match h {
Hit::SketchCurve { .. } => !off("sketch") && !off("sketch_curves"),
Hit::SketchPoint { .. } => !off("sketch") && !off("sketch_points"),
Hit::Profile { .. } => !off("sketch"),
Hit::Vertex { .. } => !off("vertices") && !app.ui.pick_bodies,
Hit::Edge { .. } => !off("edges") && !app.ui.pick_bodies,
Hit::Face { .. } => !off("faces") || app.ui.pick_bodies,
Hit::Plane { .. } | Hit::Axis { .. } => true,
})
.find_map(|h| match h {
Hit::SketchCurve { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
Some((h.clone(), Sel::ExternalSketchCurve { sketch: *sketch, id: id.clone() }))
}
Hit::SketchPoint { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
Some((h.clone(), Sel::ExternalSketchPoint { sketch: *sketch, id: id.clone() }))
}
Hit::Face { body, .. } if app.ui.pick_bodies => Some((h.clone(), Sel::Body { name: body.clone() })),
_ => hit_sel(h).map(|s| (h.clone(), s)),
})
match h {
Hit::SketchCurve { .. } => !off("sketch") && !off("sketch_curves"),
Hit::SketchPoint { .. } => !off("sketch") && !off("sketch_points"),
Hit::Profile { .. } => !off("sketch"),
Hit::Vertex { .. } => !off("vertices") && !app.ui.pick_bodies,
Hit::Edge { .. } => !off("edges") && !app.ui.pick_bodies,
Hit::Face { .. } => !off("faces") || app.ui.pick_bodies,
Hit::Plane { .. } | Hit::Axis { .. } => true,
}
}
/// The selection a click on `h` makes with no dialog (sketch entities only belong to the active sketch).
fn hit_candidate(app: &SolveApp, h: &Hit) -> Option<(Hit, Sel)> {
match h {
Hit::SketchCurve { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
Some((h.clone(), Sel::ExternalSketchCurve { sketch: *sketch, id: id.clone() }))
}
Hit::SketchPoint { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
Some((h.clone(), Sel::ExternalSketchPoint { sketch: *sketch, id: id.clone() }))
}
Hit::Face { body, .. } if app.ui.pick_bodies => Some((h.clone(), Sel::Body { name: body.clone() })),
_ => hit_sel(h).map(|s| (h.clone(), s)),
}
}
/// A pick for the open dialog's active input (a click, or a Select Other row).
pub(crate) fn dialog_pick(app: &mut SolveApp, hit: &Hit, sel: Sel) {
let Some(mut d) = app.dialog.take() else { return };
d.pick(&app.session, sel);
if let crate::dialogs::Kind::PathPlane { sketch, .. } = &mut d.kind {
*sketch = match hit {
Hit::SketchCurve { sketch, .. } => Some(*sketch),
_ => None,
};
d.extra.remove("path");
}
app.dialog = Some(d);
}
/// The edge a selection refers to now: by index if it still passes through the stored point,
@@ -1726,20 +1830,24 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
// A left press with the preset's keys (Alias: Shift+Alt) navigates until the button is let
// go (kept through the release frame, so the end of the drag doesn't select either).
if primary_pressed && inside {
let openscad_orbit = app.preferences.nav == crate::prefs::OPENSCAD_NAV
&& app.tool.is_none()
&& app.dialog.is_none()
&& app.viewport.context_menu.is_none()
&& !mods.shift
&& hover.is_some_and(|p| {
let proj = projection(app, rect);
pick(app, &proj, p).is_empty() && crate::dim_view::hit(p).is_none() && crate::sketch_tools::glyph_at(p).is_none()
});
app.viewport.primary_nav = if app.preferences.nav != crate::prefs::OPENSCAD_NAV || openscad_orbit {
let empty_space = |app: &SolveApp| {
app.viewport.context_menu.is_none()
&& hover.is_some_and(|p| {
let proj = projection(app, rect);
pick(app, &proj, p).is_empty() && crate::dim_view::hit(p).is_none() && crate::sketch_tools::glyph_at(p).is_none()
})
};
let openscad_orbit =
app.preferences.nav == crate::prefs::OPENSCAD_NAV && app.tool.is_none() && app.dialog.is_none() && !mods.shift && empty_space(app);
let preset = if app.preferences.nav != crate::prefs::OPENSCAD_NAV || openscad_orbit {
crate::prefs::nav_mode(app, crate::prefs::NavButton::Left, mods)
} else {
None
};
// Ctrl+left drag from empty space orbits in every preset (Cmd on macOS). A Ctrl+click
// still selects, and Ctrl on geometry still drags it without snapping.
let ctrl_orbit = (mods.ctrl || mods.command) && !mods.shift && !mods.alt && empty_space(app);
app.viewport.primary_nav = preset.or(ctrl_orbit.then_some(NavMode::Orbit));
} else if !primary_down && !primary_released {
app.viewport.primary_nav = None;
}
@@ -1851,6 +1959,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default();
let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) };
app.viewport.hover = cand.as_ref().map(|c| c.0.clone());
// Select Other: a press held still opens the list of everything under it; while the list is
// open its row under the pointer is what lights up.
let held =
crate::select_other::track_hold(app, ui.ctx(), &proj, inside, primary_pressed, primary_down, hover, primary_nav.is_some() || mode.is_some());
crate::select_other::hover_override(app);
let painter = ui.painter_at(rect);
// Background gradient.
let (top, bot) = (t.viewport_top, t.viewport_bottom);
@@ -1926,22 +2039,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.tool.is_some() {
crate::tools::on_hover(app, &proj, p);
}
if resp.clicked() {
if resp.clicked() && !held {
if app.tool.is_some() {
crate::tools::on_click(app, &proj, p);
} else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) {
if let Some((hit, Some(sel))) = cand
&& let Some(mut d) = app.dialog.take()
{
d.pick(&app.session, sel);
if let crate::dialogs::Kind::PathPlane { sketch, .. } = &mut d.kind {
*sketch = match hit {
Hit::SketchCurve { sketch, .. } => Some(sketch),
_ => None,
};
d.extra.remove("path");
}
app.dialog = Some(d);
if let Some((hit, Some(sel))) = cand {
dialog_pick(app, &hit, sel);
}
} else {
// Dimensions and constraint glyphs sit on top of the geometry.
@@ -1957,15 +2060,23 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if !crate::sketch3d::click(app, &proj, p) {
match on_dim.or(on_glyph) {
Some(id) => select(app, Some(Sel::SketchConstraint { id }), add),
None => select(app, cand.and_then(|c| c.1), add),
// Clicking again on the same spot selects the next thing under it (not
// the second click of a double-click, which has its own actions).
None if !add && !resp.double_clicked() && crate::select_other::cycle_click(app, &proj, p) => {}
None => {
let sel = cand.and_then(|c| c.1);
select(app, sel.clone(), add);
crate::select_other::note_click(app, p, if add { None } else { sel });
}
}
}
}
}
// Double-click a dimension: edit its value in place.
let dim_edit = resp.double_clicked() && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p);
let dim_edit = resp.double_clicked() && !held && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p);
// Double-click a sketch curve: select the chain joined to it end to end.
if resp.double_clicked()
&& !held
&& !dim_edit
&& app.tool.is_none()
&& app.dialog.is_none()
@@ -1979,6 +2090,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
// Double-click an edge: select the edges that continue it smoothly (the loop).
if resp.double_clicked()
&& !held
&& !dim_edit
&& app.tool.is_none()
&& app.dialog.is_none()
@@ -2002,7 +2114,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
// Dragging a sketch point of the active sketch moves it; the solver keeps the constraints.
if app.tool.is_none() && app.dialog.is_none() && primary_nav.is_none() && mode.is_none() && app.session.active_sketch.is_some() {
if app.tool.is_none() && app.dialog.is_none() && primary_nav.is_none() && mode.is_none() && app.session.active_sketch.is_some() && !held {
let on_plane = |app: &SolveApp, p: Pos2| {
let ss = app.session.active_sketch.and_then(|sid| app.session.world_state().sketch(sid).cloned())?;
let (o, d) = proj.ray(p);
@@ -2114,6 +2226,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
// Box selection: a primary drag on the model when no navigation mode is on.
if app.tool.is_none()
&& !held
&& primary_nav.is_none()
&& mode.is_none()
&& app.viewport.point_drag.is_none()
@@ -2169,6 +2282,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::canvas::show(app, ui, &painter, &proj);
crate::sketch_tools::show(app, ui, &painter, &proj);
crate::sketch_dims::show(app, ui, &proj);
crate::select_other::show(app, ui.ctx());
axis_view(app, ui, rect);
nav_bar(app, ui, rect);
// Status chip.
@@ -2188,7 +2302,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
/// A click without a command: select the candidate (replacing the selection, or toggling it
/// in with Shift/Ctrl); a click on nothing clears.
fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
pub(crate) fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
match sel {
Some(x) => {
let items = grouped_selection(&app.session, &x);
@@ -3094,6 +3208,63 @@ mod tests {
app
}
#[test]
fn ctrl_left_drag_from_empty_space_orbits_in_every_preset() {
for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) {
for action in ["orbit", "tool", "point", "click"] {
let ctx = egui::Context::default();
let mut app = hover_app(false);
app.preferences.nav = preset.into();
app.ui.show_origin = false;
hover_frame(&mut app, &ctx, vec![]);
let point = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
let (start, end) = match action {
"point" => (point, point + vec2(30.0, 20.0)),
"click" => (point, point),
_ => (point - vec2(60.0, 60.0), point - vec2(20.0, 30.0)),
};
if action == "tool" {
app.start("sketch.line");
}
let before = app.cam;
let undo = app.session.undo.len();
let modifiers = egui::Modifiers { ctrl: true, command: !cfg!(target_os = "macos"), ..Default::default() };
let frame = |app: &mut SolveApp, mut events: Vec<egui::Event>| {
events.insert(0, egui::Event::ModifiersChanged(modifiers));
hover_frame(app, &ctx, events);
};
frame(
&mut app,
vec![
egui::Event::PointerMoved(start),
egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers },
],
);
frame(&mut app, vec![egui::Event::PointerMoved(end)]);
frame(&mut app, vec![]);
match action {
"orbit" | "tool" => {
assert_ne!(app.cam, before, "{preset}: Ctrl+left drag on empty space orbits ({action})");
assert!(app.viewport.boxsel.is_none(), "{preset}: no box selection while orbiting");
}
"point" => {
assert_eq!(app.cam, before, "{preset}: Ctrl+drag on sketch geometry is not an orbit");
assert!(app.viewport.point_drag.is_some(), "{preset}: Ctrl+drag still drags the point");
}
_ => assert_eq!(app.cam, before, "{preset}: a Ctrl+click does not orbit"),
}
frame(&mut app, vec![egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers }]);
frame(&mut app, vec![]);
match action {
"click" => assert!(!app.session.selection.is_empty(), "{preset}: Ctrl+click still selects"),
"tool" => assert_eq!(app.session.undo.len(), undo, "{preset}: the orbit drag adds no geometry"),
"orbit" => assert!(app.session.selection.is_empty(), "{preset}: the end of the orbit selects nothing"),
_ => {}
}
}
}
}
#[test]
fn openscad_left_drag_preserves_editing_and_selection() {
for action in ["point", "box", "tool"] {
+15 -2
View File
@@ -1,5 +1,5 @@
//! Toolbar layout of the tabs that share commands with others (Fusion's layout): the SOLID
//! tab's ASSEMBLE panel, SHEET METAL and PLASTIC. A command is registered on one tab and panel;
//! Explicit toolbar layouts: Sketch CREATE, the SOLID tab's ASSEMBLE panel, SHEET METAL and
//! PLASTIC. A command is registered on one tab and panel;
//! these panels list the commands they show in order (the first few get big buttons), so Extrude
//! or Joint appear on several tabs. Commands registered on such a panel but not listed follow at
//! the end.
@@ -100,6 +100,19 @@ pub fn panels(tab: &str) -> Option<&'static [&'static str]> {
/// to the registry.
pub fn layout<'a>(tab: &str, panel: &str, specs: &[&'a CommandSpec]) -> Option<(Vec<&'a CommandSpec>, usize)> {
let (ids, promoted, own_tab): (&[&str], usize, &str) = match (tab, panel) {
("SKETCH", "CREATE") => (
&[
"sketch.line",
"sketch.rectangle.two_point",
"sketch.rectangle.three_point",
"sketch.rectangle.center",
"sketch.circle.center",
"sketch.circle.two_point",
"sketch.point",
],
7,
tab,
),
("SOLID", "CONFIGURE") => (&["parameters.change", "config.table"], 2, tab),
("SOLID" | "SHEET METAL" | "PLASTIC", "ASSEMBLE") => (ASSEMBLE, 3, tab),
("SHEET METAL", "CREATE") => (SHEET_CREATE, 3, tab),
@@ -81,16 +81,7 @@
}
},
{
"note": "The sheet flange face is uncertified; choose the displayed fixed-plane option explicitly before drawing the fold line."
},
{
"expect": {
"sketching": false,
"dialog": "this face has no exact planar source"
}
},
{
"widget": "Use fixed plane"
"note": "A flat face of a sheet attaches the sketch to its panel (by name), in the flat pattern's coordinates."
},
{
"expect": {
@@ -0,0 +1,117 @@
[
{
"note": "Two edge flanges in one feature; the second gets an angle of its own in its row of the Flange dialog. Default rule: T 2.5, R 2.5, gap 2.5; height 10. By hand: s0 = (R + T)·tan(θ/2) is 5 at 90° and 2.886751 at 60°, so the base keeps 57.113249 x 35 = 1998.963703 mm². Each bend holds len·θ·(R + T/2): 57.113249·(π/2)·3.75 + 35·(π/3)·3.75 = 473.869483. The 90° panel ends x(w) = ((g/2)·√2 + T − w·cos 60°) / sin 60° from the corner, 2.041241 at w = 5 and −0.845510 at w = 10: 5·(60 − 0.597866) = 297.010671. The 60° panel ends (g/2)·√2 + T = 4.267767 from the corner and is 7.113249 high: 254.172259. Volume: 2.5·3024.016116 = 7560.040 mm³."
},
{
"call": "ui.set",
"params": {
"tab": "SHEET METAL"
}
},
{
"start": "sketch.create"
},
{
"click": {
"plane": "XY"
}
},
{
"start": "sketch.rectangle.two_point"
},
{
"click": {
"sketch": [
0,
0
]
}
},
{
"click": {
"sketch": [
60,
40
]
}
},
{
"key": "Escape"
},
{
"start": "sketch.finish"
},
{
"call": "ui.view",
"params": {
"view": "iso"
}
},
{
"start": "sheet.flange"
},
{
"click": {
"world": [
30,
20,
0
]
}
},
{
"key": "Enter"
},
{
"expect": {
"bodies": 1,
"volume": 6000
}
},
{
"start": "sheet.flange"
},
{
"click": {
"world": [
30,
0,
2.5
]
}
},
{
"click": {
"world": [
60,
20,
2.5
]
}
},
{
"click": {
"handle": "dialog:Edge 2 angle"
}
},
{
"text": "60 deg"
},
{
"key": "Enter"
},
{
"expect": {
"bodies": 1,
"errors": 0,
"dialog": null,
"volume": [
7560.04,
0.0001
]
}
},
{
"shot": "s49_flange_per_edge"
}
]
@@ -0,0 +1,27 @@
[
{"note":"#191: Point is a default Sketch CREATE button; the existing six drawing buttons remain available."},
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"expect":{"shown_handles":["button:sketch.line","button:sketch.rectangle.two_point","button:sketch.rectangle.three_point","button:sketch.rectangle.center","button:sketch.circle.center","button:sketch.circle.two_point","button:sketch.point"]}},
{"click":{"handle":"button:sketch.point"}},
{"expect":{"tool":"sketch.point","dialog":null}},
{"click":{"sketch":[20,15]}},
{"key":"Escape"},
{"expect":{"errors":0,"sketch_world":{"point":"p1","at":[20,15,0]}}},
{"call":"ui.menu","params":{"x":400,"y":80,"target":{"type":"toolbar_command","id":"sketch.point"}}},
{"call":"ui.menuPick","params":{"item":"Remove from Toolbar"}},
{"expect":{"hidden_handles":["button:sketch.point"]}},
{"restart":"close"},
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"expect":{"hidden_handles":["button:sketch.point"]}},
{"click":{"handle":"panel:SKETCH:CREATE"}},
{"expect":{"shown_handles":["dropdown:sketch.point"]}},
{"click":{"handle":"dropdown:sketch.point"}},
{"key":"Escape"},
{"call":"ui.menu","params":{"x":400,"y":80,"target":{"type":"toolbar"}}},
{"call":"ui.menuPick","params":{"item":"Reset Toolbar"}},
{"expect":{"shown_handles":["button:sketch.point","button:sketch.line"],"errors":0}},
{"call":"ui.resize","params":{"width":640,"height":720}},
{"click":{"handle":"panel:SKETCH:CREATE"}},
{"click":{"handle":"dropdown:sketch.point"}},
{"expect":{"tool":"sketch.point","errors":0}}
]
@@ -0,0 +1,21 @@
[
{"note":"#333: dropping a dropdown command on a panel label appends it; moving between labels keeps the chosen order after restart."},
{"click":{"handle":"panel:SOLID:CREATE"}},
{"drag":[{"handle":"dropdown:solid.cylinder"},{"handle":"panel:SOLID:CREATE"}],"steps":12},
{"key":"Escape"},
{"expect":{"handle_order":["button:solid.sweep","button:solid.cylinder"],"dialog":null,"tool":null,"errors":0}},
{"click":{"handle":"panel:SOLID:CREATE"}},
{"drag":[{"handle":"dropdown:solid.torus"},{"handle":"panel:SOLID:MODIFY"}],"steps":12},
{"key":"Escape"},
{"expect":{"handle_order":["button:solid.scale","button:solid.torus"],"dialog":null,"tool":null,"errors":0}},
{"drag":[{"handle":"button:solid.torus"},{"handle":"panel:SOLID:CREATE"}],"steps":12},
{"expect":{"handle_order":["button:solid.cylinder","button:solid.torus"]}},
{"restart":"close"},
{"expect":{"handle_order":["button:solid.sweep","button:solid.cylinder","button:solid.torus"],"errors":0}},
{"call":"ui.resize","params":{"width":640,"height":720}},
{"click":{"handle":"panel:SOLID:CREATE"}},
{"drag":[{"handle":"dropdown:solid.box"},{"handle":"panel:SOLID:CREATE"}],"steps":12},
{"key":"Escape"},
{"call":"ui.resize","params":{"width":1600,"height":1000}},
{"expect":{"handle_order":["button:solid.torus","button:solid.box"],"tool":null,"dialog":null,"errors":0}}
]
@@ -0,0 +1,17 @@
[
{"note": "Two lines drawn on top of each other: holding the button still lists both, and the one underneath can be picked."},
{"start": "sketch.create"},
{"click": {"plane": "XY"}},
{"call": "ui.set", "params": {"showOrigin": false}},
{"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[0, 10], [20, 10]], "infer": false, "connect": false}}},
{"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[0, 10], [20, 10]], "infer": false, "connect": false}}},
{"hold": {"sketch": [10, 10]}},
{"expect": {"selection": 0, "sketching": true}},
{"move": {"handle": "select_other:1"}},
{"click": {"handle": "select_other:1"}},
{"expect": {"selection": 1}},
{"call": "ui.selectOther", "params": {"pick": 0}, "fail": true},
{"note": "Click cycling: a second deliberate click on the same spot selects the next line."},
{"click": {"sketch": [10, 10]}},
{"expect": {"selection": 1}}
]
+3 -2
View File
@@ -51,8 +51,9 @@ keeps the current design. Previews cannot be edited or measured. This experiment
requires `preview: true` in an engine Save/Save As call; ordinary saves omit display caches.
The repository's `docs/design-container.md` describes limits and encoding details.
Current designs use format 14, including persistent names from exact face history,
exact planar shell-face anchors, persistent analytic offset boundary roles and
Current designs use format 15, including sketch face anchors on cylinder caps, sketch axis
links, persistent
names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and
association profile references, durable sketch face anchors, periodic fit splines and versioned
revolve conventions. It also retains recorded body transfers, explicit 3D curve coordinate
spaces and certified face bindings, alongside semantic configuration styles, associative
+30
View File
@@ -24,6 +24,24 @@ opposite ways, ends that would overlap in the flat pattern) stays open, and the
reports a warning saying where. A flange added by a later feature leaves the corner as it was:
the earlier flange keeps its length and the new one ends at its bend.
The flanges of one edge flange feature can each have an angle and a height of their own. An
entry of `edges` is a point `[x, y, z]`, which takes the feature's `height` and `angle`, or an
object `{"point": [x, y, z], "angle": "105 deg", "height": "30 mm"}` whose `angle` and `height`
(either one, or both) replace them for that edge. They are expressions like the feature's own,
so parameters work, and each is a feature input of its own (`Edge angle`, `Edge height`) after
`Height` and `Angle`. The corners between such flanges close like those between equal ones:
the ends run toward each other the rule's gap apart, either side of the plane halfway between
the two flanges, and a taller flange's end keeps that line up to its own tip. This is how to
close a corner between flanges at different angles, because flanges of separate features
don't close toward each other. In the Flange dialog, picking more than one edge shows a row
per edge with an angle and a height box; an empty box takes the value above.
```json
{"command":"sheet.flange","params":{"edges":[[60,0,2],{"point":[120,45,2],"angle":"90 deg"},
{"point":[60,90,2],"angle":"105 deg","height":"30 mm"},{"point":[0,45,2],"angle":"120 deg","height":"25 mm"}],
"height":38.6,"angle":"75 deg"}}
```
For a shop drawing or spreadsheet, `sheet.export_bend_table` returns CSV by default; choose
`format: "json"` for structured data. It includes the sheet rule, thickness, bend angles,
radii, allowances and flat centre-line coordinates. Lengths use millimetres and angles use
@@ -71,3 +89,15 @@ tool with varying depth is refused before changing sheet geometry. Pocket profil
but a pocket crossing a bend zone cannot yet be refolded. Machining folded bend material,
tapered sheet cuts and other unsupported 3D sheet Cut/Intersect operations report an error before changing
the design; they cannot silently restore removed material during a later sheet rebuild.
A sketch can be drawn on a flat face of the sheet: the base or a flange, on either side. Pick
the face (or give `sketch.create` the point `plane: {face: [x, y, z]}`) and the sketch attaches
to that panel instead of to a certified source face. It keeps the face's name,
`sheet:<body>:<panel>:<top|bottom>` (the panel is `base` or the flange's feature tag), and finds
the face again whenever the sheet is rebuilt: after a parameter change such as the thickness or
a flange angle, through Unfold / Refold, after the file is reopened and when the body is
renamed. Sketch coordinates are the flat pattern's, so a point drawn on a flange lies where the
flat pattern and its DXF have it, and on the bottom face y runs the other way, as seen from
below. When the face is gone (its flange is suppressed or deleted) the sketch reports a lost
attachment instead of moving to another face; **Redefine Sketch Plane** attaches it to another
face. `fixed: true` still freezes the picked plane in space.
+9
View File
@@ -26,4 +26,13 @@ Keys handled by the window itself:
| F6 | fit the view to the model |
| Esc | cancel the running command, or clear the selection |
Mouse and touch:
| Action | Result |
|---|---|
| Click | select (Shift or Ctrl adds) |
| Click again on the same spot | select the next thing under the cursor (click cycling) |
| Hold the left button still (long-press on touch), or right-click › Select Other… | list everything under the cursor to pick from |
| Double-click a sketch curve or edge | select its chain or tangent loop |
Help › Keyboard Shortcuts in the app lists the current bindings, including any you changed.
+8
View File
@@ -21,6 +21,10 @@ constraint status and **Finish Sketch** button remain visible.
## Geometry
**Point** is available directly on the default Sketch **CREATE** toolbar. Click it, then
click in the sketch to place a point. Existing toolbar customizations are kept; use
**Reset Toolbar** from the toolbar's context menu to restore the default buttons.
Lines and polylines (L), rectangles (two-point R, three-point, centre), circles (centre C,
two-point, three-point, tangent), arcs (three-point, centre, tangent), polygons, slots, ellipses,
fit-point and control-point splines, conics, points and text. **Construction** (X) turns curves
@@ -35,6 +39,10 @@ is projected in one undo step. A rejected projection keeps the selection for cor
an empty or mixed selection starts the ordinary picking tool. External sketch selections
retain their source sketch, even when the active sketch uses the same point or curve names.
A sketch on a construction plane built from the model (through a sketch line, through three
points, midway between two faces…) projects, intersects, includes and holds text like a sketch
on any other plane, and its links follow when a parameter moves that plane.
Run from a script or the control channel, every command that adds sketch geometry returns the
ids of what it added (`created: {points, curves, constraints, dimensions}`). Ids are stable and
never reused within a sketch, even after a delete, so a script can dimension or constrain the new
+13 -3
View File
@@ -7,18 +7,28 @@
undo history, selection and camera).
- **Toolbar.** The workspace switcher (DESIGN) sits on the left. The tabs (SOLID, SURFACE,
SHEET METAL, PLASTIC, UTILITIES, and SKETCH while you sketch) hold panels of commands. Click a
panel's name for all its commands.
panel's name for all its commands. Drag a command from the dropdown onto a panel's label
or empty space to append it; the insertion line shows its destination. Drop onto an existing
button to insert before it. Buttons can also move between panels, and the layout is saved.
- **Browser** (left). The design as a tree: document settings and units, named views, the
origin, bodies, sketches, construction planes, canvases, joints and components. The eye shows or
hides an item, a component's radio button makes it the active component, and right-clicking any
row gives that item's menu. Rows drag into groups and other components.
- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. The
- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. To
reach something under something else (two lines drawn on top of each other, coincident points,
an edge in front of a face, overlapping bodies), hold the left button still for half a second
(long-press on a touch screen) or right-click › **Select Other…**: a list of everything under
the cursor opens there, the sketch being edited first. Hovering a row lights it up; clicking it
selects it (Shift or Ctrl adds), and in a command's dialog it goes to the active selection box.
Esc or a click elsewhere closes the list. Clicking again on the same spot, with nothing changed
in between, selects the next thing in that list. The
wheel zooms at the cursor, the middle button pans and right-drag orbits freely (the mouse
< preset in Preferences switches to SolidWorks, Inventor, Alias or Tinkercad style). On a
trackpad, pinch zooms and two-finger scroll pans (Navigation › Two-finger scroll pans;
off: scroll zooms like the wheel); mouse wheels zoom and the thumb wheel pans sideways.
Shift+middle orbits freely
in the default and Inventor presets. Shift+right-drag snaps to the nearest standard or
in the default and Inventor presets. In every preset, Ctrl+left drag (Cmd on macOS) that starts
on empty space orbits; a Ctrl+click still adds to the selection. Shift+right-drag snaps to the nearest standard or
diagonal view; Shift does not request snapping when the preset already requires it to orbit.
The axis view shows the world's X/Y/Z directions in SolidCraft's colors. Click a signed
axis tip to look along that axis; click the aligned tip again to look from the opposite side.
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

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