Compare commits

..

90 Commits

Author SHA1 Message Date
bherbruck e6252ae094 Version 0.4.0 (#330)
Minor bump: new capabilities since 0.3.2 and one breaking change
(legacy command ids are no longer accepted).


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:58:49 -04:00
bherbruck 198f45edee Toolbar: no cap on pinned buttons (Fixes #323) (#326)
* Toolbar: no cap on pinned buttons (Fixes #323)

Every pinned command gets a toolbar button; `promoted()` is only the
default layout of a fresh or reset toolbar. A toolbar too narrow for every
button now collapses panels into their drop-down (one button that opens it,
listing every command, pinned ones included) from the right, then moves the
last panels into the » menu, instead of pushing a wide panel and everything
after it into ». Nothing is dropped.

Scenario s48 pins ten commands into SOLID > CREATE: all ten show at a wide
window, a narrow one collapses CREATE into its drop-down (all ten listed),
narrower still puts it in », and Reset Toolbar restores the defaults.

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

* Narrow-toolbar test: panels collapse into their drop-down before »

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

* kernel: drop a needless borrow that fails clippy -D warnings on the current stable

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:35:19 -04:00
Jonas Wrede 354a267830 test: failing regression test for #260: Targeted cut deletes another body that the tool fully covers (#275)
* test: reproduce #260 (Targeted cut deletes another body that the tool fully covers)

* Primitives honour `targets` for join/cut/intersect (#260)

solid.box/cylinder/sphere/torus had no targets field, so a `targets` list was
ignored and every body of the component took part: a targeted cut also cut a
non-target body, and deleted one it covered fully. Their `targets` now become
the feature's participants.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:18:59 -04:00
Iraklis Kourtis c33fe37798 fix(kernel): loop fillet as tall as its wall uses the wall up (#318)
* fix(kernel): name the zero-thickness rim when a shell can't open beside a round

Shelling a body through a face that a convex round meets tangentially, with
the round at least as large as the thickness, puts the cavity wall through
the rim where the round meets the open face. Both shell strategies then fail
and the first one's generic error ("a corner whose faces don't meet the same
way after the move") was reported. The shell now says why and what to change.
Successful shells are unchanged.

Found by fuzz seed 33, which reaches this case now that a disc's two edges can
be rounded to half its thickness.

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

* fix(kernel): loop fillet as tall as its wall uses the wall up

Rounding a loop with a radius equal to the height of its walls was refused
("the blend is taller than the walls"): r = 2 on the top edge of a 2 mm
disc failed while 1.999 worked. When every wall seam ends exactly where the
wall-side rail would, the walls are consumed: their far edges become the
blend's rails and the walls and seams are removed. Walls used up only in
part, and radii past the limit, are still refused.

Refs #190

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-12 12:17:07 -04:00
Jonas Wrede 667e05a0c9 test: failing regression test for #259: Join with explicit targets also merges a non-target body that the tool touches (#274)
* test: reproduce #259 (Join with explicit `targets` also merges a non-target body that the tool touches)

* Primitives honour `targets` for join/cut/intersect (#259)

solid.box/cylinder/sphere/torus had no targets field, so a `targets` list was
ignored and every body of the component took part: a join also merged a touched
non-target body, a cut also cut (or deleted) one. Their `targets` now become the
feature's participants.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:17:02 -04:00
Iraklis Kourtis a9efae864d fix(doc): designs saved before format 14 keep their piece names (#136)
Pieces a cut splits a body into are named after their faces since names
follow history (lineage::order_lumps), so they keep their body names when
an upstream edit moves the cut. The kernel's shell order named them
before, and the two orders can differ: a slot along X through a 40x30x10
block swaps Body1 and Body1 (1). A design saved by an older build stores
body names meaning the old order (fillet and chamfer bodies, targets,
participants, materials, appearances, joints), so it would act on the
other piece.

Each feature now says which order names its pieces
(Feature::shell_piece_order). The v13 to v14 step sets it on every saved
feature, so an older design evaluates as it was saved; features made from
now on, in new or old designs, name their pieces after their faces.

The corpus gains split-body-pieces, saved by v0.3.2: a fillet, a chamfer
and an appearance refer to the two pieces by body name. Without the
setting the two pieces swap names (the corpus test fails); with it they
stay as saved. Migration tests that compared an upgraded design with the
original now expect the setting on its features.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis Kourtis cc668668ce refactor(doc): format 14 marks the fields that store names, by schema place
The v13 to v14 step walked the whole design and marked any JSON key named
face, face_name or face_names, so any future field with one of those names
would be marked too. It now marks the fields that store a face or edge name
where the schema puts them: each feature's face_names and fillets' and
chamfers' edge_names, a sketch feature's links, joint snaps on a face in
joints and named origins, and face appearances. Face and edge references
(PlaneRef::Face, GeoRef::Face/Edge) can sit in many features, so they are
still found anywhere in a feature, by their tag and their picked point.

The test fixtures now have the shape real files have, and a new test checks
that same-named keys elsewhere are left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis Kourtis cfdd8a533b test(doc): drop two timing assertions on when a source's mesh is built
The world-placement tests checked that the source body's mesh was built
before a placement adopted its names. Once names follow history they no
longer need the mesh, so that is a side effect's timing, not behaviour.
What the tests protect, a placement sharing its source's names, both
still assert through the public API: Arc::ptr_eq(face_names(placed),
face_names(source)).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis Kourtis 08cf526fe2 feat(doc): persistent names follow the kernel's exact face history (#136)
Why: every reference a user makes to a face or edge (fillet and chamfer
edges, sketches on faces, shell/draft/offset faces, joints, appearances)
is stored as a name and found again after any upstream edit. Those names
were re-derived by sampling points of each face and voting, and pieces
of a split face were numbered by where their centres lay. After an
upstream edit that splits, merges, mirrors or shrinks a face, a fillet
or sketch could silently re-attach to the wrong face, and nothing told
the user. The kernel now reports exact history for its operations, so
names follow it and fail loudly instead of guessing.

- crates/doc/src/lineage.rs: a feature's kernel histories composed into
  one lineage over nameable operands (bodies before the feature, moved
  copies, shell cavities, tools named by role: sketch curve, cap, box
  side, imported face). Instrumented: extrude/revolve/sweep/loft tools,
  join/cut/intersect, lumps after a cut, fillet/chamfer, shell, draft,
  split face/body, pattern/mirror (bodies and features), combine,
  boundary fill, move/scale, offset/delete face, thread, import.
- crates/doc/src/history_names.rs: names from lineage; split pieces told
  apart by the faces only they touch (F2:top#[F2:side:l4,F4:side:l4]),
  merged faces answer to every name (aliases), lump bodies ordered by
  their faces' names instead of shell order.
- crates/doc/src/name_lookup.rs, piece_names.rs: references resolve by
  exact name, alias or bounding faces, else nearest piece with a
  warning; names worked out from geometry (operations without history)
  are reported when only geometry decided.
- Format 14: names stored by older formats get a legacy mark and are
  found as the build that saved them found them (some geometric names
  name a face history names differently: a tube's outer wall was named
  after its inner circle). New corpus file saved by 9a979cf.
- kernel: Body::lumps carries each piece's history over the whole.
- Adversarial tests: split, merge, mirrored twins, shrink to a sliver,
  moving cut with body pieces, history-less bodies, certified sketch.
- docs/book/src/dev/naming.md describes the real mechanism and why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis Kourtis 2bda050872 feat(kernel): bodies joined apart report their history (#136)
union_apart joins bodies whose boxes are clearly apart (many pattern tools,
#261) without any intersection work: their shells side by side. Each face
and edge of the result is its piece's own, unchanged, and the result now
says so (a history over the pieces), so a pattern's cut keeps the exact
names of every copy's faces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis Kourtis 08d797235d feat(kernel): history across coalesced stepped walls (#136)
A cut of stepped prisms sews a wall over one segment through successive
slabs as one face, and the generated planar walls are then joined on
their common supports. Until now such a piece carried no history.

- prism_lineage: a sewn wall records what it is over in each slab it
  spans (Spec::Wall { levels, slabs }), so a coalesced wall comes from
  the operand walls under it in every slab
- the sewn piece's history is attached before the joining and carried
  through it (Built::Traced); planar_regularize composes the history of
  the body it joins with the join itself
- a composed face is Split only where record::mark_shared_split finds an
  origin under several faces; an inner Split alone no longer makes one
- Body::same ignores history: an operation that leaves a body as it is
  gives the same body, carrying that operation's history

The two boolean history tests that waited for this run again, and the
coalesced and regularized step cuts are covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis Kourtis 97bcdb4d06 feat(kernel): settle a slab's far cap edge by operand topology (#136)
The far edge of a stepped slab (the operand cap edge where a segment's
wall ends) was recorded by testing whether each slab segment runs along
an ending cap's segment in 2D, a tolerance comparison of coordinates.
Matching by topology makes the far edge exact by construction instead:
when the wall's top is the ending level, nothing crosses it there, and it
meets the caps at that level in exactly one edge, that edge is over every
segment beside the wall, with no position compared.

A vertex walk up the wall's side edges from the segment's ends was tried
first and does not settle the cases that need it: a notched or stepped
wall continues past a segment's end (no side edge rises from it), and
walls split by earlier stations have mid-chain vertices with no side edge.
Whenever the walk succeeds, the wall is a rectangle over the segment and
the one-edge rule above already holds.

The positional match remains only where topology cannot say which edge
is over a segment: a wall that meets the ending caps in several edges (a
top split at earlier station vertices, or beside a wall crossing it at
that level) or that carries on past the level elsewhere. It is documented
in `Section::mark_far` and its pair budget no longer stops topological
marking. Far edges are also only marked on the slab that actually ends at
the level (a gap level no longer revisits an earlier slab).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis Kourtis 688f806682 feat(kernel): exact chamfer history after pocket, step, slot and bite cuts
Chamfer is one of the most common features and is usually applied right
after a cut. A chamfer cuts with a prism cutter, and its history came out
None after four of the six common box cuts. Without exact history, any
reference to a chamfer face (or a face it trimmed) falls back to geometric
guessing in naming, which can silently re-attach to the wrong face.

Two gaps in the stepped-prism section tags caused it, both now closed by
bookkeeping at construction, never by sampling or geometric tie-breaks:

- Cap edge at the far end of a slab. A slab's section is read off the cap
  where it starts and carried up to the cap where it ends; that cap's
  outline can run along one wall in several collinear edges, so which one
  a result edge lies on was unknown. When the stack is read, each slab
  segment that runs end to end along one segment of an ending cap's
  outline (beside the same wall) records that cap edge as `far`; the
  lineage takes it when it bounds the wall and the result cap.

- Walls an earlier cut left in coplanar pieces. A segment carried through
  a level kept the wall it was read beside, even where that wall gives
  way to another piece at the level (or within the slab), so the tag named
  the wrong face. `Stations` records the operand's edges across walls;
  at each level a carried segment keeps its wall when nothing crosses it
  there, moves to the one wall across it when it ends there, and is
  otherwise unknown (no history). A wall crossed inside a slab is unknown
  too, for simple and stepped prisms alike.

chamfer_after_box_cuts now covers all six cuts (notch, pocket, step,
slot, hole, bite), each verified against geometry and composed through
the cut over [block, tool]. The layered partial-depth cut test now
verifies its second cut, whose history was None before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis Kourtis 43ddde4a5d feat(kernel): an edge's meeting sides are sets of origins (merged faces)
EdgeOrigin::Meet now names each side as a Side: the sorted, de-duplicated
origins of the face there. substitute maps a side to the union of the inner
faces' origins, so a meeting with a face an inner operation merged keeps its
lineage; meeting() derives sides from whole faces. Fillets after box cuts
that leave coplanar pieces now report exact history. Prism lineage meets a
cap of several origins and resolves wall junctions away from shared
vertices by the operand's one edge between the walls. bore_union's recut
reports history only for a one-face bore (pieces of a two-arc bore's recut
wall were given both arcs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis Kourtis 08d7037176 feat(kernel): modifiers report exact face and edge history
Fillets (local, loop, rolling ball, corner, variable, chord), chamfers
(cutters, local corners, loops, two sides), shell, draft, offset faces,
split face/body, delete face, patch/copy/stitch/trim/extend and
transforms record their history over [body] while they build: faces
rebuilt from the body's, blends in place of edges (corner patches in
place of the edges at the corner), shell walls as offsets, edges as
pieces of the body's or where two faces meet. Multi-step operations
compose each step. The push-face offset fallback now has history, and
the bore union composes its narrowed tool.

A reflection reorders a body's edges, so transforms now match edges by
identity (mirrored copies reported the wrong edges).

Refs #136

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis Kourtis d5cb276965 feat(kernel): every boolean path reports exact face and edge history
ops::boolean's result carries its history over [a, b] whichever path made
it, recorded by the code that builds the faces:

- truck path (incl. jitter retries): shapeops history mapped through the
  order-preserving copies and the cut's turned tool, then through healing
- heal records which input faces each healed face merges and which input
  edges each joined or exact edge replaces (HealMap); opt-in, so heal_keep
  callers pay nothing
- trivial shortcuts, the void, glue, faces pushed apart (push_faces records
  its own history; the slab cut back on a union maps to the operands'
  faces in that plane), the nudged retry, by_lumps and join composition
- prism booleans: section segments carry tags (operand cap edge, the wall
  beside it, its vertices) through the 2D boolean; walls, caps and edges of
  the extruded or sewn result follow from them
- planar and faceted polygon booleans: polygons carry their operand face;
  faces join the pieces across edges, edges come from the pieces either side

History gains renumber/over (and consuming forms) for composition;
record gains regions/traced/build helpers. truck-shapeops
*_with_history now return the solid with an Option history. Paths that
cannot report (bore narrowing via offset, inexpressible lineage) leave the
result without history. Boolean results, tolerances and fallback order are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis Kourtis d69ebbee1b refactor(kernel): number profile loops once and record face history as faces are built
One numbered loop type (crate::numbered) carries each segment's profile
numbers through orientation, snapping, circle re-seaming, re-sampling and
rotation; every builder orients its regions through it once. Builders that
make their own side faces pair each face with its provenance as it is made
(ruled walls by the wire edges each face holds, smooth lofts, coils and path
sweeps by segment), and cap_and_close appends the caps itself. Column
following is kept only for faces truck sweeps internally (rsweep, tsweep
along paths, cones, where an apex vertex stays put). Extrude derives face
sources and history in one topology pass. One attach entry point takes
operands with their edges already indexed.

Geometry, face order, face sources and recorded origins are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis Kourtis 8c81bc60d0 feat(kernel): creation builders report exact face history
Extrude, revolve (partial and full turn), tapered extrude, loft (incl. to a
point), sweep, path sweep, coil, primitives, lip, emboss and thicken attach
the exact history of the bodies they build: each face's profile segment, cap
or operand face, and each edge's operand edge, recorded by topology identity
inside the building call. Nothing is sampled; a body whose faces do not follow
the expected construction carries no history. Face sources are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis Kourtis cc4955a8da feat(shapeops): face and edge history from booleans
and_with_history / or_with_history return, with the solid, the operand face
each result face is a region of (and whether it is that face untouched) and,
for each result edge, the operand edge it is (a piece of) or the pair of
operand faces whose intersection made it. Faces and edges are recorded where
they are created (face division, intersection edges, split edges, edges
rebuilt on a moved vertex), resolved to operand terms at creation, so the
passes folding in extra shells compose without bookkeeping. and / or wrap
the same pass and drop the history; a missing record never fails them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis Kourtis c87efa1f35 feat(kernel): exact face and edge history model
Bodies can now carry the exact history of the operation that made them:
for each result face its origins among the operation's operands (kept,
modified, split, merged, or generated from a profile segment, cap, edge or
offset) and for each edge the faces it separates and the operand edge or
face pair it came from. History is recorded by the operation, never
sampled, and checked against the body when attached.

- history.rs: the vocabulary (moved from provenance.rs), History::check,
  identity, substitute (composes an operation with the one that made an
  operand, including bodies made from a sketch) and face-source propagation.
- One edge order for a body (body::edge_order), shared by tessellation and
  history, so history edge indices are the mesh's edge indices.
- Transforms report an identity history; placed_topology documents the
  face/edge order guarantee it relies on.
- provenance.rs keeps the geometric trace for operations that do not yet
  report history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
selune 65a99608d9 Prepare independent STEP bodies concurrently on desktop (#312) 2026-10-12 11:46:32 -04:00
Iraklis Kourtis a9e4830ded fix(kernel): fillet that uses up a side face makes a full round (#317)
A constant-radius fillet whose setback equals the width of a neighbouring
face was refused ("the blend is larger than the neighbouring faces"), so
r = 3 on the vertical edges of a 6 mm wide box failed while 2.999 worked.
The corner edge is now consumed: the rail ends at its far vertex and a side
face used up at both ends is removed, its remaining edge becoming the rail.
A face used up at one end only, and radii past the limit, are still refused.

Refs #199


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:44:32 -04:00
Iraklis Kourtis a3ff816176 fix(kernel): name the zero-thickness rim when a shell can't open beside a round (#316)
Shelling a body through a face that a convex round meets tangentially, with
the round at least as large as the thickness, puts the cavity wall through
the rim where the round meets the open face. Both shell strategies then fail
and the first one's generic error ("a corner whose faces don't meet the same
way after the move") was reported. The shell now says why and what to change.
Successful shells are unchanged.

Found by fuzz seed 33, which reaches this case now that a disc's two edges can
be rounded to half its thickness.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:44:27 -04:00
Tobias Breckle 06e517eb4e Home: turn off commands that act on the hidden design (#322)
The Home page hides the open design, yet Save, Undo, Redo, File › Insert,
Save As and Export, and the design's keyboard shortcuts (Ctrl+S/Z/Y,
Delete, V, F2, F6, Esc, S and tool keys such as E) still acted on it:
Delete removed the selected body unseen, Save wrote a design the user
could not see, and tool keys left Home without being asked to.

On Home the top bar's Save, Undo and Redo are dimmed with a translated
"open a design first" tooltip, the File menu's design items are greyed
out, and only New, Open and Preferences keep their shortcuts. Everything
works as before once Home is closed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:44:23 -04:00
selune 4d4b3a715f Create construction planes along drawn 3D sketch paths (#240)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:44:19 -04:00
Jonas Wrede 6bf86f1f1e test: failing regression test for #267: timeline.delete of a sketch leaves later features that use its body by name (#282)
* test: reproduce #267 (timeline.delete of a sketch leaves later features that use its body by name)

* fix: timeline.delete reports features that newly fail (#267)

A later feature that names a deleted body (a combine targeting a deleted extrude's body) is
listed under failing: [{feature, error}] in the reply instead of failing silently.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:44:15 -04:00
Vitali M. 516d80ae8d Clip lines at the near plane so the ground grid stays visible at low angles (#305)
A line whose end lies behind the eye has no screen point: vs_line divided
by a non-positive w, which flipped the segment or dropped it whole. At a
low perspective angle the grid lines running along the view direction
reach behind the camera and vanished. Cut each segment at clip z = 0
before the screen-space expansion.

Fixes #182

Co-authored-by: Vitali M. <6042958+vitalibr@users.noreply.github.com>
2026-10-12 11:44:11 -04:00
Tobias Breckle 250d8a2c04 Preferences open over the Home page (#313)
The Home page returned from the frame before the Preferences window was
drawn, so the gear, Ctrl+, and Home's Preferences / Keyboard Shortcuts
links only set the flag and the dialog appeared after leaving Home.
Draw the Preferences window on Home too.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:44:07 -04:00
selune e6e0a7ebb5 Snap 3D bridge lines to points on other sketch planes (#229)
* Snap 3D bridge drawing to points on visible sketch planes

* Keep cross-sketch point picks out of sketch tools

`pick()` returned every visible sketch's points while 3D drawing was on,
so `sketch_point_at` and the sketch tools could take another sketch's
point as an active-sketch local point plus id (flattening it or making a
bad coincident reference). `pick()` is local again; the bridge snap uses
`pick_3d` explicitly and hover mirrors it for feedback. `sketch_point_at`
also ignores foreign points defensively.

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

* test(sketch3d): check local point scope with a sketch tool active

Main's Project preselection fix now offers other sketches' points to idle
selection, so assert the local scope where it still applies.

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

* style: rustfmt

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:25:39 -04:00
Jonas Wrede cb8aa38cc9 test: failing regression test for #266: Multi-profile solid.extrude with body_name names only the first body (#281)
* test: reproduce #266 (Multi-profile solid.extrude with body_name names only the first body)

* fix: name every body of a multi-body feature after body_name (#266)

A new-body feature that makes more bodies than it was given names now stores Ribs (2),
Ribs (3)... for the rest instead of falling back to generic BodyN names. Command-time only:
saved designs keep their names.

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

* fix: keep body_name list for the multi-body naming step (#266)

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:24:33 -04:00
Jonas Wrede 14163d0965 test: failing regression test for #264: Sheet metal: collinear tabs cannot be folded or flanged together (#279)
* test: reproduce #264 (Sheet metal: collinear tabs cannot be folded or flanged together)

* fix(sheet): fold and flange tabs that sit side by side on one line (#264)

A fold's bend zone and a flange's setback were cut with an infinite line, so
a second tab on the same line blocked a fold ('slanted or curved edge') and
two tab-end flanges in one feature shared one trimmed edge (nonmanifold).
When the zone falls apart into pieces, fold only the pieces the fold points
span, each as its own bend; trim a flange's setback only from the material
along its own edge, and attach each flange to the edge it was picked on.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:19:21 -04:00
bherbruck 3b8028a040 Retire legacy reference data and the external command catalog (#302)
- Remove xtask/data/fusion-catalog.tsv and xtask/data/fusion-ids.tsv.
- Remove the legacy command-id table (crates/engine/src/legacy_ids.rs) and its use in
  find_command and in the shortcut, recents and pinned-command loaders. Only SolidCraft's
  dotted ids name commands; any other id is an unknown command.
- Boundary Fill's id becomes solid.boundary_fill.
- Replace the parity report with SolidCraft's own feature checklist: `cargo xtask features`
  writes docs/features.md from the command registry.
- docs/oracle.md, docs/step-import.md (corpus section) and docs/iges-import.md: reference
  results are being rebuilt on analytic and open-source (FreeCAD) values; see #292.
  `cargo xtask oracle` and `cargo xtask step-corpus [--iges]` write these placeholders.
- AGENTS.md, CLAUDE.md, CONTRIBUTING.md, README.md, ROADMAP.md and the book describe
  SolidCraft as independent, with reference tests on analytic values and open-source tools,
  and agents do not drive or extract data from commercial CAD products.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:19:15 -04:00
Jonas Wrede c7f0139ad4 test: failing regression test for #261: Rectangular pattern of a hole cut scales super-linearly (#276)
* test: reproduce #261 (Rectangular pattern of a hole cut scales super-linearly)

* Patterns of many hole cuts scale near-linearly (#261)

Two quadratic costs:
- Meshing a face tested every point against every segment of every boundary
  loop, so a plate face with hundreds of holes meshed in quadratic time. The
  vendored truck-meshalgo inside test now skips loops whose parameter box is
  clear of the point (they wind zero times around it).
- A pattern cut each copy in turn, rebuilding and re-checking an ever more
  complex target per copy. Copies whose boxes are clear of each other are now
  one tool of many pieces (kernel::union_apart, no intersection work), cut once.

512 holes now take ~7 s instead of not finishing. The test's volume tolerance is
widened from 1e-6 relative to 0.1 % of each hole: the measure meshes the bores
and reads each hole ~0.06 % small, on main too.

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

* Hole-pattern test: a budget CI can meet; coarser apart-box meshes (#261)

CI runs every test binary at once and is about ten times slower than a
machine running this test alone (15 tests passed 60 s in the last run), so the
512-hole pattern (about 6 s alone) hit the 60 s timeout there. The budget now
only has to tell linear from the quadratic hang: 300 s timeout, 120 s budget.
union_apart meshes each copy coarsely (2 % of its size, box grown by twice that).

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

* Hole-pattern test checks the scaling, not a wall-clock time (#261)

CI runs every test binary at once: the 512-hole pattern (about 6 s alone, 10 s
CPU single-threaded) took 177 s there, so no fixed time is reliable. The test
now patterns 128 and then 512 holes and requires the second to take under 8x
the first (linear is 4x, measured 3.5-4.2x here; the old quadratic cost was 16x
or more), each with the volume check and a 900 s hang guard.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:18:28 -04:00
Jonas Wrede f525704192 test: failing regression test for #268: Panic from truck-topology lib.rs:186 when joining struts that end on a bore wall (#283)
* test: reproduce #268 (Panic from truck-topology lib.rs:186 when joining struts that end on a bore wall)

* Sew refuses edges whose ends weld to one vertex (#268)

The sewer welds vertices on a 1e-6 grid but only rejected edges shorter than
1e-9, so a sliver edge between the two welded its ends into one vertex and
truck-topology panicked ("Two same vertices cannot construct an edge") inside
the guard. Such an edge is now a degenerate-edge error before any truck call,
and the boolean takes its usual fallback.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:18:24 -04:00
essorblood db6ecaf446 Add Korean translation (#234)
* Create ko.json

* Update ko.json

* Update i18n.rs

* Fonts: add a Hangul fallback so Korean doesn't render as boxes

Only the first CJK font found was loaded, and on Windows and macOS that is
a Japanese font without Hangul (Meiryo, Hiragino). Load the first readable
font of each group: the existing CJK list, then Malgun Gothic (Windows),
Apple SD Gothic Neo (macOS) or Nanum Gothic (Linux). Also rustfmt the
Korean catalog test.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:06:03 -04:00
selune 59fabc16ac Reject ambiguous origin and construction plane names (#247)
* Reject ambiguous origin and construction plane names

* Reserve origin plane names for construction planes; suggest the other alias

A construction plane can no longer be created with or renamed to XY, XZ, YZ, Top, Front or
Right, so a plane argument can't become ambiguous in new designs. For older designs that
already hold such a plane, the ambiguity error now also names the origin plane's other
alias (XY for Top...). The existing tests build those legacy names directly.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:05:57 -04:00
Jonas Wrede aea16f2b5a test: failing regression test for #265: Renaming a pattern copy renumbers the remaining copies (#280)
* test: reproduce #265 (Renaming a pattern copy renumbers the remaining copies)

* fix: renaming a body keeps its sibling bodies' names (#265)

body.rename pinned only the names of the feature's bodies before the renamed one, so unpinned
pattern copies after it were renumbered into the freed name. Pin every sibling's current name.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:59:08 -04:00
Jonas Wrede 93fd18eaad test: failing regression test for #263: sheet.fold accepts a fixed point that is not on the sheet (#278)
* test: reproduce #263 (sheet.fold accepts a fixed point that is not on the sheet)

* fix(sheet): refuse a fold's fixed point that lies off the sheet's material (#263)

A fixed point inside a cut-out or outside the base outline (in the panel's
thickness) silently picked a side. Refuse it with an error that names the
point; side-face picks and points on other panels keep working.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:56:29 -04:00
selune b45cae1e48 Reuse prepared analytic sources when certifying extrusions (#273) 2026-10-12 10:45:26 -04:00
bherbruck e12758ff91 Public reference tests: analytic and FreeCAD expected values (#310)
The oracle no longer uses Fusion measurements. tests/oracle/<case>/ now holds the 76 recipes
(our own format, with recorded measurements and tool-specific fields removed) and an
expected.json per case:

- 55 analytic cases: closed-form values from tools/oracle-analytic/ (stdlib Python, formulas
  in each case's "method").
- 21 FreeCAD cases: the same recipe built with FreeCAD's public scripting API by
  tools/freecad-oracle/ (an external program, not a dependency), with a reference.stl for 20.

solvecraft-cli oracle compares body count, volume, area, centre of mass, bbox, face counts
where the geometry fixes them, and, against reference.stl, the sampled Hausdorff distance and
the principal moments (spatial.rs from #301). cargo xtask oracle reads tests/oracle, writes
docs/oracle.md with the source of each value, and supports known_failure marks for tracked
bugs. CI runs it on every PR; a manual workflow regenerates the FreeCAD values. step-corpus,
seam normalisation and the through-all emulation, which only existed to compare with Fusion,
are removed.

75/76 pass; 75-sm-bracket-holes is a known failure (#296).


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

Co-authored-by: bherbruck <bherbruck@herbrucks.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:43:43 -04:00
selune f0dda667bb Report accepted Boolean retries on features and fuzz runs (#249)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:38:04 -04:00
bherbruck 611422e4df Alias navigation preset uses Shift+Alt with LMB/MMB/RMB (Fixes #304) (#306)
Navigation presets are now a table of bindings (button, required modifier
keys, camera mode) instead of string matches. The Alias preset binds
Shift+Alt+left to orbit, Shift+Alt+middle to pan and Shift+Alt+right to
zoom. A left press with a binding's keys is latched until release, so that
drag neither box-selects nor drags sketch geometry; plain left drags behave
as before. Shift only requests snapping when the binding without Shift
still orbits.

The scenario harness sends Alt with ui.drag/ui.click, and camera checks can
assert panned/zoomed since the last camera step. The user guide notes Alt+drag
grabs by KDE and GNOME and how to free them.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:33:53 -04:00
bherbruck 2cac9cad6a Toolbar: drag commands from drop-downs onto the toolbar; pin icon; Customize Toolbar… (#295)
* Toolbar drop-downs highlight the row under the pointer and the keyboard focus (#288)

A panel drop-down's command rows are full-width rows that take the theme's hover background
(icon, label and shortcut on it) when hovered or focused. Up/Down move the focus through the
enabled rows, Enter starts the command, Escape closes the drop-down. Rows publish
`dropdown:<id>` and the highlighted one `dropdown_hot:<id>`; scenario s46 checks pointer and
keyboard highlight.

Fixes #288

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

* Toolbar: drag commands from drop-downs onto the toolbar; pin icon; Customize Toolbar… (#289)

- A panel drop-down row drags onto the toolbar (a button or a panel's free space, with the
  drop line) and is pinned there; the dragged command's icon follows the pointer.
- A toolbar button dragged off the toolbar, or back into the open drop-down, leaves it (its
  icon shows a cross while releasing would remove it).
- A hovered drop-down row shows a pin at its end: click to pin or unpin.
- Right-click on the toolbar's empty space: Customize Toolbar… (a window listing the tab's
  commands with check boxes, and Reset Toolbar) and Reset Toolbar; command menus have
  Customize Toolbar… too.
- Still per workspace and tab, kept with the preferences. Toolbar buttons also publish
  `button:<id>`; scenario s47 drags a row to the toolbar, unpins by dragging off and into the
  drop-down, uses the pin icon and Customize Toolbar…, and checks it survives a restart.

Fixes #289

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

* Toolbar drop-down pin: a push pin drawn in code (upright and filled when pinned); control protocol lists the toolbar handles

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:27:25 -04:00
NAX LAB 3d99ab4bb1 Appease X11 users with niri/wayland specific postfixup wrapper (#212)
* Appease X11 users with niri/wayland specific postfixup wrapper

* Linux: stay on native Wayland under niri in the app, not the Nix wrapper

Move the niri check from the flake's wrapper into apps/solvecraft/src/main.rs
next to the XWayland default, so it applies to every niri user, not only
Nix builds (flake.nix is back to main's, which also clears the conflict
with #210). SOLVECRAFT_WAYLAND=1 still keeps native Wayland, and =0 now
forces XWayland. docs/nix.md notes the trade-off: no file drag-and-drop
on native Wayland.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:25:59 -04:00
Pablo López e3938defd8 Fillets along a straight edge between two cylinders: trim curved cap edges to the contacts (#238)
* Fillets along a straight edge between two cylinders: the rolling-ball blend trims curved cap edges to its contacts

The edge where two overlapping extruded arcs meet is a straight line between two cylinders
ending on planar caps. The rolling-ball blend built its sections and surface, but only
straight seams could be shortened to the contacts, so a circular cap edge was refused with
"a curved edge where the blend ends". Curved seams now keep the part of their curve between
the new ends, found from the nearest of a few samples (a search from the far end of a long
arc can settle on the opposite side of the circle).

The ball's surface projections start from each face's last answer instead of a cold search,
falling back to one when the warm answer leaves the surface's parameters or is farther than
the last point: about 4.5x faster per fillet (370 -> 80 ms), so dragging the radius is usable.

Fixes #237

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

* Curve blend: refuse a closed curved seam where the blend ends

A seam that starts and ends at the same vertex (a full circle) has no
"between" two contacts, so trimming it built a broken edge. Return
"not supported yet" instead. The curved-seam trim moves into
trim_curved_seam so it can be tested directly.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-12 10:25:52 -04:00
bherbruck f3751bd3ca Changes land through pull requests; maintainers merge their own once CI is green (#309)
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:18:25 -04:00
Jonas Wrede 7d7ef18f49 test: failing regression test for #262: sheet.fold: bend zone reaching the blank outline is reported as a slanted or curved edge (#277)
* test: reproduce #262 (sheet.fold: bend zone reaching the blank outline is reported as a slanted or curved edge)

* fix(sheet): name the bend zone reaching an outline corner instead of blaming a slanted edge (#262)

A fold whose bend zone contains a corner of the panel outline (a fold at a
tab's root) was reported as crossing a slanted or curved edge. Name the
corner and how far to move the fold line to clear it.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:08:38 -04:00
selune f77da83c93 Edit sketch text content and placement from its outlines (#253)
* Expose interactive editing and placement of sketch text

* Respect document units when editing sketch text

* Route Edit Text through the shared feature dialog

The sketch text editor is now a Kind::Text dialog opened through
Dialog::for_command (frame.rs docking, standard OK/Cancel, Enter/Esc).
Its fields are ordinary dialog fields: they take expressions, parameters
and units, and bare lengths are in the design's units. Untouched fields
keep their exact saved values. The dialog stays open across unrelated
design changes and closes only when its text is gone, the sketch ends,
or another command starts.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:08:15 -04:00
selune 62847173f4 Honor design units in sketch dimensions and measurements (#241)
* Honor design units in sketch dimensions and measurements

* fix: apply document units to specialized length controls

* Accept explicit units in length fields; document design-unit dimension values

Length fields in a design-unit control now evaluate what is typed as a
length expression: a bare number stays in the design's units, while an
explicit unit ("10 mm", "0.5 in", "1 in + 5 mm") is converted.
Document that a bare numeric sketch.dimension value from scripts, the
control channel or MCP is read in the design's units.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:08:11 -04:00
bherbruck df09fdbc32 Preferences: one fixed size, buttons flush right, a real gear icon (#290)
- The dialog draws every page into a fixed, clipped 744x400 area, so no page
  (or translation) changes the window width; descriptions wrap and tall pages
  scroll inside it.
- OK / Cancel / Apply sit flush right in the bottom bar (right-to-left layout
  within the fixed width); Restore Defaults stays left.
- The "settings" icon is a gear: a solid ring with six square-ish teeth and a
  see-through centre hole, instead of the sun-like rays.
- Scenario s46 visits every page and checks the window keeps its width and
  that Apply ends at the bottom bar's right edge (new `same_width` and
  `right_edge` scenario checks).

Fixes #290

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:07:23 -04:00
bherbruck 87bb2f37f8 fix(#271): document name?, body_name? and targets? on the commands that accept them
sheet.flange now lists name?/body_name?, solid.loft and solid.sweep list
targets?/name?/body_name?, and the primitives list name?/body_name? (all go
through add_feature, which reads them). A test pins the shared keys for
every body-creating feature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-12 10:07:10 -04:00
jonaswre 879aacacf8 test: reproduce #271 (Command docs omit parameters that work: sheet.flange body_name, solid.loft targets) 2026-10-12 10:07:10 -04:00
bherbruck a9007d7b86 fix(#270): mcp --connect failure says how to start the app with --control
When nothing listens on the address, the error now adds: start it with
`solvecraft --control PORT` (or SOLVECRAFT_CONTROL_PORT=PORT) and retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-12 10:07:04 -04:00
jonaswre 56a2ed1e4f test: reproduce #270 (solvecraft-cli mcp --connect without a listening app gives no hint) 2026-10-12 10:07:04 -04:00
bherbruck bf60758244 fix(#269): list_commands filter matches id, label, tab and panel, not params docs
Params docs name other commands ("usable in sketch.delete"), so filtering by
an id returned 56 commands. The filter now looks only at id, label, tab and
panel; the tool description and docs/mcp.md say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-12 10:06:57 -04:00
jonaswre d9613e29cb test: reproduce #269 (MCP list_commands filter also matches parameter docs) 2026-10-12 10:06:57 -04:00
bherbruck 0a526c6f85 fix(i18n): keep saved language on font failure, tighten CJK font matching
- Web: a failed CJK font fetch shows English for this session only; the
  saved language preference is kept (CjkFontLoader, persisted on save).
- Web fetches the CJK font only when a CJK language (ja) is selected.
- fonts.rs: directory scans match CJK-specific names only (no "noto" or
  "gothic"); CJK stays the last fallback after the Latin UI font.
- Revert unrelated clippy rewrites in component_clone.rs, coplanar.rs,
  canvas.rs and issue_report.rs to match main.
- Tests: default English, fallback order, CJK-only matching, fetch only
  for CJK, failed fetch keeps the saved language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-12 10:06:50 -04:00
Phaiz Mithriln 4fbf19f2f9 feat(web): self-host Japanese font and load on-demand without external CDN
- Bundle BIZ UDPGothic Regular (SIL OFL 1.1) exclusively in solvecraft-web dist

- Remove external jsDelivr CDN fallback; fetch font solely from same-origin ./fonts/

- Fetch Japanese font on-demand only when UI language is set to Japanese

- Gracefully fall back to English UI on fetch failure to prevent tofu glyphs

- Update ATTRIBUTION.md, NOTICE, and THIRD-PARTY-LICENSES.txt
2026-10-12 10:06:50 -04:00
Mithril-3d 1fee62dd54 feat: add web CJK font loading and robust cross-platform font detection 2026-10-12 10:06:50 -04:00
bherbruck ab72c5ad56 Bound mesh conversion nesting certification by loop count
certify_nesting runs two planar booleans per pair of boundary loops, but
its work budget counted segments, so a mesh with many small holes could
trigger ~1M booleans. Refuse more than 50 loops (1225 pairs) with a
"too complex to convert" error and test a 400-hole plate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:06:42 -04:00
lillith df4c8a2994 Reconstruct constant-section meshes as parametric solids 2026-10-12 10:06:42 -04:00
bherbruck 417511367c Drop the test description from the STEP import docs
User docs describe behaviour, not tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:06:33 -04:00
lillith c3f94ab616 Cover imported STEP colours through edits and round trips 2026-10-12 10:06:33 -04:00
lucas-aguilar89 8eda982489 Pattern and Mirror dialogs copy picked bodies, fillets included (#193)
With Object Type = Bodies, the Mirror, Rectangular Pattern and Circular
Pattern dialogs sent the feature that created each picked body
({"features": ["Extrude1"]}), so the copies replayed only that feature and
lost everything done to the body afterwards (a fillet, a chamfer, a cut).
They now send the bodies themselves ({"bodies": [...]}), which the commands
already support and the Path Pattern dialog already did. Picking Features
is unchanged.

Editing a body Mirror or Pattern from the timeline now reopens the dialog
with its bodies selected.

Copies made this way take the body-copy names ("Body1 (2)"), as the
commands already give; pattern_direction_edge now looks up "Body1 (3)".

Tests: UI scenarios mirror_body_keeps_fillets and pattern_body_keeps_fillets
(filleted cube, copy and edit through the dialogs); both fail without the
fix with the copies' sharp 8000 mm3 volume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:06:18 -04:00
lucas-aguilar89 cab698bf12 Offset Plane from a planar body face (#167)
construct.plane.offset now takes a planar face as its base
({"face": [x,y,z], body?, index?, path?}), captured with the same exact
face attachment a sketch on that face uses. The Offset Plane dialog accepts
planar faces as well as origin and construction planes.

A construction plane on a face is resolved through its face anchor when the
timeline is evaluated, so it follows the face when upstream features change
(and after reopening), instead of keeping the plane as it was when picked.

Tests: engine (top and side faces, follows a box resize, survives reopen,
hostile picks are refused without adding a feature) and a UI scenario that
picks a face in the dialog and builds a pad on the new plane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:06:10 -04:00
Levon Kirakosyan c308a1ba7a build: macOS support for the Nix flake
Add aarch64-darwin to the flake systems so packages, apps and both
devShells are exposed on Apple Silicon Macs. The Wayland/X11/EGL
runtime libraries, the /run/opengl-driver search paths, the freedesktop
packaging and the wrapProgram post-fixup are Linux-only and stay that
way: on macOS winit talks to AppKit, wgpu renders through Metal, and
the GPU drivers ship with the OS, so the darwin package needs no
wrapper. x86_64-darwin is deliberately not added: current nixpkgs
unstable no longer evaluates rustc for that platform (26.11
deprecation), so the package would fail on toolchain evaluation.

docs/nix.md: platform list updated, macOS paragraph in the graphics
section, desktop-entry paragraph qualified as Linux-only.
2026-10-12 10:06:03 -04:00
lillith 874315db8e fix(sketch): apply Project to complete preselection 2026-10-12 10:05:52 -04:00
lillith 56b499e951 Reject invalid explicit axis origins instead of using zero 2026-10-12 09:51:58 -04:00
lillith 8c13914d6c Ignore repeated initial sketch points while drawing 2026-10-12 09:30:18 -04:00
bherbruck f0292b1606 CONTRIBUTING: failing regression tests without a fix are marked #[ignore = "fails until #N is fixed"]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-12 13:08:35 +00:00
lillith 732447d2dc Fix dialog header chevrons to collapse and restore forms 2026-10-12 09:08:31 -04:00
lillith db0a2a007a Apply remaining sketch constraints to complete preselection 2026-10-12 08:48:05 -04:00
lillith ed102b5393 fix: reject degenerate boolean intersection polylines 2026-10-12 08:36:40 -04:00
bherbruck 970da30a99 Merge pull request #207 from crono2250/fix/spacemouse-axis-mapping-198
Fix SpaceMouse axis mapping and apply 3DxWare settings
2026-10-12 08:14:51 -04:00
bherbruck 113ded5fab Merge pull request #222 from ich777/lang-german
feat: add German (de) UI localization
2026-10-12 07:51:46 -04:00
bherbruck b4491ac605 Remove premature craft family affiliation claims 2026-10-12 07:14:49 -04:00
ich777 53b092d287 feat: add German (de) UI localization
Adds German (Deutsch) as a third UI language, following the procedure outlined in docs/localization.md
2026-10-12 08:10:14 +02:00
bherbruck 07e263d7e9 Prepare SolidCraft 0.3.2 patch release 2026-10-11 22:54:31 -04:00
kenos 25ea43e89f fix: apply 3DxWare SpaceMouse axis settings 2026-10-12 11:52:29 +09:00
bherbruck c53aff7592 Fix artificial Shell wall splits on stepped bodies 2026-10-11 22:32:53 -04:00
bherbruck 13aac4296d Keep prepared assembly geometry through selection changes 2026-10-11 21:59:33 -04:00
bherbruck 18d487a9bd Merge pull request #202 from 0xlillith/feat/173-immediate-wheel-zoom
feat(ui): allow disabling wheel zoom smoothing
2026-10-11 20:36:41 -04:00
bherbruck ff9653a353 Merge current main into wheel zoom smoothing option 2026-10-11 20:36:16 -04:00
bherbruck 400f200efa Speed up cold document geometry rebuilds 2026-10-11 20:22:19 -04:00
bherbruck 6dd202d277 Fix desktop launch, app icons and keyboard parameter editing 2026-10-11 19:09:47 -04:00
lillith 16bf3ba593 feat(ui): allow disabling wheel zoom smoothing 2026-10-12 01:07:23 +02:00
bherbruck 8fa52c8e5e Merge pull request #197 from 0xlillith/fix/185-free-middle-orbit
fix(ui): restore free Shift-middle orbit
2026-10-11 18:54:11 -04:00
lillith 8601e38d4b fix(ui): restore free Shift-middle orbit 2026-10-12 00:30:05 +02:00
bherbruck e171ff07ad Merge pull request #196 from 0xlillith/fix/solidcraft-ci-followup
docs: clarify SolidCraft clone and compatible build names
2026-10-11 18:17:47 -04:00
lillith 6ae48ed70f docs: clarify SolidCraft clone and compatible build names 2026-10-12 00:15:20 +02:00
452 changed files with 53582 additions and 5416 deletions
+13
View File
@@ -21,6 +21,19 @@ SolidCraft project Pages URLs; old-name Pages addresses are not permanent aliase
- **Web:** `solvecraft-web-{version}.zip` is a static site to host yourself.
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`.
For Flatpak feed installation and updates, add Flathub for the required runtime before
installing SolidCraft; keep both remotes in the same user installation:
```sh
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak remote-add --user --if-not-exists solvecraft https://bherbruck.github.io/solidcraft/flatpak/solvecraft.flatpakrepo
flatpak install --user solvecraft ai.storyteller.solvecraft
flatpak run ai.storyteller.solvecraft
```
Single-file `.flatpak` bundles include the runtime repository address; missing runtimes
still need an internet connection. [Linux installation details](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
The builds aren't signed yet. On macOS, right-click `SolveCraft.app` in Applications and choose
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolveCraft.app`).
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
+35 -1
View File
@@ -122,9 +122,41 @@ jobs:
if: always()
run: sccache --show-stats
oracle:
name: Reference tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 60
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: oracle
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- name: Build the command line
run: cargo build -p solvecraft-cli --profile ci --locked
- name: Replay tests/oracle and compare with expected.json
run: cargo xtask oracle --cli target/ci/solvecraft-cli
- name: Upload the report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: oracle-report
path: docs/oracle.md
# Preserve the existing check name for branch protection, including docs-only PRs.
ci:
needs: [changes, checks, clippy, tests]
needs: [changes, checks, clippy, tests, oracle]
if: always()
runs-on: ubuntu-24.04
steps:
@@ -135,12 +167,14 @@ jobs:
NATIVE: ${{ needs.changes.outputs.native }}
CLIPPY: ${{ needs.clippy.result }}
TESTS: ${{ needs.tests.result }}
ORACLE: ${{ needs.oracle.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
if [ "$NATIVE" = true ]; then
test "$CLIPPY" = success
test "$TESTS" = success
test "$ORACLE" = success
fi
wasm:
+47
View File
@@ -0,0 +1,47 @@
# Optional, run by hand: rebuild the reference cases whose expected values come from FreeCAD
# (tools/freecad-oracle/) and show what changed. FreeCAD runs as a separate program on the runner;
# it is not a dependency of SolidCraft. Nothing is committed: review the diff and the uploaded
# files, then commit them in a pull request if the change is intended.
name: FreeCAD reference values
on:
workflow_dispatch:
inputs:
cases:
description: "Case directories (space separated); empty = every case whose source is FreeCAD"
type: string
default: ""
permissions:
contents: read
jobs:
regenerate:
name: Regenerate FreeCAD expected values
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install FreeCAD
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends freecad
freecadcmd --version || true
- name: Rebuild the cases
env:
CASES: ${{ inputs.cases }}
run: |
if [ -z "$CASES" ]; then
CASES=$(grep -l '"source": *"freecad' tests/oracle/*/expected.json | xargs -n1 dirname | tr '\n' ' ')
fi
# shellcheck disable=SC2086
tools/freecad-oracle/run.sh --write $CASES
- name: Show the changes
run: git diff --stat && git diff -- tests/oracle/*/expected.json
- name: Upload the regenerated files
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: freecad-expected
path: |
tests/oracle/*/expected.json
tests/oracle/*/reference.stl
+4
View File
@@ -37,6 +37,10 @@ jobs:
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
- name: Nightly release selection regression
run: python3 packaging/tests/nightly_test.py
- name: Windows executable icon validation regression
run: python3 packaging/tests/windows_icon_test.py
- name: Flatpak desktop smoke validation regression
run: python3 packaging/tests/flatpak_test.py
- name: PowerShell
shell: pwsh
run: |
+1 -1
View File
@@ -177,7 +177,7 @@ jobs:
- name: Install flatpak-builder
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common xvfb xauth weston dbus-x11
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
+31 -27
View File
@@ -2,10 +2,9 @@
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
kernel, and a desktop UI that agents can drive. Projects published by
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
## Product name and compatibility
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
@@ -17,9 +16,7 @@ compatibility notes.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
and the relevant `plan/architecture.md` section. Behaviour reference: `plan/fusion/`
(`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their
recipes and measurements).
and the relevant `plan/architecture.md` section.
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
is rejected for licence reasons).
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
@@ -44,18 +41,22 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
- **Clean-room.** Fusion may be *observed* black-box (its UI, its toolbar dump, parts built in it
and their measurements, kept under `plan/fusion/`, never committed). Never copy Autodesk icons,
artwork, help text, tooltips, templates or materials, and never commit files produced by
Fusion. Command ids and command names are used as identifiers only (`xtask/data/fusion-catalog.tsv`
holds ids and names, nothing else). File formats come from public specifications. **Never read,
copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs), SolveSpace,
CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. The sketch solver and the blend
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
general CAD conventions and user feedback. Reference tests use analytic values and open-source
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
materials. File formats come from public specifications.
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
measurements, command lists, ids or files produced with them.
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
runs FreeCAD as a separate program through its public scripting API to compute reference values;
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
operations are our own.
- **Assets — absolutely essential.** SolidCraft contains **no Autodesk iconography, images,
fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any
file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
artwork from commercial CAD products — ever.** Every icon is drawn in code
(`crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
@@ -72,10 +73,12 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
- **Rust only.** Units are millimetres and radians internally; Z is up.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
assets, layers). Commit after every feature arc that builds.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. After each commit that
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
generated reference data; check `git ls-files` before pushing.
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
@@ -122,11 +125,12 @@ before the draft is verified.
`ui.render` or `solvecraft-cli snapshot`.
- Headless: `solvecraft-cli run script.json --out part.step`, `solvecraft-cli eval design.solvecraft`,
`solvecraft-cli snapshot script.json --out shot.png`, `solvecraft-cli commands`.
- Fusion oracle: `cargo xtask oracle` replays `plan/fusion/oracle/*/recipe.json` and compares
with Fusion's `measure.json` (writes `docs/oracle.md`).
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
values never come from a commercial CAD product.
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a
milestone task lands. `cargo xtask parity` recomputes command parity in `docs/parity.md`;
`cargo xtask oracle` recomputes `docs/oracle.md`.
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
commands by area, from the registry) when commands are added or renamed.
+31 -1
View File
@@ -26,6 +26,27 @@ Generated-in-code assets are original and have no file to list:
| docs/book/src/generated/revolve.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/sheet-metal.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
| crates/sketch/fonts/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/fonts/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
| apps/solvecraft-web/fonts/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in apps/solvecraft-web/fonts/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/23-bracket-multifeature/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/23-bracket-multifeature/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/28-blind-pocket-fillets/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/28-blind-pocket-fillets/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/30-fillet-after-shell/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/30-fillet-after-shell/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/31-fillet-l-all-edges/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/31-fillet-l-all-edges/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/34-chamfer-then-fillet/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/34-chamfer-then-fillet/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/35-fillet-curved-curved/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/35-fillet-curved-curved/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/36-corner-blend-two-radii/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/36-corner-blend-two-radii/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/37-pyramid-apex-blend/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/37-pyramid-apex-blend/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/39-tee-pipe/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/39-tee-pipe/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/41-tube-radial-holes/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/41-tube-radial-holes/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/42-overlapping-patterns/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/42-overlapping-patterns/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/47-draft-curved-pocket/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/47-draft-curved-pocket/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/51-loft-rect-circle-offset/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/51-loft-rect-circle-offset/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/56-holes-on-curved-face/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/56-holes-on-curved-face/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/58-phone-stand/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/58-phone-stand/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/59-enclosure-base/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/59-enclosure-base/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/61-shaft-keyway/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/61-shaft-keyway/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
## Code adapted from sibling projects
@@ -55,5 +76,14 @@ Generated-in-code assets are original and have no file to list:
|---|---|---|---|
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
## Vendored renderer compatibility fix
`vendor/egui-wgpu` is egui-wgpu 0.36.2 from https://github.com/emilk/egui
(MIT OR Apache-2.0; both original licence texts are included). Rust sources and
its original `egui.wgsl` shader are retained; no images or other artwork are copied.
SolidCraft's narrow patch selects a safe 1x MSAA path on the chosen OpenGL adapter
and shares that effective count with the surface attachments and CAD callbacks.
Vulkan, DirectX 12, Metal and WebGPU retain their requested multisampling.
+31 -27
View File
@@ -2,10 +2,9 @@
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
kernel, and a desktop UI that agents can drive. Projects published by
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
## Product name and compatibility
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
@@ -17,9 +16,7 @@ compatibility notes.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
and the relevant `plan/architecture.md` section. Behaviour reference: `plan/fusion/`
(`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their
recipes and measurements).
and the relevant `plan/architecture.md` section.
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
is rejected for licence reasons).
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
@@ -44,18 +41,22 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
- **Clean-room.** Fusion may be *observed* black-box (its UI, its toolbar dump, parts built in it
and their measurements, kept under `plan/fusion/`, never committed). Never copy Autodesk icons,
artwork, help text, tooltips, templates or materials, and never commit files produced by
Fusion. Command ids and command names are used as identifiers only (`xtask/data/fusion-catalog.tsv`
holds ids and names, nothing else). File formats come from public specifications. **Never read,
copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs), SolveSpace,
CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. The sketch solver and the blend
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
general CAD conventions and user feedback. Reference tests use analytic values and open-source
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
materials. File formats come from public specifications.
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
measurements, command lists, ids or files produced with them.
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
runs FreeCAD as a separate program through its public scripting API to compute reference values;
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
operations are our own.
- **Assets — absolutely essential.** SolidCraft contains **no Autodesk iconography, images,
fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any
file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
artwork from commercial CAD products — ever.** Every icon is drawn in code
(`crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
@@ -72,10 +73,12 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
- **Rust only.** Units are millimetres and radians internally; Z is up.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
assets, layers). Commit after every feature arc that builds.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. After each commit that
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
generated reference data; check `git ls-files` before pushing.
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
@@ -99,11 +102,12 @@ before the draft is verified.
`ui.render` or `solvecraft-cli snapshot`.
- Headless: `solvecraft-cli run script.json --out part.step`, `solvecraft-cli eval design.solvecraft`,
`solvecraft-cli snapshot script.json --out shot.png`, `solvecraft-cli commands`.
- Fusion oracle: `cargo xtask oracle` replays `plan/fusion/oracle/*/recipe.json` and compares
with Fusion's `measure.json` (writes `docs/oracle.md`).
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
values never come from a commercial CAD product.
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a
milestone task lands. `cargo xtask parity` recomputes command parity in `docs/parity.md`;
`cargo xtask oracle` recomputes `docs/oracle.md`.
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
commands by area, from the registry) when commands are added or renamed.
+44 -37
View File
@@ -16,12 +16,19 @@ You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web
build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://trunkrs.dev).
```sh
git clone https://github.com/bherbruck/solidcraft && cd solvecraft
git clone https://github.com/bherbruck/solidcraft && cd solidcraft
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
cargo xtask test # unit, engine and UI-scenario tests
cargo xtask ci # everything a PR must pass (see below)
```
SolidCraft is the product and repository name. Cargo packages and executables retain
their `solvecraft` names for compatibility with existing scripts and MCP clients.
Build the desktop app with `cargo build --release -p solvecraft`, or the CLI with
`cargo build --release -p solvecraft-cli`. Run the CLI with
`cargo run --release -p solvecraft-cli -- commands` (or `target/release/solvecraft-cli commands`
after building). There is no Cargo package named `solidcraft`.
Web: `cd apps/solvecraft-web && trunk serve --release`, then open the printed URL (`?sample`
opens the sample part, `?webgl` forces WebGL2). Saving and opening files in the browser needs
HTTPS or localhost, because it uses the Origin Private File System.
@@ -41,32 +48,21 @@ ERROR eframe::native::run: Exiting because of error: WGPU error: Failed to creat
ERROR rfd::backend::xdg_desktop_portal::portal::libdbus: Can't connect to a portal: libdbus-1.so not found
```
`nix develop` (the [`flake.nix`](flake.nix) in the repository root) puts the Wayland, X11,
EGL-loader and D-Bus libraries on `LD_LIBRARY_PATH`, so `cargo run --release -p solvecraft` works
from that shell as on any other distribution, file dialogs included. `nix run` builds the app and
wraps the binary the same way. The Mesa drivers stay the system's, from `/run/opengl-driver`
(`hardware.graphics.enable`): that is the driver stack the compositor runs on, and wgpu reaches the
integrated GPU through the EGL loader. The shell deliberately brings no Rust toolchain — the one on
`PATH` keeps its build cache, where a second `rustc` would make every build recompile the workspace
from scratch; `nix develop .#rust` is there for a machine with no Rust at all.
`nix develop` (the [`flake.nix`](flake.nix) in the repository root) supplies Wayland,
X11, EGL, Vulkan-loader and D-Bus runtime libraries. It also exposes the NixOS
driver manifests in `/run/opengl-driver/share`, so Vulkan can discover the system
drivers. Enable `hardware.graphics.enable` on NixOS; other Linux distributions
may need a matching [nixGL wrapper](https://github.com/nix-community/nixGL).
The default development shell retains the Rust/C toolchain already on `PATH`;
`nix develop .#rust` supplies the pinned toolchain and builds in `target/nix`.
**Anti-aliasing on the OpenGL backend.** NixOS keeps the Vulkan ICD descriptions
(`/run/opengl-driver/share/vulkan/icd.d`) off the Vulkan loader's search path, so wgpu picks its
OpenGL backend on the integrated GPU. On that backend any multisampling above 1× — the default is
4× — draws a black window: the UI still lays out and the control channel still answers, but every
frame, `ui.screenshot` included, comes out empty. Set **Preferences ▸ Graphics ▸ Anti-aliasing** to
1× and restart (the setting applies on the next start), or write it before the first run, since a
black window is no way to reach the dialog:
```sh
mkdir -p ~/.local/share/solvecraft
echo '{"msaa": 1}' > ~/.local/share/solvecraft/graphics.json
```
`apps/solvecraft/src/main.rs` reads that file (`graphics_path()`, eframe's storage dir) before the
window opens; 2× and 4× are the same black window. The Vulkan backend is no way out of it either:
`libvulkan.so.1` is not on `LD_LIBRARY_PATH`, so `WGPU_BACKEND=vulkan` exits with a
`CreateSurfaceError` instead.
To launch, use `nix run .#solidcraft -- --sample`. To install the app's terminal
commands, desktop entry and generated icons, use `nix profile install .#solidcraft`.
`nix build` alone creates a build result; it does not install a launcher in your
desktop menu. The installed `solidcraft` command and compatible `solvecraft`
command run the same wrapped app; `solvecraft-cli` is included as well. See
[Nix installation and troubleshooting](docs/nix.md) for remote-flake commands,
desktop integration and how to distinguish an old executable on `PATH`.
## How the code is laid out
@@ -92,13 +88,16 @@ The [README](README.md#architecture) has the layer table. The short version:
## Rules that aren't negotiable
1. **Clean-room.** We observe Fusion as a black box: its UI, how parts it builds measure, and
what it does with a given input. We never copy Autodesk code, icons, artwork, help text,
tooltips, templates or materials, and we never commit files Fusion produced. Command ids are
our own (`solid.fillet`, not Fusion's internal names).
1. **Independent.** SolidCraft is independent. Behaviour is designed from public
documentation, general CAD conventions and user feedback. Reference tests use analytic values
and open-source tools. We don't drive, script, automate or extract data from commercial CAD
products, and we never copy another product's code, icons, artwork, help text, tooltips,
templates or materials. Command ids are our own (`solid.fillet`).
2. **No GPL/LGPL/AGPL code, not even reading it.** That rules out OpenCASCADE, FreeCAD
(including planegcs), SolveSpace, CadQuery/OCP, LibreCAD and QCAD. SolidCraft is
MIT OR Apache-2.0, and its solver and blends are written from the maths. Permissive
MIT OR Apache-2.0, and its solver and blends are written from the maths. (Running an
open-source application to get reference values for a test is fine; reading its source is
not.) Permissive
dependencies are fine. Note which one you're adding and why in the PR, and run
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
3. **Every asset is original or openly licensed and has a row in
@@ -123,7 +122,7 @@ the most complete description of the house style.
|---|---|---|
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo xtask test` (or `cargo test --workspace --profile ci`) |
| UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | part of `cargo test` |
| Fusion oracle | `cargo xtask oracle` | needs the local oracle data (see below) |
| Reference tests | `cargo xtask oracle` | `tests/oracle/` (see below) |
**UI scenarios** drive the real app headless: start a tool, click a plane or a sketch point,
press keys, then assert on the result.
@@ -142,6 +141,10 @@ press keys, then assert on the result.
A new `.json` file in that folder is picked up automatically. To record a bug before it's fixed,
put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the bug.
**A failing test without its fix can't merge as-is:** it would turn `main`'s CI red for everyone.
Either include the fix in the same PR, or mark the test `#[ignore = "fails until #123 is fixed"]`
(it can then merge right away), and remove that line in the PR that fixes the bug.
**Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables,
no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It
builds several times faster than `--release`. If a test fails only in a plain debug build, say so
@@ -150,11 +153,15 @@ in the PR. `cargo xtask test` builds the tests with it and then runs every test
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
depend on wall-clock timing.
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
SolidCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's
measurements; [docs/oracle.md](docs/oracle.md) has the current results. The recipes and
measurements live outside the repo (`plan/`, gitignored), because they come from a Fusion
install. If you work on geometry and want to check against it, ask in an issue.
**The reference tests** are 76 parts in [`tests/oracle/`](tests/oracle/), each a `recipe.json`
in our own format with an `expected.json` beside it. SolidCraft rebuilds the recipe and compares
body count, volume, area, centre of mass and bounding box (and, where there is a `reference.stl`,
the surface itself). Expected values are either worked out in closed form
(`tools/oracle-analytic/`) or computed by building the same recipe in FreeCAD, run as a separate
program (`tools/freecad-oracle/`); each file says which. [docs/oracle.md](docs/oracle.md) has
the current results, and CI runs `cargo xtask oracle` on every pull request. A case that fails
because of a known bug names its issue in `expected.json` (`"known_failure": "#123"`); never
change an expected value to match SolidCraft's output.
## Sending a change
Generated
+94 -20
View File
@@ -971,8 +971,6 @@ dependencies = [
[[package]]
name = "egui-wgpu"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
dependencies = [
"ahash",
"bytemuck",
@@ -1041,6 +1039,20 @@ dependencies = [
"serde",
]
[[package]]
name = "embed-resource"
version = "3.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f"
dependencies = [
"cc",
"memchr",
"rustc_version",
"toml",
"vswhom",
"winreg",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -3106,6 +3118,15 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "serde_spanned"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
dependencies = [
"serde_core",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3269,12 +3290,14 @@ dependencies = [
[[package]]
name = "solvecraft"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"eframe",
"egui",
"embed-resource",
"hidapi",
"log",
"quick-xml 0.38.4",
"rfd",
"serde_json",
"solvecraft-engine",
@@ -3284,7 +3307,7 @@ dependencies = [
[[package]]
name = "solvecraft-cli"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3293,7 +3316,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"log",
"serde",
@@ -3308,7 +3331,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"log",
"serde",
@@ -3324,7 +3347,7 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde",
"serde_json",
@@ -3332,7 +3355,7 @@ dependencies = [
[[package]]
name = "solvecraft-io"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
@@ -3344,7 +3367,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"log",
"serde",
@@ -3359,7 +3382,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3367,7 +3390,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"image",
"serde",
@@ -3377,7 +3400,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde",
"serde_json",
@@ -3388,7 +3411,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -3403,7 +3426,7 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"eframe",
"egui",
@@ -3589,10 +3612,25 @@ dependencies = [
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
name = "toml"
version = "1.1.8+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
dependencies = [
"serde_core",
]
@@ -3611,13 +3649,19 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
version = "1.1.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
[[package]]
name = "tracing"
version = "0.1.44"
@@ -3913,6 +3957,26 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vswhom"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
dependencies = [
"libc",
"vswhom-sys",
]
[[package]]
name = "vswhom-sys"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -4698,6 +4762,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
@@ -4775,7 +4849,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde_json",
]
+4 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.3.0"
version = "0.4.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -100,3 +100,6 @@ truck-shapeops = { path = "vendor/truck-shapeops" }
truck-stepio = { path = "vendor/truck-stepio" }
truck-meshalgo = { path = "vendor/truck-meshalgo" }
matext4cgmath = { path = "vendor/matext4cgmath" }
# Keep OpenGL pipelines, resolve textures and CAD callbacks at the same safe sample count.
egui-wgpu = { path = "vendor/egui-wgpu" }
+3 -2
View File
@@ -19,8 +19,9 @@ licensed MIT OR Apache-2.0; ATTRIBUTION.md lists the files.
The release packaging (.github/workflows/release.yml, packaging/) is adapted from PhotoCraft
(https://github.com/storytold/photocraft), licensed under the Apache License 2.0.
None of the bundled assets come from third parties: every icon is drawn in code. The per-file
list is in ATTRIBUTION.md.
Most bundled assets are drawn in code or contributed originally; third-party fonts (Liberation Sans
for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL OFL 1.1. The
per-file list is in ATTRIBUTION.md.
SolveCraft is an independent project and is not affiliated with, sponsored by or endorsed by
Autodesk, Inc. Autodesk and Fusion are trademarks of Autodesk, Inc.; they are used here only to
+13 -7
View File
@@ -4,7 +4,7 @@
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
model through the built-in MCP server. Part of the storytold "craft" family.
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)
@@ -14,10 +14,14 @@ model through the built-in MCP server. Part of the storytold "craft" family.
developer guide and command reference; sources in [docs/book](docs/book)), and the
[web app](https://bherbruck.github.io/solidcraft/app/) to try it in the browser.
**Status: early but usable for simple parts.** The historical benchmark report records
76 of 76 reference parts matching volume, area and topology ([docs/oracle.md](docs/oracle.md)).
These checks do not establish complete geometric or workflow equivalence. Feature coverage
is tracked in [docs/parity.md](docs/parity.md) and [ROADMAP.md](ROADMAP.md).
Linux Flatpak installation needs the Freedesktop runtime from Flathub as well as the
SolidCraft application feed. See [install and launch commands](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
**Status: early but usable for simple parts.** 75 of 76 public reference parts
match their analytic or FreeCAD-computed volume, area, centre of mass and bounding box
([docs/oracle.md](docs/oracle.md)); the other is a tracked bug.
These checks do not establish complete geometric or workflow equivalence. The commands SolidCraft has
are listed by area in [docs/features.md](docs/features.md); status and plans are in [ROADMAP.md](ROADMAP.md).
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solidcraft/issues),
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -74,7 +78,8 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ;
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
need B-rep bodies);
need B-rep bodies). Convert Mesh reconstructs supported closed constant-section parts
as parametric solids with planar and circular cylindrical faces;
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
@@ -163,6 +168,7 @@ MIT OR Apache-2.0, at your option. Developed under the
[clean-room contribution rules](CLAUDE.md); third-party code and assets retain their
documented licences and attribution.
References to other products describe workflow comparisons and benchmark provenance.
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
conventions and user feedback. Reference tests use analytic values and open-source tools.
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk. Autodesk and
Fusion are trademarks of Autodesk, Inc.
+101 -17
View File
@@ -7,9 +7,9 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
| Question | Answer |
|---|---|
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
| Command parity (in scope) | **335 / 541 (62%)** in-scope commands; SOLID + SKETCH **188 / 291 (65%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity |
| Fusion oracle | **76 / 76 parts match** in the checked-in historical report — [docs/oracle.md](docs/oracle.md). The current sprint adds independent analytic and UI regressions; the local Fusion oracle corpus has not been rerun. |
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
| Reference tests | **75 / 76 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (55) or FreeCAD-computed (21) expected values, run by CI on every PR; 75-sm-bracket-holes fails (#296). |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
@@ -20,8 +20,7 @@ The issue batch adds preservation of blind pockets in through cuts, associative
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
acceptance scenarios now include board resizes; STEP reopening is automated. Inspection in
Fusion remains an external acceptance step. See [WIP reconciliation](docs/wip-reconciliation.md).
acceptance scenarios now include board resizes; STEP reopening is automated. See [WIP reconciliation](docs/wip-reconciliation.md).
In scope, in priority order:
1. SOLID and SKETCH (now).
@@ -38,8 +37,7 @@ In scope, in priority order:
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need, PCB / 3D
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. The parity report's
headline counts in-scope tabs only and lists the deferred ones separately.
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Milestones
@@ -49,13 +47,13 @@ headline counts in-scope tabs only and lists the deferred ones separately.
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| 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 (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), 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) | 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) | 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 |
@@ -63,10 +61,33 @@ headline counts in-scope tabs only and lists the deferred ones separately.
## Dimensioning and error reporting
- 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
stored references keep their saved meaning. Section Analysis uses the same check.
- Sketch text can be edited from a selected outline's context menu or the Edit Text command.
Content, baseline X/Y, height and rotation update the original text source in one undo step;
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
use the document units captured when editing starts; generated outlines remain associated
with their text source, and untouched inputs preserve their saved internal values.
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
waits for a distinct point without a degenerate-shape error or history entry. Typed input
and programmatic commands retain their validation.
- Circular-pattern and angled-plane axes reject explicitly supplied malformed or expression
origins instead of silently rotating about zero. Numeric origins remain in millimetres;
associative axis expressions and construction-axis references remain unsupported.
- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints.
Type a value and press Enter to commit one undo step; rejected values remain editable and
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
and navigation controls, including enlarged displays and nested pending placements.
- Sketch annotations, live drawing lengths and Measure use document units. Inches show
three decimals and retain a meaningful fourth digit. Bare length dimension entry captures
the current design unit; changing units leaves existing geometry and expressions intact.
Component/canvas distance controls, sketch cursor coordinates, flat-pattern dimensions
and pocket depths, interference volumes and parts-library lengths also use design units.
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
consumes complete point/curve selections consistently; mixed or ambiguous selections
retain the picking workflow. Rejected constraints preserve selection and undo history.
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
useful reproduction context. The draft is editable, with optional capture of the actual
@@ -141,7 +162,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
convex planar body or of an extruded part; fillets inside patterns; closed chains of curved
edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the
blend a rational B-spline through exact arcs), closed or ending at planar faces square to the
edges.
edges, where straight or curved cap edges are trimmed to the contacts (the straight edge
between two overlapping extruded arcs).
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve
annular holes with overrun cutters, including inward Press Pull after a joined extrusion;
@@ -155,16 +177,15 @@ headline counts in-scope tabs only and lists the deferred ones separately.
containment shortcut. Spheres are built from six pole-free patches.
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
- STEP import (our own reader, crates/kernel/src/step_in; all 29 Fusion STEP files pass
`cargo xtask step-corpus`) reads solids (B-rep with analytic and B-spline geometry), units,
- STEP import (our own reader, crates/kernel/src/step_in) reads solids (B-rep with analytic and B-spline geometry), units,
product names, colours and assemblies; analytic offsets on planes, cylinders, cones, spheres
and regular tori are read exactly (signed/nested distances, units and face sense). Freeform
offset surfaces and pcurve-only edges are not read yet,
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
- Persistent naming (plan/adr/0002) names faces from their history and edges by their faces;
references resolve by name first and warn when the entity split or vanished. Kernel-reported
face history (exact through coincident surfaces) is not wired in yet.
- Persistent naming follows the kernel's exact face history (docs/book/src/dev/naming.md):
references resolve by name first, follow splits and merges, and warn when the entity split,
merged or vanished, or was found by geometry only (operations that report no history yet).
New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face
source certificates with checked target and author occurrence paths. A missing or changed
source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while
@@ -231,6 +252,10 @@ headline counts in-scope tabs only and lists the deferred ones separately.
existing geometry before assigning positions, retaining saved curve IDs and constraints
across enumeration changes and resize, including concentric circles. Ambiguous identities
retain the last geometry and report a lost reference; permutation matching is bounded.
- Project accepts complete supported preselection in one undo step. Visible external sketch
curves and points retain their source sketch identity; model geometry, axes and planes use
the existing linked projection commands. Rejection preserves the document and selection;
mixed selections retain ordinary tool-first picking.
- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
selecting visited values for replacement. Expression completion retains forward-Tab
priority; real keyboard regressions cover rectangles, lines and single-value circles.
@@ -253,6 +278,18 @@ headline counts in-scope tabs only and lists the deferred ones separately.
- 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.
- Parameter creation supports Enter with validation and continued keyboard entry. Shared
expression autocomplete supports Up/Down selection, Enter/Tab acceptance and Escape dismissal;
accepting a suggestion does not submit a parameter or trigger viewport shortcuts.
- Movable window title bars follow the app's dark/light header palette. Radial right-click
menus are opt-in in Preferences > Navigation; ordinary context menus remain the default.
- Desktop distribution: running Windows windows and executable resources use the app icon;
packaging validates icon resources. Nix installs launchers, MIME data and generated icons,
with explicit launch apps and Vulkan-loader/driver discovery. Flatpak feed instructions
register the required runtime source; release checks exercise desktop modelling on X11/Wayland.
- OpenGL rendering clamps effective anti-aliasing to 1x across UI pipelines, textures and CAD
callbacks to prevent black frames. Other backends retain the requested sample count, and
stored preferences remain unchanged.
- Configuration rules: ordered typed conditions can drive parameters and suppressions or
restrict values. Preview explains effective values without modifying the document; applying
rules validates rows atomically. Length expressions retain their authoring units across
@@ -271,13 +308,60 @@ headline counts in-scope tabs only and lists the deferred ones separately.
- 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
check, not a new Fusion oracle result. Under-resolved fits return an error.
check. Under-resolved fits return an error.
- Contact driving checks the trajectory between clear endpoints, including linked joints,
playback and sparse motion exports. Checks use bounded tessellation samples, not exact
continuous collision detection; local thin walls and small details remain limitations.
See [contact-set limits and recovery](docs/contact-sets.md).
## 3D sketch bridges
3D sketch drawing snaps to points on other visible sketch planes without flattening them onto
the active plane. Bridge endpoints capture their source positions; associative endpoint links,
construction-plane selection of 3D wires and loft guide rails remain separate work.
Feature dialog header chevrons collapse and restore the form without discarding selections
or edited values, including the Appearance panel.
## Construction on 3D paths
Plane Along Path has a viewport path selector, fractional position, live preview and timeline
editing. Construction planes and points resolve drawn 3D wires without applying their sketch
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
and reference-object placement remain separate work, as do loft rails and centerlines.
## Document-opening performance
- Desktop STEP import prepares consecutive independent solids in bounded batches with up
to eight workers, retaining serial traversal, result order and persistent source identities.
The web path remains serial. Original multi-solid fixtures show 64–81% lower complete
import times; single-solid assembly parts are not scheduled across nodes. See
[measurements and limits](docs/perf.md#parallel-step-body-preparation-2026-10-12).
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
benchmark runs reduce median rebuild time by 26% on the public sensor enclosure and 22%
on a local case assembly; shape measurements and topology match the original build.
See [performance measurements](docs/perf.md). Further cold Boolean and import costs remain.
- Prepared assembly geometry survives selection and view changes while real geometry and
placement changes invalidate it. A Pi assembly edge-selection probe drops from 9.24 s to
0.57 s; initial opening is unchanged. Actual Windows desktop profiling measures 15.87 s
median opening, with 82% spent rebuilding geometry; halving full opening remains open.
## Kernel
- Successful Boolean retries report their strategy and numerical adjustments as
feature warnings and structured inspection data. Diagnostics survive cached
evaluation, undo/redo and cold reopening; the robustness runner counts retry
events by strategy and reports any bounded-log truncation. Existing geometry
acceptance checks remain in force.
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
polylines without panicking. Direct constructor regressions retain valid open endpoints
and closed spherical intersections; the original interactive trigger remains unidentified.
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
support and shared topology, preserving real steps and intentional boundaries. The local
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
cover stepped stock, STEP round trips and saved projections from removed pieces.
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
+352 -8
View File
@@ -328,9 +328,9 @@ thiserror-impl 2.0.21 (MIT OR Apache-2.0)
tiff 0.11.3 (MIT)
tinystr 0.8.4 (Unicode-3.0)
tokio 1.53.2 (MIT)
toml_datetime 1.1.1+spec-1.1.0 (MIT OR Apache-2.0)
toml_datetime 1.1.2+spec-1.1.0 (MIT OR Apache-2.0)
toml_edit 0.25.15+spec-1.1.0 (MIT OR Apache-2.0)
toml_parser 1.1.3+spec-1.1.0 (MIT OR Apache-2.0)
toml_parser 1.1.5+spec-1.1.0 (MIT OR Apache-2.0)
tracing 0.1.44 (MIT)
tracing-attributes 0.1.31 (MIT)
tracing-core 0.1.36 (MIT)
@@ -2230,7 +2230,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
bit-set 0.10.0, bit-vec 0.9.1, downcast-rs 1.2.1, presser 0.3.1
bit-set 0.10.0, bit-vec 0.9.1, downcast-rs 1.2.1, egui-wgpu 0.36.2, presser 0.3.1
================================================================================
Apache License
@@ -3417,9 +3417,8 @@ SOFTWARE.
================================================================================
crc32fast 1.5.2, dpi 0.1.2, foreign-types 0.5.0, foreign-types-macros 0.2.4, foreign-types-shared
0.3.1, hex 0.4.3, jni-sys 0.3.1, jni-sys 0.4.1, quick-error 2.0.1, toml_datetime 1.1.1+spec-1.1.0,
toml_edit 0.25.15+spec-1.1.0, toml_parser 1.1.3+spec-1.1.0, unicode-general-category 1.1.0, winit
0.30.13
0.3.1, hex 0.4.3, jni-sys 0.3.1, jni-sys 0.4.1, quick-error 2.0.1, toml_edit 0.25.15+spec-1.1.0,
unicode-general-category 1.1.0, winit 0.30.13
================================================================================
Apache License
@@ -4226,6 +4225,36 @@ and labelled as such in comments in the individual source files. All
have been licensed under extremely permissive terms.
------------------------------------------------------------------------------
================================================================================
egui-wgpu 0.36.2
================================================================================
Copyright (c) 2018-2021 Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
either 1.19.0, itertools 0.13.0, itertools 0.15.0
================================================================================
@@ -10207,7 +10236,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
toml_datetime 1.1.1+spec-1.1.0, toml_edit 0.25.15+spec-1.1.0, toml_parser 1.1.3+spec-1.1.0
toml_datetime 1.1.2+spec-1.1.0, toml_edit 0.25.15+spec-1.1.0, toml_parser 1.1.5+spec-1.1.0
================================================================================
Copyright (c) Individual contributors
@@ -10230,6 +10259,212 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
toml_datetime 1.1.2+spec-1.1.0, toml_parser 1.1.5+spec-1.1.0
================================================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright Individual contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================================================
tracing 0.1.44, tracing-attributes 0.1.31, tracing-core 0.1.36
================================================================================
@@ -13061,7 +13296,6 @@ dispatch2 0.3.1: Zlib OR Apache-2.0 OR MIT (https://github.com/madsmtm/objc2)
ecolor 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui)
eframe 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/eframe)
egui 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui)
egui-wgpu 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/egui-wgpu)
egui-winit 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/egui-winit)
egui_glow 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/egui_glow)
emath 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/emath)
@@ -13111,3 +13345,113 @@ truck-geotrait 0.4.0: Apache-2.0 (https://github.com/ricosjp/truck)
truck-modeling 0.6.0: Apache-2.0 (https://github.com/ricosjp/truck)
truck-polymesh 0.6.0: Apache-2.0 (https://github.com/ricosjp/truck)
truck-topology 0.6.0: Apache-2.0 (https://github.com/ricosjp/truck)
================================================================================
Bundled fonts (SIL Open Font License, Version 1.1)
================================================================================
- LiberationSans-Regular.ttf (crates/sketch/fonts/LiberationSans-Regular.ttf)
Copyright 2012 Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google)
Source: https://github.com/liberationfonts
- BIZUDPGothic-Regular.ttf (apps/solvecraft-web/fonts/BIZUDPGothic-Regular.ttf)
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
Source: https://github.com/googlefonts/morisawa-biz-ud-gothic
================================================================================
SIL OPEN FONT LICENSE Version 1.1
================================================================================
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+126
View File
@@ -0,0 +1,126 @@
//! Original SolidCraft mark, shared by desktop, packaging and CLI.
/// This block is the icon image data
pub fn rgba(size: usize) -> Result<Vec<u8>, String> {
if !(1..=2048).contains(&size) {
return Err("icon size must be 1..=2048".into());
}
type P = (f32, f32);
// Coverage buffer at 4x4 samples per pixel (2x2 for big sizes), averaged down at the end.
let ss = if size > 512 { 2 } else { 4 };
let n = size * ss;
let mut buf = vec![[0u8; 4]; n * n];
let s = n as f32 / 412.217; // SVG units -> samples: the logo fills the height
let ox = (n as f32 - 357.0 * s) / 2.0; // and is centred sideways
// Fill a convex polygon given in SVG units.
let mut fill = |pts: &[P], col: [u8; 3]| {
let v: Vec<P> = pts.iter().map(|p| (ox + p.0 * s, p.1 * s)).collect();
let (top, bottom) = v.iter().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.1), b.max(p.1)));
for y in ((top - 0.5).ceil() as i32).max(0)..=((bottom - 0.5).floor() as i32).min(n as i32 - 1) {
let cy = y as f32 + 0.5;
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
for i in 0..v.len() {
let (a, b) = (v[i], v[(i + 1) % v.len()]);
if (a.1 <= cy) != (b.1 <= cy) {
let x = a.0 + (cy - a.1) / (b.1 - a.1) * (b.0 - a.0);
lo = lo.min(x);
hi = hi.max(x);
}
}
if lo > hi {
continue;
}
for x in ((lo - 0.5).ceil() as i32).max(0)..=((hi - 0.5).floor() as i32).min(n as i32 - 1) {
buf[y as usize * n + x as usize] = [col[0], col[1], col[2], 255];
}
}
};
// Faces: each is a few overlapping convex pieces (the filler only does convex shapes).
let (light, mid, dark) = ([96, 158, 236], [58, 136, 238], [55, 108, 197]);
fill(&[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)], light);
fill(&[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], light);
fill(&[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)], mid);
fill(&[(3.5, 105.9), (3.5, 205.7), (90.3, 256.2), (177.0, 206.1)], mid);
fill(&[(177.0, 206.1), (90.3, 256.2), (90.0, 356.2), (177.0, 406.4)], mid);
fill(&[(264.1, 155.8), (177.0, 206.1), (177.0, 406.4), (263.8, 256.2)], dark);
fill(&[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], dark);
fill(&[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)], dark);
fill(&[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)], dark);
// White edge lines, 10 units wide: a rectangle per segment and a disc at each inner corner.
let white = [255, 255, 255];
let mut stroke = |pts: &[P], closed: bool| {
let m = pts.len();
for i in 0..(if closed { m } else { m - 1 }) {
let (a, b) = (pts[i], pts[(i + 1) % m]);
let l = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
let (nx, ny) = (-(b.1 - a.1) / l * 5.0, (b.0 - a.0) / l * 5.0);
fill(&[(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)], white);
}
for c in &pts[if closed { 0 } else { 1 }..if closed { m } else { m - 1 }] {
let disc: Vec<P> = (0..16)
.map(|k| {
let t = k as f32 * std::f32::consts::TAU / 16.0;
(c.0 + 5.0 * t.cos(), c.1 + 5.0 * t.sin())
})
.collect();
fill(&disc, white);
}
};
stroke(&[(263.8, 55.9), (177.1, 105.9), (263.8, 156.0), (263.8, 256.2), (350.6, 206.1)], false);
stroke(&[(5.0, 105.9), (178.5, 206.1), (178.5, 406.4)], false);
stroke(&[(177.0, 206.1), (263.8, 156.0)], false);
stroke(
&[
(178.5, 5.8),
(5.0, 105.9),
(5.0, 206.1),
(91.7, 256.2),
(91.7, 356.4),
(178.5, 406.4),
(352.0, 306.3),
(352.0, 206.1),
(265.2, 156.0),
(265.2, 55.9),
],
true,
);
// Average each ss x ss block down to one pixel (straight alpha = covered share).
let mut rgba = Vec::with_capacity(size * size * 4);
for y in 0..size {
for x in 0..size {
let (mut sum, mut cnt) = ([0u32; 3], 0u32);
for j in 0..ss {
for i in 0..ss {
let p = buf[(y * ss + j) * n + x * ss + i];
if p[3] != 0 {
cnt += 1;
for k in 0..3 {
sum[k] += u32::from(p[k]);
}
}
}
}
if cnt == 0 {
rgba.extend_from_slice(&[0, 0, 0, 0]);
} else {
let a = 255 * cnt / (ss * ss) as u32;
rgba.extend_from_slice(&[(sum[0] / cnt) as u8, (sum[1] / cnt) as u8, (sum[2] / cnt) as u8, a as u8]);
}
}
}
Ok(rgba)
}
#[cfg(test)]
mod tests {
#[test]
fn shared_mark_has_transparency_and_original_colors_at_window_and_package_sizes() {
for size in [16, 24, 32, 48, 64, 128, 256] {
let pixels = super::rgba(size).expect("valid size");
assert_eq!(pixels.len(), size * size * 4);
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] == 0));
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] == 255 && p[2] > p[0]));
}
assert!(super::rgba(0).is_err());
assert!(super::rgba(2049).is_err());
}
}
+39
View File
@@ -33,6 +33,8 @@ pub fn one(args: &[String]) -> Result<(), String> {
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
"unsupported": run.unsupported,
"rejected": run.rejected,
"boolean_fallbacks": run.boolean_fallbacks,
"boolean_reports_truncated": run.boolean_reports_truncated,
"panics": PANICS.swap(0, std::sync::atomic::Ordering::SeqCst),
});
if run.outcome.is_bug() {
@@ -179,9 +181,23 @@ fn summarise(results: &[Value], took: Duration) -> String {
let mut boolean: BTreeMap<String, usize> = BTreeMap::new();
let mut at_step: BTreeMap<String, usize> = BTreeMap::new();
let mut unsupported: BTreeMap<String, usize> = BTreeMap::new();
let mut fallbacks: BTreeMap<String, [u64; 2]> = BTreeMap::new();
let mut truncated = [0usize; 2];
let mut steps = 0;
let (mut dp, mut bp) = (0, 0);
for v in results {
for (i, group) in ["design", "boolean"].into_iter().enumerate() {
if v[group]["boolean_reports_truncated"].as_bool().unwrap_or(false) {
truncated[i] += 1;
}
if let Some(counts) = v[group]["boolean_fallbacks"].as_object() {
for (strategy, count) in counts {
if let Some(total) = fallbacks.entry(strategy.clone()).or_default().get_mut(i) {
*total = total.saturating_add(count.as_u64().unwrap_or(0));
}
}
}
}
dp += usize::from(v["design"]["panics"].as_u64().unwrap_or(0) > 0);
bp += usize::from(v["boolean_panics"].as_u64().unwrap_or(0) > 0);
let k = if v.get("timeout").is_some() {
@@ -220,5 +236,28 @@ fn summarise(results: &[Value], took: Duration) -> String {
for (k, c) in &boolean {
s += &format!("- {k}: {}\n", pct(*c));
}
s += "\nSuccessful Boolean retries (events, including nested operations):\n";
for (strategy, [designs, checks]) in &fallbacks {
s += &format!("- {strategy}: designs {designs}, boolean checks {checks}\n");
}
s += &format!("- Capped reports: designs {}, boolean checks {}\n", truncated[0], truncated[1]);
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_counts_retry_events_separately_and_marks_incomplete_logs() {
let results = [
json!({"design":{"outcome":{"kind":"ok"},"boolean_fallbacks":{"shifted_operands":2}},"boolean":{"outcome":"ok","boolean_fallbacks":{"shifted_operands":3},"boolean_reports_truncated":true}}),
json!({"design":{"outcome":{"kind":"ok"},"boolean_fallbacks":{"shifted_operands":1}},"boolean":{"outcome":"ok"}}),
json!({"timeout":90}),
];
let text = summarise(&results, Duration::ZERO);
assert!(text.contains("shifted_operands: designs 3, boolean checks 3"), "{text}");
assert!(text.contains("Capped reports: designs 0, boolean checks 1"), "{text}");
assert!(text.contains("timeout: 1 (33.3 %)"), "{text}");
}
}
+13 -112
View File
@@ -8,8 +8,7 @@
//! solvecraft-cli exec <command> [json-params] run one command on an empty design
//! solvecraft-cli commands the command registry as JSON
//! solvecraft-cli recipe <recipe.json> translate an oracle recipe to a script
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with measure.json
//! solvecraft-cli step-corpus <case-dir>... import part.step and compare with measure.json
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with expected.json
//! solvecraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
//! solvecraft-cli licences SolveCraft's licence and the third-party licences
//! solvecraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
@@ -20,11 +19,14 @@
mod fuzz;
mod oracle;
mod recipe;
mod seams;
mod spatial;
use std::process::ExitCode;
use serde_json::{Value, json};
#[path = "../../app_icon.rs"]
mod app_icon;
use solvecraft_engine::Session;
use solvecraft_engine::render::{StandardView, render_png};
@@ -39,7 +41,6 @@ const USAGE: &str = "usage:
solvecraft-cli recipe <recipe.json>
solvecraft-cli oracle <case-dir>... [--json]
solvecraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
solvecraft-cli step-corpus <case-dir>... [--json] [--iges]
solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
solvecraft-cli licences
solvecraft-cli icon --out icon.png [--size 256] | --out icon.ico
@@ -70,7 +71,6 @@ fn main() -> ExitCode {
recipe::to_script(&v).map(|s| println!("{}", pretty(&s)))
}),
Some("oracle") => oracle::run(&args[1..]),
Some("step-corpus") => oracle::step_corpus(&args[1..]),
Some("fuzz") => fuzz::run(&args[1..]),
Some("fuzz-one") => fuzz::one(&args[1..]),
Some("mcp") => cmd_mcp(&args[1..]),
@@ -245,113 +245,8 @@ fn cmd_icon(args: &[String]) -> Result<(), String> {
Ok(())
}
/// This block is the icon image data
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
use solvecraft_engine::render::png;
type P = (f32, f32);
// Coverage buffer at 4x4 samples per pixel (2x2 for big sizes), averaged down at the end.
let ss = if size > 512 { 2 } else { 4 };
let n = size * ss;
let mut buf = vec![[0u8; 4]; n * n];
let s = n as f32 / 412.217; // SVG units -> samples: the logo fills the height
let ox = (n as f32 - 357.0 * s) / 2.0; // and is centred sideways
// Fill a convex polygon given in SVG units.
let mut fill = |pts: &[P], col: [u8; 3]| {
let v: Vec<P> = pts.iter().map(|p| (ox + p.0 * s, p.1 * s)).collect();
let (top, bottom) = v.iter().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.1), b.max(p.1)));
for y in ((top - 0.5).ceil() as i32).max(0)..=((bottom - 0.5).floor() as i32).min(n as i32 - 1) {
let cy = y as f32 + 0.5;
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
for i in 0..v.len() {
let (a, b) = (v[i], v[(i + 1) % v.len()]);
if (a.1 <= cy) != (b.1 <= cy) {
let x = a.0 + (cy - a.1) / (b.1 - a.1) * (b.0 - a.0);
lo = lo.min(x);
hi = hi.max(x);
}
}
if lo > hi {
continue;
}
for x in ((lo - 0.5).ceil() as i32).max(0)..=((hi - 0.5).floor() as i32).min(n as i32 - 1) {
buf[y as usize * n + x as usize] = [col[0], col[1], col[2], 255];
}
}
};
// Faces: each is a few overlapping convex pieces (the filler only does convex shapes).
let (light, mid, dark) = ([96, 158, 236], [58, 136, 238], [55, 108, 197]);
fill(&[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)], light);
fill(&[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], light);
fill(&[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)], mid);
fill(&[(3.5, 105.9), (3.5, 205.7), (90.3, 256.2), (177.0, 206.1)], mid);
fill(&[(177.0, 206.1), (90.3, 256.2), (90.0, 356.2), (177.0, 406.4)], mid);
fill(&[(264.1, 155.8), (177.0, 206.1), (177.0, 406.4), (263.8, 256.2)], dark);
fill(&[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], dark);
fill(&[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)], dark);
fill(&[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)], dark);
// White edge lines, 10 units wide: a rectangle per segment and a disc at each inner corner.
let white = [255, 255, 255];
let mut stroke = |pts: &[P], closed: bool| {
let m = pts.len();
for i in 0..(if closed { m } else { m - 1 }) {
let (a, b) = (pts[i], pts[(i + 1) % m]);
let l = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
let (nx, ny) = (-(b.1 - a.1) / l * 5.0, (b.0 - a.0) / l * 5.0);
fill(&[(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)], white);
}
for c in &pts[if closed { 0 } else { 1 }..if closed { m } else { m - 1 }] {
let disc: Vec<P> = (0..16)
.map(|k| {
let t = k as f32 * std::f32::consts::TAU / 16.0;
(c.0 + 5.0 * t.cos(), c.1 + 5.0 * t.sin())
})
.collect();
fill(&disc, white);
}
};
stroke(&[(263.8, 55.9), (177.1, 105.9), (263.8, 156.0), (263.8, 256.2), (350.6, 206.1)], false);
stroke(&[(5.0, 105.9), (178.5, 206.1), (178.5, 406.4)], false);
stroke(&[(177.0, 206.1), (263.8, 156.0)], false);
stroke(
&[
(178.5, 5.8),
(5.0, 105.9),
(5.0, 206.1),
(91.7, 256.2),
(91.7, 356.4),
(178.5, 406.4),
(352.0, 306.3),
(352.0, 206.1),
(265.2, 156.0),
(265.2, 55.9),
],
true,
);
// Average each ss x ss block down to one pixel (straight alpha = covered share).
let mut rgba = Vec::with_capacity(size * size * 4);
for y in 0..size {
for x in 0..size {
let (mut sum, mut cnt) = ([0u32; 3], 0u32);
for j in 0..ss {
for i in 0..ss {
let p = buf[(y * ss + j) * n + x * ss + i];
if p[3] != 0 {
cnt += 1;
for k in 0..3 {
sum[k] += u32::from(p[k]);
}
}
}
}
if cnt == 0 {
rgba.extend_from_slice(&[0, 0, 0, 0]);
} else {
let a = 255 * cnt / (ss * ss) as u32;
rgba.extend_from_slice(&[(sum[0] / cnt) as u8, (sum[1] / cnt) as u8, (sum[2] / cnt) as u8, a as u8]);
}
}
}
png(size, size, rgba).ok_or_else(|| "icon: PNG encoding failed".to_string())
solvecraft_engine::render::png(size, size, app_icon::rgba(size)?).ok_or_else(|| "icon: PNG encoding failed".to_string())
}
/// A Windows icon file holding PNG images (Vista and later read these).
@@ -391,7 +286,13 @@ fn cmd_exec(args: &[String]) -> Result<(), String> {
fn cmd_mcp(args: &[String]) -> Result<(), String> {
let backend: Box<dyn solvecraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
(Some(addr), None) => Box::new(solvecraft_mcp::Remote::connect(addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?),
(Some(addr), None) => Box::new(solvecraft_mcp::Remote::connect(addr).map_err(|e| {
let port = addr.rsplit(':').next().filter(|p| p.parse::<u16>().is_ok()).unwrap_or("PORT");
format!(
"cannot connect to {addr}: {e}\nno SolidCraft app is listening there; start it with \
`solvecraft --control {port}` (or SOLVECRAFT_CONTROL_PORT={port}) and retry"
)
})?),
(None, Some(path)) => Box::new(solvecraft_mcp::Headless::new(load(path)?)),
(None, None) => Box::new(solvecraft_mcp::Headless::default()),
};
+153 -167
View File
@@ -1,129 +1,149 @@
//! Replay oracle recipes and compare with Fusion's measurements; import oracle STEP files and
//! compare them with the same measurements.
//! Reference tests: replay `tests/oracle/<case>/recipe.json` through the command engine and
//! compare the part with `expected.json` (closed-form values, or values from an independent
//! kernel; see tests/oracle/README.md), and with `reference.stl` where one exists.
#![allow(clippy::needless_range_loop)]
use serde_json::{Value, json};
use solvecraft_engine::geom::Vec3;
use crate::{read_json, recipe, seams};
use crate::spatial::{self, Tri};
use crate::{read_json, recipe};
/// Relative tolerance for volume and area (our curved-face tessellation is within ~2e-4).
/// Default relative tolerance for volume and area (our curved-face tessellation is within ~2e-4).
pub const REL_TOL: f64 = 1e-3;
/// Default tolerance for centre of mass and bounding box, as a fraction of the bbox diagonal.
const ABS_FRAC: f64 = 1e-3;
/// Chord tolerance used to triangulate our part for the surface-distance check (mm).
const MESH_TOL: f64 = 0.01;
/// Relative tolerance on the principal second moments of the two triangulations.
const INERTIA_REL: f64 = 2e-3;
/// Cases compared loosely (relative tolerance on volume and area, topology not compared), and why.
const APPROXIMATE: [(&str, f64, &str); 1] = [(
"49-helical-sweep",
5e-3,
"Fusion sweeps along a spline fitted through helix points; SolidCraft builds the true helix (a coil), so the size is compared within 0.5% and the topology not at all",
)];
/// What SolidCraft built: totals over every body.
struct Got {
body_count: usize,
volume: f64,
area: f64,
com: [f64; 3],
bbox: ([f64; 3], [f64; 3]),
faces: u64,
}
fn v3(v: &Value) -> Option<[f64; 3]> {
let a = v.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
}
fn totals(m: &Value) -> Got {
let bodies = m["bodies"].as_array().cloned().unwrap_or_default();
let (mut volume, mut area, mut faces) = (0.0, 0.0, 0);
let mut moment = [0.0; 3];
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
for b in &bodies {
let v = b["volume_mm3"].as_f64().unwrap_or(f64::NAN);
volume += v;
area += b["area_mm2"].as_f64().unwrap_or(f64::NAN);
faces += b["faces"].as_u64().unwrap_or(0);
let c = v3(&b["center_of_mass"]).unwrap_or([f64::NAN; 3]);
let (bl, bh) = (v3(&b["bbox"]["min"]).unwrap_or([f64::NAN; 3]), v3(&b["bbox"]["max"]).unwrap_or([f64::NAN; 3]));
for k in 0..3 {
moment[k] += v * c[k];
lo[k] = lo[k].min(bl[k]);
hi[k] = hi[k].max(bh[k]);
}
}
let com = moment.map(|x| x / volume);
Got { body_count: bodies.len(), volume, area, com, bbox: (lo, hi), faces }
}
struct Checks {
list: Vec<Value>,
pass: bool,
}
impl Checks {
fn push(&mut self, what: &str, got: Value, want: Value, ok: bool) {
self.pass &= ok;
self.list.push(json!({"check": what, "got": got, "want": want, "ok": ok}));
}
fn rel(&mut self, what: &str, g: f64, w: Option<f64>, rel: f64) {
let ok = w.is_some_and(|w| g.is_finite() && (g - w).abs() <= rel * w.abs().max(1.0));
self.push(what, json!(g), json!(w), ok);
}
fn vec(&mut self, what: &str, g: [f64; 3], w: Option<[f64; 3]>, tol: f64) {
let ok = w.is_some_and(|w| g.iter().zip(w).all(|(g, w)| g.is_finite() && (g - w).abs() <= tol));
self.push(what, json!(g), json!(w), ok);
}
}
fn check_case(dir: &str) -> Value {
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let rp = format!("{dir}/recipe.json");
let mp = format!("{dir}/measure.json");
let (recipe, want) = match (read_json(&rp), read_json(&mp)) {
(Ok(r), Ok(m)) => (r, m),
(Err(e), _) | (_, Err(e)) => return json!({"case": name, "pass": false, "error": e}),
let recipe = match read_json(&format!("{dir}/recipe.json")) {
Ok(r) => r,
Err(e) => return json!({"case": name, "pass": false, "error": e}),
};
let want = read_json(&format!("{dir}/expected.json")).ok();
let script = match recipe::to_script(&recipe) {
Ok(s) => s,
Err(e) => return json!({"case": name, "pass": false, "skipped": true, "error": e}),
};
let mut s = solvecraft_engine::Session::default();
if let Err(e) = s.run_script(&script) {
return json!({"case": name, "pass": false, "error": e.to_string()});
return json!({"case": name, "pass": false, "error": e.to_string(), "source": want.as_ref().map(|w| w["source"].clone())});
}
let got = match s.execute("inspect.measure", &json!({})) {
Ok(m) => m,
Ok(m) => totals(&m),
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
let mut checks = Vec::new();
let mut pass = true;
let mut num = |what: &str, g: Option<f64>, w: Option<f64>, rel: f64| {
let ok = match (g, w) {
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
_ => false,
};
pass &= ok;
checks.push(json!({"check": what, "got": g, "want": w, "ok": ok}));
let got_json = json!({
"body_count": got.body_count, "volume_mm3": got.volume, "area_mm2": got.area,
"center_of_mass": got.com, "bbox": {"min": got.bbox.0, "max": got.bbox.1}, "faces": got.faces,
});
let Some(want) = want else {
return json!({"case": name, "pass": false, "skipped": true, "error": "no expected.json yet", "got": got_json});
};
// Cases where Fusion's part is itself an approximation: compare size loosely, not topology.
let approx = APPROXIMATE.iter().find(|(c, _, _)| *c == name);
let tol = approx.map(|(_, t, _)| *t).unwrap_or(REL_TOL);
let within = |k: &str, rel: f64| match (got["total"][k].as_f64(), want["total"][k].as_f64()) {
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
_ => false,
};
let size_ok =
got["body_count"] == want["body_count"] && within("volume_mm3", tol) && (FUSION_AREA_OFF.contains(&name.as_str()) || within("area_mm2", tol));
num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0);
num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), tol);
let area_off = FUSION_AREA_OFF.contains(&name.as_str());
if !area_off {
num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), tol);
let only: Option<Vec<String>> = want["compare"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());
let wanted = |k: &str| want.get(k).is_some_and(|v| !v.is_null()) && only.as_ref().is_none_or(|o| o.iter().any(|x| x == k));
let rel = want["tolerance"]["rel"].as_f64().unwrap_or(REL_TOL);
let diag = |b: ([f64; 3], [f64; 3])| ((b.1[0] - b.0[0]).powi(2) + (b.1[1] - b.0[1]).powi(2) + (b.1[2] - b.0[2]).powi(2)).sqrt();
let want_bbox = v3(&want["bbox"]["min"]).zip(v3(&want["bbox"]["max"]));
let abs = want["tolerance"]["abs_mm"].as_f64().unwrap_or_else(|| ABS_FRAC * want_bbox.map(diag).unwrap_or_else(|| diag(got.bbox)).max(1.0));
let mut c = Checks { list: Vec::new(), pass: true };
if wanted("body_count") {
let ok = want["body_count"].as_u64() == Some(got.body_count as u64);
c.push("body_count", json!(got.body_count), want["body_count"].clone(), ok);
}
let mut seam_note = None;
if approx.is_none() {
let exact = ["faces", "edges", "vertices"].iter().all(|k| got["total"][k].as_f64().is_some() && got["total"][k] == want["total"][k]);
let fusion_seams = want["bodies"]
.as_array()
.and_then(|bs| bs.iter().map(seams::normalised).try_fold((0, 0, 0), |a, n| n.map(|n| (a.0 + n.0, a.1 + n.1, a.2 + n.2))));
match fusion_seams {
// Size matches and only the topology differs: compare with Fusion's seam splits merged.
Some((f, e, v)) if size_ok && !exact => {
let raw: Vec<String> = ["faces", "edges", "vertices"]
.iter()
.map(|k| {
let ours: i64 = got["bodies"].as_array().into_iter().flatten().filter_map(|b| b["kernel"][k].as_i64()).sum();
format!("{k} {ours}/{}", want["total"][k])
})
.collect();
seam_note = Some(format!(
"topology compared seam-normalised: Fusion's {}/{}/{} faces/edges/vertices become {f}/{e}/{v} with closed-surface patches counted once and seam edges and vertices dropped (raw SolidCraft/Fusion: {})",
want["total"]["faces"],
want["total"]["edges"],
want["total"]["vertices"],
raw.join(", ")
));
num("faces (seam-normalised)", got["total"]["faces"].as_f64(), Some(f as f64), 0.0);
num("edges (seam-normalised)", got["total"]["edges"].as_f64(), Some(e as f64), 0.0);
num("vertices (seam-normalised)", got["total"]["vertices"].as_f64(), Some(v as f64), 0.0);
}
_ => {
num("faces", got["total"]["faces"].as_f64(), want["total"]["faces"].as_f64(), 0.0);
num("edges", got["total"]["edges"].as_f64(), want["total"]["edges"].as_f64(), 0.0);
num("vertices", got["total"]["vertices"].as_f64(), want["total"]["vertices"].as_f64(), 0.0);
}
}
if wanted("volume_mm3") {
c.rel("volume_mm3", got.volume, want["volume_mm3"].as_f64(), rel);
}
if wanted("area_mm2") {
c.rel("area_mm2", got.area, want["area_mm2"].as_f64(), rel);
}
if wanted("center_of_mass") {
c.vec("center_of_mass", got.com, v3(&want["center_of_mass"]), abs);
}
if wanted("bbox") {
c.vec("bbox.min", got.bbox.0, want_bbox.map(|b| b.0), abs);
c.vec("bbox.max", got.bbox.1, want_bbox.map(|b| b.1), abs);
}
if wanted("faces") {
let ok = want["faces"].as_u64() == Some(got.faces);
c.push("faces", json!(got.faces), want["faces"].clone(), ok);
}
let stl = format!("{dir}/reference.stl");
if std::path::Path::new(&stl).is_file() && only.as_ref().is_none_or(|o| o.iter().any(|x| x == "surface")) {
let tol = want["tolerance"]["surface_mm"].as_f64().unwrap_or(0.05);
reference_checks(&s, &stl, tol, &mut c);
}
// Our STEP export of the rebuilt part: well formed, and read back with no warnings and the
// same volume.
let (ok, note) = step_export_check(&s, got["total"]["volume_mm3"].as_f64().unwrap_or(0.0));
pass &= ok;
checks.push(json!({"check": "step_export", "got": note, "want": "valid, re-imports cleanly", "ok": ok}));
let mut out = json!({"case": name, "pass": pass, "checks": checks});
if let Some((_, _, why)) = approx {
out["note"] = json!(why);
}
if let Some(n) = seam_note {
let (ok, note) = step_export_check(&s, got.volume);
c.push("step_export", json!(note), json!("valid, re-imports cleanly"), ok);
let mut out = json!({"case": name, "pass": c.pass, "source": want["source"], "checks": c.list, "got": got_json});
if let Some(n) = want["note"].as_str() {
out["note"] = json!(n);
}
if area_off {
out["note"] = json!(
"area not compared: Fusion reports this tight sweep's horn torus face at three times its area (π²·R·r quarter-turn value; 740 mm² against 246.7 by Pappus); volume and topology are compared"
);
}
let effective: Vec<String> = recipe["features"]
.as_array()
.into_iter()
.flatten()
.filter(|f| f.get("fusion_effective_extent").is_some())
.map(|f| f["name"].as_str().unwrap_or("?").to_string())
.collect();
if !effective.is_empty() {
out["note"] = json!(format!(
"known Fusion difference: {} replayed with the extent Fusion actually used (its through-all is sized as 1.1 × the farthest vertex, so curved tops stop it short; SolidCraft's through-all goes truly through)",
effective.join(", ")
));
}
out
}
@@ -163,77 +183,43 @@ fn step_export_check(s: &solvecraft_engine::Session, volume: f64) -> (bool, Stri
}
}
/// Cases whose Fusion area is not the geometric one: Fusion reports the horn torus face of a
/// tight sweep (tube touching its axis) at three times π²·R·r, its quarter-turn area; the
/// volumes agree. Their area is not compared.
const FUSION_AREA_OFF: &[&str] = &["50-sweep-tight-bend"];
/// Import a case's `part.step` (as File → Open does) and compare with Fusion's measurements:
/// body count, volume and area (relative 1e-3) and the faces read from the file.
/// Open the case's `part.step` (or `part.igs` with `iges`) and compare with measure.json.
fn check_step_case(dir: &str, iges: bool) -> Value {
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let sp = format!("{dir}/{}", if iges { "part.igs" } else { "part.step" });
let want = match read_json(&format!("{dir}/measure.json")) {
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e}),
};
let t0 = std::time::Instant::now();
let mut s = solvecraft_engine::Session::default();
let opened = match s.execute("doc.open", &json!({"path": sp})) {
Ok(v) => v,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
let got = match s.execute("inspect.measure", &json!({})) {
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
let ms = t0.elapsed().as_secs_f64() * 1000.0;
// Faces read from the file (the kernel may split a face that wraps around a closed surface).
let file_faces = std::fs::read(&sp)
.ok()
.and_then(|b| {
let text = String::from_utf8_lossy(&b);
if iges {
solvecraft_engine::kernel::iges_import(&text).ok().map(std::sync::Arc::new)
} else {
solvecraft_engine::kernel::step_import_shared(&text).ok()
/// Our part as a triangle soup (curved faces refined onto the surface).
fn our_mesh(s: &solvecraft_engine::Session) -> Result<Vec<Tri>, String> {
let mut ours: Vec<Tri> = Vec::new();
for b in &s.world_state().bodies {
let m = b.body.tessellate_with(MESH_TOL, true).map_err(|e| e.to_string())?;
let p = |i: u32| m.positions.get(i as usize).map(|v: &Vec3| [v.x, v.y, v.z]);
for t in &m.triangles {
if let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) {
ours.push([a, b, c]);
}
})
.map(|imp| imp.bodies.iter().map(|b| b.file_faces).sum::<usize>() as f64);
let mut checks = Vec::new();
let mut pass = true;
let mut num = |what: &str, g: Option<f64>, w: Option<f64>, rel: f64| {
let ok = match (g, w) {
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
_ => false,
};
pass &= ok;
checks.push(json!({"check": what, "got": g, "want": w, "ok": ok}));
};
num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0);
num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), REL_TOL);
if !FUSION_AREA_OFF.contains(&name.as_str()) {
num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), REL_TOL);
}
}
num("faces", file_faces, want["total"]["faces"].as_f64(), 0.0);
json!({
"case": name, "pass": pass, "checks": checks, "ms": ms,
"bodies": opened["bodies"], "warnings": opened["warnings"],
"info": {"kernel_faces_merged": got["total"]["faces"], "edges": got["total"]["edges"], "want_edges": want["total"]["edges"], "vertices": got["total"]["vertices"], "want_vertices": want["total"]["vertices"]},
})
if ours.is_empty() { Err("empty mesh".into()) } else { Ok(ours) }
}
pub fn step_corpus(args: &[String]) -> Result<(), String> {
let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
if dirs.is_empty() {
return Err("step-corpus: give one or more case directories (each with part.step and measure.json)".into());
}
let iges = args.iter().any(|a| a == "--iges");
let results: Vec<Value> = dirs.iter().map(|d| check_step_case(d, iges)).collect();
report(args, &results);
let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false)).count();
if failed > 0 { Err(format!("{failed} STEP case(s) failed")) } else { Ok(()) }
/// Compare our part with the reference triangulation: the symmetric surface distance, and the
/// principal second moments of volume of both meshes (the shape's inertia, independent of how it
/// sits in space).
fn reference_checks(s: &solvecraft_engine::Session, stl: &str, tol: f64, c: &mut Checks) {
let (ours, reference) = match (our_mesh(s), spatial::read_stl(stl)) {
(Ok(o), Ok(r)) if !r.is_empty() => (o, r),
(Err(e), _) | (_, Err(e)) => return c.push("surface_distance_mm", json!(e), json!(format!("<= {tol}")), false),
_ => return c.push("surface_distance_mm", json!("empty reference.stl"), json!(format!("<= {tol}")), false),
};
let (lo, hi) = spatial::bounds(&reference);
let size = (0..3).map(|k| hi[k] - lo[k]).fold(0.0, f64::max);
let h = spatial::hausdorff(&ours, &reference, size.max(1.0));
let worst = h.a_to_b.max(h.b_to_a);
c.push(
"surface_distance_mm",
json!({"max": worst, "mean": h.mean, "ours_to_reference": h.a_to_b, "reference_to_ours": h.b_to_a, "worst_at": h.worst_at}),
json!(format!("<= {tol}")),
worst <= tol,
);
let ((_, _, pa), (_, _, pb)) = (spatial::mass_properties(&ours), spatial::mass_properties(&reference));
let ok = pa.iter().zip(pb).all(|(a, b)| (a - b).abs() <= INERTIA_REL * b.abs().max(1.0));
c.push("principal_moments_mm5", json!(pa), json!(pb), ok);
}
fn report(args: &[String], results: &[Value]) {
@@ -268,7 +254,7 @@ fn report(args: &[String], results: &[Value]) {
pub fn run(args: &[String]) -> Result<(), String> {
let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
if dirs.is_empty() {
return Err("oracle: give one or more case directories".into());
return Err("oracle: give one or more case directories (each with recipe.json and expected.json)".into());
}
let results: Vec<Value> = dirs.iter().map(|d| check_case(d)).collect();
report(args, &results);
+3 -11
View File
@@ -1,4 +1,4 @@
//! Oracle recipes (`plan/fusion/oracle/*/recipe.json`) → SolveCraft command scripts.
//! Reference-test recipes (`tests/oracle/*/recipe.json`) → SolidCraft command scripts.
//!
//! A recipe lists features with sketch entities in sketch-local coordinates; point references
//! (`l1.end`) share points. Each feature becomes one or more commands.
@@ -85,14 +85,6 @@ fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
/// Extent parameters for the Extrude command.
fn extent(f: &Value, p: &mut Value) {
// Where Fusion's own result differs from the geometric meaning (its through-all is sized
// from vertices), the recipe records the extent Fusion actually used: replay that.
if let Some(fx) = f.get("fusion_effective_extent").filter(|x| x.get("type").and_then(Value::as_str) == Some("distance")) {
let dir = fx.get("direction").and_then(Value::as_str).unwrap_or("positive");
p["distance"] = fx.get("distance").cloned().unwrap_or(Value::Null);
p["direction"] = json!(if dir.starts_with("negative") { "negative" } else { "positive" });
return;
}
let e = f.get("extent").cloned().unwrap_or(Value::Null);
let ty = e.get("type").and_then(Value::as_str).unwrap_or("distance");
let dist = e.get("distance").cloned().or_else(|| f.get("distance").cloned()).unwrap_or(Value::Null);
@@ -545,8 +537,8 @@ fn edge_points(f: &Value) -> Value {
)
}
/// Fusion names a pattern's source features with an occurrence suffix ("Hole1 (1)"); the
/// features are the recipe's own names.
/// A pattern's source features may carry an occurrence suffix ("Hole1 (1)"); the features are
/// the recipe's own names.
fn feature_refs(v: Option<&Value>) -> Value {
let list: Vec<Value> = v
.and_then(Value::as_array)
-321
View File
@@ -1,321 +0,0 @@
//! Seam-normalised topology of Fusion's measurements.
//!
//! Fusion splits a closed surface along seams: a full cylinder can be two half faces, and a
//! closed curve crossing a seam becomes two edges with a vertex on the seam. SolveCraft's merged
//! counts already count each closed surface patch once and ignore seam edges and vertices, so
//! the oracle normalises Fusion's side the same way from its face, edge and vertex lists:
//! - faces cut from one periodic surface (cylinder, cone, sphere, torus) and separated only by
//! seam edges count once;
//! - a seam edge lies on that surface and on no other face's surface: it is dropped;
//! - a vertex left with exactly two edges is no corner (a solid's corners have three or more):
//! the two edges join, and a curve that closes on itself keeps one vertex.
use std::collections::HashMap;
use serde_json::Value;
type V3 = [f64; 3];
const TOL: f64 = 1e-4;
fn v3(v: &Value) -> Option<V3> {
let a = v.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
}
fn sub(a: V3, b: V3) -> V3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn add(a: V3, b: V3) -> V3 {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn mul(a: V3, s: f64) -> V3 {
[a[0] * s, a[1] * s, a[2] * s]
}
fn dot(a: V3, b: V3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross(a: V3, b: V3) -> V3 {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn len(a: V3) -> f64 {
dot(a, a).sqrt()
}
fn unit(a: V3) -> Option<V3> {
let l = len(a);
(l > 1e-12).then(|| mul(a, 1.0 / l))
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Surf {
Plane { p: V3, n: V3 },
Cylinder { o: V3, a: V3, r: f64 },
Cone { o: V3, a: V3, r: f64, tan: f64 },
Sphere { c: V3, r: f64 },
Torus { c: V3, a: V3, big: f64, small: f64 },
Other,
}
impl Surf {
fn of(f: &Value) -> Surf {
let g = |k: &str| v3(&f[k]);
let n = |k: &str| f[k].as_f64();
let s = match f["type"].as_str().unwrap_or_default() {
"plane" => g("centroid").zip(g("plane_normal_outward").and_then(unit)).map(|(p, n)| Surf::Plane { p, n }),
"cylinder" => (|| Some(Surf::Cylinder { o: g("axis_origin")?, a: unit(g("axis")?)?, r: n("radius_mm")? }))(),
"cone" => {
(|| Some(Surf::Cone { o: g("axis_origin")?, a: unit(g("axis")?)?, r: n("radius_mm")?, tan: n("half_angle_deg")?.to_radians().tan() }))(
)
}
"sphere" => (|| Some(Surf::Sphere { c: g("center")?, r: n("radius_mm")? }))(),
"torus" => (|| Some(Surf::Torus { c: g("center")?, a: unit(g("axis")?)?, big: n("major_radius_mm")?, small: n("minor_radius_mm")? }))(),
_ => None,
};
s.unwrap_or(Surf::Other)
}
fn periodic(&self) -> bool {
matches!(self, Surf::Cylinder { .. } | Surf::Cone { .. } | Surf::Sphere { .. } | Surf::Torus { .. })
}
/// Whether the point lies on the surface; `None` when that can't be told (free-form faces).
fn contains(&self, p: V3) -> Option<bool> {
let radial = |o: V3, a: V3| {
let d = sub(p, o);
let t = dot(d, a);
(t, len(sub(d, mul(a, t))))
};
Some(match *self {
Surf::Plane { p: q, n } => dot(sub(p, q), n).abs() < TOL,
Surf::Cylinder { o, a, r } => (radial(o, a).1 - r).abs() < TOL,
Surf::Cone { o, a, r, tan } => {
let (t, rho) = radial(o, a);
(rho - (r + t * tan)).abs() < TOL || (rho - (r - t * tan)).abs() < TOL
}
Surf::Sphere { c, r } => (len(sub(p, c)) - r).abs() < TOL,
Surf::Torus { c, a, big, small } => {
let (t, rho) = radial(c, a);
((rho - big).hypot(t) - small).abs() < TOL
}
Surf::Other => return None,
})
}
fn same(&self, o: &Surf) -> bool {
let close = |a: f64, b: f64| (a - b).abs() < TOL;
let pt = |a: V3, b: V3| len(sub(a, b)) < TOL;
let par = |a: V3, b: V3| len(cross(a, b)) < TOL;
// Same axis line: parallel, and either origin on the other's axis.
let axis = |o1: V3, a1: V3, o2: V3, a2: V3| par(a1, a2) && len(cross(sub(o2, o1), a1)) < TOL;
match (*self, *o) {
(Surf::Cylinder { o: o1, a: a1, r: r1 }, Surf::Cylinder { o: o2, a: a2, r: r2 }) => axis(o1, a1, o2, a2) && close(r1, r2),
(Surf::Cone { o: o1, a: a1, r: r1, tan: t1 }, Surf::Cone { .. }) => {
// Same cone: the other's frame circle lies on this one, same opening angle.
let Surf::Cone { o: o2, a: a2, r: r2, tan: t2 } = *o else { return false };
axis(o1, a1, o2, a2) && close(t1, t2) && {
let t = dot(sub(o2, o1), a1) * dot(a1, a2).signum();
close(r2, r1 + t * t1) || close(r2, r1 - t * t1)
}
}
(Surf::Sphere { c: c1, r: r1 }, Surf::Sphere { c: c2, r: r2 }) => pt(c1, c2) && close(r1, r2),
(Surf::Torus { c: c1, a: a1, big: b1, small: s1 }, Surf::Torus { c: c2, a: a2, big: b2, small: s2 }) => {
pt(c1, c2) && par(a1, a2) && close(b1, b2) && close(s1, s2)
}
_ => false,
}
}
}
/// Points along an edge from Fusion's edge list.
fn edge_points(e: &Value) -> Vec<V3> {
let (s, t) = (v3(&e["start"]), v3(&e["end"]));
let mut pts: Vec<V3> = s.into_iter().chain(t).collect();
let (c, r, a) = (v3(&e["center"]), e["radius_mm"].as_f64(), v3(&e["axis"]).and_then(unit));
match (e["type"].as_str().unwrap_or_default(), s, t) {
("line", Some(s), Some(t)) => pts.push(mul(add(s, t), 0.5)),
("arc", Some(s), Some(t)) => {
if let (Some(c), Some(r)) = (c, r)
&& let Some(m) = unit(add(sub(s, c), sub(t, c)))
{
let long = e["length_mm"].as_f64().is_some_and(|l| l > std::f64::consts::PI * r);
pts.push(add(c, mul(m, if long { -r } else { r })));
}
}
("circle", Some(s), _) => {
if let (Some(c), Some(a)) = (c, a) {
let d = sub(s, c);
for th in [2.0 * std::f64::consts::FRAC_PI_3, 4.0 * std::f64::consts::FRAC_PI_3] {
pts.push(add(c, add(mul(d, th.cos()), mul(cross(a, d), th.sin()))));
}
}
}
_ => {}
}
pts
}
fn key(p: V3) -> [i64; 3] {
p.map(|x| (x * 1e6).round() as i64)
}
fn find(p: &mut [usize], mut i: usize) -> usize {
while let Some(&q) = p.get(i) {
if q == i {
break;
}
i = q;
}
i
}
/// Faces, edges and vertices of one Fusion body after merging seam splits; `None` when the
/// lists are missing.
pub fn normalised(body: &Value) -> Option<(usize, usize, usize)> {
let faces = body["face_list"].as_array()?;
let edges = body["edge_list"].as_array()?;
let surfs: Vec<Surf> = faces.iter().map(Surf::of).collect();
// Group faces cut from one periodic surface.
let mut group: Vec<usize> = (0..surfs.len()).collect();
for i in 0..surfs.len() {
for j in 0..i {
if surfs[i].periodic() && surfs[i].same(&surfs[j]) {
group[i] = group[j];
break;
}
}
}
let size = |g: usize| group.iter().filter(|x| **x == g).count();
// Seam edges: on a split group's surface and on no other face's surface.
let mut seams_in: HashMap<usize, usize> = HashMap::new();
let mut seam = vec![false; edges.len()];
for (ei, e) in edges.iter().enumerate() {
let pts = edge_points(e);
if pts.is_empty() {
continue;
}
let on = |s: &Surf| pts.iter().map(|p| s.contains(*p)).try_fold(true, |acc, x| Some(acc && x?));
let carriers: Vec<Option<bool>> = surfs.iter().map(on).collect();
if carriers.contains(&None) {
continue;
}
let groups: Vec<usize> = (0..surfs.len()).filter(|f| carriers[*f] == Some(true)).map(|f| group[f]).collect();
if let Some(&g) = groups.first()
&& groups.iter().all(|x| *x == g)
&& surfs[g].periodic()
&& size(g) > 1
{
seam[ei] = true;
*seams_in.entry(g).or_insert(0) += 1;
}
}
let mut face_count = 0;
for g in 0..surfs.len() {
let k = size(g);
if k > 0 {
face_count += k.saturating_sub(seams_in.get(&g).copied().unwrap_or(0)).max(1);
}
}
// Vertex degrees over the remaining edges; a closed edge counts once at its vertex.
let mut ends: Vec<(usize, Option<[i64; 3]>, Option<[i64; 3]>)> = Vec::new();
for (ei, e) in edges.iter().enumerate() {
if seam[ei] {
continue;
}
let (s, t) = (v3(&e["start"]).map(key), v3(&e["end"]).map(key));
let t = if t == s { None } else { t };
ends.push((ends.len(), s, t));
}
// A seam between two closed curves (a closed face's seam whose surface we can't evaluate):
// an open edge whose ends each meet only a closed edge. At a real vertex three edges meet.
let closed_at = |v: &[i64; 3], ends: &[(usize, Option<[i64; 3]>, Option<[i64; 3]>)]| {
let closed = ends.iter().filter(|(_, s, t)| t.is_none() && s.as_ref() == Some(v)).count();
let open = ends.iter().filter(|(_, s, t)| t.is_some() && (s.as_ref() == Some(v) || t.as_ref() == Some(v))).count();
closed == 1 && open == 1
};
let loop_seams: Vec<usize> =
ends.iter().filter(|(_, s, t)| matches!((s, t), (Some(s), Some(t)) if closed_at(s, &ends) && closed_at(t, &ends))).map(|e| e.0).collect();
ends.retain(|e| !loop_seams.contains(&e.0));
for (k, e) in ends.iter_mut().enumerate() {
e.0 = k;
}
let mut deg: HashMap<[i64; 3], Vec<usize>> = HashMap::new();
for (i, s, t) in &ends {
for v in [s, t].into_iter().flatten() {
deg.entry(*v).or_default().push(*i);
}
}
let mut ep: Vec<usize> = (0..ends.len()).collect();
let mut merged: Vec<[i64; 3]> = Vec::new();
for (v, es) in &deg {
// Two open edges meeting: one curve split at a seam.
if let [i, j] = es[..]
&& i != j
&& ends.get(i).is_some_and(|e| e.2.is_some())
&& ends.get(j).is_some_and(|e| e.2.is_some())
{
let (ri, rj) = (find(&mut ep, i), find(&mut ep, j));
if let Some(x) = ep.get_mut(ri) {
*x = rj;
}
merged.push(*v);
}
}
let mut real: HashMap<usize, bool> = HashMap::new();
for (i, s, t) in &ends {
let r = find(&mut ep, *i);
let has_real = [s, t].into_iter().flatten().any(|v| !merged.contains(v)) || (s.is_none() && t.is_none());
*real.entry(r).or_insert(false) |= has_real;
}
let closed = real.values().filter(|r| !**r).count();
let vertices = deg.keys().filter(|v| !merged.contains(v)).count() + closed;
Some((face_count, real.len(), vertices))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// A cylinder (r 5, height 10) split by Fusion into two halves along two seams.
#[test]
fn split_cylinder_counts_once() {
let cyl = json!({"type": "cylinder", "axis_origin": [0, 0, 0], "axis": [0, 0, 1], "radius_mm": 5.0});
let plane = |z: f64, n: f64| json!({"type": "plane", "centroid": [0, 0, z], "plane_normal_outward": [0, 0, n]});
let arc = |z: f64, s: [f64; 3], t: [f64; 3]| json!({"type": "arc", "start": s, "end": t, "center": [0, 0, z], "radius_mm": 5.0, "axis": [0, 0, 1], "length_mm": 15.707963});
let line = |x: f64| json!({"type": "line", "start": [x, 0, 0], "end": [x, 0, 10]});
let body = json!({
"face_list": [cyl.clone(), cyl, plane(0.0, -1.0), plane(10.0, 1.0)],
"edge_list": [line(5.0), line(-5.0), arc(0.0, [5.0, 0.0, 0.0], [-5.0, 0.0, 0.0]), arc(0.0, [-5.0, 0.0, 0.0], [5.0, 0.0, 0.0]), arc(10.0, [5.0, 0.0, 10.0], [-5.0, 0.0, 10.0]), arc(10.0, [-5.0, 0.0, 10.0], [5.0, 0.0, 10.0])],
});
assert_eq!(normalised(&body), Some((3, 2, 2)));
}
/// Oracle 35: a fillet (free-form face, surface unknown) between two cylinders is bounded by
/// two closed contact curves joined by its seam arc. The arc goes; the curves and circles stay.
#[test]
fn seam_between_closed_curves_on_an_unknown_surface() {
let circle =
|s: [f64; 3], c: [f64; 3], a: [f64; 3]| json!({"type": "circle", "start": s, "end": s, "center": c, "radius_mm": 1.0, "axis": a});
let lp = |p: [f64; 3]| json!({"type": "nurbs", "start": p, "end": p});
let (p, q) = ([0.0, -10.833333333, 10.374916332], [0.0, -10.0, 12.449899598]);
let body = json!({
"face_list": [{"type": "nurbs"}],
"edge_list": [
{"type": "arc", "start": p, "end": q, "center": [0.0, -13.0, 12.449899598], "radius_mm": 3.0, "axis": [1, 0, 0]},
circle([-40.0, 0.0, 15.0], [-40.0, 0.0, 0.0], [-1.0, 0.0, 0.0]),
circle([-10.0, 0.0, 35.0], [0.0, 0.0, 35.0], [0.0, 0.0, 1.0]),
circle([40.0, 0.0, 15.0], [40.0, 0.0, 0.0], [1.0, 0.0, 0.0]),
lp(p),
lp(q),
],
});
assert_eq!(normalised(&body), Some((1, 5, 5)));
}
/// A closed curve split into two edges at a seam vertex joins again (Fusion part 61's keyway).
#[test]
fn degree_two_vertices_join() {
let n = |s: [f64; 3], t: [f64; 3]| json!({"type": "nurbs", "start": s, "end": t});
let body = json!({"face_list": [], "edge_list": [n([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), n([1.0, 0.0, 0.0], [0.0, 0.0, 0.0])]});
assert_eq!(normalised(&body), Some((0, 1, 1)));
}
}
+333
View File
@@ -0,0 +1,333 @@
//! Geometry comparison helpers: an STL reader, bounding boxes, volume, centre of mass and principal
//! second moments of a closed triangle mesh, and a sampled symmetric Hausdorff distance between two
//! triangle meshes.
use std::collections::HashMap;
/// A triangle soup in mm.
pub type Tri = [[f64; 3]; 3];
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
[a[0] * s, a[1] * s, a[2] * s]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
/// Read a binary (or ASCII) STL file.
pub fn read_stl(path: &str) -> Result<Vec<Tri>, String> {
let b = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
if b.len() >= 84 {
let n = u32::from_le_bytes([b[80], b[81], b[82], b[83]]) as usize;
if b.len() == 84 + 50 * n {
let f = |o: usize| f64::from(f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]));
return Ok((0..n)
.map(|i| {
let o = 84 + 50 * i + 12;
let v = |k: usize| [f(o + 12 * k), f(o + 12 * k + 4), f(o + 12 * k + 8)];
[v(0), v(1), v(2)]
})
.collect());
}
}
let text = String::from_utf8_lossy(&b);
let mut verts = Vec::new();
for line in text.lines() {
let mut it = line.split_whitespace();
if it.next() == Some("vertex") {
let c: Vec<f64> = it.filter_map(|x| x.parse().ok()).collect();
if c.len() == 3 {
verts.push([c[0], c[1], c[2]]);
}
}
}
if verts.is_empty() || verts.len() % 3 != 0 {
return Err(format!("{path}: not an STL file"));
}
Ok(verts.chunks(3).map(|c| [c[0], c[1], c[2]]).collect())
}
/// Volume, centre of mass and the principal second moments of volume (mm^5, sorted ascending) of a
/// closed, outward-oriented triangle soup, by signed tetrahedra from the origin.
pub fn mass_properties(tris: &[Tri]) -> (f64, [f64; 3], [f64; 3]) {
let (mut v6, mut m1) = (0.0, [0.0; 3]);
let mut c = [[0.0; 3]; 3]; // ∫ x xᵀ dV
for t in tris {
let d = dot(t[0], cross(t[1], t[2]));
v6 += d;
let s = add(add(t[0], t[1]), t[2]);
m1 = add(m1, scale(s, d / 24.0));
for (i, row) in c.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
let vv: f64 = t.iter().map(|p| p[i] * p[j]).sum::<f64>() + s[i] * s[j];
*cell += d / 120.0 * vv;
}
}
}
let vol = v6 / 6.0;
if vol.abs() < 1e-12 {
return (0.0, [0.0; 3], [0.0; 3]);
}
let com = scale(m1, 1.0 / vol);
// Central covariance and inertia tensor I = tr(C)·1 − C.
let mut cc = c;
for (i, row) in cc.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
*cell -= vol * com[i] * com[j];
}
}
let tr = cc[0][0] + cc[1][1] + cc[2][2];
let mut inertia = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
inertia[i][j] = if i == j { tr - cc[i][j] } else { -cc[i][j] };
}
}
let mut ev = eigen_sym3(inertia);
ev.sort_by(f64::total_cmp);
(vol, com, ev)
}
/// Eigenvalues of a symmetric 3×3 matrix (Jacobi rotations).
fn eigen_sym3(mut a: [[f64; 3]; 3]) -> [f64; 3] {
for _ in 0..50 {
let (mut p, mut q, mut big) = (0, 1, 0.0);
for (i, j) in [(0, 1), (0, 2), (1, 2)] {
if a[i][j].abs() > big {
(p, q, big) = (i, j, a[i][j].abs());
}
}
if big <= 1e-15 * (a[0][0].abs() + a[1][1].abs() + a[2][2].abs()).max(1e-300) {
break;
}
let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
let t = if theta == 0.0 { 1.0 } else { t };
let (cs, sn) = (1.0 / (t * t + 1.0).sqrt(), t / (t * t + 1.0).sqrt());
let mut b = a;
for k in 0..3 {
b[k][p] = cs * a[k][p] - sn * a[k][q];
b[k][q] = sn * a[k][p] + cs * a[k][q];
}
let mut c2 = b;
for k in 0..3 {
c2[p][k] = cs * b[p][k] - sn * b[q][k];
c2[q][k] = sn * b[p][k] + cs * b[q][k];
}
a = c2;
}
[a[0][0], a[1][1], a[2][2]]
}
/// Axis-aligned bounds of a triangle soup.
pub fn bounds(tris: &[Tri]) -> ([f64; 3], [f64; 3]) {
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for p in tris.iter().flatten() {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
(lo, hi)
}
/// Distance from a point to a triangle (Ericson, Real-Time Collision Detection 5.1.5).
fn point_tri(p: [f64; 3], t: &Tri) -> f64 {
let (a, b, c) = (t[0], t[1], t[2]);
let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a));
let (d1, d2) = (dot(ab, ap), dot(ac, ap));
let q = if d1 <= 0.0 && d2 <= 0.0 {
a
} else {
let bp = sub(p, b);
let (d3, d4) = (dot(ab, bp), dot(ac, bp));
if d3 >= 0.0 && d4 <= d3 {
b
} else {
let vc = d1 * d4 - d3 * d2;
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
add(a, scale(ab, d1 / (d1 - d3)))
} else {
let cp = sub(p, c);
let (d5, d6) = (dot(ab, cp), dot(ac, cp));
if d6 >= 0.0 && d5 <= d6 {
c
} else {
let vb = d5 * d2 - d1 * d6;
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
add(a, scale(ac, d2 / (d2 - d6)))
} else {
let va = d3 * d6 - d5 * d4;
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
add(b, scale(sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6))))
} else {
let den = va + vb + vc;
if den.abs() < 1e-300 { a } else { add(a, add(scale(ab, vb / den), scale(ac, vc / den))) }
}
}
}
}
}
};
let d = sub(p, q);
dot(d, d).sqrt()
}
/// Uniform grid of triangles for distance queries up to `cap`.
struct Grid<'a> {
tris: &'a [Tri],
cell: f64,
cells: HashMap<(i64, i64, i64), Vec<u32>>,
}
impl<'a> Grid<'a> {
fn new(tris: &'a [Tri], cell: f64) -> Self {
let mut cells: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
let key = |x: f64| (x / cell).floor() as i64;
for (i, t) in tris.iter().enumerate() {
let (lo, hi) = bounds(std::slice::from_ref(t));
for x in key(lo[0])..=key(hi[0]) {
for y in key(lo[1])..=key(hi[1]) {
for z in key(lo[2])..=key(hi[2]) {
cells.entry((x, y, z)).or_default().push(i as u32);
}
}
}
}
Grid { tris, cell, cells }
}
/// Distance from `p` to the nearest triangle, or `cap` if none is that close. Searches shells
/// of cells outwards and stops once the nearest hit is closer than the unsearched shells.
fn distance(&self, p: [f64; 3], cap: f64, seen: &mut [u32], stamp: u32) -> f64 {
let rmax = (cap / self.cell).ceil() as i64;
let k = |x: f64| (x / self.cell).floor() as i64;
let (cx, cy, cz) = (k(p[0]), k(p[1]), k(p[2]));
let mut best = cap;
for r in 0..=rmax {
for x in cx - r..=cx + r {
for y in cy - r..=cy + r {
for z in cz - r..=cz + r {
// only the shell at Chebyshev distance r
if (x - cx).abs() != r && (y - cy).abs() != r && (z - cz).abs() != r {
continue;
}
let Some(list) = self.cells.get(&(x, y, z)) else { continue };
for &i in list {
let Some(slot) = seen.get_mut(i as usize) else { continue };
if *slot == stamp {
continue;
}
*slot = stamp;
if let Some(t) = self.tris.get(i as usize) {
best = best.min(point_tri(p, t));
}
}
}
}
}
// Everything not yet searched is at least r·cell away from p.
if best <= r as f64 * self.cell {
break;
}
}
best
}
}
/// At most this many sample points per side (larger meshes are sampled with an even stride).
const MAX_SAMPLES: usize = 250_000;
/// Sample points of a triangle soup: its vertices and triangle centroids (every k-th triangle for
/// very large meshes).
fn samples(tris: &[Tri]) -> Vec<[f64; 3]> {
let stride = (tris.len() * 4).div_ceil(MAX_SAMPLES).max(1);
let mut out: Vec<[f64; 3]> = Vec::with_capacity(tris.len() * 4 / stride + 4);
for t in tris.iter().step_by(stride) {
out.extend_from_slice(t);
out.push(scale(add(add(t[0], t[1]), t[2]), 1.0 / 3.0));
}
out
}
/// One-sided sampled distance from `from` to `to`: (max, mean, worst sample point). Distances are
/// capped at `cap`.
fn one_sided(from: &[Tri], to: &[Tri], cap: f64) -> (f64, f64, [f64; 3]) {
let (lo, hi) = bounds(to);
let diag = dot(sub(hi, lo), sub(hi, lo)).sqrt();
// Cells about twice the typical triangle size (so a cell holds a handful of triangles), but no
// finer than diag/400 (so big flat triangles don't fill too many cells).
let area: f64 = to.iter().map(|t| dot(cross(sub(t[1], t[0]), sub(t[2], t[0])), cross(sub(t[1], t[0]), sub(t[2], t[0]))).sqrt() / 2.0).sum();
let typical = (area / to.len().max(1) as f64).sqrt();
let grid = Grid::new(to, (2.0 * typical).max(diag / 400.0).max(1e-3));
let mut seen = vec![0u32; to.len()];
let (mut max, mut sum, mut worst, mut n) = (0.0f64, 0.0, [0.0; 3], 0usize);
for (i, p) in samples(from).into_iter().enumerate() {
let d = grid.distance(p, cap, &mut seen, i as u32 + 1);
sum += d;
n += 1;
if d > max {
max = d;
worst = p;
}
}
(max, if n > 0 { sum / n as f64 } else { 0.0 }, worst)
}
/// Sampled symmetric Hausdorff distance between two triangle soups (distances capped at `cap`).
pub struct Hausdorff {
pub a_to_b: f64,
pub b_to_a: f64,
pub mean: f64,
/// Where the larger one-sided distance was found (mm, world).
pub worst_at: [f64; 3],
}
pub fn hausdorff(a_mesh: &[Tri], b_mesh: &[Tri], cap: f64) -> Hausdorff {
let (a, am, ap) = one_sided(a_mesh, b_mesh, cap);
let (b, bm, bp) = one_sided(b_mesh, a_mesh, cap);
Hausdorff { a_to_b: a, b_to_a: b, mean: (am + bm) / 2.0, worst_at: if a >= b { ap } else { bp } }
}
#[cfg(test)]
mod tests {
use super::*;
fn cube(s: f64, o: [f64; 3]) -> Vec<Tri> {
let v = |x: f64, y: f64, z: f64| [o[0] + x * s, o[1] + y * s, o[2] + z * s];
let q = |a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]| vec![[a, b, c], [a, c, d]];
let mut t = Vec::new();
t.extend(q(v(0., 0., 0.), v(0., 1., 0.), v(1., 1., 0.), v(1., 0., 0.)));
t.extend(q(v(0., 0., 1.), v(1., 0., 1.), v(1., 1., 1.), v(0., 1., 1.)));
t.extend(q(v(0., 0., 0.), v(1., 0., 0.), v(1., 0., 1.), v(0., 0., 1.)));
t.extend(q(v(0., 1., 0.), v(0., 1., 1.), v(1., 1., 1.), v(1., 1., 0.)));
t.extend(q(v(0., 0., 0.), v(0., 0., 1.), v(0., 1., 1.), v(0., 1., 0.)));
t.extend(q(v(1., 0., 0.), v(1., 1., 0.), v(1., 1., 1.), v(1., 0., 1.)));
t
}
#[test]
fn box_moments_and_distance() {
let c = cube(10.0, [5.0, -3.0, 2.0]);
let (v, com, pm) = mass_properties(&c);
assert!((v - 1000.0).abs() < 1e-9 && (com[0] - 10.0).abs() < 1e-9 && (com[2] - 7.0).abs() < 1e-9);
// V·(a²+b²)/12 for a cube of side 10: 1000·200/12.
for m in pm {
assert!((m - 1000.0 * 200.0 / 12.0).abs() < 1e-6, "{pm:?}");
}
let moved = cube(10.0, [5.0, -3.0, 2.3]);
let h = hausdorff(&c, &moved, 1.0);
assert!((h.a_to_b - 0.3).abs() < 1e-9 && (h.b_to_a - 0.3).abs() < 1e-9);
let same = hausdorff(&c, &c, 1.0);
assert!(same.a_to_b < 1e-12);
}
}
@@ -15,6 +15,8 @@ hostile variants of it never panic.
| `modern-sheet-metal` | bd98035 | sheet metal contour flange (oracle 72) |
| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) |
| `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)`) |
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.
@@ -0,0 +1,144 @@
{
"bodies": [
{
"area_mm2": 4078.7680390666596,
"bbox": {
"max": [
40.0,
30.000000000000014,
12.000000000000002
],
"min": [
0.0,
0.0,
0.0
]
},
"center_of_mass": [
19.25890231218174,
15.000000010054242,
5.04117761026949
],
"density_g_cm3": 1.29,
"edges": 30,
"face_types": {
"cylinder": 1,
"plane": 11
},
"faces": 12,
"kernel": {
"edges": 30,
"faces": 12,
"shells": 1,
"vertices": 20
},
"mass_g": 14.435720327850033,
"material": "Default",
"name": "Body1",
"vertices": 20,
"volume_mm3": 11190.480874302351
}
],
"body_count": 1,
"params": [],
"sketches": [
{
"curves": 4,
"dof": 2,
"id": 1,
"name": "Base",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 4,
"dof": 4,
"id": 3,
"name": "SlotSketch",
"profiles": 1,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"error": null,
"id": 1,
"ms": 0.09895799999999999,
"name": "Base",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 2,
"ms": 20.768292000000002,
"name": "Block",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 3,
"ms": 0.057,
"name": "SlotSketch",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 4,
"ms": 136.28875,
"name": "Slot",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 5,
"ms": 153.58625,
"name": "Round",
"rolled_back": false,
"suppressed": false,
"type": "FilletFeature",
"warning": null
},
{
"error": null,
"id": 6,
"ms": 169.174375,
"name": "Bevel",
"rolled_back": false,
"suppressed": false,
"type": "ChamferFeature",
"warning": null
},
{
"error": null,
"id": 7,
"ms": 47.772166,
"name": "Raise",
"rolled_back": false,
"suppressed": false,
"type": "OffsetFacesFeature",
"warning": null
}
],
"total": {
"area_mm2": 4078.7680390666596,
"edges": 30,
"faces": 12,
"mass_g": 14.435720327850033,
"vertices": 20,
"volume_mm3": 11190.480874302351
}
}
@@ -0,0 +1,373 @@
{
"format": "solvecraft/13",
"name": "Untitled",
"units": "mm",
"params": [],
"features": [
{
"id": 1,
"name": "Base",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
0.0,
0.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
40.0,
0.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
40.0,
30.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
0.0,
30.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
},
{
"id": "k5",
"type": "coincident",
"p": 0,
"q": 1
}
],
"counters": {
"k": 5,
"l": 4,
"p": 4
}
}
},
{
"id": 2,
"name": "Block",
"suppressed": false,
"param_names": [
"d1"
],
"type": "extrude",
"sketch": 1,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive"
},
"operation": "new_body"
},
{
"id": 3,
"name": "SlotSketch",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
15.0,
-5.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
25.0,
-5.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
25.0,
35.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
15.0,
35.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
}
],
"counters": {
"k": 4,
"l": 4,
"p": 4
}
}
},
{
"id": 4,
"name": "Slot",
"suppressed": false,
"param_names": [
"d2",
"d3"
],
"type": "extrude",
"sketch": 3,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive",
"start_offset": "5"
},
"operation": "cut"
},
{
"id": 5,
"name": "Round",
"suppressed": false,
"param_names": [
"d4"
],
"edge_refs": [
{
"dir": [
0.0,
-1.0,
0.0
],
"min": [
0.0,
0.0,
0.0
],
"max": [
40.0,
30.0,
10.0
]
}
],
"edge_names": [
"F2:side:l2|F2:top#2"
],
"type": "fillet",
"edges": [
[
40.0,
15.0,
10.0
]
],
"radius": "2"
},
{
"id": 6,
"name": "Bevel",
"suppressed": false,
"param_names": [
"d5"
],
"edge_refs": [
{
"dir": [
0.0,
1.0,
0.0
],
"min": [
0.0,
0.0,
0.0
],
"max": [
40.0,
30.000000000000007,
10.0
]
}
],
"edge_names": [
"F2:side:l4|F2:top#1"
],
"type": "chamfer",
"edges": [
[
0.0,
15.0,
10.0
]
],
"distance": "1.5"
},
{
"id": 7,
"name": "Raise",
"suppressed": false,
"param_names": [
"d6"
],
"face_names": [
"F2:top#1"
],
"type": "offset_face",
"faces": [
[
7.0,
15.0,
10.0
]
],
"distance": "2"
}
],
"next_id": 8
}
@@ -0,0 +1,111 @@
{
"commands": [
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "Base"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
0,
0
],
"p1": [
40,
30
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"distance": 10,
"name": "Block"
}
},
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "SlotSketch"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
15,
-5
],
"p1": [
25,
35
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"sketch": "SlotSketch",
"distance": 10,
"start_offset": 5,
"operation": "cut",
"name": "Slot"
}
},
{
"command": "solid.fillet",
"params": {
"edges": [
[
40,
15,
10
]
],
"radius": 2,
"name": "Round"
}
},
{
"command": "solid.chamfer",
"params": {
"edges": [
[
0,
15,
10
]
],
"distance": 1.5,
"name": "Bevel"
}
},
{
"command": "solid.offset_face",
"params": {
"faces": [
[
7,
15,
10
]
],
"distance": 2,
"name": "Raise"
}
}
]
}
@@ -0,0 +1,107 @@
{
"commands": [
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "Base"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
0,
0
],
"p1": [
40,
30
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"distance": 10,
"name": "Block"
}
},
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "SlotSketch"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
-5,
12
],
"p1": [
45,
18
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"sketch": "SlotSketch",
"distance": 10,
"operation": "cut",
"name": "Slot"
}
},
{
"command": "solid.fillet",
"params": {
"edges": [
[
20,
0,
10
]
],
"radius": 2,
"body": "Body1 (1)",
"name": "NearRound"
}
},
{
"command": "solid.chamfer",
"params": {
"edges": [
[
20,
30,
10
]
],
"distance": 1.5,
"body": "Body1",
"name": "FarBevel"
}
},
{
"command": "appearance.assign",
"params": {
"bodies": [
"Body1 (1)"
],
"color": "#cc3333"
}
}
]
}
@@ -0,0 +1,171 @@
{
"bodies": [
{
"area_mm2": 1962.6028137423857,
"bbox": {
"max": [
40.0,
30.0,
10.0
],
"min": [
0.0,
18.0,
0.0
]
},
"center_of_mass": [
20.0,
23.94794952681388,
4.957413249211356
],
"density_g_cm3": 1.29,
"edges": 15,
"face_types": {
"plane": 7
},
"faces": 7,
"kernel": {
"edges": 15,
"faces": 7,
"shells": 1,
"vertices": 10
},
"mass_g": 6.13395,
"material": "Default",
"name": "Body1",
"vertices": 10,
"volume_mm3": 4755.0
},
{
"area_mm2": 1963.9401350108699,
"bbox": {
"max": [
40.00000000000002,
12.000000000000002,
10.0
],
"min": [
-8.881784197001252e-15,
0.0,
0.0
]
},
"center_of_mass": [
20.00000000000079,
6.040035701025577,
4.967173993940324
],
"density_g_cm3": 1.29,
"edges": 15,
"face_types": {
"cylinder": 1,
"plane": 6
},
"faces": 7,
"kernel": {
"edges": 15,
"faces": 7,
"shells": 1,
"vertices": 10
},
"mass_g": 6.147677129601969,
"material": "Default",
"name": "Body1 (1)",
"vertices": 10,
"volume_mm3": 4765.64118573796
}
],
"body_count": 2,
"params": [],
"sketches": [
{
"curves": 4,
"dof": 2,
"id": 1,
"name": "Base",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 4,
"dof": 4,
"id": 3,
"name": "SlotSketch",
"profiles": 1,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"error": null,
"id": 1,
"ms": 0.07066700000000001,
"name": "Base",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 2,
"ms": 0.7462909999999999,
"name": "Block",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 3,
"ms": 0.035125,
"name": "SlotSketch",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 4,
"ms": 9.647208000000001,
"name": "Slot",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 5,
"ms": 3.443708,
"name": "NearRound",
"rolled_back": false,
"suppressed": false,
"type": "FilletFeature",
"warning": null
},
{
"error": null,
"id": 6,
"ms": 3.947834,
"name": "FarBevel",
"rolled_back": false,
"suppressed": false,
"type": "ChamferFeature",
"warning": null
}
],
"total": {
"area_mm2": 3926.5429487532556,
"edges": 30,
"faces": 14,
"mass_g": 12.281627129601969,
"vertices": 20,
"volume_mm3": 9520.64118573796
}
}
@@ -0,0 +1,366 @@
{
"format": "solvecraft/13",
"name": "Untitled",
"units": "mm",
"params": [],
"features": [
{
"id": 1,
"name": "Base",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
0.0,
0.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
40.0,
0.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
40.0,
30.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
0.0,
30.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
},
{
"id": "k5",
"type": "coincident",
"p": 0,
"q": 1
}
],
"counters": {
"k": 5,
"l": 4,
"p": 4
}
}
},
{
"id": 2,
"name": "Block",
"suppressed": false,
"param_names": [
"d1"
],
"type": "extrude",
"sketch": 1,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive"
},
"operation": "new_body"
},
{
"id": 3,
"name": "SlotSketch",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
-5.0,
12.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
45.0,
12.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
45.0,
18.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
-5.0,
18.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
}
],
"counters": {
"k": 4,
"l": 4,
"p": 4
}
}
},
{
"id": 4,
"name": "Slot",
"suppressed": false,
"param_names": [
"d2"
],
"type": "extrude",
"sketch": 3,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive"
},
"operation": "cut"
},
{
"id": 5,
"name": "NearRound",
"suppressed": false,
"param_names": [
"d3"
],
"edge_refs": [
{
"dir": [
-1.0,
0.0,
0.0
],
"min": [
0.0,
0.0,
0.0
],
"max": [
40.0,
12.000000000000002,
10.0
]
}
],
"edge_names": [
"F2:side:l1|F2:top"
],
"type": "fillet",
"edges": [
[
20.0,
0.0,
10.0
]
],
"radius": "2",
"body": "Body1 (1)"
},
{
"id": 6,
"name": "FarBevel",
"suppressed": false,
"param_names": [
"d4"
],
"edge_refs": [
{
"dir": [
1.0,
0.0,
0.0
],
"min": [
0.0,
18.0,
0.0
],
"max": [
40.0,
30.0,
10.0
]
}
],
"edge_names": [
"F2:side:l3|F2:top"
],
"type": "chamfer",
"edges": [
[
20.0,
30.0,
10.0
]
],
"distance": "1.5",
"body": "Body1"
}
],
"next_id": 7,
"appearances": {
"bodies": {
"Body1 (1)": {
"name": "Custom",
"color": [
204,
51,
51
],
"opacity": 1.0
}
}
}
}
@@ -90,6 +90,44 @@ fn old_designs_open_and_evaluate_as_saved() {
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
/// Names saved before format 14 were worked out from geometry, pieces of a split face numbered by
/// where their centres lay (`F2:top#2`). They keep naming the faces they named: marked as such,
/// they are found as the build that saved them found them, without new notes.
#[test]
fn names_saved_before_exact_history_find_the_faces_they_named() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/naming-split-pieces.solvecraft");
let s = open(&path);
let feature = |name: &str| s.doc.features.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name}"));
assert_eq!(feature("Round").edge_names, vec!["?F2:side:l2|F2:top#2".to_string()]);
assert_eq!(feature("Bevel").edge_names, vec!["?F2:side:l4|F2:top#1".to_string()]);
assert_eq!(feature("Raise").face_names, vec!["?F2:top#1".to_string()]);
for r in &s.model.results {
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
}
// The raised piece is the left one, as saved (the stored point is on it).
let state = s.model.state();
let solid = |p: [f64; 3]| state.bodies.iter().any(|b| b.body.contains(solvecraft_engine::geom::Vec3::new(p[0], p[1], p[2])));
assert!(solid([7.0, 15.0, 11.0]) && !solid([32.0, 15.0, 11.0]));
}
/// Body names saved before format 14 name the pieces of a split body in the kernel's shell order.
/// The features after the split that refer to a piece by its body name still find that piece:
/// the far one stays `Body1` (chamfered), the near one `Body1 (1)` (filleted, coloured).
#[test]
fn body_names_saved_before_exact_history_keep_their_pieces() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/split-body-pieces.solvecraft");
let s = open(&path);
assert!(s.doc.features.iter().all(|f| f.shell_piece_order), "every saved feature keeps the shell order");
for r in &s.model.results {
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
}
let state = s.model.state();
let piece = |name: &str| state.bodies.iter().find(|b| b.name == name).unwrap_or_else(|| panic!("{name}"));
let solid = |name: &str, p: [f64; 3]| piece(name).body.contains(solvecraft_engine::geom::Vec3::new(p[0], p[1], p[2]));
assert!(solid("Body1", [20.0, 24.0, 5.0]) && solid("Body1 (1)", [20.0, 6.0, 5.0]));
assert!(s.doc.appearances.bodies.contains_key("Body1 (1)"));
}
/// A small deterministic generator (xorshift).
struct Rng(u64);
impl Rng {
+18
View File
@@ -0,0 +1,18 @@
//! `mcp --connect` to a port where no app listens tells the user how to start one (#270).
use std::net::TcpListener;
use std::process::{Command, Stdio};
#[test]
fn mcp_connect_without_a_listening_app_names_the_control_flag() {
// A port that was free a moment ago: nothing listens on it.
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
.args(["mcp", "--connect", &format!("127.0.0.1:{port}")])
.stdin(Stdio::null())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "{stderr}");
assert!(stderr.contains("--control"), "say how to start the app with a control port: {stderr}");
}
+1 -1
View File
@@ -29,5 +29,5 @@ web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"BeforeUnloadEvent", "Event", "EventTarget",
"BeforeUnloadEvent", "Event", "EventTarget", "Response",
] }
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+2
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>SolidCraft</title>
<link data-trunk rel="rust" data-bin="solvecraft-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" />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e8ebf0; }
#solvecraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
+59
View File
@@ -46,6 +46,10 @@ mod web {
on_unload.forget();
}
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
struct Shell {
app: SolveApp,
files: Rc<Files>,
@@ -58,6 +62,9 @@ mod web {
rename_focus: bool,
/// The start page's recent designs were filled from the stored ones.
recents_synced: bool,
/// On-demand CJK font: fetched only when a CJK language is selected.
font: solvecraft_ui_egui::fonts::CjkFontLoader,
font_result: FontLoadResult,
}
impl Shell {
@@ -304,10 +311,42 @@ mod web {
}
files::autosave(&self.files, None);
}
/// Fetch and install the CJK font when a CJK language is selected. If that fails, this
/// session falls back to English (no tofu); the saved language preference is kept.
fn tick_font(&mut self, ctx: &egui::Context) {
let done = self.font_result.borrow_mut().take();
if let Some(res) = done {
let installed = match res {
Ok(bytes) => solvecraft_ui_egui::fonts::install_cjk_font(ctx, bytes),
Err(err) => {
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
false
}
};
if installed {
log::info!("loaded web CJK font from {JAPANESE_FONT_PATH}");
} else {
log::warn!("CJK font unavailable; showing English for this session");
}
self.font.finished(installed, &mut self.app.preferences.language);
ctx.request_repaint();
}
if self.font.tick(&mut self.app.preferences.language) {
let slot = self.font_result.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let res = fetch_font_bytes(JAPANESE_FONT_PATH).await;
*slot.borrow_mut() = Some(res);
ctx.request_repaint();
});
}
}
}
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.tick_font(ctx);
self.app.logic(ctx);
self.files_tick(ctx);
UNSAVED.with(|u| u.set(solvecraft_ui_egui::documents::any_dirty(&self.app)));
@@ -323,7 +362,11 @@ mod web {
self.recovery_prompt(&ctx);
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
// Persist the user's own language, not a session-only English fallback.
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
let live = std::mem::replace(&mut self.app.preferences.language, saved);
storage.set_string(PREFS_KEY, self.app.prefs());
self.app.preferences.language = live;
}
}
@@ -334,6 +377,19 @@ mod web {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// Fetch font bytes asynchronously through the browser.
async fn fetch_font_bytes(url: &str) -> Result<Vec<u8>, String> {
let window = web_sys::window().ok_or("no window")?;
let resp_val = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url)).await.map_err(|e| format!("{e:?}"))?;
let resp: web_sys::Response = resp_val.dyn_into().map_err(|_| "response cast failed")?;
if !resp.ok() {
return Err(format!("HTTP {}", resp.status()));
}
let ab_val = wasm_bindgen_futures::JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?).await.map_err(|e| format!("{e:?}"))?;
let uint8_arr = js_sys::Uint8Array::new(&ab_val);
Ok(uint8_arr.to_vec())
}
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
@@ -355,6 +411,7 @@ mod web {
canvas,
options,
Box::new(move |cc| {
solvecraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
let files = Rc::new(Files::default());
files::start(&files);
let panel = files.clone();
@@ -398,6 +455,8 @@ mod web {
renaming: None,
rename_focus: false,
recents_synced: false,
font: Default::default(),
font_result: Rc::new(std::cell::RefCell::new(None)),
}))
}),
)
+4
View File
@@ -21,6 +21,10 @@ rfd = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
quick-xml = { workspace = true }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[build-dependencies]
embed-resource = "3.0.11"
+61
View File
@@ -0,0 +1,61 @@
#![forbid(unsafe_code)]
#[path = "../app_icon.rs"]
mod app_icon;
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed=../app_icon.rs");
println!("cargo:rerun-if-changed=build.rs");
if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" {
return Ok(());
}
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").ok_or("missing OUT_DIR")?);
let sizes = [16usize, 24, 32, 48, 64, 128, 256];
let mut images = Vec::new();
for size in sizes {
let rgba = app_icon::rgba(size)?;
// ICO DIB: bottom-up BGRA plus a padded one-bit transparency mask.
let mask_stride = size.div_ceil(32) * 4;
let mut dib = Vec::new();
dib.extend_from_slice(&40u32.to_le_bytes());
dib.extend_from_slice(&(size as i32).to_le_bytes());
dib.extend_from_slice(&((size * 2) as i32).to_le_bytes());
dib.extend_from_slice(&1u16.to_le_bytes());
dib.extend_from_slice(&32u16.to_le_bytes());
dib.extend_from_slice(&[0; 24]);
for row in rgba.chunks_exact(size * 4).rev() {
for p in row.as_chunks::<4>().0.iter() {
dib.extend_from_slice(&[p[2], p[1], p[0], p[3]]);
}
}
for row in rgba.chunks_exact(size * 4).rev() {
let mut mask = vec![0u8; mask_stride];
for (x, p) in row.as_chunks::<4>().0.iter().enumerate() {
if p[3] == 0 {
mask[x / 8] |= 0x80 >> (x % 8);
}
}
dib.extend_from_slice(&mask);
}
images.push((size, dib));
}
let mut ico = vec![0, 0, 1, 0];
ico.extend_from_slice(&(images.len() as u16).to_le_bytes());
let mut offset = 6 + 16 * images.len();
for (size, data) in &images {
let dim = if *size == 256 { 0 } else { *size as u8 };
ico.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]);
ico.extend_from_slice(&(data.len() as u32).to_le_bytes());
ico.extend_from_slice(&(offset as u32).to_le_bytes());
offset += data.len();
}
for (_, data) in images {
ico.extend_from_slice(&data);
}
let icon = out.join("solidcraft.ico");
std::fs::write(&icon, ico)?;
let rc = out.join("solidcraft.rc");
std::fs::write(&rc, format!("1 ICON \"{}\"\n", icon.display().to_string().replace('\\', "/")))?;
// Missing resource tools are a build error, rather than silently shipping a generic icon.
embed_resource::compile_for(&rc, ["solvecraft"], embed_resource::NONE).manifest_required()?;
Ok(())
}
+94 -15
View File
@@ -12,6 +12,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[path = "../../app_icon.rs"]
mod app_icon;
mod control_server;
#[cfg(target_os = "windows")]
mod space_mouse;
@@ -124,6 +126,24 @@ const DEPTH_BITS: u8 = 24;
const STENCIL_BITS: u8 = 8;
const MSAA: u16 = 4;
fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
Ok(egui::ViewportBuilder::default()
.with_title("SolidCraft")
.with_icon(egui::IconData {
rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?,
width: 64,
height: 64,
})
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_decorations(!CUSTOM_TITLEBAR)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id("ai.storyteller.solvecraft"))
}
fn main() -> eframe::Result {
let samples = msaa();
let mut control_port: Option<u16> = std::env::var("SOLVECRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
@@ -146,16 +166,7 @@ fn main() -> eframe::Result {
}
}
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("SolidCraft")
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_decorations(!CUSTOM_TITLEBAR)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id("ai.storyteller.solvecraft"),
viewport: app_viewport()?,
depth_buffer: DEPTH_BITS,
stencil_buffer: STENCIL_BITS,
multisampling: samples,
@@ -164,10 +175,12 @@ fn main() -> eframe::Result {
#[cfg(all(unix, not(target_os = "macos")))]
let mut options = options;
#[cfg(all(unix, not(target_os = "macos")))]
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; SOLVECRAFT_WAYLAND=1
// keeps the native Wayland backend.
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("SOLVECRAFT_WAYLAND").is_none() {
if prefer_x11(
std::env::var_os("DISPLAY").is_some(),
std::env::var_os("WAYLAND_DISPLAY").is_some(),
std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref(),
std::env::var("SOLVECRAFT_WAYLAND").ok().as_deref(),
) {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
@@ -185,7 +198,8 @@ fn main() -> eframe::Result {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, u32::from(samples));
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
@@ -223,3 +237,68 @@ fn main() -> eframe::Result {
}),
)
}
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
/// on Wayland (only on X11), so dropping files from the file manager showed a "no" cursor: use
/// XWayland when it's there. Except on niri, whose XWayland (xwayland-satellite) breaks window
/// management (maximize/restore), so it stays on native Wayland and gives up file drops.
/// `SOLVECRAFT_WAYLAND=1` keeps native Wayland, `SOLVECRAFT_WAYLAND=0` forces XWayland.
#[cfg(all(unix, not(target_os = "macos")))]
fn prefer_x11(display: bool, wayland_display: bool, desktop: Option<&str>, wayland_override: Option<&str>) -> bool {
match wayland_override.map(str::trim) {
Some("0") => return display,
Some(_) => return false,
None => {}
}
let niri = desktop.is_some_and(|d| d.split(':').any(|name| name.trim().eq_ignore_ascii_case("niri")));
display && !(niri && wayland_display)
}
#[cfg(all(test, unix, not(target_os = "macos")))]
mod backend_tests {
use super::prefer_x11;
#[test]
fn xwayland_by_default_native_wayland_on_niri_and_the_override_wins() {
// XWayland whenever there's an X display, for file drag-and-drop.
assert!(prefer_x11(true, true, Some("GNOME"), None));
assert!(prefer_x11(true, false, None, None));
assert!(!prefer_x11(false, true, Some("KDE"), None));
// niri (alone or in a desktop list) under Wayland stays native.
assert!(!prefer_x11(true, true, Some("niri"), None));
assert!(!prefer_x11(true, true, Some("niri:GNOME"), None));
// niri without a Wayland socket (nested X session) still uses X11.
assert!(prefer_x11(true, false, Some("niri"), None));
// SOLVECRAFT_WAYLAND: anything but 0 keeps Wayland, 0 forces XWayland (when present).
assert!(!prefer_x11(true, true, Some("GNOME"), Some("1")));
assert!(!prefer_x11(true, true, Some("GNOME"), Some("")));
assert!(prefer_x11(true, true, Some("niri"), Some("0")));
assert!(!prefer_x11(false, true, Some("niri"), Some("0")));
}
}
#[cfg(test)]
mod icon_tests {
#[test]
fn desktop_viewport_supplies_the_original_mark_to_the_window() {
let viewport = super::app_viewport().expect("viewport");
let icon = viewport.icon.expect("running window must have an icon");
assert_eq!((icon.width, icon.height), (64, 64));
assert_eq!(icon.rgba, super::app_icon::rgba(64).expect("original mark"));
assert_eq!(viewport.app_id.as_deref(), Some("ai.storyteller.solvecraft"));
}
}
#[cfg(test)]
mod msaa_tests {
#[test]
fn saved_multisampling_uses_the_selected_backend_policy() {
use eframe::wgpu::Backend;
for requested in [1, 2, 4, 8] {
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(Backend::Gl, requested), 1);
for backend in [Backend::Vulkan, Backend::Dx12, Backend::Metal] {
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(backend, requested), requested);
}
}
}
}
+292 -9
View File
@@ -6,11 +6,14 @@ use std::time::{Duration, Instant};
use egui::Context;
use hidapi::{DeviceInfo, HidApi};
use quick_xml::Reader;
use quick_xml::events::Event;
use solvecraft_engine::render::Camera;
use solvecraft_ui_egui::SolveApp;
const STALE_AFTER: Duration = Duration::from_millis(150);
const RETRY_AFTER: Duration = Duration::from_secs(2);
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Axes {
@@ -18,6 +21,172 @@ struct Axes {
rotation: [i16; 3],
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct AxisRule {
output: usize,
enabled: bool,
reversed: bool,
scale: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct DriverMapping {
axes: [AxisRule; 6],
overall_scale: f64,
}
impl Default for DriverMapping {
fn default() -> Self {
Self { axes: std::array::from_fn(|output| AxisRule { output, enabled: true, reversed: false, scale: 1.0 }), overall_scale: 1.0 }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Motion {
translation: [f64; 3],
rotation: [f64; 3],
}
impl DriverMapping {
fn apply(self, axes: Axes) -> Motion {
let raw = [
normalized(axes.translation[0]),
normalized(axes.translation[1]),
normalized(axes.translation[2]),
normalized(axes.rotation[0]),
normalized(axes.rotation[1]),
normalized(axes.rotation[2]),
];
let mut logical = [0.0; 6];
for (input, rule) in self.axes.iter().enumerate() {
if rule.enabled {
let sign = if rule.reversed { -1.0 } else { 1.0 };
logical[rule.output] = raw[input] * sign * rule.scale * self.overall_scale;
}
}
Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] }
}
}
#[derive(Clone, Copy, Debug, Default)]
struct ProfileAxis {
input: Option<usize>,
output: Option<usize>,
enabled: bool,
reversed: bool,
scale: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum ProfilePart {
Input,
Output,
}
fn axis_index(name: &str) -> Option<usize> {
match name.trim() {
"HIDMultiAxis_X" => Some(0),
"HIDMultiAxis_Y" => Some(1),
"HIDMultiAxis_Z" => Some(2),
"HIDMultiAxis_Rx" => Some(3),
"HIDMultiAxis_Ry" => Some(4),
"HIDMultiAxis_Rz" => Some(5),
_ => None,
}
}
/// 3DxWare stores the active KMJ application's axis selection, swapping, inversion and speed in
/// `%APPDATA%/3Dconnexion/3DxWare/Cfg/<app>-KMJ.xml`. Direct HID reads bypass the driver, so apply
/// that profile explicitly when it exists. Unknown or incomplete fields retain the raw HID axis.
fn parse_driver_mapping(xml: &[u8]) -> Option<DriverMapping> {
let mut reader = Reader::from_reader(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut mapping = DriverMapping::default();
let mut axis: Option<ProfileAxis> = None;
let mut part = None;
let mut field = Vec::new();
let mut found = false;
loop {
match reader.read_event_into(&mut buf).ok()? {
Event::Start(start) => match start.local_name().as_ref() {
b"Axis" => axis = Some(ProfileAxis { enabled: true, scale: 1.0, ..Default::default() }),
b"Input" => part = Some(ProfilePart::Input),
b"Output" => part = Some(ProfilePart::Output),
name @ (b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale") => field = name.to_vec(),
_ => {}
},
Event::Text(text) => {
let value = String::from_utf8_lossy(text.as_ref());
match field.as_slice() {
b"ActionID" => {
if let Some(current) = axis.as_mut() {
match part {
Some(ProfilePart::Input) => current.input = axis_index(&value),
Some(ProfilePart::Output) => current.output = axis_index(&value),
None => {}
}
}
}
b"Enabled" => {
if let Some(current) = axis.as_mut() {
current.enabled = value.trim().eq_ignore_ascii_case("true");
}
}
b"Reversed" => {
if part == Some(ProfilePart::Output)
&& let Some(current) = axis.as_mut()
{
current.reversed = value.trim().eq_ignore_ascii_case("true");
}
}
b"Scale" => {
if part == Some(ProfilePart::Output)
&& let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite())
&& let Some(current) = axis.as_mut()
{
current.scale = scale.clamp(0.0, 10.0);
}
}
b"OverallScale" => {
if let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite()) {
mapping.overall_scale = scale.clamp(0.0, 10.0);
}
}
_ => {}
}
}
Event::End(end) => match end.local_name().as_ref() {
b"Axis" => {
if let Some(current) = axis.take()
&& let (Some(input), Some(output)) = (current.input, current.output)
{
mapping.axes[input] = AxisRule { output, enabled: current.enabled, reversed: current.reversed, scale: current.scale };
found = true;
}
}
b"Input" | b"Output" => part = None,
b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale" => field.clear(),
_ => {}
},
Event::Eof => break,
_ => {}
}
buf.clear();
}
found.then_some(mapping)
}
fn load_driver_mapping() -> Option<DriverMapping> {
let profile = std::path::PathBuf::from(std::env::var_os("APPDATA")?).join("3Dconnexion").join("3DxWare").join("Cfg").join("solvecraft-KMJ.xml");
if std::fs::metadata(&profile).ok()?.len() > MAX_PROFILE_BYTES {
log::warn!("3D mouse: ignoring oversized 3DxWare profile {}", profile.display());
return None;
}
let xml = std::fs::read(&profile).ok()?;
parse_driver_mapping(&xml)
}
#[derive(Clone, Copy)]
struct Reading {
axes: Axes,
@@ -107,6 +276,8 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
pub struct SpaceMouse {
shared: Arc<Mutex<Reading>>,
last_frame: Instant,
driver_mapping: DriverMapping,
mapping_checked_at: Instant,
}
impl SpaceMouse {
@@ -118,13 +289,21 @@ impl SpaceMouse {
if let Err(error) = std::thread::Builder::new().name("solvecraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
log::warn!("3D mouse: reader could not start: {error}");
}
Self { shared, last_frame: now }
Self { shared, last_frame: now, driver_mapping: load_driver_mapping().unwrap_or_default(), mapping_checked_at: now }
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
let now = Instant::now();
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
self.last_frame = now;
if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
let updated = load_driver_mapping().unwrap_or_default();
if updated != self.driver_mapping {
log::info!("3D mouse: applying updated 3DxWare axis profile");
self.driver_mapping = updated;
}
self.mapping_checked_at = now;
}
if !app.preferences.space_mouse_enabled || app.home.open {
return;
}
@@ -141,7 +320,14 @@ impl SpaceMouse {
}
app.begin_external_navigation();
let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0)));
navigate(&mut app.cam, axes, seconds * speed.clamp(0.1, 5.0), height, app.session.active_sketch.is_some());
navigate(
&mut app.cam,
self.driver_mapping.apply(axes),
seconds * speed.clamp(0.1, 5.0),
height,
app.session.active_sketch.is_some(),
app.preferences.zoom_reverse,
);
ctx.request_repaint_after(Duration::from_millis(16));
}
}
@@ -153,15 +339,21 @@ fn normalized(value: i16) -> f64 {
if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) }
}
fn navigate(cam: &mut Camera, axes: Axes, seconds: f64, height: f64, sketch: bool) {
fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
if !(seconds > 0.0) || !seconds.is_finite() {
return;
}
let [x, y, z] = axes.translation.map(normalized);
let [rx, ry, rz] = axes.rotation.map(normalized);
cam.pan(x * height * 0.6 * seconds, -y * height * 0.6 * seconds, height);
cam.zoom_at((-z * 1.5 * seconds).exp(), None);
let [x, y, z] = motion.translation;
let [rx, ry, rz] = motion.rotation;
// Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
// profile above can swap the physical forward/back and up/down axes before they reach here.
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None);
if !sketch {
// These are logical 3DxWare coordinates after the profile mapping above: Rx is Tilt,
// Ry is Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected
// navigation mode, so swapping them again here would make Roll behave like Spin.
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
cam.roll_by(rz * 2.0 * seconds);
}
@@ -171,6 +363,10 @@ fn navigate(cam: &mut Camera, axes: Axes, seconds: f64, height: f64, sketch: boo
mod tests {
use super::*;
fn raw(axes: Axes) -> Motion {
DriverMapping::default().apply(axes)
}
#[test]
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
let now = Instant::now();
@@ -192,15 +388,102 @@ mod tests {
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
let mut cam = Camera::default();
let before = cam;
navigate(&mut cam, Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }, 0.02, 600.0, false);
navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, false);
assert_ne!(cam.target, before.target);
assert!(cam.distance < before.distance);
assert_ne!(cam.yaw, before.yaw);
assert_ne!(cam.pitch, before.pitch);
assert_ne!(cam.roll, before.roll);
let orientation = (cam.yaw, cam.pitch, cam.roll);
navigate(&mut cam, Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }, 0.02, 600.0, true);
navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false);
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
assert!(cam.is_valid());
}
#[test]
fn individual_axes_have_physical_spacemouse_meaning() {
let before = Camera::default();
let mut slide = before;
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_ne!(slide.target, before.target);
assert_eq!(slide.distance, before.distance);
let mut push = before;
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_eq!(push.target, before.target);
assert!(push.distance < before.distance);
let mut lift = before;
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_ne!(lift.target, before.target);
assert_eq!(lift.distance, before.distance);
let mut tilt = before;
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false);
assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll));
assert_ne!(tilt.pitch, before.pitch);
let mut roll = before;
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll));
assert_ne!(roll.yaw, before.yaw);
let mut twist = before;
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch));
assert_ne!(twist.roll, before.roll);
}
#[test]
fn reverse_zoom_setting_applies_to_spacemouse() {
let before = Camera::default();
let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] });
let mut normal = before;
navigate(&mut normal, motion, 0.02, 600.0, false, false);
let mut reversed = before;
navigate(&mut reversed, motion, 0.02, 600.0, false, true);
assert!(normal.distance < before.distance);
assert!(reversed.distance > before.distance);
}
#[test]
fn applies_3dxware_axis_swaps_and_reversals() {
let profile = br#"
<AppCfg><Devices><Device><AxisBank>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_X</ActionID></Input><Output><ActionID>HIDMultiAxis_X</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Y</ActionID></Input><Output><ActionID>HIDMultiAxis_Z</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Z</ActionID></Input><Output><ActionID>HIDMultiAxis_Y</ActionID><Reversed>true</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rx</ActionID></Input><Output><ActionID>HIDMultiAxis_Rx</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
</AxisBank></Device></Devices></AppCfg>
"#;
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
let motion = mapping.apply(Axes { translation: [350, 0, 350], rotation: [350, 0, 350] });
assert_eq!(motion.translation, [1.0, -1.0, 0.0]);
assert_eq!(motion.rotation, [1.0, -1.0, 0.0]);
}
#[test]
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
let profile = br#"
<AppCfg><Devices><Device><AxisBank>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
</AxisBank></Device></Devices></AppCfg>
"#;
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
let before = Camera::default();
let mut physical_roll = before;
navigate(&mut physical_roll, mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
assert_eq!((physical_roll.yaw, physical_roll.pitch), (before.yaw, before.pitch));
assert_ne!(physical_roll.roll, before.roll);
let mut physical_spin = before;
navigate(&mut physical_spin, mapping.apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
assert_ne!(physical_spin.yaw, before.yaw);
assert_eq!((physical_spin.pitch, physical_spin.roll), (before.pitch, before.roll));
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anc
body.source_name = raw.source_name.clone();
body.source_transform = crate::mat_mul(&relative, &raw.source_transform);
body.imported = raw.imported.clone();
body.names = crate::naming::NameCell::known(crate::naming::face_names(raw));
body.names = crate::naming::NameCell::same(raw);
let slot = output.bodies.iter_mut().find(|body| body.name == raw.name).ok_or_else(|| DocError::Unknown("anchored body frame".into()))?;
*slot = body;
Ok(Cow::Owned(output))
+1 -2
View File
@@ -217,8 +217,7 @@ impl Document {
}
// By name (every piece of a split face), else by the point.
if let Some(n) = &f.name {
let stem = crate::naming::strip_piece(n);
let hits: Vec<usize> = (0..names.len()).filter(|i| names.get(*i).is_some_and(|x| crate::naming::strip_piece(x) == stem)).collect();
let hits = crate::naming::faces_with_stem(b, n);
if !hits.is_empty() {
for i in hits {
out.retain(|(j, _)| *j != i);
+1
View File
@@ -329,6 +329,7 @@ impl FeatureKind {
| FeatureKind::Thicken { .. }
| FeatureKind::Import { .. }
| FeatureKind::MeshImport { .. }
| FeatureKind::MeshConvert { .. }
| FeatureKind::BodyTransfer { .. } => {}
FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
+1 -1
View File
@@ -46,7 +46,7 @@ pub(crate) fn evaluate(doc: &Document, st: &mut ModelState, bodies: &[String], f
body.source_name = old.source_name.clone();
body.source_transform = crate::mat_mul(&transform, &old.source_transform);
body.imported = old.imported.clone();
body.names = crate::naming::NameCell::known(crate::naming::face_names(old));
body.names = crate::naming::NameCell::same(old);
let slot = next.bodies.iter_mut().find(|b| b.name == *name).ok_or_else(|| DocError::Unknown(format!("transferred body `{name}`")))?;
*slot = body;
}
+12 -4
View File
@@ -80,6 +80,10 @@ impl FeatureKind {
/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels.
pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>) -> String {
// An older design's name keeps its mark; what follows it is remapped as any name.
if let Some(old) = crate::naming::legacy_name(name) {
return format!("{}{}", crate::naming::LEGACY_MARK, remap_topology(old, 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:")
@@ -91,7 +95,9 @@ pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<St
let mut at = 0;
while at < name.len() {
let rest = &name[at..];
if rest.starts_with('F') && (at == 0 || matches!(name.as_bytes().get(at - 1), Some(b'@') | Some(b':') | Some(b'|'))) {
// A feature token starts a name, or one inside it: after `@`, `:` or `|`, or in a
// piece's list of the faces around it (`[`, `,`).
if rest.starts_with('F') && (at == 0 || matches!(name.as_bytes().get(at - 1), Some(b'@' | b':' | b'|' | b'[' | b','))) {
let end = rest.as_bytes().iter().skip(1).take_while(|b| b.is_ascii_digit()).count() + 1;
if end > 1
&& matches!(rest.as_bytes().get(end), Some(b':') | Some(b'.'))
@@ -356,9 +362,11 @@ impl crate::Feature {
mapped_name(n, bodies);
}
}
K::Split { body, .. } | K::SheetConvert { body, .. } | K::SurfaceExtend { body, .. } | K::SurfaceTrim { body, .. } => {
mapped_name(body, bodies)
}
K::Split { body, .. }
| K::SheetConvert { body, .. }
| K::SurfaceExtend { body, .. }
| K::SurfaceTrim { body, .. }
| K::MeshConvert { body, .. } => mapped_name(body, bodies),
_ => {}
}
match &mut self.kind {
+116 -29
View File
@@ -68,12 +68,12 @@ pub(crate) fn cap_key(outer: &[String], holes: &[Vec<String>]) -> String {
}
format!("{:x}", hash.finalize())
}
pub(crate) fn exact_region_signature(sketch: &SolvedSketch, region: &solvecraft_geom::Region2) -> Option<solvecraft_kernel::CapSignature> {
fn exact_region_signature(curves: &ExactSketchCurves, region: &solvecraft_geom::Region2) -> Option<solvecraft_kernel::CapSignature> {
if region.outer.segs.len() + region.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() > 4096 || region.holes.len() > 128 {
return None;
}
let signature = |outline: &solvecraft_geom::Loop2| {
let mut ids = outline.segs.iter().map(|segment| exact_sketch_curve(sketch, segment)).collect::<Option<Vec<_>>>()?;
let mut ids = outline.segs.iter().map(|segment| curves.find(segment)).collect::<Option<Vec<_>>>()?;
ids.sort();
ids.dedup();
Some(ids)
@@ -112,6 +112,7 @@ pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solvecraft_k
}
}
}
let curves = ExactSketchCurves::new(sketch);
let mut counts = std::collections::BTreeMap::new();
let mut bindings = Vec::with_capacity(bodies.len());
for body in bodies {
@@ -121,7 +122,7 @@ pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solvecraft_k
let signature = if let Some(FaceSource::ExtrudeCap { region, high, .. }) = source {
let signature = match &cached {
Some((previous, signature)) if *previous == region => signature.clone(),
_ => exact_region_signature(sketch, region),
_ => exact_region_signature(&curves, region),
};
cached = Some((region, signature.clone()));
if let Some((outer, holes)) = &signature {
@@ -307,36 +308,55 @@ pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solvec
/// Match a generated segment to one exact analytic sketch curve. Coincident
/// candidates are ambiguous and never resolved by distance or candidate order.
pub(crate) fn exact_sketch_curve(sketch: &SolvedSketch, generated: &Seg2) -> Option<String> {
if sketch.sketch.curves.len() > 2000 {
return None;
}
let mut found = None;
for (index, curve) in sketch.sketch.curves.iter().enumerate() {
if curve.construction
|| !matches!(
curve.kind,
solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Arc { .. } | solvecraft_sketch::CurveKind::Circle { .. }
)
{
continue;
pub(crate) struct ExactSketchCurves<'a> {
segments: Vec<(&'a str, Vec<Seg2>)>,
}
impl<'a> ExactSketchCurves<'a> {
/// Prepare only bounded analytic curves, once per certification pass. Point lookup
/// and segment allocation must not be repeated for every generated face.
pub(crate) fn new(sketch: &'a SolvedSketch) -> Self {
let mut segments = Vec::new();
if sketch.sketch.curves.len() > 2000 {
return Self { segments };
}
let matches = if let solvecraft_sketch::CurveKind::Circle { c, r } = curve.kind {
sketch
.sketch
.point(c)
.is_some_and(|center| exact_segment_contains(&Seg2::Arc { center, radius: r, start: 0., sweep: std::f64::consts::TAU }, generated))
} else {
sketch.sketch.segs(index).iter().any(|original| exact_segment_contains(original, generated))
};
if matches {
if found.is_some() {
return None;
for (index, curve) in sketch.sketch.curves.iter().enumerate() {
if curve.construction
|| !matches!(
curve.kind,
solvecraft_sketch::CurveKind::Line { .. }
| solvecraft_sketch::CurveKind::Arc { .. }
| solvecraft_sketch::CurveKind::Circle { .. }
)
{
continue;
}
found = Some(curve.id.clone());
let originals = if let solvecraft_sketch::CurveKind::Circle { c, r } = curve.kind {
sketch
.sketch
.point(c)
.map(|center| vec![Seg2::Arc { center, radius: r, start: 0., sweep: std::f64::consts::TAU }])
.unwrap_or_default()
} else {
sketch.sketch.segs(index)
};
segments.push((curve.id.as_str(), originals));
}
Self { segments }
}
pub(crate) fn find(&self, generated: &Seg2) -> Option<String> {
let mut found = None;
for (id, originals) in &self.segments {
if originals.iter().any(|original| exact_segment_contains(original, generated)) {
if found.is_some() {
return None;
}
found = Some(*id);
}
}
found.map(str::to_owned)
}
found
}
fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool {
@@ -486,6 +506,73 @@ mod shell_tests;
mod tests {
use super::*;
use serde_json::json;
fn solved(mut sketch: solvecraft_sketch::Sketch) -> SolvedSketch {
let report = solvecraft_sketch::solve(&mut sketch);
SolvedSketch { feature: 1, name: "sources".into(), plane: solvecraft_geom::Plane::XY, sketch, report, profiles: Vec::new() }
}
#[test]
fn prepared_sources_keep_exact_identity_and_reject_ambiguity() {
use solvecraft_geom::Vec2;
let mut sketch = solvecraft_sketch::Sketch::new();
sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("line")).unwrap();
sketch.add_circle(Vec2::new(20., 0.), 2., None, Some("circle")).unwrap();
let construction = sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("construction")).unwrap();
sketch.curves[construction].construction = true;
let sketch = solved(sketch);
let curves = ExactSketchCurves::new(&sketch);
let partial = Seg2::Line { a: Vec2::new(8., 0.), b: Vec2::new(2., 0.) };
assert_eq!(curves.find(&partial).as_deref(), Some("line"));
assert_eq!(curves.find(&Seg2::Arc { center: Vec2::new(20., 0.), radius: 2., start: 1., sweep: -2. }).as_deref(), Some("circle"));
assert_eq!(curves.find(&Seg2::Line { a: Vec2::ZERO, b: Vec2::new(11., 0.) }), None);
let mut ambiguous = sketch.clone();
ambiguous.sketch.add_line(Vec2::new(1., 0.), Vec2::new(9., 0.), None, None, Some("overlap")).unwrap();
assert_eq!(ExactSketchCurves::new(&ambiguous).find(&partial), None);
// A prepared pass borrows one immutable evaluated sketch, so an upstream edit
// necessarily prepares fresh sources rather than retaining stale coordinates.
let mut moved = sketch.clone();
moved.sketch = solvecraft_sketch::Sketch::new();
moved.sketch.add_line(Vec2::new(0., 1.), Vec2::new(10., 1.), None, None, Some("line")).unwrap();
assert_eq!(ExactSketchCurves::new(&moved).find(&partial), None);
}
#[test]
#[ignore = "manual optimized certification microbenchmark; no timing assertion"]
fn benchmark_prepared_extrude_sources() {
use solvecraft_geom::Vec2;
let mut sketch = solvecraft_sketch::Sketch::new();
for i in 0..1000 {
let y = i as f64;
sketch.add_line(Vec2::new(0., y), Vec2::new(10., y), None, None, None).unwrap();
}
let sketch = solved(sketch);
let queries: Vec<_> = (0..1000).map(|i| Seg2::Line { a: Vec2::new(2., i as f64), b: Vec2::new(8., i as f64) }).collect();
let start = std::time::Instant::now();
let old: Vec<_> = queries
.iter()
.map(|generated| {
let mut found = None;
for (index, curve) in sketch.sketch.curves.iter().enumerate() {
if sketch.sketch.segs(index).iter().any(|original| exact_segment_contains(original, generated)) {
if found.is_some() {
return None;
}
found = Some(curve.id.clone());
}
}
found
})
.collect();
let old_ms = start.elapsed().as_secs_f64() * 1000.;
let start = std::time::Instant::now();
let curves = ExactSketchCurves::new(&sketch);
let new: Vec<_> = queries.iter().map(|query| curves.find(query)).collect();
let new_ms = start.elapsed().as_secs_f64() * 1000.;
assert_eq!(old, new);
assert!(new.iter().all(Option::is_some));
eprintln!("1000 analytic sources / 1000 face segments: repeated={old_ms:.3}ms prepared={new_ms:.3}ms (includes preparation)");
}
#[test]
fn certificate_budgets_apply_before_saved_target_and_paint_decode() {
for certificate in [
+130 -6
View File
@@ -146,7 +146,7 @@ fn shell_planar_anchor_rebuild_cold_geometry_and_missing_source_errors() {
assert!((after.max.x - before.max.x - 9.).abs() < 1e-8, "{before:?} -> {after:?}");
assert!((after.min.y - before.min.y).abs() < 1e-8 && (after.min.z - before.min.z).abs() < 1e-8);
let saved = doc.to_json();
assert!(saved.contains("solvecraft/13"));
assert!(saved.contains(&crate::format::format_string(crate::format::FORMAT)));
let loaded = crate::format::read(&saved).unwrap();
let mut cold = Model::new();
cold.evaluate(&loaded);
@@ -176,10 +176,11 @@ fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
legacy["format"] = json!("solvecraft/12");
let loaded = crate::format::read(&legacy.to_string()).unwrap();
let mut current: serde_json::Value = serde_json::from_str(&loaded.to_json()).unwrap();
assert_eq!(current["format"], "solvecraft/13");
assert_eq!(current["format"], crate::format::format_string(crate::format::FORMAT));
current["format"] = json!("solvecraft/12");
assert_eq!(legacy, current, "identity migration must preserve every prior field");
legacy["format"] = json!("solvecraft/14");
crate::format::pieces_in_shell_order(&mut legacy["features"]);
assert_eq!(legacy, current, "the migration preserves every prior field");
legacy["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
assert!(crate::format::read(&legacy.to_string()).is_err());
}
@@ -253,8 +254,10 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
depth: 1,
mats: vec![],
})));
let former = crate::naming::face_name_at_body(&old_body, Vec3::new(20., 6., 1.)).unwrap();
assert_eq!(former, format!("{stem}#1"), "historical actual strip capture records full piece name");
let captured = crate::naming::face_name_at_body(&old_body, Vec3::new(20., 6., 1.)).unwrap();
assert_eq!(crate::naming::parent_piece(&captured), Some(stem.as_str()), "actual strip capture records a piece name");
// What the builds before format 14 saved for that piece: its number by centroid order.
let former = format!("{stem}#1");
let mut sketch = solvecraft_sketch::Sketch::new();
let corners = [Vec2::new(0., 0.), Vec2::new(12., 0.), Vec2::new(12., 2.), Vec2::new(0., 2.)];
let geometry: Vec<_> = (0..4).map(|i| solvecraft_sketch::LinkGeom::Line(corners[i], corners[(i + 1) % 4])).collect();
@@ -457,3 +460,124 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
}
}
}
#[test]
fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
use solvecraft_kernel::{BoolOp, FacePaint, Paint};
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
let mut doc = Document::new("public stepped tab case");
doc.set_param("length", "40", Some("mm"), None).unwrap();
doc.set_param("wall", "2", Some("mm"), None).unwrap();
for (name, kind) in [
("Case", json!({"type":"box","corner":[0,0,0],"length":"length","width":"30","height":"20"})),
("Tab", json!({"type":"box","corner":["length",10,12],"length":"8","width":"10","height":"8"})),
] {
let id = doc.add_feature(serde_json::from_value(kind).unwrap(), Some(name)).unwrap();
doc.feature_mut(id).unwrap().body_names = vec![name.into()];
}
doc.add_feature(
FeatureKind::Combine { target: "Case".into(), tools: vec!["Tab".into()], operation: crate::Operation::Join, keep_tools: false },
None,
)
.unwrap();
let tool = doc
.add_feature(
serde_json::from_value(
json!({"type":"box","corner":["wall","wall","wall"],"length":"length-2*wall","width":"30-2*wall","height":"22-wall"}),
)
.unwrap(),
Some("Pocket tool"),
)
.unwrap();
doc.feature_mut(tool).unwrap().body_names = vec!["Pocket tool".into()];
let cut = doc
.add_feature(
FeatureKind::Combine { target: "Case".into(), tools: vec!["Pocket tool".into()], operation: crate::Operation::Cut, keep_tools: false },
Some("Pocket"),
)
.unwrap();
let mut model = Model::new();
model.evaluate(&doc);
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results);
let before = model.state_before(cut);
// A real per-face paint control preserves the former slab construction;
// no historical names or geometry are fabricated.
let old_stock = before
.body("Case")
.unwrap()
.body
.clone()
.with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
let old_cut = solvecraft_kernel::boolean(&old_stock, &before.body("Pocket tool").unwrap().body, BoolOp::Cut).unwrap().unwrap();
let mut old_body = ModelBody::new("Case".into(), old_cut, cut);
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
feature: doc.feature(cut).unwrap().clone(),
before,
depth: 1,
mats: vec![],
})));
let at = Vec3::new(0., 15., 1.);
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
assert_ne!(former, crate::naming::strip_piece(&former), "actual former bounded wall patch has a full piece name");
let index = crate::naming::face_on_body(&old_body, &former, at).unwrap().0;
let mut old_state = crate::ModelState::default();
old_state.bodies.push(old_body);
let plane = solvecraft_geom::Plane::YZ;
let resolved = crate::project::resolve_face_indices(&old_state, &plane, LinkKind::Project, "Case", &[index]).unwrap();
let current_state = model.state();
let current = current_state.body("Case").unwrap();
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");
assert!(!current_names.contains(&former));
let mut sketch = Sketch::new();
sketch.add_link(LinkKind::Project, LinkSource::Face { body: "Case".into(), at }, &resolved.geom).unwrap();
sketch.links[0].face_name = Some(former);
let original = sketch.clone();
let captured = original.points.iter().filter(|p| p.link.is_some()).map(|p| p.pos).collect::<Vec<_>>();
assert_eq!(captured.len(), 4);
assert!(captured.iter().all(|p| (p.x == 10. || p.x == 20.) && (p.y == 0. || p.y == 20.)));
let sk = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch }, Some("Saved wall patch")).unwrap();
let material = doc
.add_feature(
FeatureKind::Extrude {
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
operation: crate::Operation::NewBody,
targets: vec![],
},
Some("Saved band stock"),
)
.unwrap();
for (length, wall) in [(40., 2.), (50., 3.)] {
doc.set_param("length", &length.to_string(), Some("mm"), None).unwrap();
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
model.evaluate(&doc);
let reopened = crate::format::read(&doc.to_json()).unwrap();
let mut cold = Model::new();
cold.evaluate(&reopened);
for current in [&model, &cold] {
let result = current.results.iter().find(|r| r.id == sk).unwrap();
assert!(result.error.is_none(), "{result:?}");
assert!(result.warning.as_deref().is_some_and(|w| w.contains("no longer exists; last geometry retained")), "{result:?}");
let state = current.state();
let saved = state.sketch(sk).unwrap();
assert!(saved.sketch.links[0].lost);
assert_eq!(saved.sketch.points, original.points);
assert_eq!(saved.sketch.curves, original.curves);
let stock = state.bodies.iter().find(|b| b.feature == material).unwrap();
assert!(
(solvecraft_kernel::measure(&stock.body).unwrap().volume - 200.).abs() < 1e-7,
"saved10x20 patch must not become30x20 wall; actualvolume={}, bounds={:?}, originalpoints={:?}",
solvecraft_kernel::measure(&stock.body).unwrap().volume,
stock.mesh().bounds(),
original.points
);
let case = state.body("Case").unwrap();
let expected = length * 30. * 20. + 8. * 10. * 8. - (length - 2. * wall) * (30. - 2. * wall) * (20. - wall);
assert!((solvecraft_kernel::measure(&case.body).unwrap().volume - expected).abs() < 1e-6);
}
}
}
+8
View File
@@ -202,6 +202,14 @@ fn sketch_world(ctx: &Ctx, sketch: u64, q: Vec2) -> Result<Vec3> {
fn sketch_curve_points(ctx: &Ctx, sketch: u64, curve: &str) -> Result<Vec<Vec3>> {
let ss = ctx.st.sketch(sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
if let Some(wire) = ss.sketch.wires.iter().find(|wire| wire.id == curve) {
if wire.pts.len() < 2 {
return Err(bad(format!("curve `{curve}` has no length")));
}
// Evaluation has already mapped wires into this component's frame. Their coordinates
// are independent of the sketch plane; applying it again would move the path twice.
return Ok(wire.pts.clone());
}
let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("curve `{curve}`")))?;
let pl = ss.sketch.polyline(ci);
if pl.len() < 2 {
+16 -2
View File
@@ -883,8 +883,12 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
components: Vec<solvecraft_kernel::StepNode>,
},
/// A non-parametric base feature: mesh bodies imported from a 3MF or STL file (kept in the
/// design). Mesh bodies render, measure, move and export; solid features cannot use them.
/// Rebuild an upstream constant-section mesh with analytic planar/cylindrical faces.
MeshConvert {
body: String,
tolerance: String,
},
/// Mesh bodies imported from a 3MF or STL file, embedded in the design.
MeshImport {
/// Source file name (display only).
file: String,
@@ -1162,6 +1166,7 @@ impl FeatureKind {
| K::Move { .. }
| K::Import { .. }
| K::MeshImport { .. }
| K::MeshConvert { .. }
| K::BodyTransfer { .. } => Vec::new(),
}
}
@@ -1247,6 +1252,7 @@ impl FeatureKind {
FeatureKind::SurfaceExtend { .. } => "ExtendFeature",
FeatureKind::Import { .. } => "BaseFeature",
FeatureKind::MeshImport { .. } => "MeshFeature",
FeatureKind::MeshConvert { .. } => "MeshConvertFeature",
}
}
/// Default name prefix (`Extrude` → `Extrude1`).
@@ -1312,6 +1318,7 @@ impl FeatureKind {
FeatureKind::SurfaceExtend { .. } => "Extend",
FeatureKind::Import { .. } => "Import",
FeatureKind::MeshImport { .. } => "Mesh",
FeatureKind::MeshConvert { .. } => "ConvertMesh",
}
}
/// Every expression the feature uses.
@@ -1520,6 +1527,7 @@ impl FeatureKind {
FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v),
FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } | FeatureKind::DeleteFace { .. } => {}
FeatureKind::Stitch { tolerance, .. } => v.push(tolerance),
FeatureKind::MeshConvert { tolerance, .. } => v.push(tolerance),
FeatureKind::Thicken { thickness, .. } => v.push(thickness),
FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::SurfaceExtend { distance, .. } => v.push(distance),
@@ -1559,6 +1567,11 @@ pub struct Feature {
/// component's bodies.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub participants: Vec<String>,
/// The pieces this feature splits a body into take its body names in the kernel's shell order,
/// as builds before format 14 named them, instead of after their faces (`lineage::order_lumps`).
/// Set on the features of designs saved before then, whose stored body names mean that order.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub shell_piece_order: bool,
#[serde(flatten)]
pub kind: FeatureKind,
}
@@ -1893,6 +1906,7 @@ impl Document {
edge_names: Vec::new(),
face_names: Vec::new(),
participants: Vec::new(),
shell_piece_order: false,
kind,
};
match self.marker {
+428 -154
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -51,7 +51,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
let (index, _, _) = crate::project::nearest_face(&mb.mesh(), picked)
.ok_or_else(|| DocError::Invalid("selected cylinder face cannot be resolved".into()))?;
if crate::naming::face_names(&mb).get(index as usize) != Some(name) {
if !crate::naming::face_has_name(&mb, index as usize, name) {
return Err(DocError::Invalid("selected cylinder face is ambiguous".into()));
}
at = kernel::cylinder_emboss_anchor(&mb.body, index as usize, picked)?;
+203 -4
View File
@@ -39,13 +39,20 @@
//! - 13: exact planar shell face anchors retain the shell producer, original certified
//! source and distinct inner/outer role. Version 12 anchors retain their identities;
//! migration never guesses attachments for older designs.
//! - 14: persistent names follow the kernel's exact face history. Pieces of a split face are
//! named by the faces around them (`F2:top#[F2:side:l4,F4:side:l4]`) instead of numbered by
//! where their centres lay (`F2:top#2`). Names stored by older formats were worked out from
//! geometry, and a few of them name a face that history names differently; they get the
//! 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.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 13;
pub const FORMAT: u32 = 14;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -58,8 +65,129 @@ 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; 12] =
[|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())];
const JSON_STEPS: [JsonStep; 13] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
v4_to_v5,
v5_to_v6,
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
v13_to_v14,
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
/// legacy mark (`?`, see `naming::LEGACY_MARK`) so they are found again as those builds found
/// them, and an older design evaluates as it was saved. The step marks the fields that store
/// such a name, by their place in the schema:
/// - each feature's `face_names`, and fillets' and chamfers' `edge_names` (other features' edge
/// names are sheet metal's own);
/// - a sketch feature's links' `face_name`;
/// - face and edge references (`PlaneRef::Face`, `GeoRef::Face`, `GeoRef::Edge`: tagged
/// `"type": "face"|"edge"`, picked `at` a point), wherever a feature holds one;
/// - joint snaps on a face (`face_center`, `circle_center`), in joints and named origins;
/// - face appearances, but not strict ones, whose names are certified lineage.
///
/// Every feature also keeps naming the pieces it splits a body into in the kernel's shell order
/// (`Feature::shell_piece_order`): the body names stored after it (bodies, targets, participants,
/// materials, appearances, joints) mean the pieces in that order.
fn v13_to_v14(v: &mut Value) -> Result<()> {
if let Some(Value::Array(features)) = v.get_mut("features") {
for f in features.iter_mut() {
let Value::Object(o) = f else { continue };
let kind = o.get("type").and_then(Value::as_str).map(str::to_string);
if matches!(kind.as_deref(), Some("fillet" | "chamfer")) {
mark_all(o.get_mut("edge_names"));
}
mark_all(o.get_mut("face_names"));
if kind.as_deref() == Some("sketch")
&& let Some(Value::Array(links)) = o.get_mut("sketch").and_then(|s| s.get_mut("links"))
{
links.iter_mut().filter_map(|l| l.get_mut("face_name")).for_each(mark);
}
o.insert("shell_piece_order".into(), Value::Bool(true));
references(f);
}
}
if let Some(Value::Array(joints)) = v.get_mut("assembly").and_then(|a| a.get_mut("joints")) {
for j in joints.iter_mut() {
for side in ["a", "b"] {
snap(j.get_mut(side));
}
}
}
if let Some(Value::Array(origins)) = v.get_mut("assembly").and_then(|a| a.get_mut("origins")) {
origins.iter_mut().for_each(|o| snap(o.get_mut("origin")));
}
// Face appearances carry the face's name without a type tag.
if let Some(Value::Array(faces)) = v.get_mut("appearances").and_then(|a| a.get_mut("faces")) {
for face in faces.iter_mut().filter(|f| f.get("strict_name").and_then(Value::as_bool) != Some(true)) {
if let Some(n) = face.get_mut("name") {
mark(n);
}
}
}
Ok(())
}
/// Mark the `name` of every face or edge reference in `v` (see [`v13_to_v14`]).
fn references(v: &mut Value) {
match v {
Value::Object(o) => {
let reference = matches!(o.get("type").and_then(Value::as_str), Some("face" | "edge")) && o.contains_key("at");
for (key, x) in o.iter_mut() {
if reference && key == "name" {
mark(x);
} else {
references(x);
}
}
}
Value::Array(a) => a.iter_mut().for_each(references),
_ => {}
}
}
/// Mark the face name of a joint origin's snap on a face.
fn snap(origin: Option<&mut Value>) {
if let Some(snap) = origin.and_then(|o| o.get_mut("snap"))
&& matches!(snap.get("type").and_then(Value::as_str), Some("face_center" | "circle_center"))
&& let Some(n) = snap.get_mut("face")
{
mark(n);
}
}
/// Mark every name in a list of stored names.
fn mark_all(names: Option<&mut Value>) {
if let Some(Value::Array(names)) = names {
names.iter_mut().for_each(mark);
}
}
/// `features` (a design's, as JSON) as an upgrade from before format 14 leaves them: each keeps
/// the shell order of the pieces it makes (see [`v13_to_v14`]).
#[cfg(test)]
pub(crate) fn pieces_in_shell_order(features: &mut Value) {
for f in features.as_array_mut().into_iter().flatten().filter_map(Value::as_object_mut) {
f.insert("shell_piece_order".into(), Value::Bool(true));
}
}
/// A stored name with the legacy mark (an empty name stays empty: no reference).
fn mark(n: &mut Value) {
if let Value::String(s) = n
&& !s.is_empty()
&& crate::naming::legacy_name(s).is_none()
{
s.insert(0, crate::naming::LEGACY_MARK);
}
}
/// Old XZ references retain their exact frame, including nested offset and angled planes.
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
@@ -596,6 +724,74 @@ fn normalise(d: &mut Document) -> Result<()> {
mod tests {
use super::*;
#[test]
fn version_thirteen_names_keep_their_meaning_under_the_legacy_mark() {
let file = serde_json::json!({
"format": "solvecraft/13",
"features": [
{"type": "fillet", "edge_names": ["F2:side:l2|F2:top#2~1", ""], "face_names": ["F2:top#1", ""]},
{"type": "sheet_flange", "edge_names": ["base:l1:top"]},
{"type": "sketch", "plane": {"type": "face", "plane": {}, "at": [0, 0, 0], "name": "F2:top#1"},
"sketch": {"links": [{"id": "l1", "face_name": "F2:top#2"}]}},
{"type": "construction_axis", "def": {"type": "two_points",
"a": {"type": "edge", "at": [0, 0, 0], "name": "F2:start|F2:side:l1"},
"b": {"type": "face", "at": [0, 0, 0], "name": "F2:start"}}},
{"name": "Hole #2", "type": "extrude"},
{"type": "thread", "face": [1, 2, 3]}
],
"assembly": {
"joints": [{"a": {"occurrence": 0, "snap": {"type": "face_center", "pick": [0, 0, 0], "face": "F2:top#2"}},
"b": {"occurrence": 1, "snap": {"type": "point", "point": [0, 0, 0]}}}],
"origins": [{"name": "O1", "origin": {"occurrence": 0, "snap": {"type": "circle_center", "pick": [0, 0, 0], "face": "F3:side"}}}]
},
"appearances": {"faces": [{"body": "Body1", "name": "F2:top#1"}, {"body": "Body1", "name": "F2:+x", "strict_name": true}]}
});
let mut v = file.clone();
v13_to_v14(&mut v).unwrap();
assert_eq!(v["features"][0]["edge_names"], serde_json::json!(["?F2:side:l2|F2:top#2~1", ""]));
assert_eq!(v["features"][0]["face_names"], serde_json::json!(["?F2:top#1", ""]));
assert_eq!(v["features"][1]["edge_names"][0], "base:l1:top", "sheet metal's edge names are its own");
assert_eq!(v["features"][2]["plane"]["name"], "?F2:top#1");
assert_eq!(v["features"][2]["sketch"]["links"][0]["face_name"], "?F2:top#2");
assert_eq!(v["features"][3]["def"]["a"]["name"], "?F2:start|F2:side:l1");
assert_eq!(v["features"][3]["def"]["b"]["name"], "?F2:start");
assert_eq!(v["features"][4]["name"], "Hole #2", "a feature's own name is not a face name");
assert_eq!(v["features"][5]["face"], serde_json::json!([1, 2, 3]));
assert_eq!(v["assembly"]["joints"][0]["a"]["snap"]["face"], "?F2:top#2");
assert_eq!(v["assembly"]["origins"][0]["origin"]["snap"]["face"], "?F3:side");
assert_eq!(v["appearances"]["faces"][0]["name"], "?F2:top#1");
assert_eq!(v["appearances"]["faces"][1]["name"], "F2:+x", "strict appearance names are certified lineage");
// Every feature keeps naming the pieces it splits a body into as the saving build did.
assert!(v["features"].as_array().unwrap().iter().all(|f| f["shell_piece_order"] == true));
// The step never marks twice.
let once = v.clone();
v13_to_v14(&mut v).unwrap();
assert_eq!(v, once);
}
/// Only the fields that store a face or edge name are marked: other fields with the same key
/// names, and objects tagged `face` or `edge` that are not references, are left alone.
#[test]
fn version_thirteen_marking_targets_name_fields_not_key_names() {
let file = serde_json::json!({
"format": "solvecraft/13",
"features": [
{"type": "extrude", "options": {"face": "flat", "face_name": "label", "face_names": ["a"]}},
{"type": "fillet", "settings": {"edge_names": ["kept"]}},
{"type": "sketch", "sketch": {"style": {"face_name": "kept"}}},
{"type": "split_body", "tool": {"type": "face", "name": "not a reference"}}
],
"assembly": {"joints": [{"a": {"occurrence": 0, "snap": {"type": "frame", "face": "kept"}}}]},
"notes": {"face": "F2:top", "face_name": "F2:top", "face_names": ["F2:top"]}
});
let mut v = file.clone();
v13_to_v14(&mut v).unwrap();
for f in v["features"].as_array_mut().unwrap() {
f.as_object_mut().unwrap().remove("shell_piece_order");
}
assert_eq!(v, file);
}
#[test]
fn version_nine_wire_spaces_preserve_saved_points_and_explicit_markers() {
use solvecraft_geom::Vec3;
@@ -1006,6 +1202,7 @@ mod tests {
legacy["format"] = Value::from("solvecraft/6");
let original_table = legacy["configs"].clone();
let upgraded = read(&legacy.to_string()).unwrap();
d.features.iter_mut().for_each(|f| f.shell_piece_order = true);
assert_eq!(upgraded, d, "the identity migration preserves the complete design");
assert_eq!(serde_json::to_value(&upgraded.configs).unwrap(), original_table);
assert_eq!(upgraded.format, format_string(FORMAT));
@@ -1057,9 +1254,11 @@ mod tests {
d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Dimensioned")).unwrap();
let mut legacy: Value = serde_json::from_str(&d.to_json()).unwrap();
legacy["format"] = Value::from("solvecraft/7");
let features = legacy["features"].clone();
let mut features = legacy["features"].clone();
pieces_in_shell_order(&mut features);
let params = legacy["params"].clone();
let upgraded = read(&legacy.to_string()).unwrap();
d.features.iter_mut().for_each(|f| f.shell_piece_order = true);
assert_eq!(upgraded, d);
let upgraded_json: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
assert_eq!(upgraded_json["features"], features);
+396
View File
@@ -0,0 +1,396 @@
//! Face names from exact history, and how a stored name matches a face.
//!
//! A face's name says what made it (see `naming` for the vocabulary). Where the feature that made
//! a body reports its history (a [`Lineage`]), each face is named after the faces it came from:
//! kept, trimmed or moved faces keep their names, a face made from a profile segment or cap is
//! named by that role, a fillet face by the edge it replaces, a shell's inner wall by the face it
//! offsets. Nothing is sampled. Where a face's origin cannot be named exactly the face is named
//! from geometry, as a body without history is, and marked unverified.
//!
//! Two faces with the same name are pieces of one face (a cut split it). Each piece is told apart
//! by the faces around it that the others do not have: `F2:top#[F2:side:l4,F4:side:l4]` is the
//! piece of `F2:top` bounded by the block's left wall and the slot's left wall. Pieces nothing
//! around them tells apart are numbered (`#1`, `#2`): in the order of what they came from when
//! that differs (exact), else in the kernel's order (unverified). A face made of several (a
//! coplanar join) takes one name and answers to the others (aliases).
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, OnceLock};
use solvecraft_kernel::history::{FaceOrigin, FaceProv, History};
use crate::ModelBody;
use crate::lineage::{Lineage, Operand};
use crate::naming::NamingOrigin;
/// The names of a body's faces, with what finding them again needs.
#[derive(Debug, Default)]
pub struct Names {
pub faces: Arc<Vec<String>>,
pub(crate) info: Vec<FaceInfo>,
}
/// What a face answers to besides its name, and how sure its name is.
#[derive(Clone, Debug, Default)]
pub(crate) struct FaceInfo {
/// Names of the other faces merged into it (a reference to one of them finds this face).
pub aliases: Vec<String>,
/// The stems of the faces around it: what tells it apart from other pieces of its face.
pub bounds: BTreeSet<String>,
/// Whether the name follows exact history (`false`: worked out from geometry, or a piece
/// numbered because the faces around it did not tell it apart).
pub verified: bool,
}
impl Names {
/// Names worked out from geometry: the same names, none of them verified, pieces told apart
/// by the faces around them (`adjacent`: pairs of faces sharing an edge).
pub(crate) fn unverified(names: Vec<String>, adjacent: &[(usize, usize)]) -> Names {
let named = names
.into_iter()
.map(|name| Named { name: crate::naming::strip_piece(&name).to_string(), aliases: Vec::new(), verified: false, origins: None })
.collect();
qualify(named, adjacent)
}
/// Names given as they are (a moved copy keeps its source's), taken as exact.
pub(crate) fn given(names: Arc<Vec<String>>) -> Names {
let info = vec![FaceInfo { verified: true, ..FaceInfo::default() }; names.len()];
Names { faces: names, info }
}
/// Whether face `index`'s name follows exact history.
pub fn verified(&self, index: usize) -> bool {
self.info.get(index).is_some_and(|i| i.verified)
}
}
/// A face's name before pieces are told apart.
#[derive(Clone, Debug)]
struct Named {
name: String,
aliases: Vec<String>,
verified: bool,
/// What it came from (`None`: not known): faces with one name but different origins (an
/// extrude's two caps both above its sketch plane) are numbered in this order.
origins: Option<Vec<FaceOrigin>>,
}
/// Names of a body (`b`, made by `o`'s feature) from its lineage; `None` when the lineage does
/// not describe it (then it is named from geometry).
pub(crate) fn derive(b: &ModelBody, o: &NamingOrigin, lineage: &Lineage) -> Option<Names> {
let h = &lineage.history;
if h.faces.len() != b.body.face_count() || h.edges.len() != b.body.edge_count() {
return None;
}
let operands: Vec<OperandNames> = lineage.operands.iter().map(|op| OperandNames::new(op, o)).collect::<Option<_>>()?;
let fid = o.feature.id;
let mut named: Vec<Option<Named>> = h.faces.iter().map(|f| from_origins(f, &operands, fid)).collect();
if named.iter().any(Option::is_none) {
let geometric = crate::naming::geometric_names(b, o, named.len());
for (slot, name) in named.iter_mut().zip(geometric) {
if slot.is_none() {
*slot = Some(Named { name: crate::naming::strip_piece(&name).to_string(), aliases: Vec::new(), verified: false, origins: None });
}
}
}
let named: Vec<Named> = named.into_iter().collect::<Option<_>>()?;
Some(qualify(named, &adjacency(h)))
}
/// Pairs of faces that share an edge, from a history.
fn adjacency(h: &History) -> Vec<(usize, usize)> {
face_pairs(h.edges.iter().map(|e| e.faces.iter().copied()))
}
/// The two faces of each edge (edges given by their faces) that has exactly two.
pub(crate) fn face_pairs<I: Iterator<Item = usize>>(edges: impl Iterator<Item = I>) -> Vec<(usize, usize)> {
edges
.filter_map(|mut faces| match (faces.next(), faces.next(), faces.next()) {
(Some(a), Some(b), None) => Some((a, b)),
_ => None,
})
.collect()
}
/// An operand's faces' names.
struct OperandNames<'a> {
operand: &'a Operand,
/// The model body (or a stand-in for a stage) the operand's names belong to.
body: Option<ModelBody>,
names: Arc<Names>,
/// Its edges' names, worked out on first use (for the faces that blend them).
edges: OnceLock<Vec<String>>,
}
impl<'a> OperandNames<'a> {
fn new(operand: &'a Operand, o: &NamingOrigin) -> Option<OperandNames<'a>> {
match operand {
Operand::Body(name) | Operand::Copy { body: name, .. } | Operand::Inner(name) => {
let body = o.before.body(name)?;
Some(OperandNames { operand, names: crate::naming::names(body), body: Some(body.clone()), edges: OnceLock::new() })
}
Operand::Made(_) | Operand::CopiedTool { .. } => {
Some(OperandNames { operand, body: None, names: Arc::default(), edges: OnceLock::new() })
}
Operand::Stage(stage) => {
let body = &stage.body;
let names = stage
.names
.get_or_init(|| {
Arc::new(derive(body, o, &stage.lineage).unwrap_or_else(|| {
let names = crate::naming::geometric_names(body, o, body.body.face_count());
Names::unverified(names, &crate::naming::mesh_adjacency(body))
}))
})
.clone();
Some(OperandNames { operand, body: Some(body.clone()), names, edges: OnceLock::new() })
}
}
}
/// The name of face `face`.
fn face(&self, face: usize, fid: u64) -> Option<Named> {
let given = |names: &Names| {
let info = names.info.get(face)?;
Some(Named { name: names.faces.get(face)?.clone(), aliases: info.aliases.clone(), verified: info.verified, origins: None })
};
match self.operand {
Operand::Body(_) | Operand::Stage(_) => given(&self.names),
Operand::Copy { instance, .. } => given(&self.names).map(|n| Named {
name: format!("{}@F{fid}.{instance}", n.name),
aliases: n.aliases.iter().map(|a| format!("{a}@F{fid}.{instance}")).collect(),
verified: n.verified,
origins: None,
}),
Operand::Inner(_) => given(&self.names).map(|n| Named {
name: format!("F{fid}:inner:{}", n.name),
aliases: Vec::new(),
verified: n.verified,
origins: None,
}),
Operand::Made(roles) => {
let (first, rest) = roles.get(face)?.split_first()?;
Some(Named {
name: format!("F{fid}:{first}"),
aliases: rest.iter().map(|r| format!("F{fid}:{r}")).collect(),
verified: true,
origins: None,
})
}
Operand::CopiedTool { roles, feature, instance } => {
let named = |role: &String| format!("F{feature}:{role}@F{fid}.{instance}");
let (first, rest) = roles.get(face)?.split_first()?;
Some(Named { name: named(first), aliases: rest.iter().map(named).collect(), verified: true, origins: None })
}
}
}
/// The name of edge `edge`, and whether it is exact.
fn edge(&self, edge: usize) -> Option<(String, bool)> {
match self.operand {
Operand::Body(_) | Operand::Stage(_) => {
let body = self.body.as_ref()?;
let names = self.edges.get_or_init(|| crate::naming::edge_names(body));
let faces = body.mesh().edge_faces.get(edge).cloned().unwrap_or_default();
let verified = faces.iter().all(|f| self.names.verified(*f as usize));
Some((names.get(edge)?.clone(), verified))
}
Operand::Copy { .. } | Operand::Inner(_) | Operand::Made(_) | Operand::CopiedTool { .. } => None,
}
}
}
/// A face's name from what it came from; `None` when an origin cannot be named exactly.
fn from_origins(face: &FaceProv, operands: &[OperandNames], fid: u64) -> Option<Named> {
let mut origins: Vec<FaceOrigin> = face.origins.clone();
origins.sort_unstable();
let mut names: Vec<Named> = Vec::with_capacity(origins.len());
for origin in origins.iter().copied() {
let named = match origin {
FaceOrigin::Face { operand, face } => operands.get(operand)?.face(face, fid)?,
FaceOrigin::Edge { operand, edge } => {
let (edge, verified) = operands.get(operand)?.edge(edge)?;
Named { name: format!("F{fid}:blend:{edge}"), aliases: Vec::new(), verified, origins: None }
}
FaceOrigin::Offset { operand, face } => {
let source = operands.get(operand)?.face(face, fid)?;
Named { name: format!("F{fid}:inner:{}", source.name), aliases: Vec::new(), verified: source.verified, origins: None }
}
// A profile or cap of a body made from nothing: only a built body's own roles name
// these, and those come in as `Made` operands.
FaceOrigin::Profile { .. } | FaceOrigin::Cap { .. } => return None,
};
if !names.iter().any(|n| n.name == named.name) {
names.push(named);
}
}
merged(names).map(|n| Named { origins: Some(origins), ..n })
}
/// One name for a face made of faces with these names: the face they are all pieces of, else the
/// first (the target's before a tool's); the others become aliases.
fn merged(names: Vec<Named>) -> Option<Named> {
let verified = names.iter().all(|n| n.verified);
let first = names.first()?.name.clone();
let mut common = Some(first.as_str());
while let Some(c) = common {
if names.iter().all(|n| descends(&n.name, c)) {
break;
}
common = crate::naming::parent_piece(c);
}
let name = common.map(str::to_string).unwrap_or(first);
let mut aliases: Vec<String> = Vec::new();
for n in names {
for alias in std::iter::once(n.name).chain(n.aliases) {
if alias != name && !aliases.contains(&alias) {
aliases.push(alias);
}
}
}
Some(Named { name, aliases, verified, origins: None })
}
/// Whether `name` is `stem` or a piece of it (at any depth).
pub(crate) fn descends(name: &str, stem: &str) -> bool {
let mut current = Some(name);
while let Some(n) = current {
if n == stem {
return true;
}
current = crate::piece_names::parent_piece(n);
}
false
}
/// Tell pieces apart: faces sharing a name are named by the faces around them that the others
/// lack (`adjacent`: pairs of faces sharing an edge).
fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
let n = named.len();
let mut bounds: Vec<BTreeSet<String>> = vec![BTreeSet::new(); n];
for &(a, b) in adjacent {
let (Some(na), Some(nb)) = (named.get(a), named.get(b)) else { continue };
let (sa, sb) = (crate::naming::strip_piece(&na.name), crate::naming::strip_piece(&nb.name));
if a == b || sa == sb {
continue;
}
if let Some(set) = bounds.get_mut(a) {
set.insert(sb.to_string());
}
if let Some(set) = bounds.get_mut(b) {
set.insert(sa.to_string());
}
}
let mut groups: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
for (i, x) in named.iter().enumerate() {
groups.entry(x.name.as_str()).or_default().push(i);
}
let mut names: Vec<String> = named.iter().map(|x| x.name.clone()).collect();
let mut verified: Vec<bool> = named.iter().map(|x| x.verified).collect();
for (base, members) in groups.iter().filter(|(_, m)| m.len() > 1) {
let empty = BTreeSet::new();
let common: BTreeSet<&String> = members
.iter()
.map(|i| bounds.get(*i).unwrap_or(&empty))
.fold(None::<BTreeSet<&String>>, |acc, set| {
Some(match acc {
None => set.iter().collect(),
Some(acc) => acc.into_iter().filter(|x| set.contains(*x)).collect(),
})
})
.unwrap_or_default();
let own = |i: usize| -> Vec<&String> { bounds.get(i).unwrap_or(&empty).iter().filter(|x| !common.contains(x)).collect() };
let mut by_key: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for &i in members {
let key: Vec<&str> = own(i).into_iter().map(String::as_str).collect();
// Nothing around it of its own: the piece is only numbered (below).
let name = if key.is_empty() { base.to_string() } else { format!("{base}#[{}]", key.join(",")) };
by_key.entry(name).or_default().push(i);
}
for (key, tied) in by_key {
if let [i] = tied.as_slice() {
if let Some(slot) = names.get_mut(*i) {
*slot = key;
}
continue;
}
// The faces around them don't tell these apart: numbered by what they came from when
// that differs (exact), else in the kernel's order (unverified).
let mut tied = tied;
let origins: Vec<Option<&Vec<FaceOrigin>>> = tied.iter().map(|i| named.get(*i).and_then(|x| x.origins.as_ref())).collect();
let distinct = origins.iter().all(Option::is_some) && origins.iter().collect::<BTreeSet<_>>().len() == origins.len();
if distinct {
tied.sort_by_key(|i| named.get(*i).and_then(|x| x.origins.clone()));
}
for (k, i) in tied.iter().enumerate() {
if let Some(slot) = names.get_mut(*i) {
*slot = format!("{key}#{}", k + 1);
}
if let Some(v) = verified.get_mut(*i).filter(|_| !distinct) {
*v = false;
}
}
}
}
// A piece's new name can meet a face that already had it: numbered too, never shared.
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
for name in &names {
*seen.entry(name.clone()).or_default() += 1;
}
let mut counter: BTreeMap<String, usize> = BTreeMap::new();
for (i, name) in names.iter_mut().enumerate() {
if seen.get(name.as_str()).is_some_and(|c| *c > 1) {
let k = counter.entry(name.clone()).or_default();
*k += 1;
*name = format!("{name}#{k}");
if let Some(v) = verified.get_mut(i) {
*v = false;
}
}
}
let info = named.into_iter().zip(bounds).zip(verified).map(|((x, bounds), verified)| FaceInfo { aliases: x.aliases, bounds, verified }).collect();
Names { faces: Arc::new(names), info }
}
/// How a stored face name matches a current face.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Match {
/// Only its root stem is the same: some piece of the same face (the caller picks by point).
Stem,
/// A piece of the named face, or the named piece told apart by the faces around it.
Piece,
/// One of the faces merged into this one.
Alias,
/// The very name.
Exact,
}
/// How the stored name `name` matches face `index` (`None`: not at all).
pub(crate) fn matches(names: &Names, index: usize, name: &str) -> Option<Match> {
let current = names.faces.get(index)?;
let info = names.info.get(index);
if current == name {
return Some(Match::Exact);
}
if info.is_some_and(|i| i.aliases.iter().any(|a| a == name)) {
return Some(Match::Alias);
}
// The named face (or piece) was split further: this is one of its pieces.
if descends(current, name) {
return Some(Match::Piece);
}
// The named piece, told apart by the faces around it: they still bound this piece.
if let Some((stem, around)) = crate::piece_names::piece_parts(name)
&& crate::naming::parent_piece(current) == Some(stem)
&& info.is_some_and(|i| around.iter().all(|f| i.bounds.contains(*f)))
{
return Some(Match::Piece);
}
let root = crate::naming::strip_piece(name);
(crate::naming::strip_piece(current) == root).then_some(Match::Stem)
}
#[cfg(test)]
#[path = "history_names_tests.rs"]
mod tests;
+68
View File
@@ -0,0 +1,68 @@
use super::*;
#[test]
fn piece_names_parse_into_stem_and_bounding_faces() {
use crate::naming::{parent_piece, strip_edge_piece, strip_piece};
use crate::piece_names::piece_parts;
assert_eq!(strip_piece("F2:top#[F2:side:l4,F4:side:l4]"), "F2:top");
assert_eq!(strip_piece("F2:top#[a]#[b]"), "F2:top");
assert_eq!(parent_piece("F2:top#[a]#[b]"), Some("F2:top#[a]"));
assert_eq!(strip_piece("F2:top#[a]#2"), "F2:top");
assert_eq!(strip_piece("F2:top#2"), "F2:top", "an older design's numbered piece");
assert_eq!(strip_piece("F2:top"), "F2:top");
assert_eq!(parent_piece("F2:top"), None);
assert_eq!(piece_parts("F2:top#[F2:side:l4,F5:blend:A#[x,y]|B]"), Some(("F2:top", vec!["F2:side:l4", "F5:blend:A#[x,y]|B"])));
assert_eq!(piece_parts("F2:top#[]"), Some(("F2:top", vec![])));
assert_eq!(strip_edge_piece("F2:side:l2|F2:top#[F4:side:l2]~2"), "F2:side:l2|F2:top");
assert_eq!(strip_edge_piece("F2:side:l2#1|F2:top#2"), "F2:side:l2|F2:top");
// Hostile names never panic and are their own stems.
for n in ["", "#", "#[", "]", "a#[", "a]", "#[]", "a#[b", "x#[[]]]", "é#[ü]", "#1", "a#[b]#", "~", "a~"] {
let _ = (strip_piece(n), parent_piece(n), piece_parts(n), strip_edge_piece(n));
}
assert_eq!(strip_piece("#[]"), "#[]");
}
fn named(names: &[&str]) -> Vec<Named> {
names.iter().map(|n| Named { name: n.to_string(), aliases: Vec::new(), verified: true, origins: None }).collect()
}
#[test]
fn pieces_are_told_apart_by_the_faces_only_they_touch() {
// A top face cut in two by a slot: each piece touches the front and back walls (common),
// its own end wall and its own slot wall.
let faces = named(&["top", "top", "front", "back", "left", "right", "slot:a", "slot:b"]);
let adjacent = [(0, 2), (0, 3), (0, 4), (0, 6), (1, 2), (1, 3), (1, 5), (1, 7)];
let names = qualify(faces, &adjacent);
assert_eq!(names.faces[0], "top#[left,slot:a]");
assert_eq!(names.faces[1], "top#[right,slot:b]");
assert!(names.verified(0) && names.verified(1));
// A stored name finds its piece, though the faces around it have changed since.
assert_eq!(matches(&names, 0, "top#[left]"), Some(Match::Piece));
assert_eq!(matches(&names, 1, "top#[left]"), Some(Match::Stem));
assert_eq!(matches(&names, 0, "top"), Some(Match::Piece), "a piece of the named face");
assert_eq!(matches(&names, 0, "top#[left,slot:a]"), Some(Match::Exact));
assert_eq!(matches(&names, 2, "top"), None);
}
#[test]
fn pieces_nothing_tells_apart_are_numbered_and_unverified() {
let faces = named(&["top", "top", "ring"]);
let names = qualify(faces, &[(0, 2), (1, 2)]);
assert_eq!(names.faces[0], "top#1");
assert_eq!(names.faces[1], "top#2");
assert!(!names.verified(0) && !names.verified(1));
}
#[test]
fn a_merged_face_takes_the_shared_stem_or_the_first_name_and_answers_to_the_others() {
let m = merged(named(&["top#[a]", "top#[b]"])).unwrap();
assert_eq!(m.name, "top");
assert_eq!(m.aliases, vec!["top#[a]".to_string(), "top#[b]".to_string()]);
let m = merged(named(&["top#1", "top#2"])).unwrap();
assert_eq!(m.name, "top", "numbered pieces joined again");
let m = merged(named(&["F2:top", "F9:top"])).unwrap();
assert_eq!(m.name, "F2:top");
assert_eq!(m.aliases, vec!["F9:top".to_string()]);
let names = qualify(vec![m], &[]);
assert_eq!(matches(&names, 0, "F9:top"), Some(Match::Alias));
}
+4
View File
@@ -22,11 +22,15 @@ pub mod face_anchor;
pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
pub mod format;
mod format_offsets;
mod history_names;
pub use format_offsets::whole_profile_selection_from_value;
pub mod joints;
mod lineage;
pub mod motion;
mod name_lookup;
pub mod naming;
mod params;
mod piece_names;
mod profile_refs;
pub use profile_refs::{capture_profile_selection, profile_selection_indices};
pub mod plastic;
+388
View File
@@ -0,0 +1,388 @@
//! What each face of a body a feature made came from, in the model's terms.
//!
//! The kernel reports, for every operation, which faces and edges of its operands each face and
//! edge of its result came from ([`History`]). A feature may run several operations (a tool
//! built from a sketch, then cut from each target, then split into lumps) whose operands are
//! the feature's tools, intermediate bodies or bodies of the model before it. A [`Lineage`]
//! composes those histories into one over the operands that names can be read from: bodies of
//! the model before the feature (whose faces already have names), moved copies of them, and
//! bodies the feature built itself (whose faces are named by their role: the sketch curve they
//! sweep, the cap they are). Naming (`history_names`) reads it; nothing here samples geometry.
use std::sync::{Arc, OnceLock};
use solvecraft_geom::Region2;
use solvecraft_kernel::Body;
use solvecraft_kernel::history::{Change, FaceOrigin, FaceProv, History};
use crate::SolvedSketch;
/// Something a body's faces came from, and how its faces are named.
#[derive(Debug)]
pub(crate) enum Operand {
/// A body of the model before the feature (by name): its faces keep their names.
Body(String),
/// A moved copy of a body of the model before the feature, instance `instance` of a pattern
/// or mirror: its faces are named after their sources (`<name>@F<feature>.<instance>`).
Copy { body: String, instance: usize },
/// The cavity side of a shell grown from a body's faces (by name): each face is the inner
/// wall of the face it offsets (`F<feature>:inner:<name>`).
Inner(String),
/// A body the feature built: each face's role (`side:<curve>`, `top`, `+x`, …; the first is
/// its name, the others aliases), empty where the role is not known exactly.
Made(Vec<Vec<String>>),
/// A moved copy of a body another feature (`feature`) builds, instance `instance` of a
/// pattern or mirror of that feature: its faces named by their roles in that feature
/// (`F<feature>:<role>@F<pattern>.<instance>`).
CopiedTool { roles: Vec<Vec<String>>, feature: u64, instance: usize },
/// A body the feature made on the way to its result (a cut body before it falls into lumps),
/// named as a whole first so pieces of one face in different results are told apart.
Stage(Box<Stage>),
}
/// An intermediate body of a feature, with its lineage and, once worked out, its names.
#[derive(Debug)]
pub(crate) struct Stage {
/// The whole body (a stand-in model body: its mesh is worked out once for every lump).
pub body: crate::ModelBody,
pub lineage: Lineage,
pub names: OnceLock<Arc<crate::history_names::Names>>,
}
/// The history of a body over operands that can be named.
#[derive(Clone, Debug)]
pub(crate) struct Lineage {
pub(crate) history: History,
pub(crate) operands: Vec<Arc<Operand>>,
}
impl Lineage {
/// `body` as it is: every face and edge its own, named as `operand` names them.
pub(crate) fn of(body: &Body, operand: Operand) -> Lineage {
Lineage { history: History::identity(body), operands: vec![Arc::new(operand)] }
}
/// The lineage of `result`, made by one kernel operation from operands whose lineages are
/// `inputs` (in the operation's operand order). `None` where the operation reports no
/// history or its history cannot be composed with theirs: the result is then named from
/// geometry, flagged as unverified.
pub(crate) fn step(result: &Body, inputs: &[&Lineage]) -> Option<Lineage> {
let mut history = result.history()?.clone();
if history.operands != inputs.len() {
return None;
}
// From the last operand back: each one's operands come in at its place.
for (k, input) in inputs.iter().enumerate().rev() {
if let Err(e) = history.substitute(k, &input.history) {
log::debug!("no lineage: {e}");
return None;
}
}
let operands: Vec<Arc<Operand>> = inputs.iter().flat_map(|l| l.operands.iter().cloned()).collect();
(history.operands == operands.len()).then_some(Lineage { history, operands })
}
/// This lineage for a moved copy of what feature `feature` built (instance `instance` of a
/// pattern or mirror of it): the faces it built are named as that feature's, copied.
pub(crate) fn copied(&self, feature: u64, instance: usize) -> Lineage {
let operands = self
.operands
.iter()
.map(|op| match op.as_ref() {
Operand::Made(roles) => Arc::new(Operand::CopiedTool { roles: roles.clone(), feature, instance }),
_ => op.clone(),
})
.collect();
Lineage { history: self.history.clone(), operands }
}
/// A tool of `faces` faces and no edges (a mesh's triangles) whose faces have no role.
pub(crate) fn unnamed_faces(faces: usize) -> Lineage {
let faces = (0..faces).map(|face| FaceProv { origins: vec![FaceOrigin::Face { operand: 0, face }], change: Change::Kept }).collect();
Lineage { history: History { operands: 1, faces, edges: Vec::new() }, operands: vec![Arc::new(Operand::Made(Vec::new()))] }
}
/// The lineages of `pieces`, the lumps of `whole` (whose lineage is `lineage`): their faces
/// named after the whole's, worked out once for the whole, so pieces of one face that end up
/// in different bodies are told apart. `None` for a piece whose history is not known.
/// A body in one piece is its own lump: its lineage as it is.
pub(crate) fn lumps(whole: &Body, lineage: Option<&Lineage>, pieces: &[Body]) -> Vec<Option<Lineage>> {
let Some(lineage) = lineage else { return vec![None; pieces.len()] };
if pieces.len() < 2 {
return vec![Some(lineage.clone()); pieces.len()];
}
let body = crate::ModelBody::new(String::new(), whole.clone(), 0);
let stage = Arc::new(Operand::Stage(Box::new(Stage { body, lineage: lineage.clone(), names: OnceLock::new() })));
pieces
.iter()
.map(|piece| {
let history = piece.history()?.clone();
(history.operands == 1).then(|| Lineage { history, operands: vec![stage.clone()] })
})
.collect()
}
}
/// A body made during a feature, with its lineage (`None`: not known; it is then named from
/// geometry).
#[derive(Clone, Debug)]
pub(crate) struct Traced {
pub body: Body,
pub lineage: Option<Lineage>,
}
impl Traced {
/// A body the feature built whose faces' roles are not known: its faces are named from
/// geometry, those of the bodies it is combined with by their history.
pub fn unnamed(body: Body) -> Traced {
let roles = vec![Vec::new(); body.face_count()];
Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body }
}
/// A body the feature built, its faces' roles given (one per face), else not known.
pub fn made(body: Body, roles: Option<Vec<Vec<String>>>) -> Traced {
match roles.filter(|r| r.len() == body.face_count()) {
Some(roles) => Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body },
None => Traced::unnamed(body),
}
}
/// `copy`, this tool moved: instance `instance` of a pattern or mirror of feature `feature`,
/// which built it.
pub fn copied(&self, copy: Body, feature: u64, instance: usize) -> Traced {
let lineage = self.lineage.as_ref().and_then(|l| Lineage::step(&copy, &[&l.copied(feature, instance)]));
Traced { body: copy, lineage }
}
/// `result`, made by one kernel operation from `inputs` (in its operand order).
pub fn step(result: Body, inputs: &[&Traced]) -> Traced {
let lineages: Option<Vec<&Lineage>> = inputs.iter().map(|t| t.lineage.as_ref()).collect();
let lineage = lineages.and_then(|l| Lineage::step(&result, &l));
Traced { body: result, lineage }
}
}
/// How the bodies a feature builds from a profile name their faces: what each profile segment's
/// face is called, and each cap.
pub(crate) struct ProfileRoles<'a> {
/// The sketch the profile is in (its curves name the side faces), if any.
pub sketch: Option<&'a SolvedSketch>,
/// The region the body was built from, in the sketch's coordinates; its segments (outer loop,
/// then holes) number [`FaceOrigin::Profile`].
pub region: Option<&'a Region2>,
/// The start and end caps' roles (`None`: not known).
pub caps: [Option<String>; 2],
}
impl<'a> ProfileRoles<'a> {
/// The roles of the faces of a body built from the profile, whose history (over no
/// operands: it was built, not changed) is `history`. A face swept from profile segments is
/// `side:<curve>` of the sketch curve each lies on exactly; a cap is its cap's role. Unknown
/// (empty) where a segment lies on no single sketch curve, or the history is not a builder's.
pub fn of(&self, history: &History) -> Option<Vec<Vec<String>>> {
if history.operands != 0 {
return None;
}
let segments: Vec<&solvecraft_geom::Seg2> =
self.region.map(|r| std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).collect()).unwrap_or_default();
let curves = self.sketch.map(crate::config::ExactSketchCurves::new);
let curve = |segment: usize| -> Option<String> { curves.as_ref()?.find(segments.get(segment)?).map(|id| format!("side:{id}")) };
let roles = history
.faces
.iter()
.map(|face| {
let mut roles: Vec<String> = Vec::new();
for origin in &face.origins {
let role = match *origin {
FaceOrigin::Profile { segment } => curve(segment),
FaceOrigin::Cap { end } => self.caps.get(usize::from(end)).cloned().flatten(),
_ => None,
};
match role {
Some(role) if !roles.contains(&role) => roles.push(role),
Some(_) => {}
// One unknown origin leaves the face unknown: never a partial guess.
None => return Vec::new(),
}
}
roles
})
.collect();
Some(roles)
}
/// A body swept from a profile (revolve, sweep, loft): its sides named by the sketch curves,
/// its caps `start` and `end`.
pub fn swept(sketch: Option<&'a SolvedSketch>, region: Option<&'a Region2>) -> ProfileRoles<'a> {
ProfileRoles { sketch, region, caps: [Some("start".into()), Some("end".into())] }
}
/// A tool built from the profile (and then moved, from `built`, when given), its faces named
/// by their roles where its history gives them.
pub fn tool(&self, body: Body, built: Option<&Body>) -> Traced {
let history = match built {
Some(built) => built_history(&body, built),
None => body.history().cloned(),
};
Traced::made(body, history.and_then(|h| self.of(&h)))
}
}
/// A box primitive, its faces named by the side they face (`+x`, `-z`, …), as its construction
/// records them.
pub(crate) fn box_tool(body: Body) -> Traced {
let roles: Option<Vec<Vec<String>>> = body.face_sources().map(|sources| {
sources
.iter()
.map(|source| match source {
Some(solvecraft_kernel::FaceSource::Box { axis, high, .. }) => {
["x", "y", "z"].get(usize::from(*axis)).map(|a| vec![format!("{}{a}", if *high { "+" } else { "-" })]).unwrap_or_default()
}
_ => Vec::new(),
})
.collect()
});
Traced::made(body, roles)
}
/// A cylinder primitive: its wall `side`, its caps `bottom` (at the base) and `top`.
pub(crate) fn cylinder_tool(body: Body) -> Traced {
let roles = body.history().filter(|h| h.operands == 0).map(|h| {
h.faces
.iter()
.map(|f| match f.origins.as_slice() {
[FaceOrigin::Cap { end }] => vec![if *end { "top" } else { "bottom" }.to_string()],
origins if !origins.is_empty() && origins.iter().all(|o| matches!(o, FaceOrigin::Profile { .. })) => vec!["side".to_string()],
_ => Vec::new(),
})
.collect()
});
Traced::made(body, roles)
}
/// Faces named by their place in a body built as a whole (an imported part's faces in the
/// file's order): `face:<k>`.
pub(crate) fn numbered_tool(body: Body) -> Traced {
let roles = (0..body.face_count()).map(|k| vec![format!("face:{k}")]).collect();
Traced::made(body, Some(roles))
}
/// The role of an extrude's cap at `height` along the sketch normal: on the sketch plane, above
/// it or below it.
pub(crate) fn extrude_cap_role(height: f64) -> String {
let tol = 1e-6 * (1.0 + height.abs());
if height.abs() <= tol {
"start".into()
} else if height > 0.0 {
"top".into()
} else {
"bottom".into()
}
}
/// The history over no operands of a body built and then moved (or otherwise rebuilt in place)
/// by `moved`'s operation from `built`.
pub(crate) fn built_history(moved: &Body, built: &Body) -> Option<History> {
let mut history = moved.history()?.clone();
if history.operands == 1 {
history.substitute(0, built.history()?).ok()?;
Some(history)
} else {
(history.operands == 0).then_some(history)
}
}
/// How a body of the model being evaluated came to be during the feature being evaluated.
#[derive(Clone, Debug, Default)]
pub(crate) enum Made {
/// Made or changed by the feature, how not known: named from geometry.
#[default]
Unknown,
/// As it was before the feature.
Before,
/// Made or changed by the feature, with this lineage.
Traced(Arc<Lineage>),
}
impl crate::ModelBody {
/// A body made or changed by the feature, with what it came from.
pub(crate) fn traced_new(name: String, made: Traced, feature: u64) -> Self {
crate::ModelBody::new(name, made.body, feature).with_lineage(made.lineage)
}
/// This body with what it came from during the feature (`None`: not known).
pub(crate) fn with_lineage(mut self, lineage: Option<Lineage>) -> Self {
self.made = match lineage {
Some(l) => Made::Traced(Arc::new(l)),
None => Made::Unknown,
};
self
}
/// This body as an operand of the feature being evaluated: a body of the model before it, or
/// what the feature made of one so far.
pub(crate) fn traced(&self) -> Traced {
let lineage = match &self.made {
Made::Before => Some(Lineage::of(&self.body, Operand::Body(self.name.clone()))),
Made::Traced(l) => Some(l.as_ref().clone()),
Made::Unknown => None,
};
Traced { body: self.body.clone(), lineage }
}
/// This body, as it was before the feature, as an operand named by `operand` (a moved copy of
/// it, the cavity side of a shell grown from it); `None` once the feature has changed it.
pub(crate) fn before_as(&self, operand: Operand) -> Traced {
let lineage = matches!(self.made, Made::Before).then(|| Lineage::of(&self.body, operand));
Traced { body: self.body.clone(), lineage }
}
/// The lineage a feature left this body with, when it made or changed it with one.
pub(crate) fn lineage(&self) -> Option<Arc<Lineage>> {
match &self.made {
Made::Traced(l) => Some(l.clone()),
Made::Before | Made::Unknown => None,
}
}
}
/// Name the pieces a body fell into after their faces: of the bodies in `bodies` that are lumps
/// of one body (made in the same step, from one stage), the one whose least face name sorts first
/// takes the first of their body names, and so on. The kernel's shell order, which named them
/// before, can swap when an upstream edit moves the cut; their faces' names follow history.
/// `origin` is the feature that made them, for naming their faces.
pub(crate) fn order_lumps(bodies: &mut [crate::ModelBody], origin: &Arc<crate::naming::NamingOrigin>) {
let mut families: Vec<(*const Operand, Vec<usize>)> = Vec::new();
for (i, b) in bodies.iter().enumerate() {
let Made::Traced(l) = &b.made else { continue };
let [op] = l.operands.as_slice() else { continue };
if !matches!(op.as_ref(), Operand::Stage(_)) {
continue;
}
let key = Arc::as_ptr(op);
match families.iter_mut().find(|(k, _)| *k == key) {
Some((_, members)) => members.push(i),
None => families.push((key, vec![i])),
}
}
for (_, members) in families.into_iter().filter(|(_, m)| m.len() > 1) {
let feature = members.first().and_then(|i| bodies.get(*i)).map(|b| b.feature);
if members.iter().any(|i| bodies.get(*i).map(|b| b.feature) != feature) {
continue;
}
let least = |b: &crate::ModelBody| {
let mut named = b.clone();
named.names = crate::naming::NameCell::traced(Some(origin.clone()), b.lineage());
crate::naming::face_names(&named).iter().min().cloned()
};
let keys: Option<Vec<(String, usize)>> = members.iter().map(|i| bodies.get(*i).and_then(least).map(|k| (k, *i))).collect();
let Some(mut keys) = keys else { continue };
keys.sort();
let names: Vec<String> = members.iter().filter_map(|i| bodies.get(*i).map(|b| b.name.clone())).collect();
for (name, (_, i)) in names.into_iter().zip(keys) {
if let Some(b) = bodies.get_mut(i) {
b.name = name;
}
}
}
}
+333
View File
@@ -0,0 +1,333 @@
//! Finding a face or edge again by the name a feature stored for it.
//!
//! The face with that name is the face; else the one face answering to it (a face merged into
//! it, or the named piece told apart by the faces around it, which still bound it); else, when
//! several faces are pieces of the named one, the piece nearest the reference's point, and the
//! feature says the face was split. Names saved before format 14 ([`LEGACY_MARK`]) are found
//! among the names geometry gives, as the builds that saved them found them. A face whose name
//! was worked out from geometry, found where the reference's point no longer lies, is reported:
//! only geometry decided.
use solvecraft_geom::{Mesh, Vec3};
use crate::history_names::{Match, Names};
use crate::naming::{self, closest, edge_names, face_names, legacy_edge_names, legacy_face_names, strip_piece};
use crate::piece_names::{strip_edge_number, strip_edge_piece};
use crate::{ModelBody, ModelState};
/// The mark on a name saved before format 14 (`?F2:top#2`). Those names were worked out from
/// geometry, and a few of them name a face that exact history names differently (a tube's outer
/// wall was named after the nearest sketch curve to its centre, its inner circle): such a name is
/// found again among the names that geometry gives today, as the build that saved it did, so an
/// older design evaluates as it was saved. Picking the face again stores its exact name.
pub const LEGACY_MARK: char = '?';
/// A name saved before format 14, without its mark (`None`: an exact name).
pub fn legacy_name(name: &str) -> Option<&str> {
name.strip_prefix(LEGACY_MARK)
}
/// Where a stored edge name was found.
#[derive(Clone, Copy, Debug)]
pub struct EdgeHit {
/// A point on the edge: the reference's own point when it lies on it, else the edge's middle.
pub point: Vec3,
/// The named edge is not one edge any more: the piece nearest the point was taken.
pub split: bool,
/// Whether the names of the faces it separates follow exact history (and the edge is not
/// one of several between them, numbered by where they lie).
pub verified: bool,
/// Whether the reference's point still lies on the edge.
pub on_point: bool,
/// The name was saved before format 14 and found as that build did ([`LEGACY_MARK`]).
pub legacy: bool,
}
/// The edge of a body a stored edge name finds: the edge with that name; else the one edge
/// between the faces it names (followed through splits and merges of those faces); else the
/// piece of it nearest `near` (`split`). `Err` explains when there is none.
pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, String> {
if let Some(old) = legacy_name(name) {
return legacy_locate_edge(b, old, near).map_err(|_| format!("edge `{name}` no longer exists"));
}
let m = b.mesh();
let names = edge_names(b);
let faces = naming::names(b);
let mid = |i: usize| edge_mid(&m, i);
// Several edges between the same two faces are numbered by where they lie: not exact.
let verified = |i: usize| {
m.edge_faces.get(i).is_some_and(|fs| fs.iter().all(|f| faces.verified(*f as usize)))
&& names.get(i).is_some_and(|n| strip_edge_number(n).len() == n.len())
};
let exact = |i: usize| -> Result<EdgeHit, String> {
let on = edge_near(b, &m, i, near);
mid(i)
.map(|p| EdgeHit { point: if on { near } else { p }, split: false, verified: verified(i), on_point: on, legacy: false })
.ok_or_else(|| "edge".into())
};
if let Some(i) = names.iter().position(|n| n == name) {
return exact(i);
}
// By the faces it separates: the best way any edge matches.
let wanted = strip_edge_number(name);
let mut best: Option<(Match, Vec<usize>)> = None;
for (i, fs) in m.edge_faces.iter().enumerate() {
let [fa, fb] = fs.as_slice() else { continue };
let Some(level) = edge_match(&faces, *fa as usize, *fb as usize, wanted) else { continue };
keep_best(&mut best, level, i);
}
let (level, cands) = best.ok_or_else(|| format!("edge `{name}` no longer exists"))?;
if let ([i], true) = (cands.as_slice(), level >= Match::Piece) {
return exact(*i);
}
cands
.iter()
.filter_map(|i| mid(*i).map(|p| (p, *i)))
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
.map(|(p, i)| EdgeHit { point: p, split: true, verified: verified(i), on_point: false, legacy: false })
.ok_or_else(|| format!("edge `{name}` no longer exists"))
}
/// Whether the picked point `near` lies on edge `i` of body `b` (whose mesh is `m`): then a
/// reference keeps it.
fn edge_near(b: &ModelBody, m: &Mesh, i: usize, near: Vec3) -> bool {
m.edges.get(i).is_some_and(|e| e.windows(2).any(|w| near.dist_to_segment(w[0], w[1]) < (b.body.size() * 2e-3).max(1e-3)))
}
/// The point of face `face` of a mesh nearest `p`, and how far it is.
fn nearest_on_face(m: &Mesh, face: usize, p: Vec3) -> Option<(f64, Vec3)> {
let mut best: Option<(f64, Vec3)> = None;
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
if *f as usize != face {
continue;
}
let Some([a, b, c]) = m.tri(t) else { continue };
let q = closest(p, a, b, c);
let d = q.dist(p);
if best.is_none_or(|x| d < x.0) {
best = Some((d, q));
}
}
best
}
/// The middle of edge `i` of a mesh (along its polyline).
fn edge_mid(m: &Mesh, i: usize) -> Option<Vec3> {
let e = m.edges.get(i)?;
let total: f64 = e.windows(2).map(|w| w[0].dist(w[1])).sum();
let mut acc = 0.0;
for w in e.windows(2) {
let l = w[0].dist(w[1]);
if acc + l >= total / 2.0 {
let t = if l > 0.0 { (total / 2.0 - acc) / l } else { 0.0 };
return Some(w[0] + (w[1] - w[0]) * t);
}
acc += l;
}
e.first().copied()
}
/// [`locate_edge`] for a name saved before format 14 (without its mark), as those builds found
/// it: the edge with that name among the names geometry gives, else the piece of it nearest
/// `near`.
fn legacy_locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, ()> {
let m = b.mesh();
let names = legacy_edge_names(b);
if let Some(i) = names.iter().position(|n| n == name) {
let on = edge_near(b, &m, i, near);
return edge_mid(&m, i)
.map(|p| EdgeHit { point: if on { near } else { p }, split: false, verified: false, on_point: on, legacy: true })
.ok_or(());
}
let stem = strip_edge_piece(name);
(0..names.len())
.filter(|i| names.get(*i).is_some_and(|n| strip_edge_piece(n) == stem))
.filter_map(|i| edge_mid(&m, i))
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
.map(|p| EdgeHit { point: p, split: true, verified: false, on_point: false, legacy: true })
.ok_or(())
}
/// How well the stored edge name `wanted` (without its own number) names the edge between faces
/// `a` and `b`: the weaker of its two faces' matches, read every way the name splits into two
/// face names (a face name may hold `|` itself: a fillet face is named after its edge).
fn edge_match(faces: &Names, a: usize, b: usize, wanted: &str) -> Option<Match> {
let mut best = None;
for (i, _) in wanted.match_indices('|') {
let (Some(x), Some(y)) = (wanted.get(..i), wanted.get(i + 1..)) else { continue };
for (p, q) in [(a, b), (b, a)] {
if let (Some(m1), Some(m2)) = (crate::history_names::matches(faces, p, x), crate::history_names::matches(faces, q, y)) {
best = best.max(Some(m1.min(m2)));
}
}
}
best
}
/// Keep `item` among the best matches so far.
fn keep_best<T>(best: &mut Option<(Match, Vec<T>)>, level: Match, item: T) {
match best {
Some((b, items)) if *b == level => items.push(item),
Some((b, _)) if *b > level => {}
_ => *best = Some((level, vec![item])),
}
}
/// Where a stored face name was found.
#[derive(Clone, Copy, Debug)]
pub struct FaceHit {
/// The body (its index in the model's bodies).
pub body: usize,
/// The face (its index in the body).
pub face: usize,
/// The point on the face nearest the reference's point.
pub point: Vec3,
/// The named face is not one face any more (split, or found on several bodies): the piece
/// nearest the point was taken.
pub split: bool,
/// The named face was merged into this one.
pub merged: bool,
/// Whether the face's name follows exact history.
pub verified: bool,
/// Whether the reference's point still lies on the face (so a name worked out from geometry
/// and the pick agree).
pub on_point: bool,
/// The name was saved before format 14 and found as that build did ([`LEGACY_MARK`]).
pub legacy: bool,
}
impl FaceHit {
/// What a feature says about how it found a face by its stored name `name` (`None`: exactly).
pub fn note(&self, name: &str) -> Option<String> {
if self.split {
Some(format!("face `{name}` was split; using the piece nearest the picked point"))
} else if self.merged {
Some(format!("face `{name}` was merged into another face; using that face"))
} else if !self.verified && !self.on_point && !self.legacy {
Some(format!("face `{name}` was found by geometry, not exact history; check it"))
} else {
None
}
}
}
impl EdgeHit {
/// What a feature says about how it found an edge by its stored name `name` (`None`: exactly).
pub fn note(&self, name: &str) -> Option<String> {
if self.split {
Some(format!("edge `{name}` was split; using the piece nearest the picked point"))
} else if !self.verified && !self.on_point && !self.legacy {
Some(format!("edge `{name}` was found by geometry, not exact history; check it"))
} else {
None
}
}
}
/// The face a stored face name finds, on any body: the face with that name, or the one face
/// answering to it (merged into it, or the named piece told apart by the faces around it); else
/// the piece of it nearest `p` (`split`).
pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option<FaceHit> {
locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p)
}
/// [`locate_face`] among `bodies` (with their indices).
fn locate_in<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name: &str, p: Vec3) -> Option<FaceHit> {
if let Some(old) = legacy_name(name) {
return legacy_locate(bodies, old, p);
}
let mut best: Option<(Match, Vec<(usize, &ModelBody, usize)>)> = None;
for (bi, b) in bodies {
let names = naming::names(b);
for face in 0..names.faces.len() {
if let Some(level) = crate::history_names::matches(&names, face, name) {
keep_best(&mut best, level, (bi, b, face));
}
}
}
let (level, faces) = best?;
let unique = faces.len() == 1 && level >= Match::Piece;
let mut nearest: Option<(f64, usize, &ModelBody, usize, Vec3)> = None;
for (bi, b, face) in faces {
if let Some((d, q)) = nearest_on_face(&b.mesh(), face, p)
&& nearest.as_ref().is_none_or(|n| d < n.0)
{
nearest = Some((d, bi, b, face, q));
}
}
let (distance, body, b, face, point) = nearest?;
let on_point = distance <= (b.body.size() * 1e-3).max(1e-3);
Some(FaceHit {
body,
face,
point,
split: !unique,
merged: level == Match::Alias,
verified: naming::names(b).verified(face),
on_point,
legacy: false,
})
}
/// [`locate_face`] for a name saved before format 14 (without its mark), as those builds found
/// it: among the names geometry gives, the face with that name (or a piece of the same face, if
/// nearer `p`), the nearest point on it.
fn legacy_locate<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name: &str, p: Vec3) -> Option<FaceHit> {
let stem = strip_piece(name);
let mut best: Option<(f64, usize, usize, Vec3, bool, f64)> = None;
for (bi, b) in bodies {
let names = legacy_face_names(b);
let tol = (b.body.size() * 1e-3).max(1e-3);
let m = b.mesh();
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let Some(n) = names.get(*f as usize) else { continue };
let exact = n == name;
if !exact && strip_piece(n) != stem {
continue;
}
let Some([a, bb, c]) = m.tri(t) else { continue };
let q = closest(p, a, bb, c);
let d = q.dist(p) - if exact { 1e-9 } else { 0.0 };
if best.is_none_or(|x| d < x.0) {
best = Some((d, bi, *f as usize, q, !exact, tol));
}
}
}
best.map(|(d, body, face, point, split, tol)| FaceHit {
body,
face,
point,
split,
merged: false,
verified: false,
on_point: d <= tol,
legacy: true,
})
}
/// Whether face `index` of a body is the face a stored name names: that name (or one merged into
/// it); for a name saved before format 14, that name among the names those builds gave.
pub fn face_has_name(b: &ModelBody, index: usize, name: &str) -> bool {
match legacy_name(name) {
Some(old) => legacy_face_names(b).get(index).is_some_and(|n| n == old),
None => crate::history_names::matches(&naming::names(b), index, name).is_some_and(|m| m >= Match::Alias),
}
}
/// The faces of a body that are the stored name's face or pieces of it (by stem), as the name's
/// own era names them.
pub fn faces_with_stem(b: &ModelBody, name: &str) -> Vec<usize> {
let (names, wanted) = match legacy_name(name) {
Some(old) => (legacy_face_names(b), old),
None => (face_names(b), name),
};
let stem = strip_piece(wanted);
let aliases =
|i: usize| legacy_name(name).is_none() && naming::names(b).info.get(i).is_some_and(|x| x.aliases.iter().any(|a| strip_piece(a) == stem));
(0..names.len()).filter(|i| names.get(*i).is_some_and(|n| strip_piece(n) == stem) || aliases(*i)).collect()
}
/// [`locate_face`] on one body.
pub(crate) fn locate_on(body: &ModelBody, name: &str, p: Vec3) -> Option<FaceHit> {
locate_in(std::iter::once((0, body)), name, p)
}
+131 -127
View File
@@ -1,16 +1,26 @@
//! Persistent names of faces and edges (plan/adr/0002-persistent-naming.md).
//! Persistent names of faces and edges.
//!
//! Every face of an evaluated body is named after how it was made: a face that lies on a face
//! of the feature's input keeps that face's name; a face new to the feature is named by its role
//! (`F<feature>:side:<sketch curve>`, `F<feature>:start`/`end`, `F<feature>:blend:<k>`, …).
//! Pieces of a split face get `#1`, `#2`, … along the longest axis. Edges are named by the faces
//! they separate. Names are worked out lazily, on first request, from the body, the feature
//! that made it and the model before that feature.
//! A fillet stores the edges it rounds, a sketch the face it sits on: by name, so that an
//! upstream edit that adds, removes, splits or reorders faces does not move them to another
//! face. Every face of an evaluated body is named after how it was made: a face that came from a
//! face of the feature's input keeps that face's name; a face new to the feature is named by its
//! role (`F<feature>:side:<sketch curve>`, `F<feature>:start`/`top`/`bottom`/`end`,
//! `F<feature>:blend:<edge>`, `F<feature>:inner:<face>`, …). Pieces of a split face are told
//! apart by the faces around them (`F2:top#[F2:side:l4,F4:side:l4]`). Edges are named by the
//! faces they separate (`<face A>|<face B>`, `~k` for several edges between the same two).
//!
//! Where the feature that made a body reports exact history (`crate::lineage`), the names follow
//! it (`crate::history_names`). Bodies without history (made by operations that do not report
//! it yet, imported meshes, legacy chains) are named from geometry: sample points of each face
//! are looked up on the input's faces. Those names are marked unverified, and references that
//! resolve through them say so. Names are worked out lazily, on first request.
use std::sync::{Arc, OnceLock};
use solvecraft_geom::{Mesh, Seg2, Vec2, Vec3};
use crate::history_names::Names;
use crate::lineage::Lineage;
use crate::{Feature, FeatureKind, ModelBody, ModelState};
/// How a body came to be: the feature and the model just before it.
@@ -27,21 +37,43 @@ pub struct NamingOrigin {
#[derive(Clone, Debug, Default)]
pub struct NameCell {
pub origin: Option<Arc<NamingOrigin>>,
names: Arc<OnceLock<Arc<Vec<String>>>>,
/// What the body's faces came from, when the feature that made it reports it.
lineage: Option<Arc<Lineage>>,
names: Arc<OnceLock<Arc<Names>>>,
/// The names designs saved before format 14 gave its faces (see [`LEGACY_MARK`]).
legacy: Arc<OnceLock<Arc<Vec<String>>>>,
/// The body this one is a moved copy of (its legacy names are that body's).
copy_of: Option<Arc<ModelBody>>,
}
impl NameCell {
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
NameCell { origin, names: Arc::new(OnceLock::new()) }
Self::traced(origin, None)
}
/// Names already known (a moved copy of a body keeps its faces' names).
/// Names of a body made by `origin`'s feature, following `lineage` where it is known.
pub(crate) fn traced(origin: Option<Arc<NamingOrigin>>, lineage: Option<Arc<Lineage>>) -> Self {
NameCell { origin, lineage, ..NameCell::default() }
}
/// Names already known, taken as they are.
pub fn known(names: Arc<Vec<String>>) -> Self {
let legacy = OnceLock::new();
let _ = legacy.set(names.clone());
NameCell { legacy: Arc::new(legacy), ..Self::shared(Arc::new(Names::given(names)), None) }
}
/// The names of `body` (a moved copy of a body keeps its faces' names, and how sure they are).
pub fn same(body: &ModelBody) -> Self {
Self::shared(names(body), Some(Arc::new(body.clone())))
}
fn shared(names: Arc<Names>, copy_of: Option<Arc<ModelBody>>) -> Self {
let cell = OnceLock::new();
let _ = cell.set(names);
NameCell { origin: None, names: Arc::new(cell) }
NameCell { names: Arc::new(cell), copy_of, ..NameCell::default() }
}
}
pub use crate::name_lookup::{EdgeHit, FaceHit, LEGACY_MARK, face_has_name, faces_with_stem, legacy_name, locate_edge, locate_face};
pub use crate::piece_names::parent_piece;
/// Deeper chains than this are named by position only (bounded work and stack).
const MAX_DEPTH: usize = 400;
@@ -80,7 +112,7 @@ fn samples(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
.collect()
}
fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
pub(crate) fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
let (ab, ac, ap) = (b - a, c - a, p - a);
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
if d1 <= 0.0 && d2 <= 0.0 {
@@ -158,12 +190,53 @@ fn face_count(b: &ModelBody) -> usize {
/// Names of a body's faces (by B-rep face index).
pub fn face_names(b: &ModelBody) -> Arc<Vec<String>> {
names(b).faces.clone()
}
/// Whether face `index`'s name follows exact history (`false`: worked out from geometry, which an
/// upstream edit can fool).
pub fn face_name_exact(b: &ModelBody, index: usize) -> bool {
names(b).verified(index)
}
/// A body's names, with what finding them again needs: from the history of the feature that
/// made it where it reports one, else from geometry (unverified).
pub(crate) fn names(b: &ModelBody) -> Arc<Names> {
b.names
.names
.get_or_init(|| {
// Exact history needs no mesh; geometry does (its face count is the mesh's).
let names = match b.names.origin.as_ref() {
Some(o) if o.depth < MAX_DEPTH => b
.names
.lineage
.as_ref()
.and_then(|l| crate::history_names::derive(b, o, l))
.unwrap_or_else(|| Names::unverified(geometric_names(b, o, face_count(b)), &mesh_adjacency(b))),
_ => Names::unverified((0..face_count(b)).map(|k| format!("{}:face:{k}", b.name)).collect(), &[]),
};
Arc::new(names)
})
.clone()
}
/// The names builds before format 14 gave a body's faces: from geometry, over the inputs' names
/// as those builds gave them, pieces of a split face numbered along the longest axis of their
/// spread (`#1`, `#2`, …).
pub(crate) fn legacy_face_names(b: &ModelBody) -> Arc<Vec<String>> {
if let Some(source) = &b.names.copy_of {
return legacy_face_names(source);
}
b.names
.legacy
.get_or_init(|| {
let nf = face_count(b);
let names = match b.names.origin.as_ref() {
Some(o) if o.depth < MAX_DEPTH => derive(b, o, nf),
Some(o) if o.depth < MAX_DEPTH => {
let mut names = geometric_from(b, o, nf, legacy_face_names);
number_pieces(&mut names, &samples(&b.mesh(), nf), '#');
names
}
_ => (0..nf).map(|k| format!("{}:face:{k}", b.name)).collect(),
};
Arc::new(names)
@@ -171,14 +244,31 @@ pub fn face_names(b: &ModelBody) -> Arc<Vec<String>> {
.clone()
}
/// Work out a body's face names from the feature that made it and the model before it.
fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
/// The names builds before format 14 gave a body's edges.
pub(crate) fn legacy_edge_names(b: &ModelBody) -> Vec<String> {
edge_names_from(b, &legacy_face_names(b))
}
/// Pairs of faces of a body that share an edge (from its mesh's edges).
pub(crate) fn mesh_adjacency(b: &ModelBody) -> Vec<(usize, usize)> {
crate::history_names::face_pairs(b.mesh().edge_faces.iter().map(|fs| fs.iter().map(|f| *f as usize)))
}
/// Work out a body's face names from the feature that made it and the model before it, by
/// geometry: where each face's sample points lie on the input's faces. Faces sharing a name are
/// pieces of one, told apart afterwards.
pub(crate) fn geometric_names(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
geometric_from(b, o, nf, face_names)
}
/// [`geometric_names`], the input bodies' faces named by `names_of`.
fn geometric_from(b: &ModelBody, o: &NamingOrigin, nf: usize, names_of: fn(&ModelBody) -> Arc<Vec<String>>) -> Vec<String> {
// A rigid Move changes coordinates, not topology or face ownership. Geometric
// overlap with the unmoved input cannot recover the names at the new position.
if matches!(o.feature.kind, FeatureKind::Move { .. })
&& let Some(input) = o.before.body(&b.name)
{
return face_names(input).as_ref().clone();
return names_of(input).as_ref().clone();
}
let m = b.mesh();
let size = b.body.size().max(1e-6);
@@ -214,7 +304,7 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
.take(64)
.map(|x| {
let n = face_count(x);
(x, FaceIndex::new(&x.mesh(), n), face_names(x))
(x, FaceIndex::new(&x.mesh(), n), names_of(x))
})
.collect();
let fid = o.feature.id;
@@ -229,7 +319,7 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
.iter()
.filter(|x| !x.body.is_mesh())
.take(64)
.map(|x| (x, FaceIndex::new(&x.mesh(), face_count(x)), face_names(x)))
.map(|x| (x, FaceIndex::new(&x.mesh(), face_count(x)), names_of(x)))
.collect();
let mut best: Option<(usize, usize, Vec<Option<String>>)> = None;
for (k, mat) in o.mats.iter().enumerate() {
@@ -245,7 +335,7 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
}
}
let empty = Vec::new();
let mut names: Vec<String> = names
let names: Vec<String> = names
.into_iter()
.enumerate()
.map(|(fi, n)| {
@@ -261,7 +351,6 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
})
})
.collect();
number_pieces(&mut names, &pts, '#');
names
}
@@ -347,37 +436,23 @@ fn vote(ps: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<String>>)], tol:
votes.first().filter(|(_, n)| *n * 2 >= ps.len().max(1) && votes.get(1).is_none_or(|(_, other)| n > other)).map(|(n, _)| n.clone())
}
/// A face name without its piece number (`#k`).
/// A face name without its piece qualifiers: the face it is a piece of (`F2:top` for
/// `F2:top#[F4:side:l1]`, for pieces of pieces, and for the numbered pieces of older designs).
pub fn strip_piece(n: &str) -> &str {
strip_mark(n, '#')
let mut stem = n;
while let Some(parent) = parent_piece(stem) {
stem = parent;
}
stem
}
/// An edge name without piece numbers: its own (`~k`) and its faces' (`#k`), so pieces of a
/// split edge, or of an edge between split faces, match.
/// An edge name without piece numbers: its own (`~k`) and its faces' (`#[…]`, and the numbered
/// pieces of older designs), so pieces of a split edge, or of an edge between split faces, match.
pub fn strip_edge_piece(n: &str) -> String {
let n = strip_mark(n, '~');
let mut out = String::with_capacity(n.len());
let mut it = n.chars().peekable();
while let Some(c) = it.next() {
if c == '#' && it.peek().is_some_and(|d| d.is_ascii_digit()) {
while it.peek().is_some_and(|d| d.is_ascii_digit()) {
it.next();
}
continue;
}
out.push(c);
}
out
crate::piece_names::strip_edge_piece(n)
}
fn strip_mark(n: &str, mark: char) -> &str {
match n.rfind(mark) {
Some(i) if n[i + 1..].chars().all(|c| c.is_ascii_digit()) && i + 1 < n.len() => &n[..i],
_ => n,
}
}
/// Faces sharing a name are pieces of one: number them along the longest axis of their spread.
/// Edges sharing a name: number them along the longest axis of their spread.
fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
let mut seen: Vec<String> = Vec::new();
for i in 0..names.len() {
@@ -429,18 +504,7 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
let flat = !ns.is_empty() && ns.iter().all(|m| m.cross(n).len() < 1e-6);
if flat {
// On the sketch plane, above it or below it.
let h = (c - ss.plane.origin).dot(n);
let tol = 1e-6 * (1.0 + h.abs());
return format!(
"F{fid}:{}",
if h.abs() <= tol {
"start"
} else if h > 0.0 {
"top"
} else {
"bottom"
}
);
return format!("F{fid}:{}", crate::lineage::extrude_cap_role((c - ss.plane.origin).dot(n)));
}
let q = ss.plane.to_local(c);
nearest_curve(ss, Vec2::new(q.x, q.y)).map(|id| format!("F{fid}:side:{id}")).unwrap_or_else(|| format!("F{fid}:face:{fi}"))
@@ -576,8 +640,12 @@ fn seg_dist(s: &Seg2, q: Vec2) -> f64 {
/// Names of a body's edges (by mesh edge index): `<face A>|<face B>`, numbered when repeated.
pub fn edge_names(b: &ModelBody) -> Vec<String> {
edge_names_from(b, &face_names(b))
}
/// Names of a body's edges from its faces' names `faces`.
fn edge_names_from(b: &ModelBody, faces: &[String]) -> Vec<String> {
let m = b.mesh();
let faces = face_names(b);
let mut names: Vec<String> = m
.edge_faces
.iter()
@@ -595,35 +663,7 @@ pub fn edge_names(b: &ModelBody) -> Vec<String> {
/// The edge of a body with this name: exact, else the pieces of the same edge nearest `near`
/// (`Err` explains when there is none).
pub fn find_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<(Vec3, bool), String> {
let m = b.mesh();
let names = edge_names(b);
let mid = |i: usize| -> Option<Vec3> {
let e = m.edges.get(i)?;
let total: f64 = e.windows(2).map(|w| w[0].dist(w[1])).sum();
let mut acc = 0.0;
for w in e.windows(2) {
let l = w[0].dist(w[1]);
if acc + l >= total / 2.0 {
let t = if l > 0.0 { (total / 2.0 - acc) / l } else { 0.0 };
return Some(w[0] + (w[1] - w[0]) * t);
}
acc += l;
}
e.first().copied()
};
if let Some(i) = names.iter().position(|n| n == name) {
// Keep the picked point when it is on that edge.
let on = m.edges.get(i).is_some_and(|e| e.windows(2).any(|w| near.dist_to_segment(w[0], w[1]) < (b.body.size() * 2e-3).max(1e-3)));
return mid(i).map(|p| (if on { near } else { p }, false)).ok_or_else(|| "edge".into());
}
let stem = strip_edge_piece(name);
let cands: Vec<usize> = (0..names.len()).filter(|i| names.get(*i).is_some_and(|n| strip_edge_piece(n) == stem)).collect();
cands
.iter()
.filter_map(|i| mid(*i))
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
.map(|p| (p, true))
.ok_or_else(|| format!("edge `{name}` no longer exists"))
locate_edge(b, name, near).map(|hit| (hit.point, hit.split))
}
/// Names of the edges nearest each point on a body (for storing references).
@@ -675,28 +715,10 @@ pub fn face_name_at_body(body: &ModelBody, point: Vec3) -> Option<String> {
}
/// The point on the face with this name nearest `p` (a split face: its nearest piece, with
/// `true` for "it was split"); `None` when no face has the name.
/// `true` for "not exactly that face": split, or merged into another); `None` when no face has
/// the name.
pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool)> {
let stem = strip_piece(name);
let mut best: Option<(f64, Vec3, bool)> = None;
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
let names = face_names(b);
let m = b.mesh();
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let Some(n) = names.get(*f as usize) else { continue };
let exact = n == name;
if !exact && strip_piece(n) != stem {
continue;
}
let Some([a, bb, c]) = m.tri(t) else { continue };
let q = closest(p, a, bb, c);
let d = q.dist(p) - if exact { 1e-9 } else { 0.0 };
if best.is_none_or(|(bd, _, _)| d < bd) {
best = Some((d, q, !exact));
}
}
}
best.map(|(_, q, split)| (q, split))
locate_face(st, name, p).map(|hit| (hit.point, hit.split || hit.merged))
}
/// Resolve a legacy named face only on its recorded source body. Return the
@@ -706,25 +728,7 @@ pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec
if body.body.is_mesh() {
return None;
}
let names = face_names(body);
let mesh = body.mesh();
let stem = strip_piece(name);
let mut best: Option<(f64, usize, Vec3, bool)> = None;
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
let index = usize::try_from(*face).ok()?;
let Some(candidate) = names.get(index) else { continue };
let exact = candidate == name;
if !exact && strip_piece(candidate) != stem {
continue;
}
let Some([a, b, c]) = mesh.tri(triangle) else { continue };
let q = closest(p, a, b, c);
let distance = q.dist(p) - if exact { 1e-9 } else { 0. };
if best.is_none_or(|(old, _, _, _)| distance < old) {
best = Some((distance, index, q, !exact));
}
}
best.map(|(_, index, q, split)| (index, q, split))
crate::name_lookup::locate_on(body, name, p).map(|hit| (hit.face, hit.point, hit.split || hit.merged))
}
#[cfg(test)]
+1
View File
@@ -259,6 +259,7 @@ impl FeatureKind {
}
FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v),
FeatureKind::Stitch { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
FeatureKind::MeshConvert { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
FeatureKind::Thicken { thickness, .. } => v.push(("Thickness", thickness, L)),
FeatureKind::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v),
FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)),
+139
View File
@@ -0,0 +1,139 @@
//! The grammar of piece names: a face split into pieces is named `<face>#[<faces around it>]`
//! (the faces only that piece touches), `#k` after that when nothing tells pieces apart, and
//! designs saved before format 14 numbered pieces `<face>#k`. Parsing never panics on hostile
//! names: an unbalanced or empty qualifier is simply not one.
/// The face a piece name is a piece of (one qualifier off), `None` when it is not a piece.
pub fn parent_piece(n: &str) -> Option<&str> {
piece_split(n).map(|(stem, _)| stem)
}
/// A piece name's stem and the faces its qualifier lists (`F2:top`, [`F2:side:l4`, `F4:side:l4`]
/// for `F2:top#[F2:side:l4,F4:side:l4]`; none for an older design's numbered piece).
pub(crate) fn piece_parts(n: &str) -> Option<(&str, Vec<&str>)> {
let (stem, around) = piece_split(n)?;
Some((stem, around.map(split_top_level).unwrap_or_default()))
}
/// `n` as stem and qualifier contents: `stem#[…]`, `stem#[…]#k` (a numbered tie) or `stem#k` (an
/// older design's numbered piece, no contents).
fn piece_split(n: &str) -> Option<(&str, Option<&str>)> {
let mut rest = n;
let mut numbered = false;
if let Some(i) = rest.rfind('#')
&& let Some(digits) = rest.get(i + 1..)
&& !digits.is_empty()
&& digits.bytes().all(|b| b.is_ascii_digit())
{
rest = rest.get(..i)?;
numbered = true;
}
if let Some(inner_end) = rest.len().checked_sub(1).filter(|_| rest.ends_with(']')) {
let open = matching_open(rest, inner_end)?;
let stem = rest.get(..open.checked_sub(1)?)?;
if rest.get(open - 1..open) == Some("#") && !stem.is_empty() {
return Some((stem, rest.get(open + 1..inner_end)));
}
}
(numbered && !rest.is_empty()).then_some((rest, None))
}
/// The index of the `[` matching the `]` at byte `close`.
fn matching_open(s: &str, close: usize) -> Option<usize> {
let bytes = s.as_bytes();
let mut depth = 0usize;
for i in (0..=close).rev() {
match bytes.get(i)? {
b']' => depth += 1,
b'[' => {
depth = depth.checked_sub(1)?;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
None
}
/// `s` split at the commas outside brackets (a qualifier's list of faces).
fn split_top_level(s: &str) -> Vec<&str> {
let mut out = Vec::new();
let (mut depth, mut from) = (0usize, 0usize);
for (i, b) in s.bytes().enumerate() {
match b {
b'[' => depth += 1,
b']' => depth = depth.saturating_sub(1),
b',' if depth == 0 => {
out.extend(s.get(from..i).filter(|x| !x.is_empty()));
from = i + 1;
}
_ => {}
}
}
out.extend(s.get(from..).filter(|x| !x.is_empty()));
out
}
/// An edge name without piece qualifiers: its own number (`~k`) and its faces' (`#[…]`, and the
/// numbered pieces of older designs), so pieces of a split edge, or of an edge between split
/// faces, match.
pub(crate) fn strip_edge_piece(n: &str) -> String {
let n = strip_edge_number(n);
let mut out = String::with_capacity(n.len());
let bytes = n.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes.get(i) == Some(&b'#') {
match bytes.get(i + 1) {
Some(b'[') => {
// Skip the bracketed qualifier (and a tie number after it).
let mut depth = 0usize;
let mut j = i + 1;
while j < bytes.len() {
match bytes.get(j) {
Some(b'[') => depth += 1,
Some(b']') => {
depth = depth.saturating_sub(1);
if depth == 0 {
break;
}
}
_ => {}
}
j += 1;
}
i = j + 1;
continue;
}
Some(d) if d.is_ascii_digit() => {
i += 1;
while bytes.get(i).is_some_and(u8::is_ascii_digit) {
i += 1;
}
continue;
}
_ => {}
}
}
// Copy one whole character.
let ch = n.get(i..).and_then(|rest| rest.chars().next());
match ch {
Some(c) => {
out.push(c);
i += c.len_utf8();
}
None => break,
}
}
out
}
/// An edge name without its own number (`~k`).
pub(crate) fn strip_edge_number(n: &str) -> &str {
match n.rfind('~') {
Some(i) if n.get(i + 1..).is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit())) => n.get(..i).unwrap_or(n),
_ => n,
}
}
+1 -1
View File
@@ -497,7 +497,7 @@ fn refresh_links_in(
&& name != stem
&& st.body(body).is_some_and(|target| {
(certified_planar_construction(target, index) || target.body.face_was_coalesced(index))
&& crate::naming::face_names(target).get(index).is_some_and(|current| current == stem)
&& crate::naming::face_has_name(target, index, stem)
});
if removed_piece {
sk.set_link_lost(&l.id, true);
+209 -8
View File
@@ -1134,7 +1134,29 @@ impl SheetBody {
match panel {
None => {
let pts = self.base.outer.ccw().polyline(1e-3);
let cut = clip(&pts, a + inward * by, inward);
let o = a + inward * by;
let mut cut = clip(&pts, o, inward);
// Only the material along this edge goes: other parts of the outline past the
// line (tabs side by side on one line) stay.
let mut work = FlatWork::new();
let off = merge_loops(&[clip(&pts, o, inward * -1.0)], &mut work)?;
if off.len() > 1 {
let mid = (a + b) * 0.5;
let touches = |p: &Vec<Vec2>| {
let m = p.len();
(0..m).filter_map(|i| Some((*p.get(i)?, *p.get((i + 1) % m)?))).any(|(p0, p1)| {
let e = p1 - p0;
let t = if e.len2() > 1e-18 { ((mid - p0).dot(e) / e.len2()).clamp(0.0, 1.0) } else { 0.0 };
(p0 + e * t).dist(mid) < 1e-6
})
};
let mut keep = vec![cut];
keep.extend(off.into_iter().filter(|p| !touches(p)));
cut = match merge_loops(&keep, &mut work)?.as_slice() {
[one] => one.clone(),
_ => return Err(DocError::Invalid("not supported yet: a flange setback that splits the sheet".into())),
};
}
if cut.len() < 3 {
return Err(DocError::Invalid("the flange's setback removes the whole panel".into()));
}
@@ -1190,13 +1212,13 @@ impl SheetBody {
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, d, top));
found.push((panel, a, b, d, top));
}
for (panel, a, d, _) in &found {
self.trim(*panel, *a, *a + *d, setback)?;
for (panel, a, b, _, _) in &found {
self.trim(*panel, *a, *b, setback)?;
}
let mut made = Vec::new();
for (panel, a, d, top) in found {
for (panel, a, b, d, top) 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.
@@ -1204,14 +1226,21 @@ impl SheetBody {
None => {
let pts = self.base.outer.ccw().polyline(1e-3);
let m = pts.len();
(0..m)
let on_line: Vec<(Vec2, f64)> = (0..m)
.filter_map(|k| {
let (p0, p1) = (*pts.get(k)?, *pts.get((k + 1) % m)?);
let e = p1 - p0;
let on = (p0 - line_o).dot(outward).abs() < 1e-6 && (p1 - line_o).dot(outward).abs() < 1e-6;
(on && e.dot(d) > 0.0).then_some((p0, e.len()))
})
.next()
.collect();
// Of several edges on the line (tabs side by side), the one the pick was on.
let (ua, ub) = (a.dot(d), b.dot(d));
on_line
.iter()
.find(|(p0, len)| (p0.dot(d) + len).min(ub) - p0.dot(d).max(ua) > 1e-9)
.or_else(|| on_line.first())
.copied()
.ok_or_else(|| DocError::Invalid("the flange's edge was trimmed away".into()))?
}
Some(i) => {
@@ -1355,7 +1384,29 @@ impl SheetBody {
}
(None, Some(p)) => {
let q = apply_point(&inv, p);
Vec2::new(q.x, q.y).dot(n) < l0
let q2 = Vec2::new(q.x, q.y);
// A point in the panel's slab must be on material: inside the outline and
// outside every cut-out (a side face of either counts as material).
let near = |poly: &[Vec2]| {
let m = poly.len();
(0..m).any(|i| match (poly.get(i), poly.get((i + 1) % m)) {
(Some(&a), Some(&b)) => {
let d = b - a;
let t = if d.len2() > 1e-18 { ((q2 - a).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 };
(a + d * t).dist(q2) < 1e-3
}
_ => false,
})
};
let in_slab = q.z > -1e-4 && q.z < self.t + 1e-4;
let in_hole = self.holes.iter().any(|h| h.contains(q2) && !near(&h.outer.ccw().polyline(1e-3)));
if in_slab && ((!point_in(&pts, q2) && !near(&pts)) || in_hole) {
return Err(DocError::Invalid(format!(
"the fixed point ({:.3}, {:.3}, {:.3}) is not on the sheet: pick a point on the face of the side that stays",
p.x, p.y, p.z
)));
}
q2.dot(n) < l0
}
(None, None) => side_area(n) <= side_area(n * -1.0),
};
@@ -1378,11 +1429,32 @@ impl SheetBody {
if signed_area(&fixed_part).abs() < 1e-9 || signed_area(&moving).abs() < 1e-9 {
return Err(DocError::Invalid("the fold line must cross the sheet with material on both sides of the bend".into()));
}
// Separate tabs on one line (the zone falls apart into several pieces): fold the ones
// the line's points span, each as its own bend.
let strips = merge_loops(std::slice::from_ref(&strip), &mut FlatWork::new())?;
if strips.len() > 1 {
let span = (a2.dot(d).min(b2.dot(d)), a2.dot(d).max(b2.dot(d)));
let angle = if flip { -angle } else { angle };
return self.fold_tabs(panel, &pts, (n, d), (s0, ba), span, (angle, radius), &strips);
}
// The bend zone must be a rectangle across the panel (a slanted or curved edge would
// run across the bend as a helix, which the sheet's faces can't carry yet).
let us: Vec<f64> = strip.iter().map(|q| q.dot(d)).collect();
let (u0, u1) = (us.iter().cloned().fold(f64::MAX, f64::min), us.iter().cloned().fold(f64::MIN, f64::max));
if ((u1 - u0) * ba - signed_area(&strip).abs()).abs() > 1e-6 * (u1 - u0).max(1.0) * ba.max(1.0) {
// A corner of the outline inside the zone (e.g. a fold right at a tab's root): name
// it and how far the line has to move to clear it.
let corners: Vec<(Vec2, f64)> = pts.iter().map(|q| (*q, q.dot(n) - s0)).filter(|(_, s)| *s > 1e-6 && *s < ba - 1e-6).collect();
if let Some(&(c, _)) = corners.first() {
let on = corners.iter().fold(0.0_f64, |m, (_, s)| m.max(*s));
let back = corners.iter().fold(0.0_f64, |m, (_, s)| m.max(ba - *s));
let w = apply_point(&self.world(panel), Vec3::new(c.x, c.y, self.t));
return Err(DocError::Invalid(format!(
"the bend zone ({ba:.3} mm wide) reaches a corner of the sheet's outline at ({:.3}, {:.3}, {:.3}): \
move the fold line at least {on:.3} mm toward the side that folds or {back:.3} mm toward the side that stays",
w.x, w.y, w.z
)));
}
return Err(DocError::Invalid("not supported yet: folds whose bend zone crosses a slanted or curved edge of the sheet".into()));
}
let up = !flip;
@@ -1436,6 +1508,135 @@ impl SheetBody {
Ok(i)
}
/// A fold whose bend zone falls apart into several pieces (`strips`: tabs on one line). The
/// pieces the line's points span (`span`, along `d`) bend, each with the material beyond it;
/// the rest of the panel stays put. `n` points to the moving side, the zone is `s0..s0 + ba`.
#[allow(clippy::too_many_arguments)]
fn fold_tabs(
&mut self,
panel: Option<usize>,
pts: &[Vec2],
(n, d): (Vec2, Vec2),
(s0, ba): (f64, f64),
span: (f64, f64),
(angle, radius): (f64, f64),
strips: &[Vec<Vec2>],
) -> Result<usize> {
const EPS: f64 = 1e-6;
let urange = |p: &[Vec2]| p.iter().fold((f64::MAX, f64::MIN), |(lo, hi), q| (lo.min(q.dot(d)), hi.max(q.dot(d))));
let overlap = |a: (f64, f64), b: (f64, f64)| a.1.min(b.1) - a.0.max(b.0);
let mut work = FlatWork::new();
// The material beyond the zone, piece by piece, and the zone piece each one hangs from.
let beyond = merge_loops(&[clip(pts, n * (s0 + ba), n)], &mut work)?;
let far_edges = |p: &[Vec2]| -> Vec<(f64, f64)> {
let m = p.len();
(0..m)
.filter_map(|i| Some((*p.get(i)?, *p.get((i + 1) % m)?)))
.filter(|(a, b)| (a.dot(n) - s0 - ba).abs() < EPS && (b.dot(n) - s0 - ba).abs() < EPS)
.map(|(a, b)| (a.dot(d).min(b.dot(d)), a.dot(d).max(b.dot(d))))
.collect()
};
let strip_u: Vec<(f64, f64)> = strips.iter().map(|s| urange(s)).collect();
let mut hangs: Vec<Vec<usize>> = vec![Vec::new(); strips.len()];
for (k, part) in beyond.iter().enumerate() {
let edges = far_edges(part);
let on: Vec<usize> = (0..strips.len()).filter(|&i| strip_u.get(i).is_some_and(|&u| edges.iter().any(|&e| overlap(e, u) > EPS))).collect();
match on.as_slice() {
[i] => {
if let Some(h) = hangs.get_mut(*i) {
h.push(k);
}
}
_ => return Err(DocError::Invalid("not supported yet: folding tabs that join again beyond the bend".into())),
}
}
let chosen: Vec<usize> = (0..strips.len()).filter(|&i| strip_u.get(i).is_some_and(|&u| overlap(u, span) > EPS)).collect();
if chosen.is_empty() {
return Err(DocError::Invalid("the fold line must cross the sheet with material on both sides of the bend".into()));
}
// What stays: the panel short of the zone plus the zone pieces (and what hangs from
// them) that don't fold.
let mut keep = vec![clip(pts, n * s0, n * -1.0)];
for i in 0..strips.len() {
if chosen.contains(&i) {
continue;
}
keep.extend(strips.get(i).cloned());
keep.extend(hangs.get(i).into_iter().flatten().filter_map(|&k| beyond.get(k).cloned()));
}
let fixed = match merge_loops(&keep, &mut work)?.as_slice() {
[one] if signed_area(one) > 1e-9 => one.clone(),
_ => return Err(DocError::Invalid("not supported yet: a fold that splits the part that stays".into())),
};
let mut made = Vec::new();
for &i in &chosen {
let (Some(strip), Some(&(u0, u1))) = (strips.get(i), strip_u.get(i)) else { continue };
// Each piece of the zone must be a rectangle (see `add_fold`).
if ((u1 - u0) * ba - signed_area(strip).abs()).abs() > 1e-6 * (u1 - u0).max(1.0) * ba.max(1.0) {
return Err(DocError::Invalid("not supported yet: folds whose bend zone crosses a slanted or curved edge of the sheet".into()));
}
let moving = match hangs.get(i).map(Vec::as_slice) {
Some([k]) => beyond.get(*k).cloned().ok_or_else(|| DocError::Invalid("fold".into()))?,
_ => return Err(DocError::Invalid("the fold line must cross the sheet with material on both sides of the bend".into())),
};
let leg = moving.iter().map(|q| q.dot(n) - s0 - ba).fold(0.0, f64::max);
made.push((u0, u1, n * s0 + d * u0, leg, moving));
}
// Flanges already on this panel: those beyond a folding tab move with it.
let mut moves = Vec::new();
for k in 0..self.flanges.len() {
let Some(f) = self.flanges.get(k) else { continue };
if f.parent != panel {
continue;
}
let (e0, e1) = (f.p.dot(n) - s0, (f.p + f.d * f.len).dot(n) - s0);
let fu = (f.p.dot(d).min((f.p + f.d * f.len).dot(d)), f.p.dot(d).max((f.p + f.d * f.len).dot(d)));
let crosses = Err(DocError::Invalid("not supported yet: a fold through a flange's bend".into()));
match made.iter().position(|m| fu.0 >= m.0 - 1e-9 && fu.1 <= m.1 + 1e-9) {
Some(j) if e0.min(e1) >= ba - 1e-9 => moves.push((k, j)),
Some(_) if e0.max(e1) > 1e-9 => return crosses,
None if e0.max(e1) > 1e-9 && made.iter().any(|m| overlap((m.0, m.1), fu) > EPS) => return crosses,
_ => {}
}
}
let ccw = |mut v: Vec<Vec2>| {
if signed_area(&v) < 0.0 {
v.reverse();
}
v.into_iter().map(snap).collect::<Vec<_>>()
};
match panel {
None => self.base = Region2 { outer: Loop2::polygon(&fixed).ccw(), holes: self.base.holes.clone() },
Some(j) => {
if let Some(f) = self.flanges.get_mut(j) {
f.shape = Some(ccw(fixed));
}
}
}
let first = self.flanges.len();
for (u0, u1, p0, leg, moving) in made {
self.flanges.push(SheetFlange {
parent: panel,
p: snap(p0),
d,
len: u1 - u0,
n,
angle,
radius,
leg,
ext: (0.0, 0.0),
shape: Some(ccw(moving)),
tag: String::new(),
});
}
for (k, j) in moves {
if let Some(f) = self.flanges.get_mut(k) {
f.parent = Some(first + j);
}
}
Ok(first)
}
/// Name a panel edge (flat coordinates a→b on `panel`, top or bottom face): base edges by the
/// base sketch line they lie on, flange edges by the flange (its tag) and side.
fn edge_name_of(&self, panel: Option<usize>, a: Vec2, b: Vec2, top: bool) -> Option<String> {
@@ -64,3 +64,27 @@ fn oblique_cut_through_a_small_arc_matches_a_split() {
assert!((got - above).abs() < 1e-6 * above, "{got} vs {above}");
}
}
/// Joining struts that end on a bore wall reached a truck-topology panic (#268). The guard caught it,
/// but no kernel call should panic at all (wasm aborts on a panic).
#[test]
fn struts_ending_on_a_bore_wall_join_without_a_panic() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let me = std::thread::current().id();
let count = Arc::new(AtomicUsize::new(0));
let c2 = count.clone();
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |_| {
if std::thread::current().id() == me {
c2.fetch_add(1, Ordering::SeqCst);
}
}));
let mut s = Session::default();
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/join_struts_on_bore_wall.json")).unwrap();
let result = s.run_script(&script);
std::panic::set_hook(prev);
assert_eq!(count.load(Ordering::SeqCst), 0, "kernel panics during the join");
result.unwrap();
assert_eq!(s.model.state().bodies.len(), 1);
}
+3 -2
View File
@@ -445,13 +445,14 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
if st.body(name).is_some() {
return Err(bad(cmd, format!("another body is called `{name}`")));
}
// The body is the k-th new body of its feature; the names before it are kept as they are.
// The body is the k-th new body of its feature; its siblings keep the names they have now
// (unpinned pattern copies would otherwise be renumbered into the freed name, #265).
let fid = b.feature;
let siblings: Vec<String> = st.bodies.iter().filter(|x| x.feature == fid).map(|x| x.name.clone()).collect();
let k = siblings.iter().position(|n| *n == old).unwrap_or(0);
let d = s.doc_mut();
let f = d.feature_mut(fid).ok_or_else(|| bad(cmd, "the body's feature is gone"))?;
while f.body_names.len() <= k {
while f.body_names.len() < siblings.len().max(k + 1) {
let i = f.body_names.len();
f.body_names.push(siblings.get(i).cloned().unwrap_or_default());
}
@@ -108,7 +108,25 @@ fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renam
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
legacy["format"] = json!("solvecraft/9");
let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap());
// Saved before format 14, it keeps naming the pieces it makes in the kernel's shell order.
let anchored = solvecraft_doc::Feature { shell_piece_order: true, ..anchored };
assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored);
// Names saved before format 14 keep naming the face they named, marked so they are found as
// the build that saved them found them.
let mark = |name: &Option<String>| name.as_ref().map(|n| format!("{}{n}", solvecraft_doc::naming::LEGACY_MARK));
let mut saved_plane = saved_plane;
if let solvecraft_doc::PlaneRef::Face { name, .. } = &mut saved_plane {
*name = mark(name);
}
let paint: Vec<_> = paint
.into_iter()
.map(|mut face| {
if !face.strict_name {
face.name = mark(&face.name);
}
face
})
.collect();
let FeatureKind::Sketch { plane, .. } = &reopened.doc.feature(badge).unwrap().kind else { panic!() };
assert_eq!(plane, &saved_plane);
assert_eq!(reopened.doc.appearances.faces, paint);
@@ -156,10 +174,10 @@ fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() {
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":20,"profiles":[{"point":[9.5,0]}],"body_name":"Sleeve"}));
let names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
// Preserve the legacy centroid-family name even though strict paint uses
// the separately certified outer source curve.
// The outer wall is named after its own sketch curve: its history says so. (Builds before
// format 14 named it after the curve nearest its centre, the inner circle; see below.)
let name = solvecraft_doc::naming::face_names_at(&s.model.state(), &[Vec3::new(0., 10., 7.5)]).remove(0);
assert!(name.contains("side:inner"));
assert!(name.contains("side:outer"));
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
@@ -172,6 +190,13 @@ fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() {
assert!((volume - std::f64::consts::PI * 380. - 21.).abs() < 0.2, "volume {volume}");
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
legacy["format"] = json!("solvecraft/9");
// What a version 9 design stored for that pick: the centroid-family name of the outer wall
// piece holding the point. It keeps finding that piece, as it did when saved.
for feature in legacy["features"].as_array_mut().unwrap() {
if feature["id"] == json!(feature_id) {
feature["face_names"] = json!(["F2:side:inner#4"]);
}
}
let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap());
assert!(reopened.model.result(feature_id).unwrap().error.is_none());
assert!((reopened.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8);
+1
View File
@@ -90,6 +90,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
},
Sel::Edge { .. } | Sel::Vertex { .. } => skip("edges and vertices are deleted with their body or face".into()),
Sel::Profile { .. } => skip("a profile goes with its sketch".into()),
Sel::ExternalSketchCurve { .. } | Sel::ExternalSketchPoint { .. } => skip("edit the source sketch to delete its geometry".into()),
}
}
features.dedup();
+20 -2
View File
@@ -21,7 +21,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("delete")
.key("Delete")
.params("features: [id or name] (a sketch takes its dependent features with it)"),
.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.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
@@ -120,6 +120,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if keys.is_empty() {
return Err(bad(cmd, "`features` must list features"));
}
// Features that work now; any of them failing after the delete is reported (a later
// feature that names a deleted body, #267).
let worked: std::collections::BTreeSet<u64> = s.model.results.iter().filter(|r| r.error.is_none() && !r.skipped).map(|r| r.id).collect();
let mut gone = Vec::new();
for k in keys {
let Some(id) = s.doc.find_feature(&k).map(|f| f.id) else {
@@ -134,7 +137,18 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
s.active_sketch = None;
}
s.selection.clear();
Ok(json!({"deleted": gone}))
s.refresh();
let failing: Vec<Value> = s
.model
.results
.iter()
.filter(|r| worked.contains(&r.id))
.filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e})))
.collect();
if failing.is_empty() {
return Ok(json!({"deleted": gone}));
}
Ok(json!({"deleted": gone, "failing": failing}))
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
@@ -145,6 +159,9 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
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) {
f.name = name.to_string();
}
@@ -261,6 +278,7 @@ fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": rolled_back,
"definition": def, "omitted": omitted, "inputs": inputs,
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()),
"boolean_diagnostics": r.map(|r| &r.boolean_diagnostics),
}))
}
+111 -14
View File
@@ -21,12 +21,12 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.sweep", "Sweep", sweep)
.at("SOLID", "CREATE")
.icon("sweep")
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?"),
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"),
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"),
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?"),
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"),
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"),
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.fillet", "Fillet", fillet)
.at("SOLID", "MODIFY")
.icon("fillet")
@@ -87,7 +87,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("construct.plane.offset", "Offset Plane", plane_offset)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("base: XY|XZ|YZ|plane name; offset: expr; name?"),
.params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; offset: expr; name?"),
CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle)
.at("SOLID", "CONSTRUCT")
.icon("plane")
@@ -359,10 +359,17 @@ pub(super) fn check_expr(s: &Session, e: &str, kind: Kind, cmd: &str, what: &str
/// Add a feature, evaluate, and report its result (an evaluation error fails the command).
pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result<Value> {
add_feature_in(s, p, kind, true)
}
/// `add_feature`; `world` is false when `kind` is already in the active component's frame.
fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> Result<Value> {
let name = str_(p, "name");
// Commands speak world coordinates; the feature is authored in the active component.
let mut kind = kind;
super::component::to_active_frame(s, &mut kind);
if world {
super::component::to_active_frame(s, &mut kind);
}
// Bare lengths typed in an inch (or cm…) design mean that unit.
if s.doc.units.trim() != "mm" {
let doc = s.doc.clone();
@@ -406,9 +413,19 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
solvecraft_doc::naming::face_names_at(&s.model.state(), &pts)
}
};
let participants = string_list(p, "participants");
// Primitives have no `targets` of their own: their `targets` are the bodies taking part (#259).
let primitive = matches!(kind, FeatureKind::Box { .. } | FeatureKind::Cylinder { .. } | FeatureKind::Sphere { .. } | FeatureKind::Torus { .. });
let (participants, key) = match string_list(p, "participants") {
v if v.is_empty() && primitive => (string_list(p, "targets"), "targets"),
v => (v, "participants"),
};
if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) {
return Err(EngineError::Other("`participants` must list existing bodies".into()));
return Err(EngineError::Other(format!("`{key}` must list existing bodies")));
}
if matches!(kind, FeatureKind::ConstructionPlane { .. })
&& let Some(n) = name
{
check_plane_name(n, "construct.plane")?;
}
let id = s.doc_mut().add_feature(kind, name)?;
if let Some(f) = s.doc_mut().feature_mut(id) {
@@ -431,10 +448,33 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
if !names.is_empty()
&& let Some(f) = s.doc_mut().feature_mut(id)
{
f.body_names = names;
f.body_names = names.clone();
}
s.active_sketch = None;
s.refresh();
// A feature that makes more bodies than it was given names (a multi-profile extrude with one
// `body_name`) names the rest after the first: `Ribs`, `Ribs (2)`, `Ribs (3)`… (#266). The
// names are stored on the feature, so recomputes and saved designs keep them.
if let Some(base) = names.first().map(|n| n.trim().to_string()).filter(|n| !n.is_empty()) {
let st = s.model.state();
let made = st.bodies.iter().filter(|b| b.feature == id).count();
if made > names.len() {
let mut taken: std::collections::BTreeSet<String> = st.bodies.iter().map(|b| b.name.clone()).collect();
taken.extend(names.iter().cloned());
let mut i = 1usize;
while names.len() < made {
i += 1;
let n = format!("{base} ({i})");
if taken.insert(n.clone()) {
names.push(n);
}
}
if let Some(f) = s.doc_mut().feature_mut(id) {
f.body_names = names;
}
s.refresh();
}
}
let r = s.model.result(id).cloned();
if let Some(e) = r.as_ref().and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
@@ -854,7 +894,12 @@ fn axis_line(p: &Value, cmd: &str) -> Result<(Vec3, Vec3)> {
_ => Err(bad(cmd, "axis must be X, Y, Z or {origin, dir}")),
},
Some(o @ Value::Object(_)) => {
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
let origin = match o.get("origin") {
None => Vec3::ZERO,
Some(v) => vec3(v).ok_or_else(|| {
bad(cmd, "axis.origin must contain three finite numeric coordinates in millimetres; expressions are not supported")
})?,
};
let dir = o.get("dir").and_then(vec3).and_then(|d| d.normalized()).ok_or_else(|| bad(cmd, "axis needs a non-zero `dir`"))?;
Ok((origin, dir))
}
@@ -965,6 +1010,31 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::Mirror { features, plane, bodies, combine })
}
/// The other name of an origin plane (`Top` for `XY`, `XY` for `Top`...), if `name` is one.
pub(super) fn origin_plane_alias(name: &str) -> Option<&'static str> {
match name.trim().to_ascii_uppercase().as_str() {
"XY" => Some("Top"),
"TOP" => Some("XY"),
"XZ" => Some("Front"),
"FRONT" => Some("XZ"),
"YZ" => Some("Right"),
"RIGHT" => Some("YZ"),
_ => None,
}
}
/// A construction plane may not take an origin plane's name (`XY`, `Top`...): a plane
/// parameter could then mean either.
pub(super) fn check_plane_name(name: &str, cmd: &str) -> Result<()> {
if solvecraft_geom::Plane::named(name.trim()).is_some() {
return Err(bad(
cmd,
format!("`{}` is reserved for an origin plane (XY, XZ, YZ, Top, Front, Right); give the construction plane another name", name.trim()),
));
}
Ok(())
}
/// A plane reference from a parameter: an origin plane, a construction plane (by name or
/// feature id; kept by name, so the feature follows the plane when it changes), or an
/// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`. Every command that takes a
@@ -978,7 +1048,22 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
None => Err(bad(cmd, format!("no plane `{r}` (a plane is {WANT})"))),
};
match v {
Some(Value::String(n)) if solvecraft_geom::Plane::named(n.trim()).is_some() => Ok(PlaneRef::Origin { name: n.trim().to_ascii_uppercase() }),
Some(Value::String(n)) if solvecraft_geom::Plane::named(n.trim()).is_some() => {
if let Some(f) = s.doc.find_feature(n.trim())
&& matches!(f.kind, FeatureKind::ConstructionPlane { .. })
{
return Err(bad(
cmd,
format!(
"plane `{}` is ambiguous: an origin-plane alias and a construction plane; use `{}` for the origin plane, construction plane feature id {}, or an explicit {{origin, normal}} plane",
n.trim(),
origin_plane_alias(n).unwrap_or("XY"),
f.id
),
));
}
Ok(PlaneRef::Origin { name: n.trim().to_ascii_uppercase() })
}
Some(Value::String(n)) => construction(n.trim()),
Some(Value::Number(id)) => match id.as_u64() {
Some(id) => construction(&id.to_string()),
@@ -1002,9 +1087,17 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.offset";
let base = plane_param(s, p.get("base"), cmd)?;
let d = req_expr(cmd, p, "offset")?;
check_expr(s, &d, Kind::Length, cmd, "offset")?;
// A planar body face: attached exactly, as a sketch on that face is, so the plane follows it.
if let Some(face) = p.get("base").filter(|b| b.get("face").is_some()) {
let base = super::sketch::face::capture(s, face, cmd)?;
super::sketch::face::capture_positions(s, &base)?;
let kind = FeatureKind::ConstructionPlane { plane: solvecraft_doc::PlaneRef::Offset { base: Box::new(base), distance: d } };
// The capture is already in the authoring frame.
return add_feature_in(s, p, kind, false);
}
let base = plane_param(s, p.get("base"), cmd)?;
add_feature(s, p, FeatureKind::ConstructionPlane { plane: solvecraft_doc::PlaneRef::Offset { base: Box::new(base), distance: d } })
}
@@ -1429,3 +1522,7 @@ mod offset_identity_tests;
#[cfg(test)]
#[path = "offset_edit_guard_tests.rs"]
mod offset_edit_guard_tests;
#[cfg(test)]
#[path = "offset_plane_face_tests.rs"]
mod offset_plane_face_tests;
+2 -2
View File
@@ -11,7 +11,7 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("SurfaceSculpt", "Boundary Fill", boundary_fill).at("SOLID", "CREATE").icon("boundary_fill").params(
CommandSpec::new("solid.boundary_fill", "Boundary Fill", boundary_fill).at("SOLID", "CREATE").icon("boundary_fill").params(
"tools: [bodies]; cells: [[x,y,z] a point inside each cell to fill (a cell: inside some tools, outside the rest)]; operation?: new|join|cut|intersect; remove_tools?: bool",
),
CommandSpec::new("solid.coil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params(
@@ -422,7 +422,7 @@ pub(super) fn snap_point(s: &Session, kind: &str, at: Vec3) -> Option<Vec3> {
}
fn boundary_fill(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SurfaceSculpt";
let cmd = "solid.boundary_fill";
let tools = string_list(p, "tools");
let st = s.model.state();
if tools.is_empty() || tools.len() > 64 || tools.iter().any(|t| st.body(t).is_none()) {
+5 -1
View File
@@ -205,6 +205,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
json!({
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": i >= marker,
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()), "ms": r.map(|r| r.ms),
"boolean_diagnostics": r.map(|r| &r.boolean_diagnostics),
})
})
.collect();
@@ -261,7 +262,10 @@ fn model_faces(s: &mut Session, p: &Value) -> Result<Value> {
.body
.faces(tol)?
.iter()
.map(|f| json!({"index": f.index, "name": names.get(f.index), "area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal}))
.map(|f| {
json!({"index": f.index, "name": names.get(f.index), "exact": solvecraft_doc::naming::face_name_exact(b, f.index),
"area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal})
})
.collect();
Ok(json!({"body": b.name, "faces": faces}))
}
+37
View File
@@ -0,0 +1,37 @@
//! Parametric reconstruction of imported mechanical mesh bodies.
use serde_json::Value;
use solvecraft_doc::FeatureKind;
use solvecraft_doc::expr::Kind;
use super::{
CommandSpec,
features::{add_feature, check_expr},
};
use crate::{
Result, Session,
params::{bad, expr, str_},
};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("mesh.convert", "Convert Mesh", convert)
.at("MESH", "MODIFY")
.icon("import")
.params("body: mesh body name; tolerance?: length expression (default 0.05 mm): reconstruct a closed constant-section mechanical mesh as editable planar and cylindrical faces, with a parametric timeline feature; name?"),
];
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "mesh.convert";
let body = str_(p, "body").filter(|b| !b.trim().is_empty()).ok_or_else(|| bad(cmd, "select a mesh body"))?;
let state = s.model.state();
let source = state.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
if !source.body.is_mesh() {
return Err(bad(cmd, "select a mesh body"));
}
if s.doc.model_body_component(source) != s.active_component {
return Err(bad(cmd, "activate the mesh body's component before converting it"));
}
let tolerance = expr(p, "tolerance").unwrap_or_else(|| "0.05 mm".into());
check_expr(s, &tolerance, Kind::Length, cmd, "tolerance")?;
add_feature(s, p, FeatureKind::MeshConvert { body: body.into(), tolerance })
}
+3 -3
View File
@@ -1,6 +1,6 @@
//! The command registry. Ids are SolveCraft's own dotted ids (`solid.extrude`,
//! `sketch.rectangle.two_point`); the toolbar placement (`tab`, `panel`) follows the familiar
//! Design workspace layout. Ids used before the rename still resolve (`legacy_ids`).
//! Design workspace layout.
/// The params-doc note of every command that adds sketch geometry (see `sketch::created`).
macro_rules! created_doc {
@@ -26,6 +26,7 @@ mod file;
mod inspect;
pub(crate) mod joints;
mod measure_sel;
mod mesh;
pub(crate) mod motion;
mod parts;
mod section;
@@ -157,6 +158,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(sketch_project::COMMANDS.iter());
v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter());
v.extend(mesh::COMMANDS.iter());
v.extend(construct::COMMANDS.iter());
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
@@ -178,7 +180,5 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
let all = command_specs();
// Ids from before the rename still find their command.
let id = crate::legacy_ids::current_id(id);
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
}
@@ -0,0 +1,82 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use crate::Session;
use serde_json::{Value, json};
use solvecraft_doc::construct::ConstructGeom;
use solvecraft_doc::{Document, FeatureKind, PlaneRef};
use solvecraft_geom::Plane;
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command} {params}: {e}"))
}
fn plane(s: &Session, name: &str) -> Plane {
let state = s.model.state();
match state.construct.iter().find(|c| c.name == name).map(|c| &c.geom) {
Some(ConstructGeom::Plane(p)) => *p,
other => panic!("construction plane `{name}`: {other:?}"),
}
}
fn volume(s: &Session) -> f64 {
s.model.state().bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).sum()
}
#[test]
fn offset_plane_from_a_planar_face_follows_the_face() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
// Top face (z = 20), 5 mm out along its normal.
run(&mut s, "construct.plane.offset", json!({"base": {"face": [5, 5, 20]}, "offset": 5, "name": "Above"}));
let above = plane(&s, "Above");
assert!((above.origin.z - 25.0).abs() < 1e-9, "{above:?}");
assert!((above.normal().z.abs() - 1.0).abs() < 1e-9, "{above:?}");
// Stored as an exact face attachment, not a frozen copy of the plane.
let feature = s.doc.find_feature("Above").unwrap();
let FeatureKind::ConstructionPlane { plane: PlaneRef::Offset { base, .. } } = &feature.kind else { panic!("offset plane") };
assert!(matches!(base.as_ref(), PlaneRef::Face { anchor: Some(_), .. }), "{base:?}");
// Side face (x = 40), 3 mm out.
run(&mut s, "construct.plane.offset", json!({"base": {"face": [40, 15, 10]}, "offset": 3, "name": "Side"}));
let side = plane(&s, "Side");
assert!((side.origin.x - 43.0).abs() < 1e-9, "{side:?}");
assert!((side.normal().x.abs() - 1.0).abs() < 1e-9, "{side:?}");
// A 10 × 10 × 10 pad sketched on the new plane sits 5 mm above the box.
run(&mut s, "sketch.create", json!({"plane": "Above"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": "new"}));
s.refresh();
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
assert!((volume(&s) - (40.0 * 30.0 * 20.0 + 1000.0)).abs() < 1e-6, "{}", volume(&s));
// Taller and longer box: both planes move with their faces.
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "30", "length": "50"}}));
assert!((plane(&s, "Above").origin.z - 35.0).abs() < 1e-9);
assert!((plane(&s, "Side").origin.x - 53.0).abs() < 1e-9);
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
// Saved and reopened, the attachment still resolves.
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
assert!((plane(&reopened, "Above").origin.z - 35.0).abs() < 1e-9);
assert!((plane(&reopened, "Side").origin.x - 53.0).abs() < 1e-9);
}
#[test]
fn offset_plane_rejects_bad_face_picks_without_panicking() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
for base in [
json!({"face": [100, 100, 100]}),
json!({"face": [1e300, 0, 0]}),
json!({"face": "top"}),
json!({"face": [5, 5]}),
json!({"face": [5, 5, 20], "index": 0}),
json!({"face": [5, 5, 20], "body": 7, "index": 0}),
] {
let features = s.doc.features.len();
assert!(s.execute("construct.plane.offset", &json!({"base": base, "offset": 5})).is_err(), "{base}");
assert_eq!(s.doc.features.len(), features, "{base}");
}
}
+110
View File
@@ -34,6 +34,71 @@ fn plate_with_hole() -> Session {
const PLATE: f64 = 100.0 * 50.0 * 5.0;
const HOLE: f64 = PI * 4.0 * 5.0;
#[test]
fn circular_axis_origin_rejects_invalid_values_atomically() {
let mut s = plate_with_hole();
run(&mut s, "parameters.add", json!({"name":"cx","expression":"50 mm"}));
run(&mut s, "parameters.add", json!({"name":"cy","expression":"25 mm"}));
let before = s.doc.to_json();
let undo = s.undo.len();
for origin in [
json!(["cx", "cy", 0]),
json!(["50", 25, 0]),
json!([0, 0]),
json!([0, 0, 0, 0]),
json!(null),
json!(false),
json!({"x":0,"y":0}),
json!([1e9, 0, 0]),
json!([0, 1e100, 0]),
] {
for (cmd, params) in [
("solid.pattern.circular", json!({"features":["Hole1"],"axis":{"origin":origin,"dir":[0,0,1]},"count":4})),
("construct.plane.at_angle", json!({"base":"XY","axis":{"origin":origin,"dir":[1,0,0]},"angle":45})),
] {
let error = s.execute(cmd, &params).unwrap_err().to_string();
assert!(error.contains("axis.origin") && error.contains("expressions are not supported"), "{error}");
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.undo.len(), undo);
}
}
}
#[test]
fn circular_numeric_axis_origin_preserves_hole_geometry_and_reopens() {
for origin in [json!([50, 50, 0]), json!({"x":50,"y":50,"z":0})] {
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":[100,100]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":10}));
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.circle.center", json!({"center":[50,20],"radius":5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":10,"operation":"cut","name":"Hole"}));
let before = s.doc.to_json();
run(&mut s, "solid.pattern.circular", json!({"features":["Hole"],"axis":{"origin":origin,"dir":[0,0,1]},"count":4}));
let expected = 100.0 * 100.0 * 10.0 - 4.0 * PI * 25.0 * 10.0;
assert!(rel(volume(&mut s), expected) < 1e-4);
let saved = s.doc.to_json();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.to_json(), saved);
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&saved).unwrap());
assert!(rel(volume(&mut reopened), expected) < 1e-4);
}
// The documented omission of origin still means zero.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[20,0,0],"body_name":"Cube"}));
let before = s.doc.to_json();
run(&mut s, "solid.pattern.circular", json!({"bodies":["Cube"],"axis":{"dir":[0,0,1]},"count":4}));
let implicit = s.doc.to_json();
let mut explicit = Session::new(solvecraft_doc::Document::from_json(&before).unwrap());
run(&mut explicit, "solid.pattern.circular", json!({"bodies":["Cube"],"axis":{"origin":[0,0,0],"dir":[0,0,1]},"count":4}));
assert_eq!(implicit, explicit.doc.to_json());
}
#[test]
fn pattern_on_path_spacing_extent_and_flip() {
let mut s = plate_with_hole();
@@ -142,3 +207,48 @@ fn patterns_of_a_blind_hole_drill_every_copy() {
));
assert!((c - 2.0 * one).abs() < 1e-3 * one, "{c} vs 2 × {one}");
}
/// 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) {
use std::time::{Duration, Instant};
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 200, "width": 100, "height": 1, "operation": "new_body", "body_name": "Plate"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "2", "name": "H"}));
run(&mut s, "sketch.circle.center", json!({"center": [4, 4], "diameter": 2}));
run(&mut s, "sketch.finish", json!({}));
run(
&mut s,
"solid.extrude",
json!({"sketch": "H", "distance": "4", "direction": "negative", "operation": "cut", "targets": ["Plate"], "name": "Hole"}),
);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let t = Instant::now();
let r = s.execute(
"solid.pattern.rectangular",
&json!({"features": ["Hole"], "dir1": [1, 0, 0], "count1": count1, "spacing1": 6, "dir2": [0, 1, 0], "count2": count2, "spacing2": 6}),
);
let took = t.elapsed();
let v = r.map(|_| volume(&mut s)).map_err(|e| e.to_string());
let _ = tx.send((v, took));
});
let (v, took) = rx.recv_timeout(Duration::from_secs(900)).expect("the hole pattern did not finish within 900 s");
let n = (count1 * count2) as f64;
let v = v.expect("pattern");
// The measure meshes the bores: each hole reads about 0.06 % small, so allow 0.1 % a hole.
assert!((v - (200.0 * 100.0 - n * PI)).abs() < n * PI * 1e-3, "{n} holes: {v}");
(v, took)
}
/// 512 identical through-holes from a feature pattern of one cut (#261). 128 took 30 s and 512 did
/// not finish in 150 s: each copy was cut into an ever more complex plate. The time must grow about
/// linearly with the count. A ratio, not a time: CI runs every test binary at once and is many
/// times slower than a machine running this alone (about 6 s for 512 holes there).
#[test]
fn pattern_of_512_hole_cuts_finishes_in_reasonable_time() {
let (_, t128) = hole_pattern(16, 8);
let (_, t512) = hole_pattern(32, 16);
// Linear is 4×, the old quadratic cost 16× or more.
assert!(t512.as_secs_f64() < 8.0 * t128.as_secs_f64() + 2.0, "128 holes took {t128:?}, 512 took {t512:?}");
}
+2
View File
@@ -25,6 +25,8 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
// A named plane's world axis: which component of a point lies along its normal.
let (origin, normal, axis) = match p.get("plane") {
Some(Value::String(n)) if Plane::named(n.trim()).is_some() => {
// Apply the same ambiguity check as modelling commands before treating a name as an origin alias.
super::features::plane_param(s, p.get("plane"), cmd)?;
let pl = Plane::named(n.trim()).ok_or_else(|| bad(cmd, format!("unknown plane `{n}`")))?;
let normal = pl.normal();
let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5);
+1 -1
View File
@@ -16,7 +16,7 @@ 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), \
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs)",
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)
.at("SHEET METAL", "CREATE")
+88
View File
@@ -1558,3 +1558,91 @@ fn partial_depth_intersect_sub_extrusion_resolution_layers_refuse_atomically() {
assert!((solvecraft_kernel::measure(&s.world_state().body(&name).unwrap().body).unwrap().volume - 15000.0).abs() < 1e-5);
}
}
/// A 0.8 mm steel rule and a base flange from a closed outline on XY (the sheet spans z 0..0.8).
fn outline_blank(s: &mut Session, outline: Value) {
run(s, "sheet.manage_rules", json!({"name": "Steel 0.8", "thickness": "0.8", "active": true}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Blank"}));
run(s, "sketch.line", json!({"points": outline, "closed": true}));
run(s, "sketch.finish", json!({}));
run(s, "sheet.flange", json!({"sketch": "Blank"}));
}
/// A 100 x 40 blank with two 15 mm tabs on its right edge (y 5..12 and y 28..35), on one bend line.
fn two_tab_blank(s: &mut Session) {
outline_blank(
s,
json!([[0, 0], [100, 0], [100, 5], [115, 5], [115, 12], [100, 12], [100, 28], [115, 28], [115, 35], [100, 35], [100, 40], [0, 40]]),
);
}
/// Folding one tab must not be refused because another tab lies on the same line (#264).
#[test]
fn fold_one_of_two_collinear_tabs() {
let mut s = Session::default();
two_tab_blank(&mut s);
let flat = volume(&mut s);
run(&mut s, "sheet.fold", json!({"points": [[107, 5, 0.8], [107, 12, 0.8]], "fixed": [50, 20, 0.8]}));
assert!(rel(volume(&mut s), flat) < 1e-3, "a fold keeps the material");
}
/// One fold line across both tabs bends both (#264).
#[test]
fn fold_across_two_collinear_tabs() {
let mut s = Session::default();
two_tab_blank(&mut s);
let flat = volume(&mut s);
run(&mut s, "sheet.fold", json!({"points": [[107, 5, 0.8], [107, 35, 0.8]], "fixed": [50, 20, 0.8]}));
assert!(rel(volume(&mut s), flat) < 1e-3);
let bb = &run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"];
assert!(bb["max"][2].as_f64().unwrap() > 7.0, "both tabs stand up: {bb}");
}
/// Edge flanges on both tab ends in one feature (two separate features already work) (#264).
#[test]
fn edge_flanges_on_two_collinear_tab_ends_in_one_feature() {
let mut s = Session::default();
two_tab_blank(&mut s);
run(&mut s, "sheet.flange", json!({"edges": [[115, 8, 0.8], [115, 31, 0.8]], "height": "10"}));
let mut separate = Session::default();
two_tab_blank(&mut separate);
run(&mut separate, "sheet.flange", json!({"edges": [[115, 8, 0.8]], "height": "10"}));
run(&mut separate, "sheet.flange", json!({"edges": [[115, 31, 0.8]], "height": "10"}));
assert!(rel(volume(&mut s), volume(&mut separate)) < 1e-6);
}
/// `fixed` inside a cut-out touches no material: the fold must refuse it or say which side it kept (#263).
#[test]
fn fold_fixed_point_off_the_sheet_is_not_silently_ignored() {
let mut s = Session::default();
outline_blank(&mut s, json!([[0, 0], [100, 0], [100, 60], [0, 60]]));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "2", "name": "Window"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 15], "p1": [40, 45]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Window", "distance": "4", "direction": "negative", "operation": "cut"}));
match s.execute("sheet.fold", &json!({"points": [[80, 0, 0.8], [80, 60, 0.8]], "fixed": [25, 30, 0.8]})) {
Err(e) => assert!(e.to_string().contains("fixed"), "{e}"),
Ok(r) => {
let warned =
["warning", "warnings"].iter().any(|k| r.get(*k).is_some_and(|w| !w.is_null() && w != &json!([]) && w.to_string().contains("fixed")));
assert!(warned, "fixed [25, 30] is inside the window, yet the fold succeeded silently: {r}");
}
}
}
/// A tab whose fold line sits exactly at its root: the bend zone reaches the blank outline beside
/// the tab. Every edge is straight, so the error must not blame a slanted or curved edge (#262).
#[test]
fn fold_at_a_tab_root_names_the_real_cause() {
let mut s = Session::default();
outline_blank(&mut s, json!([[0, 0], [100, 0], [100, 50], [60, 50], [60, 70], [40, 70], [40, 50], [0, 50]]));
if let Err(e) = s.execute("sheet.fold", &json!({"points": [[40, 50, 0.8], [60, 50, 0.8]], "fixed": [50, 25, 0.8]})) {
let e = e.to_string();
assert!(!e.contains("slanted or curved"), "all edges are straight: {e}");
assert!(e.contains("bend zone"), "name the bend zone reaching the outline: {e}");
}
// 1 mm into the tab the same fold works.
let mut ok = Session::default();
outline_blank(&mut ok, json!([[0, 0], [100, 0], [100, 50], [60, 50], [60, 70], [40, 70], [40, 50], [0, 50]]));
run(&mut ok, "sheet.fold", json!({"points": [[40, 51, 0.8], [60, 51, 0.8]], "fixed": [50, 25, 0.8]}));
}
+2 -2
View File
@@ -103,7 +103,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
.at("SKETCH", "CONSTRAINTS")
@@ -1601,7 +1601,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
}
let (unit, default_expr) =
if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) };
let e = value.clone().unwrap_or(default_expr);
let e = value.as_ref().map(|e| doc.with_design_unit(e, if is_angle { Kind::Angle } else { Kind::Length })).unwrap_or(default_expr);
let pname = doc.new_model_param(&e, unit);
// The value must evaluate.
let (vals, errs) = doc.param_values();
+154
View File
@@ -216,6 +216,47 @@ fn plane_along_a_path() {
run(&mut s, "sketch.create", json!({"plane": "P", "name": "OnPath"}));
}
#[test]
fn construction_on_a_3d_wire_ignores_its_sketch_plane_and_follows_edits() {
let mut s = Session::default();
let sketch = run(&mut s, "sketch.create", json!({"plane":{"origin":[100,200,300],"x_dir":[0,1,0],"y_dir":[0,0,1]}}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
let wire = run(&mut s, "sketch.line3d", json!({"points":[[10,20,10],[30,35,40]]}))["curves"][0].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
let path = json!({"sketch":sketch,"curve":wire});
run(&mut s, "construct.plane.along_path", json!({"path":path,"t":0.5,"name":"BridgePlane"}));
run(&mut s, "construct.point.along_path", json!({"path":path,"t":0.25,"name":"Quarter"}));
let p = plane(&s, "BridgePlane");
assert!(near(p.origin, Vec3::new(20.0, 27.5, 25.0)), "{p:?}");
assert!(parallel(p.normal(), Vec3::new(20.0, 15.0, 30.0)));
assert!(near(point(&s, "Quarter"), Vec3::new(15.0, 23.75, 17.5)));
run(&mut s, "sketch.edit", json!({"sketch":sketch}));
run(&mut s, "sketch.move3d", json!({"wire":wire,"index":1,"to":[50,35,40]}));
run(&mut s, "sketch.finish", json!({}));
let updated = plane(&s, "BridgePlane");
assert!(near(updated.origin, Vec3::new(30.0, 27.5, 25.0)), "{updated:?}");
assert!(parallel(updated.normal(), Vec3::new(40.0, 15.0, 30.0)));
let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
let mut cold = Session { doc: restored.into(), ..Default::default() };
cold.refresh();
assert!(near(plane(&cold, "BridgePlane").origin, updated.origin));
}
#[test]
fn plane_on_a_3d_wire_moves_with_its_component() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name":"Part"}));
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
let wire = run(&mut s, "sketch.line3d", json!({"points":[[0,0,10],[0,0,40]]}))["curves"][0].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "construct.plane.along_path", json!({"path":{"sketch":sketch,"curve":wire},"t":0.5,"name":"Section"}));
run(&mut s, "occurrence.move", json!({"occurrence":"Part:1","translate":[50,20,0],"capture":true}));
let p = plane(&s, "Section");
assert!(near(p.origin, Vec3::new(50.0, 20.0, 25.0)), "{p:?}");
assert!(parallel(p.normal(), Vec3::Z));
}
/// Built from model geometry, they follow when the model changes: the box gets taller.
#[test]
fn construction_follows_the_model() {
@@ -272,3 +313,116 @@ fn plane_perpendicular_to_a_plane_through_a_line() {
// A line square to the plane has no single answer.
assert!(s.execute("construct.plane.perpendicular", &json!({"plane": "XY", "line": {"axis": "Z"}})).is_err());
}
#[test]
fn ambiguous_plane_aliases_refuse_without_document_or_history_changes() {
for alias in ["Top", "Front", "Right", "XY", "XZ", "YZ"] {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name":"height","expression":"19.05 mm"}));
run(&mut s, "sketch.create", json!({"plane":alias}));
run(&mut s, "sketch.finish", json!({}));
let made = run(&mut s, "construct.plane.offset", json!({"base":{"origin":[0,0,0],"normal":[0,0,1]},"offset":"height","name":"Lid"}));
let id = made["feature"].as_u64().unwrap();
legacy_plane_name(&mut s, id, alias);
let before = s.doc.to_json();
let history = s.undo.len();
for command in ["sketch.create", "inspect.section"] {
let error = s.execute(command, &json!({"plane":format!(" {} ",alias.to_lowercase())})).unwrap_err();
assert!(error.to_string().contains("ambiguous"), "{error}");
assert!(error.to_string().contains(&format!("feature id {id}")), "{error}");
let other = match alias {
"Top" => "XY",
"XY" => "Top",
"Front" => "XZ",
"XZ" => "Front",
"Right" => "YZ",
_ => "Right",
};
assert!(error.to_string().contains(&format!("use `{other}` for the origin plane")), "{error}");
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.undo.len(), history);
assert!(s.section.is_none());
}
run(&mut s, "sketch.create", json!({"plane":id}));
let inspected = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(inspected["plane"]["origin"], json!([0.0, 0.0, 19.05]));
let cut = run(&mut s, "inspect.section", json!({"plane":id}));
assert_eq!(cut["section"]["origin"], json!([0.0, 0.0, 19.05]));
run(&mut s, "parameters.change", json!({"name":"height","expression":"25.4 mm"}));
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
let old = run(&mut reopened, "sketch.inspect", json!({"sketch":"Sketch1"}));
assert_eq!(old["plane"]["origin"], json!([0.0, 0.0, 0.0]), "saved origin reference is not reinterpreted");
let linked = run(&mut reopened, "sketch.inspect", json!({"sketch":"Sketch2"}));
assert_eq!(linked["plane"]["origin"], json!([0.0, 0.0, 25.4]), "disambiguated reference follows edits and reopen");
}
}
#[test]
fn disambiguated_top_plane_pocket_survives_undo_and_cold_reopen() {
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":[100,200]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":19.05,"name":"Plate"}));
let top = run(&mut s, "construct.plane.offset", json!({"base":"XY","offset":19.05,"name":"Lid"}));
legacy_plane_name(&mut s, top["feature"].as_u64().unwrap(), "Top");
assert!(s.execute("sketch.create", &json!({"plane":"Top"})).unwrap_err().to_string().contains("ambiguous"));
run(&mut s, "sketch.create", json!({"plane":top["feature"]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[3,3],"p1":[97,30]}));
run(&mut s, "sketch.finish", json!({}));
let before = s.doc.to_json();
run(&mut s, "solid.extrude", json!({"distance":12.7,"direction":"negative","operation":"cut","name":"Pocket"}));
let saved = s.doc.to_json();
let expected = 100.0 * 200.0 * 19.05 - 94.0 * 27.0 * 12.7;
let measured = run(&mut s, "inspect.measure", json!({}));
assert!((measured["total"]["volume_mm3"].as_f64().unwrap() - expected).abs() < 1e-6, "{measured}");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.to_json(), saved);
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&saved).unwrap());
let measured = run(&mut reopened, "inspect.measure", json!({}));
assert!((measured["total"]["volume_mm3"].as_f64().unwrap() - expected).abs() < 1e-6, "{measured}");
}
/// A construction plane named like an origin plane, as an older saved design can hold.
fn legacy_plane_name(s: &mut Session, id: u64, name: &str) {
s.doc_mut().feature_mut(id).unwrap().name = name.to_string();
}
#[test]
fn construction_planes_cannot_take_origin_plane_names() {
let mut s = Session::default();
let made = run(&mut s, "construct.plane.offset", json!({"base":"XY","offset":5,"name":"Lid"}));
let id = made["feature"].as_u64().unwrap();
for alias in ["XY", "XZ", "YZ", "Top", "Front", "Right", " top ", "xz"] {
let before = s.doc.to_json();
let history = s.undo.len();
let created = s.execute("construct.plane.offset", &json!({"base":"XY","offset":10,"name":alias})).unwrap_err();
assert!(created.to_string().contains("reserved for an origin plane"), "{created}");
let renamed = s.execute("timeline.rename", &json!({"feature":id,"name":alias})).unwrap_err();
assert!(renamed.to_string().contains("reserved for an origin plane"), "{renamed}");
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.undo.len(), history);
}
// Other features may still use the names; only a plane parameter could confuse them.
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "timeline.rename", json!({"feature":"Sketch1","name":"Top"}));
run(&mut s, "timeline.rename", json!({"feature":id,"name":"Topside"}));
}
#[test]
fn ambiguity_error_names_the_other_origin_alias() {
for (name, other) in [("Top", "XY"), ("XY", "Top"), ("Front", "XZ"), ("XZ", "Front"), ("Right", "YZ"), ("YZ", "Right")] {
let mut s = Session::default();
let made = run(&mut s, "construct.plane.offset", json!({"base":"XY","offset":5,"name":"Lid"}));
legacy_plane_name(&mut s, made["feature"].as_u64().unwrap(), name);
let error = s.execute("sketch.create", &json!({"plane":name})).unwrap_err().to_string();
assert!(error.contains(&format!("use `{other}` for the origin plane")), "{error}");
// The suggested alias is unambiguous and reaches the origin plane.
run(&mut s, "sketch.create", json!({"plane":other}));
let inspected = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(inspected["plane"]["origin"], json!([0.0, 0.0, 0.0]));
}
}
+36 -7
View File
@@ -64,6 +64,8 @@ pub struct Run {
pub unsupported: Vec<(String, String)>,
/// Steps whose generated parameters a command refused.
pub rejected: usize,
pub boolean_fallbacks: std::collections::BTreeMap<solvecraft_kernel::BooleanStrategy, usize>,
pub boolean_reports_truncated: bool,
}
impl Run {
@@ -182,7 +184,7 @@ fn base(r: &mut Rng) -> Step {
("sketch.line", json!({"points": pts})),
("sketch.arc.three_point", json!({"start": [0, h], "end": [0, 0], "through": [-bulge, h / 2.0]})),
("sketch.finish", json!({})),
("Extrude", json!({"distance": d})),
("solid.extrude", json!({"distance": d})),
],
)
}
@@ -196,7 +198,7 @@ fn base(r: &mut Rng) -> Step {
("sketch.create", json!({"plane": "XZ"})),
("sketch.rectangle.two_point", json!({"p0": [r0, 0], "p1": [r1, h]})),
("sketch.finish", json!({})),
("Revolve", json!({"axis": "Z", "angle": angle})),
("solid.revolve", json!({"axis": "Z", "angle": angle})),
],
)
}
@@ -289,7 +291,15 @@ pub fn run(seed: u64, max_steps: usize) -> Run {
pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) -> Run {
let mut r = Rng::new(seed);
let mut s = Session::default();
let mut run = Run { seed, steps: Vec::new(), outcome: Outcome::ok(), unsupported: Vec::new(), rejected: 0 };
let mut run = Run {
seed,
steps: Vec::new(),
outcome: Outcome::ok(),
unsupported: Vec::new(),
rejected: 0,
boolean_fallbacks: Default::default(),
boolean_reports_truncated: false,
};
let mut last_feature: Option<String> = None;
let n = 2 + r.below(max_steps.max(2) - 1);
let mut tries = 0;
@@ -314,6 +324,12 @@ pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) ->
continue;
}
let kind = st.kind;
for result in s.model.results.iter().skip(before).filter(|result| result.error.is_none()) {
run.boolean_reports_truncated |= result.boolean_diagnostics.truncated;
for event in &result.boolean_diagnostics.events {
*run.boolean_fallbacks.entry(event.strategy).or_default() += 1;
}
}
run.steps.push(st);
if out.kind != "ok" {
run.outcome = out;
@@ -329,14 +345,19 @@ pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) ->
/// Run fixed steps (a recorded or minimised design): the index and outcome of the first step
/// that isn't ok (or the last step's, ok).
pub fn replay(steps: &[Step]) -> (usize, Outcome) {
let (index, outcome, _) = replay_session(steps);
(index, outcome)
}
fn replay_session(steps: &[Step]) -> (usize, Outcome, Session) {
let mut s = Session::default();
for (i, st) in steps.iter().enumerate() {
let out = apply(&mut s, st);
if out.kind != "ok" {
return (i, out);
return (i, out, s);
}
}
(steps.len().saturating_sub(1), Outcome::ok())
(steps.len().saturating_sub(1), Outcome::ok(), s)
}
/// The start of a message, to tell failures apart (numbers vary between versions of a case).
@@ -367,6 +388,14 @@ pub fn minimise(run: &Run) -> Run {
}
}
}
let (_, outcome) = replay(&steps);
Run { seed: run.seed, steps, outcome, unsupported: Vec::new(), rejected: 0 }
let (_, outcome, session) = replay_session(&steps);
let mut boolean_fallbacks = std::collections::BTreeMap::new();
let mut boolean_reports_truncated = false;
for result in &session.model.results {
boolean_reports_truncated |= result.boolean_diagnostics.truncated;
for event in &result.boolean_diagnostics.events {
*boolean_fallbacks.entry(event.strategy).or_default() += 1;
}
}
Run { seed: run.seed, steps, outcome, unsupported: Vec::new(), rejected: 0, boolean_fallbacks, boolean_reports_truncated }
}
-236
View File
@@ -1,236 +0,0 @@
//! Legacy migration: the command ids SolveCraft used before 2026-10-09, when many commands
//! carried the id of the matching command in another CAD program, and the dotted ids that
//! replaced them. This is the one place the old ids live. Old scripts, recipes, control-channel
//! calls, saved shortcuts and toolbar layouts keep working: `find_command` and the preference
//! loaders map an old id through `current_id`. `solvecraft-tools/rename_ids.sh` converts a
//! worktree with this same table.
/// (old id, current id).
pub const LEGACY_IDS: &[(&str, &str)] = &[
("AsBuiltPositionsCmd", "component.revert_position"),
("FusionCreateComponentsFromBodiesCommand", "component.from_bodies"),
("FusionCreateNewComponentCommand", "component.create"),
("SnapshotCmd", "component.capture_position"),
("FusionShowDesignConfigPanelCmd", "config.table"),
("FusionStartDesignConfigModeCmd", "config.configure"),
("FusionComputeAllCommand", "timeline.compute_all"),
("FusionDeleteCommand", "timeline.delete"),
("FusionRenameTimelineEntryCommand", "timeline.rename"),
("RedoCommand", "edit.redo"),
("UndoCommand", "edit.undo"),
("timeline.rollTo", "timeline.roll_to"),
("AlignCmd", "solid.align"),
("EmbossCmd", "solid.emboss"),
("FusionReplaceFaceCommand", "solid.replace_face"),
("FusionRibCommand", "solid.rib"),
("FusionWebCommand", "solid.web"),
("PrimitiveCoil", "solid.coil"),
("SoftDeleteCommand", "solid.remove"),
("ConstructionPlaneAtAngleCommand", "construct.plane.at_angle"),
("ConstructionPlaneOffsetFromPlaneCommand", "construct.plane.offset"),
("Extrude", "solid.extrude"),
("FusionChamferCommand", "solid.chamfer"),
("FusionCombineCommand", "solid.combine"),
("FusionDraftCommand", "solid.draft"),
("FusionFilletEdgesCommand", "solid.fillet"),
("FusionHoleCommand", "solid.hole"),
("FusionMoveCommand", "solid.move"),
("FusionOffsetFacesCommand", "solid.offset_face"),
("FusionPressPullCommand", "solid.press_pull"),
("FusionShellBodyCommand", "solid.shell"),
("FusionSplitBodyCommand", "solid.split_body"),
("FusionThreadCommand", "solid.thread"),
("MirrorCommand", "solid.mirror"),
("ModifyScale", "solid.scale"),
("PatternCircular", "solid.pattern.circular"),
("PatternOnPath", "solid.pattern.path"),
("PatternRectangular", "solid.pattern.rectangular"),
("PrimitiveBox", "solid.box"),
("PrimitiveCylinder", "solid.cylinder"),
("PrimitivePipe", "solid.pipe"),
("PrimitiveSphere", "solid.sphere"),
("PrimitiveTorus", "solid.torus"),
("Revolve", "solid.revolve"),
("SolidLoft", "solid.loft"),
("StockModelCommand", "solid.bounding_solid"),
("Sweep", "solid.sweep"),
("ExportCommand", "file.export"),
("FusionImportCommandFromToolbar", "file.insert_step"),
("FusionSaveAsSTLCommand", "file.save_mesh"),
("NewDocumentCommand", "file.new"),
("ParaMeshInsertAlignCommand", "file.insert_mesh"),
("SaveDocumentAsCommand", "file.save_as"),
("SaveDocumentCommand", "file.save"),
("AppearanceCommand", "appearance.assign"),
("MeasureCommand", "inspect.measure"),
("PhysicalMaterialCommand", "material.assign"),
("FusionMotionRelationshipCommand", "joint.motion_link"),
("FusionMoveJointsCommand", "joint.drive"),
("InterferenceCheckCommand", "inspect.interference"),
("JointAsBuiltCmd", "joint.as_built"),
("JointAssembleCmdNew", "joint.create"),
("JointOrigin", "joint.origin"),
("RigidGroupCmd", "joint.rigid_group"),
("ContactSetCmd", "contact.create"),
("DisableAllContactCmd", "contact.disable_all"),
("EnableAllContactCmd", "contact.enable_all"),
("EnableContactSetsCmd", "contact.enable_sets"),
("FusionMotionStudyCommand", "motion.study"),
("ChangeParameterCommand", "parameters.change"),
("FusionFastenersCommand", "parts.fastener"),
("FusionAssignPlasticRuleCommand", "plastic.assign_rule"),
("FusionBossCommand", "plastic.boss"),
("FusionLipCommand", "plastic.lip"),
("FusionManagePlasticRuleCommand", "plastic.manage_rules"),
("FusionRestCommand", "plastic.rest"),
("FusionSnapFitCommand", "plastic.snap_fit"),
("FusionHalfSectionViewCommand", "inspect.section"),
("ConvertToSheetMetalCmd", "sheet.convert"),
("FusionSheetMetalFlangeCommand", "sheet.flange"),
("FusionSheetMetalFlatPatternCmd", "sheet.flat_pattern"),
("FusionSheetMetalHemFlangeCommand", "sheet.hem"),
("FusionSheetMetalRulesCommand", "sheet.manage_rules"),
("FusionSheetmetalUnfoldCommand", "sheet.unfold"),
("SheetMetalFoldCmd", "sheet.fold"),
("FusionAddCanvasCommand", "canvas.insert"),
("FusionAddEditDecalCommand", "canvas.decal"),
("ConstraintSmooth", "sketch.constraint.curvature"),
("SketchAutoConstrainAndFinish", "sketch.finish_auto_constrain"),
("SketchAutoConstraintAndDimCmd", "sketch.auto_constrain"),
("SketchAutoConstraintAndDimFromDatumCmd", "sketch.auto_constrain_from_datum"),
("SketchConstrainer", "sketch.constrainer"),
("SketchPolygonConstraintCmd", "sketch.constraint.polygon"),
("ArcTangent", "sketch.arc.tangent"),
("BlendG1CurveSketchCmd", "sketch.blend_curve"),
("CircleElipse", "sketch.ellipse"),
("CircleTanTanRadius", "sketch.circle.two_tangent"),
("CircleThreeTangent", "sketch.circle.three_tangent"),
("ConicCurveCmd", "sketch.conic"),
("DrawCVMSpline3D", "sketch.spline.control_point"),
("DrawCVMSpline5D", "sketch.spline.control_point_5"),
("DrawSpline", "sketch.spline.fit_point"),
("MTextCmd", "sketch.text"),
("ShapeArcSlotCenterTwoPoint", "sketch.slot.arc_center"),
("ShapeArcSlotThreePoint", "sketch.slot.arc_three_point"),
("ShapeSlotCenterPoint", "sketch.slot.center_point"),
("SketchMidpointLine", "sketch.line.midpoint"),
("ImportDxfFileCommand", "sketch.insert_dxf"),
("SketchImportSVG", "sketch.insert_svg"),
("FusionCenterOfMassCommand", "inspect.center_of_mass"),
("FusionCurvatureCombAnalysisCommand", "inspect.curvature_comb"),
("FusionIsoCurveAnalysisCommand", "inspect.isocurve"),
("FusionMinimumRadiusAnalysisCommand", "inspect.minimum_radius"),
("BreakSketchCmd", "sketch.break"),
("ChamferSketchDistanceAngle", "sketch.chamfer.distance_angle"),
("ChamferSketchDistanceDistance", "sketch.chamfer.two_distance"),
("ChamferSketchEqualDistance", "sketch.chamfer.equal_distance"),
("CircularSketchPatternCommand", "sketch.pattern.circular"),
("ExtendSketchCmd", "sketch.extend"),
("FilletSketchCmd", "sketch.fillet"),
("MirrorSketchCommand", "sketch.mirror"),
("Offset", "sketch.offset"),
("RectangularSketchPatternCommand", "sketch.pattern.rectangular"),
("SketchScaleCmd", "sketch.scale"),
("TrimSketchCmd", "sketch.trim"),
("FitCurvesToSectionCommand", "sketch.fit_curves_to_section"),
("Include3DGeometry", "sketch.include_3d"),
("IntersectCmd", "sketch.intersect"),
("IntersectionCurve", "sketch.intersection_curve"),
("ProjectNewCmd", "sketch.project"),
("ProjectToSurface", "sketch.project_to_surface"),
("SketchIsoparametricCurve", "sketch.isoparametric_curve"),
("SpunProfileCmd", "sketch.spun_profile"),
("ArcCenterTwoPoint", "sketch.arc.center_point"),
("ArcThreePoint", "sketch.arc.three_point"),
("CircleCenterRadius", "sketch.circle.center"),
("CircleThreePoint", "sketch.circle.three_point"),
("CircleTwoPoint", "sketch.circle.two_point"),
("ConstraintCoincident", "sketch.constraint.coincident"),
("ConstraintCollinear", "sketch.constraint.collinear"),
("ConstraintConcentric", "sketch.constraint.concentric"),
("ConstraintEqual", "sketch.constraint.equal"),
("ConstraintFix", "sketch.constraint.fix"),
("ConstraintHorizontalVertical", "sketch.constraint.horizontal_vertical"),
("ConstraintMidPoint", "sketch.constraint.midpoint"),
("ConstraintParallel", "sketch.constraint.parallel"),
("ConstraintPerpendicular", "sketch.constraint.perpendicular"),
("ConstraintSymmetry", "sketch.constraint.symmetry"),
("ConstraintTangent", "sketch.constraint.tangent"),
("DrawPoint", "sketch.point"),
("DrawPolyline", "sketch.line"),
("ShapePolygonCircumscribed", "sketch.polygon.circumscribed"),
("ShapePolygonEdge", "sketch.polygon.edge"),
("ShapePolygonInscribed", "sketch.polygon.inscribed"),
("ShapeRectangleCenter", "sketch.rectangle.center"),
("ShapeRectangleThreePoint", "sketch.rectangle.three_point"),
("ShapeRectangleTwoPoint", "sketch.rectangle.two_point"),
("ShapeSlotCenterToCenter", "sketch.slot.center_to_center"),
("ShapeSlotOverall", "sketch.slot.overall"),
("SketchActivate", "sketch.edit"),
("SketchCreate", "sketch.create"),
("SketchDimension", "sketch.dimension"),
("SketchStop", "sketch.finish"),
("FusionAccessibilityAnalysisCommand", "inspect.accessibility"),
("FusionCurvatureMapAnalysisCommand", "inspect.curvature_map"),
("FusionDraftAnalysisCommand", "inspect.draft"),
("FusionEnvironmentMapAnalysisCommand", "inspect.environment_map"),
("FusionZebraAnalysisCommand", "inspect.zebra"),
("FusionExtendCommand", "surface.extend"),
("FusionSurfacePatchCommand", "surface.patch"),
("FusionSurfaceStitchCommand", "surface.stitch"),
("FusionSurfaceThickenCommand", "surface.thicken"),
("FusionSurfaceTrimCommand", "surface.trim"),
("FusionSplitFaceCommand", "solid.split_face"),
("ConstructionAxisThroughCylinderCommand", "construct.axis.through_cylinder"),
("ConstructionAxisNormalToFaceCommand", "construct.axis.normal_to_face"),
("ConstructionAxisFromTwoPlanesCommand", "construct.axis.two_planes"),
("ConstructionAxisThroughTwoPointsCommand", "construct.axis.two_points"),
("ConstructionAxisThroughEdgeCommand", "construct.axis.edge"),
("ConstructionPointFromPointCommand", "construct.point.vertex"),
("ConstructionPointThroughTwoEdgesCommand", "construct.point.two_edges"),
("ConstructionPointFromThreePlanesCommand", "construct.point.three_planes"),
("ConstructionPointFromCircleOrSphereCommand", "construct.point.center"),
("ConstructionPointAtEdgeAndPlaneCommand", "construct.point.edge_plane"),
("ConstructionPointAlongPathCommand", "construct.point.along_path"),
("ConstructionMidPlaneCommand", "construct.plane.midplane"),
("ConstructionPlaneFromTwoEdgesCommand", "construct.plane.two_edges"),
("ConstructionPlaneFromThreePointsCommand", "construct.plane.three_points"),
("ConstructionTangentPlaneCommand", "construct.plane.tangent"),
("ConstructionPlaneAlongPathCommand", "construct.plane.along_path"),
];
/// The current id for a command id that may be a legacy one (unchanged otherwise).
pub fn current_id(id: &str) -> &str {
LEGACY_IDS.iter().find(|(old, _)| old.eq_ignore_ascii_case(id)).map(|(_, new)| *new).unwrap_or(id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_legacy_id_names_a_command() {
let unknown: Vec<&str> =
LEGACY_IDS.iter().filter(|(_, new)| crate::find_command(new).is_none_or(|c| c.id != *new)).map(|(_, new)| *new).collect();
// The construction set and Split Face are on a branch not yet merged when the table was made.
let pending = ["solid.split_face"];
let unknown: Vec<&str> = unknown
.into_iter()
.filter(|n| {
!pending.contains(n)
&& !n.starts_with("construct.axis")
&& !n.starts_with("construct.point")
&& ![
"construct.plane.midplane",
"construct.plane.two_edges",
"construct.plane.three_points",
"construct.plane.tangent",
"construct.plane.along_path",
]
.contains(n)
})
.collect();
assert!(unknown.is_empty(), "{unknown:?}");
assert_eq!(crate::find_command("SketchCreate").map(|c| c.id), Some("sketch.create"));
}
}
+48 -13
View File
@@ -10,7 +10,6 @@
pub mod cmd;
pub mod frames;
pub mod fuzz;
pub mod legacy_ids;
pub mod licences;
pub mod occurrences;
pub mod params;
@@ -107,6 +106,15 @@ pub enum Sel {
SketchPoint {
id: String,
},
/// Geometry of a different sketch, retained for projection preselection.
ExternalSketchCurve {
sketch: u64,
id: String,
},
ExternalSketchPoint {
sketch: u64,
id: String,
},
/// A constraint or dimension (by id) of the active sketch.
SketchConstraint {
id: String,
@@ -150,6 +158,28 @@ pub struct Preview {
pub after: Arc<ModelState>,
}
/// Prepared world geometry depends on design/model identity and placement context, not
/// selection or the view revision. Retained Arcs prevent pointer reuse after a design closes.
#[derive(Clone)]
struct WorldCache {
doc: Arc<Document>,
model: Arc<solvecraft_doc::ModelState>,
pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
active_component: u64,
active_occurrence_path: Option<Vec<u64>>,
world: Arc<solvecraft_doc::ModelState>,
}
impl WorldCache {
fn matches(&self, session: &Session, model: &Arc<solvecraft_doc::ModelState>) -> bool {
Arc::ptr_eq(&self.doc, &session.doc)
&& Arc::ptr_eq(&self.model, model)
&& self.pending_moves == session.pending_moves
&& self.active_component == session.active_component
&& self.active_occurrence_path == session.active_occurrence_path
}
}
/// An editing session on one design.
pub struct Session {
pub doc: Arc<Document>,
@@ -173,7 +203,7 @@ pub struct Session {
pub active_occurrence_path: Option<Vec<u64>>,
/// Occurrence moves not captured yet (Capture Position keeps them, a recompute drops them).
pub pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
world_cache: std::sync::Mutex<Option<(u64, Arc<solvecraft_doc::ModelState>)>>,
world_cache: std::sync::Mutex<Option<WorldCache>>,
occurrence_visibility_cache: std::sync::Mutex<Option<occurrences::VisibilityCache>>,
/// Features copied with `timeline.copy` (pasted by `timeline.paste`).
pub clipboard: Vec<solvecraft_doc::Feature>,
@@ -308,10 +338,10 @@ impl Session {
return st;
}
if let Ok(c) = self.world_cache.lock()
&& let Some((rev, w)) = c.as_ref()
&& *rev == self.revision
&& let Some(cached) = c.as_ref()
&& cached.matches(self, &st)
{
return w.clone();
return cached.world.clone();
}
let mut w = if self.pending_moves.is_empty() {
solvecraft_doc::world_state(&self.doc, &st)
@@ -350,7 +380,14 @@ impl Session {
}
let w = Arc::new(w);
if let Ok(mut c) = self.world_cache.lock() {
*c = Some((self.revision, w.clone()));
*c = Some(WorldCache {
doc: self.doc.clone(),
model: st,
pending_moves: self.pending_moves.clone(),
active_component: self.active_component,
active_occurrence_path: self.active_occurrence_path.clone(),
world: w.clone(),
});
}
w
}
@@ -503,7 +540,7 @@ impl Session {
active_component: self.active_component,
active_occurrence_path: self.active_occurrence_path.clone(),
pending_moves: self.pending_moves.clone(),
world_cache: Default::default(),
world_cache: std::sync::Mutex::new(self.world_cache.lock().ok().and_then(|cache| cache.clone())),
occurrence_visibility_cache: Default::default(),
clipboard: self.clipboard.clone(),
section: None,
@@ -519,12 +556,6 @@ impl Session {
pub fn invalidate_view(&mut self, previous_revision: u64) {
let old = self.revision;
self.revision = old.max(previous_revision) + 1;
if let Ok(mut cache) = self.world_cache.lock()
&& let Some((revision, _)) = cache.as_mut()
&& *revision == old
{
*revision = self.revision;
}
}
/// Commit commands evaluated on a scratch copy without losing this session's file,
@@ -640,6 +671,8 @@ mod delete_face_tests;
#[cfg(test)]
mod extent_tests;
#[cfg(test)]
mod naming_history_tests;
#[cfg(test)]
mod pattern_suppress_tests;
#[cfg(test)]
mod profile_curves_tests;
@@ -651,3 +684,5 @@ mod split_face_tests;
mod tests;
#[cfg(test)]
mod thread_tests;
#[cfg(test)]
mod world_placement_cache_tests;
+273
View File
@@ -0,0 +1,273 @@
//! Persistent names follow the kernel's exact face history: a reference to a face or edge finds
//! the face it was made on after upstream edits that split, merge, mirror, shrink or move what it
//! names, or says it could not. It never quietly attaches to another face, which is what finding
//! names again by sampling geometry could do.
use serde_json::{Value, json};
use solvecraft_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}"),
}
}
fn id(s: &Session, name: &str) -> u64 {
s.doc.features.iter().rev().find(|f| f.name == name).map(|f| f.id).unwrap_or_else(|| panic!("feature {name}"))
}
fn warning(s: &Session, name: &str) -> Option<String> {
s.model.result(id(s, name)).and_then(|r| r.warning.clone())
}
fn error(s: &Session, name: &str) -> Option<String> {
s.model.result(id(s, name)).and_then(|r| r.error.clone())
}
/// Whether any body holds `p`.
fn solid_at(s: &Session, p: [f64; 3]) -> bool {
s.model.state().bodies.iter().any(|b| b.body.contains(Vec3::new(p[0], p[1], p[2])))
}
/// Which body holds `p`.
fn body_at(s: &Session, p: [f64; 3]) -> Option<String> {
s.model.state().bodies.iter().find(|b| b.body.contains(Vec3::new(p[0], p[1], p[2]))).map(|b| b.name.clone())
}
fn names(s: &Session, body: &str) -> Vec<(String, bool)> {
let state = s.model.state();
let b = state.body(body).unwrap_or_else(|| panic!("body {body}"));
let names = solvecraft_doc::naming::face_names(b);
names.iter().enumerate().map(|(i, n)| (n.clone(), solvecraft_doc::naming::face_name_exact(b, i))).collect()
}
/// A 40 x 30 x 10 block ("Block": sketch lines l1 bottom, l2 right, l3 top, l4 left).
fn block(s: &mut Session) {
run(s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 10, "name": "Block"}));
}
/// A slot across the block from x = `slot_x` to `slot_x + slot_w`, from z = `floor` up through
/// the top: it splits the top (and, cut to the floor, the block) in two.
fn slot(s: &mut Session, floor: f64) {
run(s, "parameters.add", json!({"name": "slot_x", "expression": "15 mm"}));
run(s, "parameters.add", json!({"name": "slot_w", "expression": "10 mm"}));
run(s, "sketch.create", json!({"plane": "XY", "name": "SlotSketch"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [15, -5], "p1": [25, 35]}));
run(s, "sketch.dimension", json!({"entities": ["origin", "p1"], "type": "horizontal", "value": "slot_x"}));
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "slot_w"}));
run(s, "sketch.finish", json!({}));
let mut p = json!({"sketch": "SlotSketch", "distance": 12, "operation": "cut", "name": "Slot"});
if floor > 0.0 {
p["start_offset"] = json!(floor);
}
run(s, "solid.extrude", p);
}
fn change(s: &mut Session, name: &str, expression: &str) {
run(s, "parameters.change", json!({"name": name, "expression": expression}));
}
#[test]
fn faces_built_from_a_profile_are_named_exactly() {
let mut s = Session::default();
block(&mut s);
slot(&mut s, 5.0);
let block = id(&s, "Block");
let cut = id(&s, "Slot");
let n = names(&s, "Body1");
assert!(n.iter().all(|(_, exact)| *exact), "{n:?}");
let has = |want: &str| n.iter().any(|(x, _)| x == want);
// The top is in two pieces, each told apart by the walls only it touches.
assert!(has(&format!("F{block}:top#[F{block}:side:l4,F{cut}:side:l4]")), "{n:?}");
assert!(has(&format!("F{block}:top#[F{block}:side:l2,F{cut}:side:l2]")), "{n:?}");
// The slot's floor is its start cap (above the sketch plane), its walls its sketch curves.
for want in [format!("F{cut}:top"), format!("F{cut}:side:l2"), format!("F{cut}:side:l4"), format!("F{block}:start")] {
assert!(has(&want), "{want} in {n:?}");
}
}
#[test]
fn an_upstream_cut_that_splits_a_face_keeps_the_edge_that_bounded_it() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 10]], "radius": 2, "name": "Round"}));
let block = id(&s, "Block");
let round = s.doc.find_feature("Round").cloned().unwrap();
assert_eq!(round.edge_names, vec![format!("F{block}:side:l2|F{block}:top")]);
// Upstream of the fillet, a slot splits the top it bounds.
run(&mut s, "timeline.roll_to", json!({"feature": "Block"}));
slot(&mut s, 5.0);
run(&mut s, "timeline.roll_to", json!({}));
assert_eq!(error(&s, "Round"), None);
// Only one edge runs between the right wall and a piece of the top: the same edge.
assert_eq!(warning(&s, "Round"), None);
let strip = (4.0 - std::f64::consts::PI) * 30.0;
let v = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
assert!((v - (12000.0 - 10.0 * 30.0 * 5.0 - strip)).abs() < 0.05, "{v}");
assert!(!solid_at(&s, [39.9, 15.0, 9.9]), "the right top edge is rounded");
assert!(solid_at(&s, [0.1, 15.0, 9.9]), "the left one is not");
}
#[test]
fn a_piece_keeps_its_identity_when_the_cut_that_made_it_moves_or_shrinks() {
let mut s = Session::default();
block(&mut s);
slot(&mut s, 5.0);
let block = id(&s, "Block");
let cut = id(&s, "Slot");
// Raise the piece right of the slot, picked near the slot.
run(&mut s, "solid.offset_face", json!({"faces": [[27, 15, 10]], "distance": 2, "name": "Raise"}));
let raise = s.doc.find_feature("Raise").cloned().unwrap();
assert_eq!(raise.face_names, vec![format!("F{block}:top#[F{block}:side:l2,F{cut}:side:l2]")]);
assert!(solid_at(&s, [27.0, 15.0, 11.0]) && solid_at(&s, [39.0, 15.0, 11.0]) && !solid_at(&s, [10.0, 15.0, 11.0]));
// The slot moves right past the picked point, which now lies on the other piece: the name,
// not the point, says which piece was raised.
change(&mut s, "slot_x", "28 mm");
assert_eq!(error(&s, "Raise"), None);
assert_eq!(warning(&s, "Raise"), None);
assert!(solid_at(&s, [39.0, 15.0, 11.0]), "the right piece is still the raised one");
assert!(!solid_at(&s, [27.0, 15.0, 11.0]) && !solid_at(&s, [10.0, 15.0, 11.0]), "the left piece is not raised");
// Shrunk to a sliver, the slot still splits the top: the same piece, exactly.
change(&mut s, "slot_w", "0.05 mm");
assert_eq!(warning(&s, "Raise"), None);
assert!(solid_at(&s, [39.0, 15.0, 11.0]) && !solid_at(&s, [27.0, 15.0, 11.0]));
// Gone altogether (suppressed), the piece is the whole top again: said, not guessed.
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": true}));
let w = warning(&s, "Raise").unwrap_or_default();
assert!(w.contains("split") || w.contains("no longer exists"), "{w}");
}
#[test]
fn a_face_merged_into_another_answers_to_its_name_and_says_so() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "parameters.add", json!({"name": "boss_h", "expression": "5 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "B"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [40, 0], "p1": [60, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "B", "distance": "boss_h", "operation": "join", "name": "Boss"}));
let boss = id(&s, "Boss");
run(&mut s, "solid.offset_face", json!({"faces": [[50, 15, 5]], "distance": 1, "name": "Raise"}));
let raise = s.doc.find_feature("Raise").cloned().unwrap();
assert_eq!(raise.face_names, vec![format!("F{boss}:top")]);
assert!(solid_at(&s, [50.0, 15.0, 5.5]) && !solid_at(&s, [50.0, 15.0, 6.5]));
// As tall as the block, the boss's top and the block's are one face: the reference finds
// it and says it was merged.
change(&mut s, "boss_h", "10 mm");
assert_eq!(error(&s, "Raise"), None);
let w = warning(&s, "Raise").unwrap_or_default();
assert!(w.contains("merged"), "{w}");
assert!(solid_at(&s, [50.0, 15.0, 10.5]) && solid_at(&s, [20.0, 15.0, 10.5]), "the merged top is raised as one");
}
#[test]
fn mirrored_twins_keep_their_own_edges() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "parameters.add", json!({"name": "hole_x", "expression": "10 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "H"}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 15], "radius": 3, "id": "c"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "c.center"], "type": "horizontal", "value": "hole_x"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "c.center"], "type": "vertical", "value": "15 mm"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "H", "distance": 10, "operation": "cut", "name": "Hole"}));
run(&mut s, "solid.mirror", json!({"features": ["Hole"], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}, "name": "Twin"}));
let twin = id(&s, "Twin");
// Chamfer the twin's rim (at x = 30) only.
run(&mut s, "solid.chamfer", json!({"edges": [[33, 15, 10]], "distance": 1, "name": "Bevel"}));
let bevel = s.doc.find_feature("Bevel").cloned().unwrap();
assert!(bevel.edge_names.iter().all(|n| n.contains(&format!("@F{twin}.1"))), "{:?}", bevel.edge_names);
let rim = |c: f64| [c + 3.5, 15.0, 9.8];
assert!(!solid_at(&s, rim(30.0)) && solid_at(&s, rim(10.0)));
// The holes move toward each other: the chamfer's picked point is now off both rims, and
// the twin is the hole nearer it than its source is. Its name says which.
change(&mut s, "hole_x", "14 mm");
assert_eq!(error(&s, "Bevel"), None);
assert_eq!(warning(&s, "Bevel"), None);
assert!(!solid_at(&s, rim(26.0)), "the twin's rim is chamfered");
assert!(solid_at(&s, rim(14.0)), "its source's is not");
}
#[test]
fn pieces_of_a_cut_body_keep_their_body_names_when_the_cut_moves() {
let mut s = Session::default();
block(&mut s);
slot(&mut s, 0.0);
let right = body_at(&s, [39.0, 15.0, 5.0]).unwrap();
let left = body_at(&s, [1.0, 15.0, 5.0]).unwrap();
assert_ne!(right, left);
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 10]], "radius": 2, "name": "Round"}));
for x in ["5 mm", "22 mm", "15 mm", "2 mm"] {
change(&mut s, "slot_x", x);
assert_eq!(body_at(&s, [39.0, 15.0, 5.0]), Some(right.clone()), "slot at {x}");
assert_eq!(body_at(&s, [1.0, 15.0, 5.0]), Some(left.clone()), "slot at {x}");
assert_eq!(error(&s, "Round"), None, "slot at {x}");
assert_eq!(warning(&s, "Round"), None, "slot at {x}");
assert!(!solid_at(&s, [39.9, 15.0, 9.9]), "slot at {x}");
// Faces of the pieces, of one face cut in two, are told apart across the bodies.
let all: Vec<String> = [&right, &left].iter().flat_map(|b| names(&s, b)).map(|(n, _)| n).collect();
let unique: std::collections::BTreeSet<&String> = all.iter().collect();
assert_eq!(unique.len(), all.len(), "{all:?}");
}
}
#[test]
fn a_name_of_a_body_without_history_is_found_by_geometry_and_says_so_once_it_moves() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "len", "expression": "40 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "len"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Block"}));
// Replacing the top by a plane: an operation that reports no history.
run(&mut s, "solid.replace_face", json!({"faces": [[20, 15, 10]], "target": {"origin": [0, 0, 12], "normal": [0, 0, 1]}, "name": "Lift"}));
let state = s.model.state();
let body = state.bodies.first().unwrap();
assert!((0..solvecraft_doc::naming::face_names(body).len()).all(|i| !solvecraft_doc::naming::face_name_exact(body, i)));
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 12]], "radius": 2, "name": "Round"}));
// Where it was picked, the pick and the name agree: nothing to say.
assert_eq!(warning(&s, "Round"), None);
// Moved away from the pick, only geometry found it: the fillet says so.
change(&mut s, "len", "50 mm");
assert_eq!(error(&s, "Round"), None);
let w = warning(&s, "Round").unwrap_or_default();
assert!(w.contains("found by geometry, not exact history"), "{w}");
assert!(!solid_at(&s, [49.9, 15.0, 11.9]));
}
#[test]
fn a_sketch_on_a_certified_face_survives_a_fillet_and_a_cut_upstream_of_it() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "h", "expression": "10 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
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": "h", "name": "Block"}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 5]], "radius": 3, "name": "Corner"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "H"}));
run(&mut s, "sketch.circle.center", json!({"center": [8, 8], "radius": 2}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "H", "distance": 20, "operation": "cut", "name": "Hole"}));
run(&mut s, "sketch.create", json!({"plane": {"face": [25, 18, 10]}, "name": "Top"}));
run(&mut s, "sketch.circle.center", json!({"center": [25, 18], "radius": 3}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Top", "distance": 4, "operation": "join", "name": "Peg"}));
let top = |s: &Session| s.model.state().bodies.iter().map(|b| b.mesh().bounds().max.z).fold(f64::MIN, f64::max);
assert!((top(&s) - 14.0).abs() < 1e-6);
let state = s.model.state();
let body = state.bodies.first().unwrap();
assert!((0..solvecraft_doc::naming::face_names(body).len()).all(|i| solvecraft_doc::naming::face_name_exact(body, i)));
change(&mut s, "h", "16 mm");
for f in ["Corner", "Hole", "Top", "Peg"] {
assert_eq!(error(&s, f), None, "{f}");
assert_eq!(warning(&s, f), None, "{f}");
}
assert!((top(&s) - 20.0).abs() < 1e-6, "{}", top(&s));
}
+5 -3
View File
@@ -106,11 +106,13 @@ fn enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen
let errors = session.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| (&r.name, e))).collect::<Vec<_>>();
let warnings = session.model.results.iter().filter_map(|r| r.warning.as_ref().map(|e| (&r.name, e))).collect::<Vec<_>>();
assert!(errors.is_empty(), "errors {errors:?}");
// The earlier snap-fit operation retains its documented legacy split-face
// notice. Offset/projection failures or frozen borders are not accepted.
// The earlier snap-fit operation retains its notice about its face reference: the
// shell's inner wall it sits on, which the resized boss join (no exact history there)
// leaves to be found by geometry. Offset/projection failures or frozen borders are not
// accepted.
assert!(
warnings.iter().all(|(name, message)| name.as_str() == "SnapFit1"
&& message.as_str() == "face `F11:side:bs#7` was split; using the piece nearest the picked point"),
&& message.as_str() == "face `F4:inner:F2:side:l1` was found by geometry, not exact history; check it"),
"warnings {warnings:?}"
);
assert!(session.model.results.iter().filter(|r| r.name == "Lid lip").all(|r| r.warning.is_none()));
+292 -4
View File
@@ -4,6 +4,8 @@ use serde_json::{Value, json};
use crate::*;
mod mesh_convert;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
@@ -20,6 +22,28 @@ fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
#[test]
fn round_as_tall_as_a_disc_uses_up_its_wall() {
// #190: a disc 13 in radius and 2 high, its top edge rounded by 2.
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 13, "height": 2}));
run(&mut s, "solid.fillet", json!({"edges": [[0, 13, 2], [0, -13, 2]], "radius": "2 mm"}));
let (a, c) = ((1.0 - PI / 4.0) * 4.0, 2.0 * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
let expect = PI * 169.0 * 2.0 - a * 2.0 * PI * (13.0 - c);
assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s));
}
#[test]
fn fillet_as_wide_as_a_face_rounds_it_off() {
// #199: 14 x 6 x 10 box, the four vertical edges at r = 3 (half the 6 mm end faces).
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 14, "width": 6, "height": 10, "corner": [14, 0, 0]}));
run(&mut s, "solid.fillet", json!({"edges": [[14, 0, 5], [28, 0, 5], [14, 6, 5], [28, 6, 5]], "radius": "3"}));
let expect = 840.0 - (4.0 - PI) * 9.0 * 10.0;
assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s));
assert!(s.execute("solid.fillet", &json!({"edges": [[21, 0, 10]], "radius": 3.5})).is_err());
}
#[test]
fn join_extrude_from_a_chamfered_top_face() {
let mut s = Session::default();
@@ -116,11 +140,75 @@ fn join_meeting_nothing_keeps_the_first_body() {
assert!(rel(volume(&mut s), 1064.0) < 1e-6);
}
mod boolean_diagnostics;
mod component_frames;
mod mirror_planes;
mod primitive_points;
mod robustness;
/// Plan area a round of radius `r` fills at the concave corner `x` where circles (c1, r1) and
/// (c2, r2) cross: the triangle of its contacts and the corner, less the three circular segments.
fn round_gap(c1: [f64; 2], r1: f64, c2: [f64; 2], r2: f64, x: [f64; 2], r: f64) -> f64 {
let (a, b) = (r1 + r, r2 + r);
let d = (c2[0] - c1[0]).hypot(c2[1] - c1[1]);
let u = [(c2[0] - c1[0]) / d, (c2[1] - c1[1]) / d];
let along = (a * a - b * b + d * d) / (2.0 * d);
let side = if (x[0] - c1[0]) * -u[1] + (x[1] - c1[1]) * u[0] < 0.0 { -1.0 } else { 1.0 };
let off = (a * a - along * along).sqrt() * side;
let f = [c1[0] + u[0] * along - u[1] * off, c1[1] + u[1] * along + u[0] * off];
let t1 = [c1[0] + (f[0] - c1[0]) * r1 / a, c1[1] + (f[1] - c1[1]) * r1 / a];
let t2 = [c2[0] + (f[0] - c2[0]) * r2 / b, c2[1] + (f[1] - c2[1]) * r2 / b];
let angle = |c: [f64; 2], p: [f64; 2], q: [f64; 2]| {
let (v, w) = ([p[0] - c[0], p[1] - c[1]], [q[0] - c[0], q[1] - c[1]]);
((v[0] * w[0] + v[1] * w[1]) / (v[0].hypot(v[1]) * w[0].hypot(w[1]))).clamp(-1.0, 1.0).acos()
};
let segment = |r: f64, t: f64| r * r * (t - t.sin()) / 2.0;
let triangle = ((x[0] - t1[0]) * (t2[1] - t1[1]) - (t2[0] - t1[0]) * (x[1] - t1[1])).abs() / 2.0;
triangle - segment(r1, angle(c1, t1, x)) - segment(r2, angle(c2, x, t2)) - segment(r, angle(f, t2, t1))
}
/// A profile of two overlapping arcs, extruded: the vertical edges where the two walls meet are
/// straight lines between cylinders, and their rounds end on the caps across curved edges.
#[test]
fn fillet_the_edges_between_two_overlapping_cylinders() {
let (c1, r1) = ([0.0, 0.0], 17.140595088852663);
let (c2, r2) = ([10.715766135516247, 13.378055020402751], 9.666944358807896);
let (p, q) = ([1.7728134670876865, 17.048669520256194], [16.250324167975673, 5.452243981674495]);
let profile = 1087.5018766864137;
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.arc.center_point", json!({"center": c1, "start": p, "end": q}));
run(&mut s, "sketch.arc.center_point", json!({"center": c2, "start": q, "end": p}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert!(rel(si["profiles"][0]["area_mm2"].as_f64().unwrap(), profile) < 1e-9, "{si}");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10}));
let counts = |s: &mut Session| {
let m = run(s, "inspect.measure", json!({}));
(m["total"]["faces"].as_u64(), m["total"]["edges"].as_u64(), m["total"]["vertices"].as_u64())
};
assert_eq!(counts(&mut s), (Some(4), Some(6), Some(4)));
let (gp, gq) = (round_gap(c1, r1, c2, r2, p, 6.0), round_gap(c1, r1, c2, r2, q, 6.0));
// One round: a blend face; the edge becomes two contact lines and two end arcs.
run(&mut s, "solid.fillet", json!({"edges": [[p[0], p[1], 5]], "radius": 6, "name": "Round"}));
let (v, expect) = (volume(&mut s), (profile + gp) * 10.0);
assert!(rel(v, expect) < 2e-4, "{v} vs {expect}");
assert_eq!(counts(&mut s), (Some(5), Some(9), Some(6)));
// Both corners in one fillet.
run(
&mut s,
"timeline.redefine",
json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[p[0], p[1], 5], [q[0], q[1], 5]], "radius": 6}}),
);
let (v, expect) = (volume(&mut s), (profile + gp + gq) * 10.0);
assert!(rel(v, expect) < 2e-4, "{v} vs {expect}");
assert_eq!(counts(&mut s), (Some(6), Some(12), Some(8)));
// A larger round, whose contacts are found on the long arcs from their far ends too.
run(&mut s, "timeline.redefine", json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[q[0], q[1], 5]], "radius": 13}}));
let (v, expect) = (volume(&mut s), (profile + round_gap(c1, r1, c2, r2, q, 13.0)) * 10.0);
assert!(rel(v, expect) < 2e-4, "{v} vs {expect}");
}
/// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole.
#[test]
fn box_fillet_cut_part() {
@@ -446,11 +534,22 @@ fn registry_is_consistent() {
let n = ids.len();
ids.dedup();
assert_eq!(ids.len(), n, "duplicate command ids");
assert!(find_command("extrude").is_some(), "case-insensitive lookup");
assert_eq!(find_command("Solid.Extrude").map(|c| c.id), Some("solid.extrude"), "case-insensitive lookup");
let s = Session::default();
assert!(!find_command("sketch.line").unwrap().info(&s).enabled);
}
/// Only SolidCraft's own dotted ids name commands; other ids are unknown commands.
#[test]
fn only_own_ids_name_commands() {
for old in ["Extrude", "SketchCreate", "FusionFilletEdgesCommand"] {
assert!(find_command(old).is_none(), "{old}");
let mut s = Session::default();
let err = s.execute(old, &serde_json::json!({})).unwrap_err();
assert!(matches!(err, crate::EngineError::UnknownCommand(_)), "{old}: {err}");
}
}
#[test]
fn sample_design_builds() {
let mut s = Session::default();
@@ -2051,6 +2150,67 @@ fn appearances_survive_later_features() {
let _ = std::fs::remove_dir_all(&dir);
}
/// The original #2 RGB import acceptance, including later topology and placement edits.
#[test]
fn imported_rgb_appearances_survive_edits_reopen_and_step_export() {
let dir = std::env::temp_dir().join(format!("solvecraft-imported-rgb-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let input = dir.join("rgb.step");
let saved = dir.join("edited.solvecraft");
let output = dir.join("edited.step");
let mut source = Session::default();
for (name, x, color) in [("Red", 0, "#ff0000"), ("Green", 40, "#00ff00"), ("Blue", 80, "#0000ff")] {
run(&mut source, "solid.box", json!({"corner":[x,0,0],"length":30,"width":20,"height":20,"body_name":name}));
run(&mut source, "appearance.assign", json!({"bodies":[name],"color":color}));
}
run(&mut source, "appearance.assign", json!({"faces":[[15,10,20]],"color":"#ffff00"}));
run(&mut source, "file.export", json!({"path":input}));
let check = |session: &Session| {
let state = session.model.state();
assert_eq!(state.bodies.len(), 3);
for (name, color) in [("Red", [1., 0., 0.]), ("Green", [0., 1., 0.]), ("Blue", [0., 0., 1.])] {
let body = state.body(name).unwrap();
assert_eq!(session.doc.body_color(body), Some(color), "{name} retains its imported body colour");
}
let red = state.body("Red").unwrap();
assert!(session.doc.face_colors(red).iter().any(|(_, look)| look.color == [255, 255, 0]), "Red retains its yellow top face");
assert!(session.model.results.iter().all(|result| result.error.is_none()));
};
let mut edited = Session::default();
run(&mut edited, "doc.open", json!({"path":input}));
check(&edited);
let initial_volume = volume(&mut edited);
run(&mut edited, "solid.fillet", json!({"bodies":["Red"],"edges":[[0,10,20]],"radius":2}));
check(&edited);
run(&mut edited, "solid.move", json!({"bodies":["Red"],"translate":[0,0,5]}));
check(&edited);
run(&mut edited, "solid.box", json!({"corner":[85,5,15],"length":10,"width":10,"height":10,"body_name":"Cutter"}));
run(&mut edited, "solid.combine", json!({"target":"Blue","tools":["Cutter"],"operation":"cut"}));
check(&edited);
run(&mut edited, "solid.box", json!({"corner":[65,0,0],"length":10,"width":20,"height":20,"body_name":"Joiner"}));
run(&mut edited, "solid.combine", json!({"target":"Green","tools":["Joiner"],"operation":"join"}));
check(&edited);
let edited_volume = volume(&mut edited);
// The join adds 5*20*20, the pocket removes 10*10*5, and the
// radius-2 quarter-round replaces a 2*2 square along a 20 mm edge.
let expected_volume = initial_volume + 2000. - 500. - (4. - PI) * 20.;
assert!((edited_volume - expected_volume).abs() < 0.1, "{edited_volume} vs {expected_volume}");
run(&mut edited, "edit.undo", json!({}));
run(&mut edited, "edit.redo", json!({}));
check(&edited);
run(&mut edited, "file.save_as", json!({"path":saved}));
let mut reopened = Session::default();
run(&mut reopened, "doc.open", json!({"path":saved}));
check(&reopened);
assert!(rel(volume(&mut reopened), edited_volume) < 1e-8);
run(&mut reopened, "file.export", json!({"path":output}));
let mut roundtrip = Session::default();
run(&mut roundtrip, "doc.open", json!({"path":output}));
check(&roundtrip);
assert!(rel(volume(&mut roundtrip), edited_volume) < 1e-3);
let _ = std::fs::remove_dir_all(dir);
}
/// Extrude To Object (a parallel face, with an offset; a slanted face) and From Object.
#[test]
fn extrude_to_and_from_objects() {
@@ -2209,15 +2369,15 @@ fn boundary_fill_cells() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "body_name": "A"}));
run(&mut s, "solid.box", json!({"corner": [10, 0, 0], "length": 20, "width": 20, "height": 20, "body_name": "B"}));
let r = run(&mut s, "SurfaceSculpt", json!({"tools": ["A", "B"], "cells": [[15, 10, 10]], "remove_tools": true}));
let r = run(&mut s, "solid.boundary_fill", json!({"tools": ["A", "B"], "cells": [[15, 10, 10]], "remove_tools": true}));
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["body_count"], 1, "{r} {m}");
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap_or(0.0), 10.0 * 20.0 * 20.0) < 1e-9);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "SurfaceSculpt", json!({"tools": ["A", "B"], "cells": [[5, 10, 10], [25, 10, 10]], "remove_tools": true}));
run(&mut s, "solid.boundary_fill", json!({"tools": ["A", "B"], "cells": [[5, 10, 10], [25, 10, 10]], "remove_tools": true}));
let m = run(&mut s, "inspect.measure", json!({}));
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap_or(0.0), 2.0 * 10.0 * 20.0 * 20.0) < 1e-9, "{m}");
assert!(s.execute("SurfaceSculpt", &json!({"tools": ["A"], "cells": [[100, 100, 100]]})).is_err());
assert!(s.execute("solid.boundary_fill", &json!({"tools": ["A"], "cells": [[100, 100, 100]]})).is_err());
}
/// Rectangles name their sides and corners, so they can be dimensioned without inspecting the
@@ -2350,3 +2510,131 @@ fn special_files_are_refused_by_every_file_command() {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A cut targeted at one body leaves a non-target body alone, even one the tool covers completely (#260).
#[test]
fn targeted_cut_keeps_a_covered_body_that_is_not_a_target() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "Frame"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body", "body_name": "Other"}));
// Exactly Other's extent; it does not touch Frame.
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "cut", "targets": ["Frame"]}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["Frame", "Other"]);
assert!(rel(volume(&mut s), 2000.0) < 1e-6);
}
/// A frame's opening cut over a separate mesh body that sits in it: the mesh survives (#260).
#[test]
fn targeted_cut_keeps_an_overlapping_body_that_is_not_a_target() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 50, "height": 5, "operation": "new_body", "body_name": "Frame"}));
run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 1, "corner": [10, 10, 2], "operation": "new_body", "body_name": "Mesh"}));
run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 5, "corner": [10, 10, 0], "operation": "cut", "targets": ["Frame"]}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["Frame", "Mesh"]);
assert!(rel(volume(&mut s), 50.0 * 50.0 * 5.0 - 30.0 * 30.0 * 5.0 + 900.0) < 1e-6);
}
/// A join with explicit `targets` merges into those bodies only; a touched non-target stays (#259).
#[test]
fn join_with_targets_leaves_other_touched_bodies_alone() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [10, 0, 0], "operation": "new_body", "body_name": "B"}));
// Straddles the face between A and B; only A is a target.
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [8, 3, 3], "operation": "join", "targets": ["A"]}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["A", "B"], "B is not a target and must stay a body");
let b = run(&mut s, "inspect.measure", json!({"bodies": ["B"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(b, 1000.0) < 1e-6, "B is unchanged: {b}");
// A gains the half of the tool outside it.
let a = run(&mut s, "inspect.measure", json!({"bodies": ["A"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(a, 1032.0) < 1e-6, "A: {a}");
}
/// Deleting a sketch takes its extrude with it; a later combine that names the extrude's body must
/// not be left failing silently: it is deleted too, or the reply reports it (#267).
#[test]
fn deleting_a_sketch_leaves_no_silently_failing_feature() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": "10", "body_name": "P"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [18, 5, 0], "operation": "new_body", "body_name": "Q"}));
run(&mut s, "solid.combine", json!({"target": "P", "tools": ["Q"], "name": "JoinPQ"}));
let reply = run(&mut s, "timeline.delete", json!({"features": ["S"]}));
if let Ok(join) = s.execute("timeline.inspect", &json!({"feature": "JoinPQ"})) {
assert!(
join["error"].is_null() || reply.to_string().contains("JoinPQ"),
"JoinPQ now fails ({}) but the reply only says {reply}",
join["error"]
);
}
}
/// Every body a multi-profile `new` extrude creates carries its `body_name`, not a generic BodyN (#266).
#[test]
fn multi_profile_extrude_names_every_body_after_body_name() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"}));
for x in [0, 10, 20] {
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 5, 30]}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "10", "operation": "new_body", "body_name": "Ribs"}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert!(names.iter().all(|n| n.starts_with("Ribs")), "{names:?}");
assert!(rel(volume(&mut s), 4500.0) < 1e-6);
}
/// Renaming one pattern copy leaves the other copies' names alone (#265).
#[test]
fn renaming_a_pattern_copy_keeps_the_other_copies_names() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20}));
run(&mut s, "body.rename", json!({"body": "X (2)", "name": "Second"}));
let third = run(&mut s, "inspect.measure", json!({"bodies": ["X (3)"]}));
assert_eq!(third["bodies"][0]["bbox"]["min"][0].as_f64().map(f64::round), Some(40.0), "X (3) is still the copy at x = 40: {third}");
run(&mut s, "body.rename", json!({"body": "X (3)", "name": "Third"}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["X", "Second", "Third"]);
}
/// Parameters that work are listed in the command's params doc, which is all agents see (#271).
#[test]
fn params_docs_list_working_parameters() {
let mut s = Session::default();
let cmds = run(&mut s, "engine.commands", json!({}));
let params =
|id: &str| cmds.as_array().unwrap().iter().find(|c| c["id"] == id).and_then(|c| c["params"].as_str()).unwrap_or_default().to_string();
// Both work today: a named base flange, and a loft cut limited to its targets.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "A"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [50, 50]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "A", "body_name": "Panel A"}));
assert!(s.model.state().body("Panel A").is_some());
assert!(params("sheet.flange").contains("body_name"), "sheet.flange: {}", params("sheet.flange"));
assert!(params("solid.loft").contains("targets"), "solid.loft: {}", params("solid.loft"));
}
/// Every body-creating feature documents the keys `add_feature` reads for all of them (`name`,
/// `body_name`), and those that take `targets` say so (#271).
#[test]
fn feature_params_docs_list_shared_keys() {
let mut s = Session::default();
let cmds = run(&mut s, "engine.commands", json!({}));
let params =
|id: &str| cmds.as_array().unwrap().iter().find(|c| c["id"] == id).and_then(|c| c["params"].as_str()).unwrap_or_default().to_string();
let with_targets = ["solid.extrude", "solid.revolve", "solid.sweep", "solid.loft", "solid.pipe", "solid.coil"];
let without_targets = ["solid.box", "solid.cylinder", "solid.sphere", "solid.torus", "sheet.flange"];
for id in with_targets.iter().chain(&without_targets) {
let doc = params(id);
assert!(doc.contains("name?") && doc.contains("body_name?"), "{id}: {doc}");
}
for id in with_targets {
assert!(params(id).contains("targets?"), "{id}: {}", params(id));
}
}
@@ -0,0 +1,52 @@
use super::*;
use solvecraft_kernel::BooleanStrategy;
#[test]
fn boolean_retry_warning_and_details_survive_cache_undo_and_reopen() {
let slope = 1f64.to_radians().tan();
let mut session = Session::default();
run(&mut session, "sketch.create", json!({"plane":"XZ","name":"Floor section"}));
run(&mut session, "sketch.line", json!({"points":[[0,0],[100,0],[100,-10.-slope*100.],[0,-10.]],"closed":true}));
run(&mut session, "sketch.finish", json!({}));
run(&mut session, "solid.extrude", json!({"sketch":"Floor section","distance":80,"body_name":"Floor"}));
run(&mut session, "solid.cylinder", json!({"base":[50,40,-5],"radius":10,"height":30,"operation":"cut"}));
run(&mut session, "sketch.create", json!({"plane":"XY","name":"Ring section"}));
run(&mut session, "sketch.circle.center", json!({"center":[50,40],"radius":20}));
run(&mut session, "sketch.circle.center", json!({"center":[50,40],"radius":10}));
run(&mut session, "sketch.finish", json!({}));
run(
&mut session,
"solid.extrude",
json!({"sketch":"Ring section","profiles":[{"point":[65,40]}],"start_offset":-5,"distance":7,"body_name":"Ring"}),
);
let id = run(&mut session, "solid.combine", json!({"target":"Floor","tools":["Ring"],"operation":"join"}))["feature"].as_u64().unwrap();
let check = |session: &Session| {
let result = session.model.result(id).unwrap();
assert!(result.error.is_none());
assert!(result.warning.as_ref().is_some_and(|warning| warning.contains("Boolean retry:")), "{result:?}");
assert!(
result.boolean_diagnostics.events.iter().any(|event| event.strategy == BooleanStrategy::CommonBore),
"{:?}",
result.boolean_diagnostics
);
result.boolean_diagnostics.clone()
};
let original = check(&session);
let expected = (10. + slope * 50.) * (8000. - PI * 100.) + PI * 300. * 5.;
assert!(rel(volume(&mut session), expected) < 2e-5);
let inspected = run(&mut session, "timeline.inspect", json!({"feature":id}));
assert_eq!(inspected["boolean_diagnostics"], serde_json::to_value(&original).unwrap());
session.refresh();
assert_eq!(check(&session), original);
run(&mut session, "edit.undo", json!({}));
run(&mut session, "edit.redo", json!({}));
assert_eq!(check(&session), original);
let reopened_doc = solvecraft_doc::Document::from_json(&session.doc.to_json()).unwrap();
let reopened = Session::new(reopened_doc);
assert_eq!(check(&reopened), original);
// A later independent feature must not inherit the earlier retry's warning.
run(&mut session, "solid.box", json!({"corner":[200,0,0],"length":5,"width":5,"height":5}));
let last = session.model.results.last().unwrap();
assert!(last.warning.is_none());
assert!(last.boolean_diagnostics.events.is_empty());
}
+93
View File
@@ -0,0 +1,93 @@
use super::*;
use solvecraft_doc::{Document, FeatureKind, MeshData};
use solvecraft_geom::Vec3;
fn imported_box() -> Session {
let body = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
let mesh = body.tessellate(0.01).unwrap();
let mut doc = Document::default();
doc.add_feature(
FeatureKind::MeshImport {
file: "public-safe-box.stl".into(),
meshes: vec![MeshData {
name: "Mesh".into(),
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
triangles: mesh.triangles.iter().flatten().copied().collect(),
color: Some([0.2, 0.5, 0.8]),
}],
},
Some("Imported mesh"),
)
.unwrap();
Session::new(doc)
}
#[test]
fn mesh_conversion_is_parametric_undoable_and_editable_after_cold_reopen() {
let mut s = imported_box();
run(&mut s, "parameters.add", json!({"name":"size","expression":"1","unit":""}));
run(&mut s, "solid.scale", json!({"bodies":["Mesh"],"factor":"size","name":"Mesh scale"}));
run(&mut s, "mesh.convert", json!({"body":"Mesh","tolerance":"0.01 mm","name":"Reconstructed prism"}));
let check = |s: &Session| {
let state = s.model.state();
let body = &state.body("Mesh").unwrap().body;
assert!(!body.is_mesh());
assert_eq!(body.faces(0.01).unwrap().len(), 6);
assert_eq!(body.color(), Some([0.2, 0.5, 0.8]));
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
};
check(&s);
assert!((volume(&mut s) - 6000.).abs() < 1e-6);
run(&mut s, "edit.undo", json!({}));
assert!(s.model.state().body("Mesh").unwrap().body.is_mesh());
run(&mut s, "edit.redo", json!({}));
check(&s);
run(&mut s, "parameters.change", json!({"name":"size","expression":"2"}));
check(&s);
assert!((volume(&mut s) - 48000.).abs() < 1e-5);
let mut cold = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
check(&cold);
assert!((volume(&mut cold) - 48000.).abs() < 1e-5);
run(&mut cold, "solid.fillet", json!({"body":"Mesh","edges":[[0,20,60]],"radius":2}));
assert!(volume(&mut cold) < 48000.);
assert!(cold.model.results.iter().all(|r| r.error.is_none()));
}
#[test]
fn failed_mesh_conversions_leave_the_design_unchanged() {
let mut s = imported_box();
let before = s.doc.to_json();
for params in [
json!({}),
json!({"body":"Missing"}),
json!({"body":"Mesh","tolerance":0}),
json!({"body":"Mesh","tolerance":"unknown"}),
json!({"body":"Mesh","tolerance":"2 mm"}),
] {
assert!(s.execute("mesh.convert", &params).is_err());
assert_eq!(s.doc.to_json(), before);
assert!(s.model.state().body("Mesh").unwrap().body.is_mesh());
}
}
#[test]
fn exported_binary_stl_with_a_bore_reconstructs_analytic_faces() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Section"}));
run(&mut s, "sketch.line", json!({"points":[[0,0],[40,0],[40,20],[20,20],[20,30],[0,30]],"closed":true}));
run(&mut s, "sketch.circle.center", json!({"center":[10,10],"radius":4}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":"Section","profiles":[{"point":[30,10]}],"distance":15,"body_name":"Bracket"}));
let path = std::env::temp_dir().join(format!("solvecraft-mesh-convert-{}.stl", std::process::id()));
run(&mut s, "file.save_mesh", json!({"path":path}));
run(&mut s, "doc.open", json!({"path":path}));
let name = s.model.state().bodies[0].name.clone();
run(&mut s, "mesh.convert", json!({"body":name,"tolerance":"0.05 mm"}));
let state = s.model.state();
let body = &state.bodies[0].body;
assert!(!body.is_mesh());
assert!(body.validity().is_empty());
assert_eq!(solvecraft_kernel::measure(body).unwrap().merged.face_types.get("cylinder"), Some(&1));
assert_eq!(body.faces(0.01).unwrap().len(), 10);
std::fs::remove_file(path).unwrap();
}
@@ -0,0 +1,177 @@
//! Placement-cache changes must retain physical picking, pending positions and named joints.
use crate::{Session, geom::Vec3};
use serde_json::{Value, json};
use std::sync::Arc;
fn run(session: &mut Session, command: &str, params: Value) -> Value {
session.execute(command, &params).unwrap_or_else(|error| panic!("{command}: {error}"))
}
#[test]
fn world_placement_cache_preserves_pending_picks_materials_scratch_and_named_joint_frames() {
let mut session = Session::default();
let created = run(&mut session, "component.create", json!({"name":"Part"}));
let component = created["component"].as_u64().unwrap();
let first = created["occurrence"].as_u64().unwrap();
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
run(&mut session, "material.assign", json!({"bodies":["Block"],"material":"Brass"}));
let second = run(&mut session, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"axis":[0,0,1],"angle":"90 deg","capture":false}));
let pending = session.pending_moves.clone();
let path = vec![second];
let placement = session.occurrence_placement(&path).unwrap();
let name = placement.bodies[0].world.clone();
let world = session.world_state();
assert!(Arc::ptr_eq(&world, &session.world_state()));
let source_state = session.model.state();
let source = source_state.body("Block").unwrap();
let placed = world.body(&name).unwrap();
assert_eq!(placed.source_name.as_deref(), Some("Block"));
assert_eq!(placed.component, component);
let bounds = placed.mesh().bounds();
assert!(bounds.min.dist(Vec3::new(7., 20., 0.)) < 1e-9);
assert!(bounds.max.dist(Vec3::new(10., 22., 4.)) < 1e-9);
assert_eq!(session.doc.model_body_look(placed), session.doc.model_body_look(source));
let source_names = crate::doc::naming::face_names(source);
assert_eq!(source_names, crate::doc::naming::face_names(placed));
let pick = Vec3::new(8.5, 21., 4.);
let top = placed.body.faces(0.001).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|n| n.z > 0.99)).unwrap().index;
run(&mut session, "select.set", json!({"items":[{"type":"face","body":name,"index":top,"point":[pick.x,pick.y,pick.z]}]}));
assert!(matches!(&session.selection[0], crate::Sel::Face { body, index, .. } if body == &name && *index == top));
assert!(Arc::ptr_eq(&world, &session.world_state()), "selection changes must not discard prepared placements");
let scratch = session.scratch();
assert_eq!(scratch.pending_moves, pending);
let scratch_world = scratch.world_state();
assert!(Arc::ptr_eq(&world, &scratch_world));
let scratch_body = scratch_world.body(&name).unwrap();
assert!(scratch_body.mesh().bounds().min.dist(bounds.min) < 1e-9);
assert_eq!(crate::doc::naming::face_names(scratch_body), source_names);
run(&mut session, "component.capture_position", json!({}));
run(&mut session, "joint.origin", json!({"name":"Top joint","occurrence":second,"face":[pick.x,pick.y,pick.z]}));
let joint = session.doc.assembly.origins[0].origin.clone();
let local_frame = crate::doc::joints::resolve_origin(&session.doc, &session.model.state(), &joint).unwrap();
let before_joint = crate::doc::mat_mul(&crate::doc::joints::occurrence_world(&session.doc, second), &local_frame);
assert!(crate::doc::apply_point(&before_joint, Vec3::ZERO).dist(pick) < 1e-9);
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":true}));
let moved = session.world_state();
assert!(!Arc::ptr_eq(&world, &moved), "a changed transform must invalidate placed meshes");
assert!(moved.body(&name).unwrap().mesh().bounds().min.dist(bounds.min + Vec3::new(5., 0., 0.)) < 1e-9);
assert!(world.body(&name).unwrap().mesh().bounds().min.dist(bounds.min) < 1e-9, "prior snapshots must remain immutable");
assert_eq!(crate::doc::naming::face_names(moved.body(&name).unwrap()), source_names);
let local_frame = crate::doc::joints::resolve_origin(&session.doc, &session.model.state(), &joint).unwrap();
let after_joint = crate::doc::mat_mul(&crate::doc::joints::occurrence_world(&session.doc, second), &local_frame);
assert!(crate::doc::apply_point(&after_joint, Vec3::ZERO).dist(pick + Vec3::new(5., 0., 0.)) < 1e-9);
let reopened = Session::new(crate::doc::Document::from_json(&session.doc.to_json()).unwrap());
assert!(reopened.world_state().body(&name).unwrap().mesh().bounds().min.dist(bounds.min + Vec3::new(5., 0., 0.)) < 1e-9);
assert_eq!(reopened.model.results.iter().filter(|r| r.error.is_some()).count(), 0);
}
#[test]
fn selection_visibility_section_and_view_revisions_reuse_world_geometry() {
let mut session = Session::default();
run(&mut session, "component.create", json!({"name":"Part"}));
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
let world = session.world_state();
let revision = session.revision;
run(&mut session, "select.set", json!({"items":[{"type":"body","name":"Block"}]}));
assert!(session.revision > revision);
assert!(Arc::ptr_eq(&world, &session.world_state()));
run(&mut session, "select.clear", json!({}));
assert!(Arc::ptr_eq(&world, &session.world_state()));
run(&mut session, "browser.visibility", json!({"items":["Block"],"visible":false}));
assert!(!session.body_visible("Block"));
assert!(Arc::ptr_eq(&world, &session.world_state()));
run(&mut session, "inspect.section", json!({"plane":{"origin":[0,0,2],"normal":[0,0,1]}}));
assert!(session.section.is_some());
assert!(Arc::ptr_eq(&world, &session.world_state()));
session.invalidate_view(session.revision + 10);
assert!(Arc::ptr_eq(&world, &session.world_state()));
assert!(Arc::ptr_eq(&world, &session.scratch().world_state()));
}
#[test]
fn world_cache_requires_exact_design_model_pending_and_author_context() {
let mut session = Session::default();
let created = run(&mut session, "component.create", json!({"name":"Part"}));
let component = created["component"].as_u64().unwrap();
let first = created["occurrence"].as_u64().unwrap();
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
let sketch = run(&mut session, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
run(&mut session, "sketch.finish", json!({}));
let second = run(&mut session, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
let initial = session.world_state();
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"capture":false}));
let pending = session.world_state();
assert!(!Arc::ptr_eq(&initial, &pending));
let name = session.occurrence_placement(&[second]).unwrap().bodies[0].world.clone();
assert!(pending.body(&name).unwrap().mesh().bounds().min.dist(Vec3::new(10., 20., 0.)) < 1e-9);
run(&mut session, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
let authored = session.world_state();
assert!(!Arc::ptr_eq(&pending, &authored));
assert!(authored.sketch(sketch).unwrap().plane.origin.dist(Vec3::new(10., 20., 0.)) < 1e-9);
let mut scratch = session.scratch();
assert!(Arc::ptr_eq(&authored, &scratch.world_state()));
scratch.active_occurrence_path = Some(vec![first]);
let scratch_world = scratch.world_state();
assert!(!Arc::ptr_eq(&authored, &scratch_world));
assert!(scratch_world.sketch(sketch).unwrap().plane.origin.dist(Vec3::ZERO) < 1e-9);
assert!(Arc::ptr_eq(&authored, &session.world_state()), "scratch context must not replace the source cache");
run(&mut session, "appearance.assign", json!({"bodies":["Block"],"color":"#ff0000"}));
let painted = session.world_state();
assert!(!Arc::ptr_eq(&authored, &painted));
assert_ne!(session.doc.model_body_look(painted.body("Block").unwrap()), None);
run(&mut session, "edit.undo", json!({}));
let undone = session.world_state();
assert!(!Arc::ptr_eq(&painted, &undone));
run(&mut session, "edit.redo", json!({}));
let redone = session.world_state();
assert!(!Arc::ptr_eq(&undone, &redone));
assert!(session.doc.model_body_look(redone.body("Block").unwrap()).is_some());
let before_edit = session.world_state();
run(&mut session, "solid.box", json!({"length":5,"width":6,"height":7,"body_name":"Other","operation":"new"}));
let edited = session.world_state();
assert!(!Arc::ptr_eq(&before_edit, &edited));
assert!(edited.body("Other").is_some());
session.doc = Arc::new(crate::doc::Document::from_json(&session.doc.to_json()).unwrap());
let replaced = session.world_state();
assert!(!Arc::ptr_eq(&edited, &replaced), "equal contents in a different document cannot share the cache key");
session.model = crate::doc::Model::default();
session.model.evaluate(&session.doc);
assert!(!Arc::ptr_eq(&replaced, &session.world_state()), "a new evaluated model must invalidate even when the document Arc is unchanged");
}
#[test]
fn adopted_scratch_keeps_its_warm_geometry_and_history_stages_invalidate_it() {
let mut session = Session::default();
run(&mut session, "component.create", json!({"name":"Part"}));
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
let source = session.world_state();
let source_json = session.doc.to_json();
let mut scratch = session.scratch();
run(&mut scratch, "solid.box", json!({"length":5,"width":6,"height":7,"body_name":"Draft","operation":"new"}));
let warmed = scratch.world_state();
assert!(!Arc::ptr_eq(&source, &warmed));
assert_eq!(session.doc.to_json(), source_json);
assert!(Arc::ptr_eq(&source, &session.world_state()));
assert!(source.body("Draft").is_none());
let feature = warmed.body("Draft").unwrap().feature;
session.accept_computed(scratch);
assert!(Arc::ptr_eq(&warmed, &session.world_state()), "adoption/view revision must retain the worker's prepared geometry");
session.invalidate_view(session.revision + 20);
assert!(Arc::ptr_eq(&warmed, &session.world_state()));
run(&mut session, "timeline.rollback", json!({"position":1}));
let rolled = session.world_state();
assert!(!Arc::ptr_eq(&warmed, &rolled));
assert!(rolled.body("Block").is_some());
assert!(rolled.body("Draft").is_none());
run(&mut session, "timeline.rollback", json!({}));
let restored = session.world_state();
assert!(!Arc::ptr_eq(&rolled, &restored));
assert!(restored.body("Draft").is_some());
run(&mut session, "timeline.suppress", json!({"feature":feature,"suppressed":true}));
let suppressed = session.world_state();
assert!(!Arc::ptr_eq(&restored, &suppressed));
assert!(suppressed.body("Draft").is_none());
run(&mut session, "timeline.suppress", json!({"feature":feature,"suppressed":false}));
assert!(session.world_state().body("Draft").is_some());
}
+92 -2
View File
@@ -244,8 +244,24 @@ impl Seg2 {
}
}
}
/// Parameter of the point of the segment closest to `p` (sampling, then Newton).
/// Parameter of the closest point: exact projection for lines,
/// sampling followed by Newton for the remaining curves.
pub fn closest_param(&self, p: Vec2) -> f64 {
let finite = |p: Vec2| p.x.is_finite() && p.y.is_finite();
match *self {
Seg2::Line { a, b } if finite(a) && finite(b) && finite(p) => {
let d = b - a;
if a == b {
return 0.0;
}
let denominator = d.len2();
let t = (p - a).dot(d) / denominator;
if denominator > 0.0 && denominator.is_finite() && t.is_finite() {
return t.clamp(0.0, 1.0);
}
}
_ => {}
}
let n = 64;
let mut t =
(0..=n).map(|i| i as f64 / n as f64).min_by(|x, y| self.point_at(*x).dist(p).total_cmp(&self.point_at(*y).dist(p))).unwrap_or(0.0);
@@ -381,9 +397,13 @@ impl Loop2 {
pub fn reversed(&self) -> Loop2 {
Loop2 { segs: self.segs.iter().rev().map(Seg2::reversed).collect() }
}
/// Whether the loop runs counter-clockwise ([`Loop2::ccw`] keeps it as it is).
pub fn is_ccw(&self) -> bool {
self.signed_area() >= 0.0
}
/// Counter-clockwise copy.
pub fn ccw(&self) -> Loop2 {
if self.signed_area() < 0.0 { self.reversed() } else { self.clone() }
if self.is_ccw() { self.clone() } else { self.reversed() }
}
pub fn polyline(&self, tol: f64) -> Vec<Vec2> {
let mut out = Vec::new();
@@ -540,6 +560,76 @@ impl Region2 {
mod tests {
use super::*;
#[test]
fn closest_line_projection_and_degeneracy() {
let line = Seg2::Line { a: Vec2::new(-3.0, 4.0), b: Vec2::new(7.0, 4.0) };
for (p, t) in [(Vec2::new(-9.0, 20.0), 0.0), (Vec2::new(2.0, -5.0), 0.5), (Vec2::new(15.0, 4.0), 1.0)] {
assert_eq!(line.closest_param(p), t);
assert_eq!(line.reversed().closest_param(p), 1.0 - t);
}
let a = Vec2::new(1e100, -1e100);
let b = a + Vec2::new(1e90, 2e90);
let large = Seg2::Line { a, b };
assert!((large.closest_param(a.lerp(b, 0.5)) - 0.5).abs() < 1e-6);
assert_eq!(Seg2::Line { a, b: a }.closest_param(Vec2::ZERO), 0.0);
let tiny = Seg2::Line { a: Vec2::ZERO, b: Vec2::new(1e-12, 0.0) };
assert_eq!(tiny.closest_param(Vec2::new(0.5e-12, 1.0)), 0.5);
for line in [line, line.reversed(), tiny] {
for k in 0..53 {
let p = Vec2::new((k * 17 % 37) as f64 - 18.0, (k * 31 % 29) as f64 - 14.0);
let closest = line.point_at(line.closest_param(p)).dist(p);
let dense = (0..=8192).map(|i| line.point_at(i as f64 / 8192.0).dist(p)).fold(f64::INFINITY, f64::min);
assert!(closest <= dense + 1e-12);
}
}
}
fn sampled_closest(s: &Seg2, p: Vec2) -> f64 {
let mut t = (0..=64).map(|i| i as f64 / 64.0).min_by(|x, y| s.point_at(*x).dist(p).total_cmp(&s.point_at(*y).dist(p))).unwrap_or(0.0);
for _ in 0..30 {
let (q, d) = (s.point_at(t), s.derivative(t));
let (d2, h) = ((s.derivative((t + 1e-6).min(1.0)) - s.derivative((t - 1e-6).max(0.0))) / 2e-6, q - p);
let df = d.dot(d) + h.dot(d2);
if df.abs() < 1e-300 {
break;
}
let nt = (t - h.dot(d) / df).clamp(0.0, 1.0);
if (nt - t).abs() < 1e-14 {
t = nt;
break;
}
t = nt;
}
t
}
#[test]
fn closest_projection_preserves_clearance_tolerance_and_generic_fallback() {
use std::f64::consts::TAU;
let curves = [
Seg2::Line { a: Vec2::new(-4.0, 7.0), b: Vec2::new(3.0, -2.0) },
Seg2::Arc { center: Vec2::new(3.0, -2.0), radius: 6.0, start: 5.9, sweep: 1.3 },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.2, sweep: -TAU },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: TAU },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 0.0 },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 1e-12 },
Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(2.0, 7.0), p2: Vec2::new(6.0, -3.0), p3: Vec2::new(8.0, 4.0) },
];
for (i, s) in curves.iter().enumerate() {
for k in 0..101 {
let p = Vec2::new((k * 17 % 37) as f64 - 18.0, (k * 31 % 29) as f64 - 14.0);
let old = sampled_closest(s, p);
let new = s.closest_param(p);
if i > 0 {
assert_eq!(new.to_bits(), old.to_bits());
}
assert!(s.point_at(new).dist(p) <= s.point_at(old).dist(p) + 1e-10);
let on_curve = s.point_at(k as f64 / 100.0);
assert!(s.point_at(s.closest_param(on_curve)).dist(on_curve) < 1e-9);
}
}
}
#[test]
fn triangulated_area() {
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(5.0, 3.0), Vec2::new(0.0, 8.0)]);
+166 -49
View File
@@ -13,6 +13,8 @@ use solvecraft_geom::Vec3;
use truck_modeling::{self as mt, builder};
use crate::body::{Body, Solid, from_p3, p3, v3};
use crate::history::{FaceOrigin, History};
use crate::record::{self, Rebuild};
use crate::{KernelError, Result, guard};
#[derive(Clone, Copy, PartialEq)]
@@ -43,10 +45,15 @@ pub(crate) fn plane_normal(f: &mt::Face) -> Option<Vec3> {
/// Replace edges in a face's oriented boundary (by id, keeping orientation), and insert extra
/// edges where consecutive edges no longer connect.
pub(crate) fn rebuild_face(f: &mt::Face, subst: &HashMap<mt::EdgeID, mt::Edge>, connectors: &[mt::Edge]) -> Result<mt::Face> {
rebuild_face_dropping(f, subst, &[], connectors)
}
/// [`rebuild_face`], also leaving out the `dropped` edges (consumed by a blend).
fn rebuild_face_dropping(f: &mt::Face, subst: &HashMap<mt::EdgeID, mt::Edge>, dropped: &[mt::EdgeID], connectors: &[mt::Edge]) -> Result<mt::Face> {
let mut wires = Vec::new();
for w in f.absolute_boundaries() {
let mut edges: Vec<mt::Edge> = Vec::new();
for e in w.edge_iter() {
for e in w.edge_iter().filter(|e| !dropped.contains(&e.id())) {
let ne = match subst.get(&e.id()) {
Some(n) => {
// `n` was built in the orientation of the stored edge; match this use.
@@ -84,7 +91,8 @@ pub(crate) fn rebuild_face(f: &mt::Face, subst: &HashMap<mt::EdgeID, mt::Edge>,
crate::heal::absolute_face(f, wires).ok_or_else(|| KernelError::Failed("blend: a face could not be rebuilt".into()))
}
fn blend_one(solid: &Solid, edge: &mt::Edge, size: f64, r: f64, shape: Shape) -> Result<Solid> {
/// Blend one edge of `solid`, with what was built from it (see [`Rebuild`]).
fn blend_one(solid: &Solid, edge: &mt::Edge, size: f64, r: f64, shape: Shape) -> Result<(Solid, Rebuild)> {
if !matches!(edge.curve(), mt::Curve::Line(_)) {
return Err(unsupported("only straight edges can be blended"));
}
@@ -198,10 +206,11 @@ fn blend_geometry(
size: f64,
r: f64,
shape: Shape,
) -> Result<Solid> {
) -> Result<(Solid, Rebuild)> {
let s = setback(r, phi, shape);
let tol = (size * 1e-7).max(1e-9);
// End faces and the side edges at each end.
// End faces and the side edges at each end. A side edge that the fillet's setback uses up
// exactly is consumed: the rail ends at its far vertex (`ca`, `cb`).
struct End {
v: mt::Vertex,
g: usize,
@@ -209,6 +218,8 @@ fn blend_geometry(
e2: mt::Edge,
a: Vec3,
b: Vec3,
ca: Option<mt::Vertex>,
cb: Option<mt::Vertex>,
}
let mut ends: Vec<End> = Vec::new();
for (v, p) in [(&v0, p0), (&v1, p1)] {
@@ -238,19 +249,27 @@ fn blend_geometry(
.ok_or_else(|| unsupported("unexpected corner topology"))
};
let (e1, e2) = (shared(i1)?, shared(i2)?);
for (e, q, t, fi) in [(&e1, a, t1, i1), (&e2, b, t2, i2)] {
let mut consumed: [Option<mt::Vertex>; 2] = [None, None];
for (k, (e, q, t, fi)) in [(&e1, a, t1, i1), (&e2, b, t2, i2)].into_iter().enumerate() {
if !matches!(e.curve(), mt::Curve::Line(_)) {
return Err(unsupported("the edges at the corners must be straight"));
}
let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
let far = if e.front() == v { e.back().clone() } else { e.front().clone() };
let other = vtx(&far);
let delta = other - p;
if shape == Shape::Round {
// Fillets retain the square-end check: the original corner edge
// must run in the setback direction and have enough length left.
// must run in the setback direction and have enough length left,
// or exactly the setback (the edge is consumed).
let along = delta.dot(t);
if (delta - t * along).len() > tol * 100.0 + 1e-7 || along <= s + tol {
if (delta - t * along).len() > tol * 100.0 + 1e-7 || along < s - tol {
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
if along <= s + tol
&& let Some(slot) = consumed.get_mut(k)
{
*slot = Some(far);
}
continue;
}
let len = delta.len();
@@ -265,7 +284,8 @@ fn blend_geometry(
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
}
ends.push(End { v: v.clone(), g, e1, e2, a, b });
let [ca, cb] = consumed;
ends.push(End { v: v.clone(), g, e1, e2, a, b, ca, cb });
}
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
if end0.g == end1.g {
@@ -274,10 +294,32 @@ fn blend_geometry(
if (end1.a - end0.a).dot(d) <= tol || (end1.b - end0.b).dot(d) <= tol {
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
let a0 = builder::vertex(p3(end0.a));
let b0 = builder::vertex(p3(end0.b));
let a1 = builder::vertex(p3(end1.a));
let b1 = builder::vertex(p3(end1.b));
// A side face whose corner edges are consumed at both ends is used up: its remaining edge,
// parallel to the old edge at the setback, becomes the rail and the face is removed.
let used_up = |fi: usize, c0: &Option<mt::Vertex>, c1: &Option<mt::Vertex>| -> Result<Option<mt::Edge>> {
let (Some(x0), Some(x1)) = (c0, c1) else {
return match (c0, c1) {
(None, None) => Ok(None),
_ => Err(unsupported("the blend uses up a neighbouring face at one end only")),
};
};
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
if f.boundaries().len() != 1 || f.edge_iter().count() != 4 {
return Err(unsupported("the blend uses up a neighbouring face with more edges"));
}
let rail = f
.edge_iter()
.find(|e| matches!(e.curve(), mt::Curve::Line(_)) && ((e.front() == x0 && e.back() == x1) || (e.front() == x1 && e.back() == x0)))
.ok_or_else(|| unsupported("the blend uses up a neighbouring face with more edges"))?;
Ok(Some(if rail.front() == x0 { rail.clone() } else { rail.inverse() }))
};
let rail_a = used_up(i1, &end0.ca, &end1.ca)?;
let rail_b = used_up(i2, &end0.cb, &end1.cb)?;
let vertex = |c: &Option<mt::Vertex>, p: Vec3| c.clone().unwrap_or_else(|| builder::vertex(p3(p)));
let a0 = vertex(&end0.ca, end0.a);
let b0 = vertex(&end0.cb, end0.b);
let a1 = vertex(&end1.ca, end1.a);
let b1 = vertex(&end1.cb, end1.b);
// Corner curves on the end faces (A → B) and the blend surface. The arc centre is r from
// both faces (inside the material for a convex edge, outside for a concave one); the arc's
// middle is the point nearest the old edge.
@@ -294,20 +336,32 @@ fn blend_geometry(
};
let c0 = corner(&a0, &b0, p0);
let c1 = corner(&a1, &b1, p1);
let la = builder::line(&a0, &a1);
let lb = builder::line(&b0, &b1);
let la = rail_a.clone().unwrap_or_else(|| builder::line(&a0, &a1));
let lb = rail_b.clone().unwrap_or_else(|| builder::line(&b0, &b1));
// Substitutions: side edges at the ends now stop at A/B; the blended edge moves to A or B.
// Substitutions: side edges at the ends now stop at A/B (consumed ones are dropped); the
// blended edge moves to A or B.
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
let mut dropped: Vec<mt::EdgeID> = Vec::new();
for (end, a, b) in [(end0, &a0, &b0), (end1, &a1, &b1)] {
for (e, nv) in [(&end.e1, a), (&end.e2, b)] {
for (e, nv, used) in [(&end.e1, a, &end.ca), (&end.e2, b, &end.cb)] {
if used.is_some() {
dropped.push(e.id());
continue;
}
let (f, bk) = (e.absolute_front(), e.absolute_back());
let ne = if f == &end.v { builder::line(nv, bk) } else { builder::line(f, nv) };
subst.insert(e.id(), ne);
}
}
// The side edges at the ends are pieces of themselves; the rails and corner curves are new.
let mut rec = Rebuild::default();
rec.pieces(&subst);
let mut new_faces: Vec<mt::Face> = Vec::with_capacity(faces.len() + 1);
for (i, f) in faces.iter().enumerate() {
if (i == i1 && rail_a.is_some()) || (i == i2 && rail_b.is_some()) {
continue;
}
let nf = if i == i1 {
let mut m = subst.clone();
m.insert(edge.id(), la.clone());
@@ -317,12 +371,13 @@ fn blend_geometry(
m.insert(edge.id(), lb.clone());
rebuild_face(f, &m, &[])?
} else if i == end0.g {
rebuild_face(f, &subst, std::slice::from_ref(&c0))?
rebuild_face_dropping(f, &subst, &dropped, std::slice::from_ref(&c0))?
} else if i == end1.g {
rebuild_face(f, &subst, std::slice::from_ref(&c1))?
rebuild_face_dropping(f, &subst, &dropped, std::slice::from_ref(&c1))?
} else {
(*f).clone()
};
rec.face(&nf, f);
new_faces.push(nf);
}
// The blend face uses each new edge opposite to its neighbour.
@@ -351,13 +406,15 @@ fn blend_geometry(
mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?
}
};
rec.blend(&blend_face, [edge.id()]);
new_faces.push(blend_face);
let mut shells = solid.boundaries().clone();
let new_shell: mt::Shell = new_faces.into();
if let Some(sh) = shells.get_mut(si) {
*sh = new_shell;
}
Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("blend produced an invalid solid: {e}")))
let solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("blend produced an invalid solid: {e}")))?;
Ok((solid, rec))
}
fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result<Body> {
@@ -370,8 +427,10 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
if edges.len() > 1000 {
return Err(KernelError::Invalid("too many edges".into()));
}
// Each step's result carries its history over `[body]`.
let counts = record::counts(&[body]);
// Coplanar neighbours as one face (as after a join), so corners look as they should.
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
let mut cur = crate::boolean_record::healed(body)?;
// Every edge of a convex planar body: rounded as a whole (sphere corners).
if shape == Shape::Round && edges.len() >= 6 {
let solid = cur.deep_copy();
@@ -388,7 +447,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
if hit.len() == all.len()
&& let Ok(b) = crate::polyhedron::round_all_edges(&cur, r)
{
return Ok(b);
return Ok(record::after(b, &cur, &counts));
}
}
}
@@ -401,8 +460,8 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
{
let _ = groups;
let loop_pts: Vec<Vec3> = edges.iter().filter(|p| !rest.iter().any(|q| q.dist(**p) < 1e-12)).copied().collect();
cur = blend(&cur, &rest, r, shape, what)?;
return blend(&cur, &loop_pts, r, shape, what);
cur = record::after(blend(&cur, &rest, r, shape, what)?, &cur, &counts);
return Ok(record::after(blend(&cur, &loop_pts, r, shape, what)?, &cur, &counts));
}
if let Some(groups) = loop_groups(&cur, edges)? {
for g in groups {
@@ -410,7 +469,10 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
let solid = cur.deep_copy();
let ids = edge_ids(&cur, &solid, &g)?;
match crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round) {
Some(res) => cur = Body::new(guard(what, || res)?)?,
Some(res) => {
let (made, rec) = guard(what, || res)?;
cur = record::after(record::rebuilt(made, &[&solid], &rec)?, &cur, &counts);
}
None => return Err(unsupported("the edges are not a whole loop of a planar face")),
}
}
@@ -429,10 +491,10 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
let solid = cur.deep_copy();
if todo.len() >= 2
&& let Some((ids, covered)) = run_on_loop(&cur, &solid, &todo)?
&& let Some(Ok(res)) = crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round)
&& let Ok(b) = guard(what, || Ok(res)).and_then(Body::new)
&& let Some(Ok((res, rec))) = crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round)
&& let Ok(b) = guard(what, || Ok(res)).and_then(|res| record::rebuilt(res, &[&solid], &rec))
{
cur = b;
cur = record::after(b, &cur, &counts);
todo.retain(|p| !covered.iter().any(|q| q.dist(*p) < 1e-12));
continue;
}
@@ -469,7 +531,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
None => Err(e),
},
};
match attempt.and_then(|(o, js)| Body::new(o).map(|b| (b, js))) {
match attempt.and_then(|((o, rec), js)| record::rebuilt(o, &[&solid], &rec).map(|b| (b, js))) {
Ok(x) => {
done = Some(x);
break;
@@ -483,7 +545,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
}
match done {
Some((b, js)) => {
cur = b;
cur = record::after(b, &cur, &counts);
let mut k = 0;
todo.retain(|_| {
let keep = !js.contains(&k);
@@ -636,7 +698,7 @@ fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option<Vec<mt::EdgeID>> {
Some(ids)
}
fn edge_mid(e: &mt::Edge) -> Option<Vec3> {
pub(crate) fn edge_mid(e: &mt::Edge) -> Option<Vec3> {
use mt::{BoundedCurve, ParametricCurve};
let c = e.curve();
let (t0, t1) = c.range_tuple();
@@ -762,15 +824,21 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
if edges.is_empty() || edges.len() > 1000 {
return Err(KernelError::Invalid("select 1…1000 edges".into()));
}
// Each step's result carries its history over `[body]`.
let counts = record::counts(&[body]);
// All cutters at once (their union mitres the corners), else one edge at a time.
let tools: Result<Vec<Body>> = edges.iter().enumerate().map(|(i, p)| chamfer_tool(body, *p, distance, i)).collect();
let tools: Result<Vec<Cutter>> = edges.iter().enumerate().map(|(i, p)| chamfer_tool(body, *p, distance, i)).collect();
if let Ok(tools) = tools {
let mut acc: Option<Body> = None;
// The union so far, with its history over the cutters it joined.
let mut acc: Option<(Body, Option<History>)> = None;
let mut ok = true;
for t in tools {
for t in &tools {
let next = match &acc {
None => Some(t),
Some(a) => crate::ops::boolean(a, &t, crate::BoolOp::Union).ok().flatten(),
None => Some((t.body.clone(), Some(History::identity(&t.body)))),
Some((a, h)) => crate::ops::boolean(a, &t.body, crate::BoolOp::Union).ok().flatten().map(|u| {
let h = crate::boolean_record::through(&u, 0, h.as_ref());
(u, h)
}),
};
if next.is_none() {
ok = false;
@@ -779,10 +847,11 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
acc = next;
}
if ok
&& let Some(tool) = acc
&& let Some((tool, h)) = acc
&& let Ok(Some(r)) = crate::ops::boolean(body, &tool, crate::BoolOp::Cut)
{
return Ok(r);
let h = cut_history(crate::boolean_record::through(&r, 1, h.as_ref()), body, &tools);
return Ok(r.with_checked_history(h, &counts));
}
}
// At a pocket rim the cutters cannot extend beyond the selected edges. Rebuild
@@ -794,28 +863,49 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
return Ok(result);
}
// Coplanar neighbours as one face (as after a join), so corners look as they should.
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
let mut cur = crate::boolean_record::healed(body)?;
if let Some(groups) = loop_groups(&cur, edges)? {
for g in groups {
let size = cur.size();
let solid = cur.deep_copy();
let ids = edge_ids(&cur, &solid, &g)?;
match crate::loopblend::loop_blend(&solid, &ids, size, distance, false) {
Some(res) => cur = Body::new(guard("chamfer", || res)?)?,
Some(res) => {
let (made, rec) = guard("chamfer", || res)?;
cur = record::after(record::rebuilt(made, &[&solid], &rec)?, &cur, &counts);
}
None => return Err(unsupported("the edges are not a whole loop of a planar face")),
}
}
return Ok(cur);
}
for p in edges {
cur = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| crate::ops::boolean(&cur, &t, crate::BoolOp::Cut)) {
let step = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| cut_one(&cur, &t)) {
Ok(Some(b)) => b,
_ => blend(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer")?,
};
cur = record::after(step, &cur, &counts);
}
Ok(cur)
}
/// `cur` cut by one cutter, with its history over `[cur]` (`None`: nothing left).
fn cut_one(cur: &Body, t: &Cutter) -> Result<Option<Body>> {
let counts = record::counts(&[cur]);
Ok(crate::ops::boolean(cur, &t.body, crate::BoolOp::Cut)?.map(|r| {
let h = cut_history(r.history().cloned(), cur, std::slice::from_ref(t));
r.with_checked_history(h, &counts)
}))
}
/// A cut of `cur` by cutters (their union), from its history `h` over `[cur, cutter 0, …]`, as
/// over `[cur]`: each cutter's chamfer face is made in place of its edge, and nothing else of a
/// cutter may be left (`None` where something is).
fn cut_history(h: Option<History>, cur: &Body, tools: &[Cutter]) -> Option<History> {
let parts: Vec<History> = std::iter::once(History::identity(cur)).chain(tools.iter().map(Cutter::history)).collect();
crate::boolean_record::composed(h?.over(&parts.iter().collect::<Vec<_>>()))
}
/// Geometry of a straight convex edge between two planar faces.
struct EdgeFrame {
p0: Vec3,
@@ -827,7 +917,8 @@ struct EdgeFrame {
t2: Vec3,
}
fn edge_frame(cur: &Body, p: Vec3) -> Result<EdgeFrame> {
/// The edge nearest `p` (its index in `cur`'s edge order) and its frame.
fn edge_frame(cur: &Body, p: Vec3) -> Result<(usize, EdgeFrame)> {
let size = cur.size();
let tol = (size * 2e-3).max(1e-3);
let Some((idx, dist)) = cur.nearest_edge(p, tol)? else { return Err(KernelError::Invalid("the body has no edges".into())) };
@@ -855,7 +946,7 @@ fn edge_frame(cur: &Body, p: Vec3) -> Result<EdgeFrame> {
if n1.dot(t2) > 1e-9 || n2.dot(t1) > 1e-9 || t1.dot(t2) < -1.0 + 1e-6 {
return Err(unsupported("only convex edges"));
}
Ok(EdgeFrame { p0, p1, d, n1, n2, t1, t2 })
Ok((idx, EdgeFrame { p0, p1, d, n1, n2, t1, t2 }))
}
/// The prism that cuts a chamfer of size `s` on the edge nearest `p`. `k` varies the parts that
@@ -885,20 +976,41 @@ pub fn chamfer_sides(body: &Body, edges: &[Vec3], d1: f64, side: ChamferSide, fl
if edges.is_empty() || edges.len() > 1000 {
return Err(KernelError::Invalid("select 1…1000 edges".into()));
}
let mut cur = body.clone();
let counts = record::counts(&[body]);
let mut cur = crate::boolean_record::operand(body, 0, &counts);
for (k, p) in edges.iter().enumerate() {
let t = chamfer_tool_sides(&cur, *p, d1, Some((side, flip)), k)?;
cur = crate::ops::boolean(&cur, &t, crate::BoolOp::Cut)?.ok_or_else(|| KernelError::Failed("the chamfer removed everything".into()))?;
let step = cut_one(&cur, &t)?.ok_or_else(|| KernelError::Failed("the chamfer removed everything".into()))?;
cur = record::after(step, &cur, &counts);
}
Ok(cur)
}
pub(crate) fn chamfer_tool(cur: &Body, p: Vec3, s: f64, k: usize) -> Result<Body> {
/// The prism that cuts a chamfer: the body, which of its faces is the chamfer plane, and the
/// edge (of the body it chamfers, in its edge order) that face is made in place of.
pub(crate) struct Cutter {
body: Body,
face: Option<usize>,
edge: usize,
}
impl Cutter {
/// The cutter as made from the body it chamfers: its chamfer face in place of the edge, its
/// other faces (all outside that body) from nothing of it.
fn history(&self) -> History {
let faces = (0..self.body.face_count())
.map(|i| record::generated(if Some(i) == self.face { vec![FaceOrigin::Edge { operand: 0, edge: self.edge }] } else { Vec::new() }))
.collect();
record::from_faces(&self.body.solid, 1, faces)
}
}
pub(crate) fn chamfer_tool(cur: &Body, p: Vec3, s: f64, k: usize) -> Result<Cutter> {
chamfer_tool_sides(cur, p, s, None, k)
}
fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, bool)>, k: usize) -> Result<Body> {
let mut f = edge_frame(cur, p)?;
fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, bool)>, k: usize) -> Result<Cutter> {
let (edge, mut f) = edge_frame(cur, p)?;
// Setbacks along each face: equal, or by the second side's rule.
let (s1, s2) = match sides {
None => (s, s),
@@ -949,11 +1061,16 @@ fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, b
}
let loc: Vec<solvecraft_geom::Vec2> = [a_ext, b_ext, e_far].iter().map(|q| plane.to_local(*q)).collect();
let mut lp = solvecraft_geom::Loop2::polygon(&loc);
// The chamfer plane is swept from the segment a → b: first, or last once turned round.
let mut chamfer_segment = 0;
if lp.signed_area() < 0.0 {
lp = lp.reversed();
chamfer_segment = lp.segs.len().saturating_sub(1);
}
let region = solvecraft_geom::Region2 { outer: lp, holes: vec![] };
crate::build::extrude(&plane, &[region], 0.0, len + 2.0 * ext)?.pop().ok_or_else(|| KernelError::Failed("chamfer tool".into()))
let body = crate::build::extrude(&plane, &[region], 0.0, len + 2.0 * ext)?.pop().ok_or_else(|| KernelError::Failed("chamfer tool".into()))?;
let face = body.history().and_then(|h| h.faces.iter().position(|f| f.origins == [FaceOrigin::Profile { segment: chamfer_segment }]));
Ok(Cutter { body, face, edge })
}
#[cfg(test)]
+28 -14
View File
@@ -33,13 +33,15 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result<Body> {
if edges.iter().any(|(p, r)| !(p.is_finite() && r.is_finite() && *r > 1e-6 && *r < 1e6)) {
return Err(KernelError::Invalid("fillet size must be positive".into()));
}
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
// Each step's result carries its history over `[body]`.
let counts = crate::record::counts(&[body]);
let mut cur = crate::boolean_record::healed(body)?;
let mut sel = edges.to_vec();
let mut first: Option<KernelError> = None;
// Earlier rounds at the edges' ends come off and are blended again with the new edges.
for _ in 0..4 {
let err = match build(&cur, &sel, true) {
Ok(b) => return Ok(b),
Ok(b) => return Ok(crate::record::after(b, &cur, &counts)),
Err(e) => e,
};
let rounds = rounds_at(&cur, &sel);
@@ -48,7 +50,7 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result<Body> {
}
first.get_or_insert(err);
let idx: Vec<usize> = rounds.iter().map(|r| r.0).collect();
cur = crate::delete_faces(&cur, &idx)?;
cur = crate::record::after(crate::delete_faces(&cur, &idx)?, &cur, &counts);
sel.extend(rounds.iter().map(|r| (r.1, r.2)));
}
Err(first.unwrap_or_else(|| unsupported("these fillets")))
@@ -57,9 +59,9 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result<Body> {
/// Local equal-distance chamfers, including pocket rims where adjacent walls have
/// different slopes. The planar strips trim each other at each selected corner.
pub(crate) fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
let cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
let cur = crate::boolean_record::healed(body)?;
let selected: Vec<_> = edges.iter().map(|p| (*p, distance)).collect();
build(&cur, &selected, false)
Ok(crate::record::after(build(&cur, &selected, false)?, &cur, &crate::record::counts(&[body])))
}
/// A face of the shell being rebuilt, or a new face.
@@ -311,7 +313,8 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
// New end points: (edge, vertex, face for a filleted edge's contact line).
let mut endpt: HashMap<(mt::EdgeID, mt::VertexID, Option<usize>), mt::Vertex> = HashMap::new();
let mut pool: HashMap<mt::VertexID, Vec<(mt::Edge, Vec<Owner>)>> = HashMap::new();
let mut patches: Vec<mt::Face> = Vec::new();
// Corner patches, with the edges meeting at their corner.
let mut patches: Vec<(mt::Face, [mt::EdgeID; 3])> = Vec::new();
let face_with = |a: mt::EdgeID, b: mt::EdgeID| -> Result<usize> {
let fa = on_edge.get(&a).cloned().unwrap_or_default();
let fb = on_edge.get(&b).cloned().unwrap_or_default();
@@ -440,7 +443,8 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
let c3 = edge(&v23, &v31, mt::Curve::NurbsCurve(t3))?;
let surf = cp.surf;
let wire: mt::Wire = vec![c2.clone(), c3.clone(), c1.inverse()].into();
patches.push(mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("corner patch: {e}")))?);
patches
.push((mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("corner patch: {e}")))?, [*e1, *e2, *e3]));
list.push((c1, vec![Owner::Cyl(*e1), Owner::Patch]));
list.push((c2, vec![Owner::Cyl(*e2), Owner::Patch]));
list.push((c3, vec![Owner::Cyl(*e3), Owner::Patch]));
@@ -468,6 +472,7 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
Ok(x)
};
let moved = |e: &mt::Edge| touched.contains_key(&e.absolute_front().id()) || touched.contains_key(&e.absolute_back().id());
let mut rec = crate::record::Rebuild::default();
let mut new_faces: Vec<mt::Face> = Vec::with_capacity(faces.len() + sels.len() + patches.len());
for (fi, f) in faces.iter().enumerate() {
if !f.vertex_iter().any(|v| touched.contains_key(&v.id())) {
@@ -482,7 +487,10 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
let ne = if sels.contains_key(&e.id()) {
line_of(e, Some(fi))?
} else if moved(e) {
line_of(e, None)?
// The edge itself, shortened at the corner.
let piece = line_of(e, None)?;
rec.piece(&piece, e);
piece
} else {
news.push(e.clone());
continue;
@@ -501,7 +509,9 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
}
wires.push(mt::Wire::from(out));
}
new_faces.push(mt::Face::try_new(wires, f.oriented_surface()).map_err(|e| KernelError::Failed(format!("fillet: face: {e}")))?);
let rebuilt = mt::Face::try_new(wires, f.oriented_surface()).map_err(|e| KernelError::Failed(format!("fillet: face: {e}")))?;
rec.face(&rebuilt, f);
new_faces.push(rebuilt);
}
// The blends: back along the first face's contact line, round the corner, along the
// second's, round the other corner.
@@ -518,10 +528,13 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
w.extend(connect(first.back(), second.front(), p1, Owner::Cyl(*id))?);
w.push(second.clone());
w.extend(connect(second.back(), first.front(), p2, Owner::Cyl(*id))?);
new_faces.push(mt::Face::try_new(vec![w.into()], s.surf.clone()).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?);
let blend = mt::Face::try_new(vec![w.into()], s.surf.clone()).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?;
rec.blend(&blend, [*id]);
new_faces.push(blend);
}
// Corner patches run each section opposite to its blend.
for mut p in patches {
// Corner patches run each section opposite to its blend; each is made in place of the
// edges meeting at its corner.
for (mut p, corner) in patches {
let uses = |f: &mt::Face, id: mt::EdgeID| {
f.boundaries().iter().flat_map(|w| w.edge_iter().cloned().collect::<Vec<_>>()).find(|x| x.id() == id).map(|x| x.front().id())
};
@@ -535,14 +548,15 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
let wires: Vec<mt::Wire> = p.boundaries().iter().map(|w| w.inverse()).collect();
p = mt::Face::try_new(wires, surf).map_err(|e| KernelError::Failed(format!("corner patch: {e}")))?;
}
rec.blend(&p, corner);
new_faces.push(p);
}
let mut shells = solid.boundaries().clone();
if let Some(sh) = shells.get_mut(si) {
*sh = new_faces.into();
}
let out = Body::new(Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("fillet produced an invalid solid: {e}")))?)?;
Ok(out)
let out = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("fillet produced an invalid solid: {e}")))?;
crate::record::rebuilt(out, &[&solid], &rec)
})
}
+11 -3
View File
@@ -229,6 +229,9 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &
subst.insert(e.id(), ne);
}
}
// The side edges at the ends are pieces of themselves; the contacts and end arcs are new.
let mut rec = crate::record::Rebuild::default();
rec.pieces(&subst);
let mut new_faces: Vec<mt::Face> = Vec::with_capacity(faces.len() + 1);
for (i, f) in faces.iter().enumerate() {
let nf = if i == fr.i1 {
@@ -246,6 +249,7 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &
} else {
(*f).clone()
};
rec.face(&nf, f);
new_faces.push(nf);
}
// The blend surface: rational quadratic arcs (tangent points and the edge point, middle
@@ -279,12 +283,14 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &
vec![la.clone(), c1.clone(), lb.inverse(), c0.inverse()].into()
};
let blend = mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?;
rec.blend(&blend, [edge.id()]);
new_faces.push(blend);
let mut shells = solid.boundaries().clone();
if let Some(sh) = shells.get_mut(si) {
*sh = new_faces.into();
}
Body::new(Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("variable fillet produced an invalid solid: {e}")))?)
let out = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("variable fillet produced an invalid solid: {e}")))?;
crate::record::rebuilt(out, &[solid], &rec)
})
}
@@ -312,9 +318,11 @@ pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result<Body> {
None => groups.push((r, vec![*p])),
}
}
let mut cur = body.clone();
// Each step's result carries its history over `[body]`.
let counts = crate::record::counts(&[body]);
let mut cur = crate::boolean_record::operand(body, 0, &counts);
for (r, pts) in groups {
cur = crate::fillet(&cur, &pts, r)?;
cur = crate::record::after(crate::fillet(&cur, &pts, r)?, &cur, &counts);
}
Ok(cur)
}
+180 -55
View File
@@ -33,6 +33,11 @@ pub struct Body {
pub(crate) splits: Option<std::sync::Arc<Vec<Vec<Vec3>>>>,
/// Exact construction/import source identities, in this body's face order.
pub(crate) face_sources: Option<std::sync::Arc<Vec<Option<FaceSource>>>>,
/// Sticky transient veto: authored face boundaries must survive later rebuilds.
pub(crate) regularization_protected: bool,
/// Exact history of the operation that made this body, relative to that operation's
/// operands; `None` when the operation does not report one.
pub(crate) history: Option<std::sync::Arc<crate::history::History>>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -120,6 +125,11 @@ fn mesh_shells(solid: &Solid, tol: f64) -> Vec<MeshedShell> {
/// `None` when nothing needs splitting or the parameters don't match the surface.
#[allow(clippy::type_complexity)]
fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3, Vec3); 3]>> {
refined_with_cache(pm, surf, tol, true)
}
#[allow(clippy::type_complexity)]
fn refined_with_cache(pm: &PolygonMesh, surf: &mt::Surface, tol: f64, memoize: bool) -> Option<Vec<[(Vec3, Vec3); 3]>> {
use mt::{ParametricSurface, ParametricSurface3D};
let (pos, uvs, nor) = (pm.positions(), pm.uv_coords(), pm.normals());
if uvs.is_empty() {
@@ -148,6 +158,15 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3,
// Edges on the face's boundary are shared with the next face: never split them.
let k = |x: &V| ((x.1.0 * 1e9).round() as i64, (x.1.1 * 1e9).round() as i64);
let ek = |a: &V, b: &V| if k(a) < k(b) { (k(a), k(b)) } else { (k(b), k(a)) };
// Only exact finite values identify reusable evaluations. In particular,
// the rounded UV keys used for boundary incidence cannot identify a point
// whose derivatives or midpoint may be reused.
let vertex_key = |v: &V| {
let values = [v.0.x, v.0.y, v.0.z, v.1.0, v.1.1, v.2.x, v.2.y, v.2.z];
values.iter().all(|x| x.is_finite()).then(|| values.map(f64::to_bits))
};
let cache_limit = cap * 3;
let mut singular_cache = std::collections::HashMap::<(u64, u64), bool>::new();
let mut changed = false;
for _ in 0..4 {
let mut uses: std::collections::HashMap<((i64, i64), (i64, i64)), u32> = std::collections::HashMap::new();
@@ -156,34 +175,60 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3,
*uses.entry(e).or_default() += 1;
}
}
let at = |a: &V, b: &V| -> Option<V> {
if uses.get(&ek(a, b)).copied().unwrap_or(0) < 2 {
return None;
let mut edge_cache = std::collections::HashMap::<([u64; 8], [u64; 8]), Option<V>>::new();
let mut at = |a: &V, b: &V| -> Option<V> {
let key = if memoize { vertex_key(a).zip(vertex_key(b)).map(|(a, b)| if a < b { (a, b) } else { (b, a) }) } else { None };
if let Some(key) = key
&& let Some(value) = edge_cache.get(&key)
{
return *value;
}
// Not at a degenerate point (a cone's apex: its parameters there are arbitrary).
let singular = |x: &V| {
let (du, dv) = (surf.uder(x.1.0, x.1.1), surf.vder(x.1.0, x.1.1));
let c = du.cross(dv);
c.x * c.x + c.y * c.y + c.z * c.z
<= 1e-12 * (du.x * du.x + du.y * du.y + du.z * du.z + dv.x * dv.x + dv.y * dv.y + dv.z * dv.z).powi(2) + 1e-300
let mut calculate = || -> Option<V> {
if uses.get(&ek(a, b)).copied().unwrap_or(0) < 2 {
return None;
}
// Not at a degenerate point (a cone's apex: its parameters there are arbitrary).
let mut singular = |x: &V| {
let key = (x.1.0.to_bits(), x.1.1.to_bits());
let cacheable = memoize && x.1.0.is_finite() && x.1.1.is_finite();
if cacheable && let Some(value) = singular_cache.get(&key) {
return *value;
}
let (du, dv) = (surf.uder(x.1.0, x.1.1), surf.vder(x.1.0, x.1.1));
let c = du.cross(dv);
let value = c.x * c.x + c.y * c.y + c.z * c.z
<= 1e-12 * (du.x * du.x + du.y * du.y + du.z * du.z + dv.x * dv.x + dv.y * dv.y + dv.z * dv.z).powi(2) + 1e-300;
if cacheable && singular_cache.len() < cache_limit {
singular_cache.insert(key, value);
}
value
};
if singular(a) || singular(b) {
return None;
}
let (u, v) = ((a.1.0 + b.1.0) / 2.0, (a.1.1 + b.1.1) / 2.0);
let q = from_p3(surf.subs(u, v));
// Within tolerance, or so far off that the parameters wrapped (a seam).
let sag = q.dist((a.0 + b.0) * 0.5);
if sag <= tol || sag >= 0.5 * a.0.dist(b.0) {
return None;
}
let n = surf.normal(u, v);
let mut n = Vec3::new(n.x, n.y, n.z);
// Keep the mesh's normal side.
if n.dot(a.2 + b.2) < 0.0 {
n = -n;
}
Some((q, (u, v), n))
};
if singular(a) || singular(b) {
return None;
let value = calculate();
if let Some(key) = key
&& edge_cache.len() < cache_limit
&& value.is_none_or(|v| vertex_key(&v).is_some())
{
edge_cache.insert(key, value);
}
let (u, v) = ((a.1.0 + b.1.0) / 2.0, (a.1.1 + b.1.1) / 2.0);
let q = from_p3(surf.subs(u, v));
// Within tolerance, or so far off that the parameters wrapped (a seam).
let sag = q.dist((a.0 + b.0) * 0.5);
if sag <= tol || sag >= 0.5 * a.0.dist(b.0) {
return None;
}
let n = surf.normal(u, v);
let mut n = Vec3::new(n.x, n.y, n.z);
// Keep the mesh's normal side.
if n.dot(a.2 + b.2) < 0.0 {
n = -n;
}
Some((q, (u, v), n))
value
};
let mut next = Vec::with_capacity(tris.len());
let mut any = false;
@@ -292,6 +337,31 @@ pub(crate) fn from_p3(p: mt::Point3) -> Vec3 {
Vec3::new(p.x, p.y, p.z)
}
/// The distinct edges of `faces` in order of first use, and the faces (by position) each one
/// bounds: the one edge order of a body, used by [`Body::edges`], tessellations and
/// [`crate::history`].
pub(crate) fn edge_order<'a, P: 'a, C: 'a, S: 'a>(
faces: impl IntoIterator<Item = &'a truck_topology::Face<P, C, S>>,
) -> (Vec<truck_topology::Edge<P, C>>, Vec<Vec<usize>>) {
let mut index = std::collections::HashMap::new();
let (mut edges, mut bounds): (Vec<truck_topology::Edge<P, C>>, Vec<Vec<usize>>) = (Vec::new(), Vec::new());
for (fi, f) in faces.into_iter().enumerate() {
for e in f.edge_iter() {
let i = *index.entry(e.id()).or_insert_with(|| {
edges.push(e.clone());
bounds.push(Vec::new());
edges.len() - 1
});
if let Some(slot) = bounds.get_mut(i)
&& !slot.contains(&fi)
{
slot.push(fi);
}
}
}
(edges, bounds)
}
fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
let len: f64 = pts.windows(2).map(|w| w.first().zip(w.get(1)).map(|(a, b)| a.dist(*b)).unwrap_or(0.0)).sum();
let mut acc = 0.0;
@@ -308,7 +378,9 @@ fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
}
impl Body {
/// The very same body (shared data), not just an equal-looking one.
/// The very same body (shared data), not just an equal-looking one. Its history (how an
/// operation made it) is not part of it: an operation that leaves a body as it is (a cut by
/// a tool clear of it) gives the same body, with that operation's history.
pub fn same(&self, other: &Body) -> bool {
std::sync::Arc::ptr_eq(&self.solid, &other.solid)
&& match (&self.mesh, &other.mesh) {
@@ -320,6 +392,7 @@ impl Body {
&& self.paint == other.paint
&& self.splits == other.splits
&& self.face_sources == other.face_sources
&& self.regularization_protected == other.regularization_protected
}
pub(crate) fn new(solid: Solid) -> Result<Body> {
@@ -358,9 +431,58 @@ impl Body {
paint: None,
splits: None,
face_sources: None,
regularization_protected: false,
history: None,
})
}
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
self.regularization_protected |= input.regularization_protected;
self
}
/// Exact history of the operation that made this body, relative to that operation's
/// operands (see [`crate::history`]); `None` when the operation does not report one.
pub fn history(&self) -> Option<&crate::history::History> {
self.history.as_deref()
}
/// Attach the history of the operation that made this body from `operands`, after checking
/// it describes this body.
pub(crate) fn with_history(mut self, history: crate::history::History, operands: &[&Body]) -> Result<Self> {
history.check(&self, operands)?;
self.history = Some(std::sync::Arc::new(history));
Ok(self)
}
/// [`Body::with_history`], given the operands' face and edge counts.
pub(crate) fn with_history_counts(mut self, history: crate::history::History, operands: &[(usize, usize)]) -> Result<Self> {
history.check_counts((self.face_count(), self.edge_count()), operands)?;
self.history = Some(std::sync::Arc::new(history));
Ok(self)
}
/// This body with `history` if it describes the body made from operands with these face
/// and edge counts, else with none: an operation whose history could not be recorded still
/// gives its result.
pub(crate) fn with_checked_history(mut self, history: Option<crate::history::History>, operands: &[(usize, usize)]) -> Self {
let counts = (self.face_count(), self.edge_count());
self.history = history.and_then(|h| match h.check_counts(counts, operands) {
Ok(()) => Some(std::sync::Arc::new(h)),
Err(e) => {
log::debug!("no history: {e}");
None
}
});
self
}
/// This body with no history: an operation whose history is not known.
pub(crate) fn without_history(mut self) -> Self {
self.history = None;
self
}
pub fn face_sources(&self) -> Option<&[Option<FaceSource>]> {
self.face_sources.as_deref().map(Vec::as_slice)
}
@@ -588,6 +710,7 @@ impl Body {
/// The same body with the lines its faces were split along on purpose.
pub fn with_split_lines(mut self, s: Option<std::sync::Arc<Vec<Vec<Vec3>>>>) -> Body {
self.splits = s.filter(|l| !l.is_empty());
self.regularization_protected |= self.splits.is_some();
self
}
@@ -612,6 +735,7 @@ impl Body {
})
.filter(|p| p.opacity < 1.0 || !p.faces.is_empty())
.map(std::sync::Arc::new);
self.regularization_protected |= self.paint().is_some_and(|paint| !paint.faces.is_empty());
self
}
@@ -658,11 +782,15 @@ impl Body {
let flip = !face.orientation();
let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX);
// Curved faces: split triangles that cut through the surface.
if refine
&& let Some(sf) = solid_faces.get(fi)
&& !matches!(sf.surface(), mt::Surface::Plane(_))
&& let Some(tris) = refined(&pm, &sf.surface(), tol)
{
let refinement = if refine {
solid_faces.get(fi).and_then(|sf| {
let surface = sf.surface();
if matches!(surface, mt::Surface::Plane(_)) { None } else { refined(&pm, &surface, tol) }
})
} else {
None
};
if let Some(tris) = refinement {
let fid = u32::try_from(fi).unwrap_or(u32::MAX);
for t in tris {
let mut idx = [0u32; 3];
@@ -713,27 +841,9 @@ impl Body {
}
}
}
let mut seen = std::collections::HashMap::new();
for e in meshed.iter().flat_map(|s| s.edge_iter()) {
if seen.contains_key(&e.id()) {
continue;
}
seen.insert(e.id(), out.edges.len());
let c = e.oriented_curve();
out.edges.push(c.0.iter().map(|p| Vec3::new(p.x, p.y, p.z)).collect());
}
// Which faces each edge bounds.
out.edge_faces = vec![Vec::new(); out.edges.len()];
for (fi, face) in meshed.iter().flat_map(|s| s.face_iter()).enumerate() {
for e in face.edge_iter() {
if let Some(slot) = seen.get(&e.id()).and_then(|i| out.edge_faces.get_mut(*i)) {
let f = u32::try_from(fi).unwrap_or(u32::MAX);
if !slot.contains(&f) {
slot.push(f);
}
}
}
}
let (edges, faces) = edge_order(meshed.iter().flat_map(|s| s.face_iter()));
out.edges = edges.iter().map(|e| e.oriented_curve().0.iter().map(|p| Vec3::new(p.x, p.y, p.z)).collect()).collect();
out.edge_faces = faces.into_iter().map(|fs| fs.into_iter().map(|f| u32::try_from(f).unwrap_or(u32::MAX)).collect()).collect();
Ok(out)
})
}
@@ -895,9 +1005,16 @@ impl Body {
impl Body {
/// The separate pieces of a body: each outer shell with the voids inside it. A body in one
/// piece comes back as itself.
/// piece comes back as itself. Pieces of a body in several carry their history over
/// `[self]` (their faces and edges are its own), so whoever names them can follow it.
pub fn lumps(&self) -> Result<Vec<Body>> {
self.lumps_with_clearance(0.0)
let pieces = self.lumps_with_clearance(0.0)?;
if pieces.len() < 2 {
return Ok(pieces);
}
let counts = [(self.face_count(), self.edge_count())];
let histories = crate::boolean_record::parts_of(&pieces, self);
Ok(pieces.into_iter().zip(histories).map(|(piece, history)| piece.with_checked_history(history, &counts)).collect())
}
/// Whether this body and `other` share material or touch (within a fine mesh tolerance).
@@ -1019,7 +1136,7 @@ impl Body {
})
.collect()
});
Ok(result.with_face_sources(sources.map(std::sync::Arc::new)))
Ok(result.with_face_sources(sources.map(std::sync::Arc::new)).inherit_regularization_protection(self))
})
.collect()
})
@@ -1029,3 +1146,11 @@ impl Body {
#[cfg(test)]
#[path = "body_lump_tests.rs"]
mod lump_tests;
#[cfg(test)]
#[path = "refine_cache_tests.rs"]
mod refine_cache_tests;
#[cfg(test)]
#[path = "regularization_protection_tests.rs"]
mod regularization_protection_tests;
+195
View File
@@ -0,0 +1,195 @@
//! Scoped reports for successful Boolean retries. Discarded attempts never enter
//! their caller's report, and independent evaluations cannot inherit old events.
use std::cell::RefCell;
use serde::Serialize;
const MAX_EVENTS: usize = 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BooleanStrategy {
RefinedTolerance,
ShiftedOperands,
CoplanarSeparation,
CommonBore,
PlanarFallback,
ToolNudge,
Piecewise,
}
/// Numerical adjustments used by a successful retry, in millimetres/radians.
/// Operand shifts are reversed on the result; face shifts are temporary working
/// offsets. A tool nudge, in contrast, changes the relative operand placement.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct BooleanFallback {
pub strategy: BooleanStrategy,
pub translation_mm: f64,
pub rotation_rad: f64,
pub face_shift_mm: f64,
pub tolerance_mm: f64,
}
impl BooleanFallback {
pub(crate) fn new(strategy: BooleanStrategy) -> Self {
Self { strategy, translation_mm: 0., rotation_rad: 0., face_shift_mm: 0., tolerance_mm: 0. }
}
fn description(&self) -> String {
match self.strategy {
BooleanStrategy::RefinedTolerance => format!("refined tolerance {:.6e} mm", self.tolerance_mm),
BooleanStrategy::ShiftedOperands => {
format!("shifted operands {:.6e} mm (result shifted back), tolerance {:.6e} mm", self.translation_mm, self.tolerance_mm)
}
BooleanStrategy::CoplanarSeparation => format!("temporary coincident-face separation up to {:.6e} mm per face", self.face_shift_mm),
BooleanStrategy::CommonBore => format!("temporary bore offset {:.6e} mm, followed by recutting the original bore", self.face_shift_mm),
BooleanStrategy::PlanarFallback => "exact planar fallback (no displacement)".into(),
BooleanStrategy::ToolNudge => format!("tool displacement {:.6e} mm, rotation {:.6e} rad", self.translation_mm, self.rotation_rad),
BooleanStrategy::Piecewise => "piecewise retry (no displacement)".into(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct BooleanDiagnostics {
pub events: Vec<BooleanFallback>,
pub truncated: bool,
}
impl BooleanDiagnostics {
pub(crate) fn push(&mut self, event: BooleanFallback) {
if self.events.len() < MAX_EVENTS {
self.events.push(event);
} else {
self.truncated = true;
}
}
pub fn warning(&self) -> Option<String> {
if self.events.is_empty() && !self.truncated {
return None;
}
let mut descriptions: Vec<String> = self.events.iter().take(4).map(BooleanFallback::description).collect();
if self.events.len() > 4 {
descriptions.push(format!("{} additional retries", self.events.len() - 4));
}
if self.truncated {
descriptions.push(format!("report capped at {MAX_EVENTS} retries"));
}
Some(format!("Boolean retry: {}", descriptions.join("; ")))
}
}
thread_local! {
static REPORT: RefCell<Option<BooleanDiagnostics>> = const { RefCell::new(None) };
}
struct Scope {
previous: Option<Option<BooleanDiagnostics>>,
}
impl Drop for Scope {
fn drop(&mut self) {
if let Some(previous) = self.previous.take() {
REPORT.with(|cell| {
if let Ok(mut report) = cell.try_borrow_mut() {
*report = previous;
}
});
}
}
}
/// Capture only the events produced by this call. Nested callers explicitly merge
/// an accepted attempt; dropping a scope restores its parent even during unwinding.
pub fn capture_boolean_diagnostics<T>(run: impl FnOnce() -> T) -> (T, BooleanDiagnostics) {
let previous = REPORT.with(|cell| cell.try_borrow_mut().ok().map(|mut report| report.replace(BooleanDiagnostics::default())));
let scope = Scope { previous };
let result = run();
let report = REPORT.with(|cell| cell.try_borrow_mut().ok().and_then(|mut report| report.take())).unwrap_or_default();
drop(scope);
(result, report)
}
pub(crate) fn record(event: BooleanFallback) {
REPORT.with(|cell| {
if let Ok(mut report) = cell.try_borrow_mut()
&& let Some(report) = report.as_mut()
{
report.push(event);
}
});
}
pub(crate) fn merge(report: BooleanDiagnostics) {
for event in report.events {
record(event);
}
if report.truncated {
REPORT.with(|cell| {
if let Ok(mut report) = cell.try_borrow_mut()
&& let Some(report) = report.as_mut()
{
report.truncated = true;
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nested_attempts_are_committed_explicitly_and_do_not_leak() {
let (_, report) = capture_boolean_diagnostics(|| {
let (_, discarded) = capture_boolean_diagnostics(|| record(BooleanFallback::new(BooleanStrategy::ToolNudge)));
assert_eq!(discarded.events.len(), 1);
let (_, accepted) = capture_boolean_diagnostics(|| record(BooleanFallback::new(BooleanStrategy::PlanarFallback)));
merge(accepted);
});
assert_eq!(report.events.len(), 1);
assert_eq!(report.events[0].strategy, BooleanStrategy::PlanarFallback);
assert!(capture_boolean_diagnostics(|| {}).1.events.is_empty());
record(BooleanFallback::new(BooleanStrategy::ToolNudge));
assert!(capture_boolean_diagnostics(|| {}).1.events.is_empty());
}
#[test]
fn exact_operations_and_failed_calls_have_no_retry_report() {
use solvecraft_geom::Vec3;
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap();
let other = crate::box_solid(Vec3::new(5., 0., 0.), Vec3::new(15., 10., 10.)).unwrap();
let (result, diagnostics) = capture_boolean_diagnostics(|| crate::boolean(&body, &other, crate::BoolOp::Union));
assert!(result.unwrap().is_some());
assert!(diagnostics.warning().is_none(), "{diagnostics:?}");
let mesh = crate::mesh_body(&[Vec3::ZERO, Vec3::X, Vec3::Y], &[[0, 1, 2]]).unwrap();
let (result, diagnostics) = capture_boolean_diagnostics(|| crate::boolean(&body, &mesh, crate::BoolOp::Cut));
assert!(result.is_err());
assert!(diagnostics.warning().is_none(), "{diagnostics:?}");
}
#[test]
fn unwinding_restores_parent_and_reports_are_thread_local_and_bounded() {
let (_, report) = capture_boolean_diagnostics(|| {
let failed = crate::guard("diagnostic test", || {
capture_boolean_diagnostics(|| -> crate::Result<()> {
record(BooleanFallback::new(BooleanStrategy::ToolNudge));
panic!("discard this attempt");
})
.0
});
assert!(failed.is_err());
let separate = std::thread::spawn(|| capture_boolean_diagnostics(|| record(BooleanFallback::new(BooleanStrategy::ToolNudge))).1);
assert_eq!(separate.join().unwrap().events.len(), 1);
for _ in 0..MAX_EVENTS + 1 {
record(BooleanFallback::new(BooleanStrategy::PlanarFallback));
}
});
assert_eq!(report.events.len(), MAX_EVENTS);
assert!(report.truncated);
assert!(report.events.iter().all(|event| event.strategy == BooleanStrategy::PlanarFallback));
assert!(report.warning().unwrap().contains("capped"));
}
}
+503
View File
@@ -0,0 +1,503 @@
//! Booleans' histories, path by path: every face and edge origin checked against the geometry
//! of the operands (in tests only: the history itself is recorded, never sampled), and the exact
//! origins asserted where the outcome is known.
use std::collections::BTreeSet;
use solvecraft_geom::{Mesh, Vec3};
use crate::body::Body;
use crate::history::{Change, EdgeOrigin, FaceOrigin, History, Side};
use crate::{BoolOp, boolean, box_solid};
const fn face(operand: usize, face: usize) -> FaceOrigin {
FaceOrigin::Face { operand, face }
}
fn closest_on_triangle(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
// Ericson, Real-Time Collision Detection, 5.1.5.
let (ab, ac, ap) = (b - a, c - a, p - a);
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
if d1 <= 0.0 && d2 <= 0.0 {
return a;
}
let bp = p - b;
let (d3, d4) = (ab.dot(bp), ac.dot(bp));
if d3 >= 0.0 && d4 <= d3 {
return b;
}
let vc = d1 * d4 - d3 * d2;
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
return a + ab * (d1 / (d1 - d3));
}
let cp = p - c;
let (d5, d6) = (ab.dot(cp), ac.dot(cp));
if d6 >= 0.0 && d5 <= d6 {
return c;
}
let vb = d5 * d2 - d1 * d6;
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
return a + ac * (d2 / (d2 - d6));
}
let va = d3 * d6 - d5 * d4;
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
}
let denom = 1.0 / (va + vb + vc);
a + ab * (vb * denom) + ac * (vc * denom)
}
/// Distance from `p` to face `f` of a mesh.
pub(crate) fn to_face(m: &Mesh, f: usize, p: Vec3) -> f64 {
m.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, x)| **x as usize == f)
.map(|(t, _)| {
let [a, b, c] = t.map(|i| m.positions[i as usize]);
closest_on_triangle(p, a, b, c).dist(p)
})
.fold(f64::INFINITY, f64::min)
}
/// Distance from `p` to edge `e` of a mesh.
pub(crate) fn to_edge(m: &Mesh, e: usize, p: Vec3) -> f64 {
m.edges[e].windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min)
}
/// How far, in a check, a face or edge may lie from what it came from.
#[derive(Clone, Copy)]
pub(crate) struct Reach {
/// A face made in place of an edge, or an offset of a face, lies within this of it.
pub made: f64,
/// A face or edge the operation moved lies within this of the one it came from.
pub moved: f64,
}
/// The result's history, checked against it and its operands, with every origin checked
/// against the geometry: each face's triangles lie on its origin faces, each origin face holds
/// some of them; each edge lies along its origin edges, or on both faces of a meeting. `tol`:
/// the meshes' tolerance.
fn verified<'a>(r: &'a Body, operands: &[&Body], tol: f64) -> &'a History {
checked(r, operands, tol, Reach { made: 0.0, moved: 0.0 }, &|_| false)
}
/// [`verified`] for any operation: a face lies on its origin faces (within `at.moved` where the
/// operation moves faces), within `at.made` of the edges it is made in place of and of the faces
/// it is an offset of; an edge lies along its origin edges (within `at.moved`). Faces from
/// nothing are allowed only where `generated` says.
pub(crate) fn checked<'a>(r: &'a Body, operands: &[&Body], tol: f64, at: Reach, generated: &dyn Fn(usize) -> bool) -> &'a History {
let h = r.history().expect("the operation reports its history");
h.check(r, operands).unwrap();
let rm = r.tessellate(tol).unwrap();
let ms: Vec<Mesh> = operands.iter().map(|b| b.tessellate(tol).unwrap()).collect();
let near = 4.0 * tol + r.size() * 1e-6;
let on = |o: &FaceOrigin, p: Vec3| match *o {
FaceOrigin::Face { operand, face } => to_face(&ms[operand], face, p) < near.max(at.moved),
FaceOrigin::Edge { operand, edge } => to_edge(&ms[operand], edge, p) < at.made + near,
FaceOrigin::Offset { operand, face } => to_face(&ms[operand], face, p) < at.made + near,
_ => false,
};
let along = |o: &EdgeOrigin, p: &Vec3| match o {
EdgeOrigin::Edge { operand, edge } => to_edge(&ms[*operand], *edge, *p) < near.max(at.moved),
EdgeOrigin::Meet { a, b } => a.origins().iter().any(|o| on(o, *p)) && b.origins().iter().any(|o| on(o, *p)),
};
for (i, f) in h.faces.iter().enumerate() {
assert!(!f.origins.is_empty() || generated(i), "face {i} has no origin");
let centres = centres(&rm, i);
for c in centres.iter().filter(|_| !f.origins.is_empty()) {
assert!(f.origins.iter().any(|o| on(o, *c)), "face {i} ({f:?}) at {c:?} lies on none of its origins");
}
for o in &f.origins {
assert!(centres.iter().any(|c| on(o, *c)), "face {i}: origin {o:?} is under none of it");
}
}
for (j, e) in h.edges.iter().enumerate() {
let pts = &rm.edges[j];
for o in &e.origins {
match o {
// A joined edge runs along all of its origins, each along part of it (one in the
// middle of a straight edge holds none of its points, but its own lie on it).
EdgeOrigin::Edge { operand, edge } if e.origins.len() > 1 => assert!(
pts.iter().any(|p| along(o, p)) || ms[*operand].edges[*edge].iter().any(|q| to_edge(&rm, j, *q) < near.max(at.moved)),
"edge {j}: {o:?} is not along it"
),
_ => assert!(pts.iter().all(|p| along(o, p)), "edge {j} ({e:?}) does not lie along {o:?}"),
}
}
if e.origins.len() > 1 {
for p in pts {
assert!(e.origins.iter().any(|o| along(o, p)), "edge {j}: {p:?} lies along none of {e:?}");
}
}
}
h
}
/// The centres of face `f`'s triangles.
pub(crate) fn centres(m: &Mesh, f: usize) -> Vec<Vec3> {
m.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, x)| **x as usize == f)
.map(|(t, _)| t.map(|k| m.positions[k as usize]).iter().fold(Vec3::ZERO, |s, p| s + *p) * (1.0 / 3.0))
.collect()
}
/// How many faces of each change.
pub(crate) fn changes(h: &History) -> [usize; 5] {
let mut n = [0; 5];
for f in &h.faces {
n[match f.change {
Change::Kept => 0,
Change::Modified => 1,
Change::Split => 2,
Change::Merged => 3,
Change::Generated => 4,
}] += 1;
}
n
}
/// The faces of the result that come from operand face `o`.
fn from(h: &History, o: FaceOrigin) -> Vec<usize> {
(0..h.faces.len()).filter(|i| h.faces[*i].origins.contains(&o)).collect()
}
/// The meetings among the edges' origins, as face pairs (each side one face here).
fn meets(h: &History) -> BTreeSet<(FaceOrigin, FaceOrigin)> {
let one = |s: &Side| s.single().expect("a side of one face");
h.edges.iter().flat_map(|e| &e.origins).filter_map(|o| if let EdgeOrigin::Meet { a, b } = o { Some((one(a), one(b))) } else { None }).collect()
}
fn boxed(x0: f64, y0: f64, z0: f64, x1: f64, y1: f64, z1: f64) -> Body {
box_solid(Vec3::new(x0, y0, z0), Vec3::new(x1, y1, z1)).unwrap()
}
/// A short summary of a history for failure messages.
fn show(h: &History) -> String {
let faces: Vec<String> = h.faces.iter().enumerate().map(|(i, f)| format!("{i}: {:?} {:?}", f.change, f.origins)).collect();
let edges: Vec<String> = h.edges.iter().enumerate().map(|(i, e)| format!("{i}: {:?} {:?}", e.faces, e.origins)).collect();
format!("faces\n{}\nedges\n{}", faces.join("\n"), edges.join("\n"))
}
/// Each face's origins and change, in face order.
fn faces_of(h: &History) -> Vec<(Change, Vec<FaceOrigin>)> {
h.faces.iter().map(|f| (f.change, f.origins.clone())).collect()
}
/// Truck's boolean with a sphere (no prism, no coincident faces): a box with a corner taken
/// off by a sphere. The three faces at the corner get a notch, the sphere's patches inside the
/// box bound it, meeting the box's faces along new edges.
#[test]
fn box_minus_sphere_through_truck() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = crate::sphere(Vec3::new(10.0, 10.0, 10.0), 4.0).unwrap();
let r = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.02);
assert_eq!(changes(h), [3, 6, 0, 0, 0], "{}", show(h));
let from_b: BTreeSet<usize> = h
.faces
.iter()
.flat_map(|f| &f.origins)
.filter_map(|o| match o {
FaceOrigin::Face { operand: 1, face } => Some(*face),
_ => None,
})
.collect();
assert_eq!(from_b.len(), 3);
// Each notched box face meets two of the sphere's patches.
assert_eq!(meets(h).len(), 6, "{}", show(h));
assert!(meets(h).iter().all(|(x, y)| matches!((x, y), (FaceOrigin::Face { operand: 0, .. }, FaceOrigin::Face { operand: 1, .. }))));
}
/// The jitter retries move both operands and the result back: the history holds for the
/// operands themselves, the same faces coming from the same operand faces.
#[test]
fn shifted_truck_attempt_keeps_the_history() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = crate::sphere(Vec3::new(10.0, 10.0, 10.0), 4.0).unwrap();
let counts = crate::record::counts(&[&a, &b]);
let mut compared = 0;
for op in [BoolOp::Cut, BoolOp::Union, BoolOp::Intersect] {
let Ok((_, Some((plain, _)))) = crate::ops::truck_boolean(&a, &b, op, None, 0.035) else { continue };
// The shifts the retries use (a hundredth of the size along each).
for j in [[0.1373, 0.2719, 0.3911], [-0.5127, 0.0833, 0.2291], [0.6911, -0.3337, -0.6127]] {
let shift = truck_modeling::Vector3::new(j[0], j[1], j[2]) * 0.17;
let Ok((s, h)) = crate::ops::truck_boolean(&a, &b, op, Some(shift), 0.035) else { continue };
let faces = h.as_ref().map(|(f, _)| f.clone());
assert_eq!(faces, Some(plain.clone()), "{op:?}");
let r = crate::boolean_record::truck_result(s, h, 17.4, &[], &counts).unwrap();
verified(&r, &[&a, &b], 0.02);
compared += 1;
}
}
assert!(compared > 0);
}
/// Two prisms along x, the tool's section inside the target's, overlapping in part: sewn slab by
/// slab, then healed. The target's face at x = 10 gets the tool's outline as a hole, which the
/// tool's sides meet; the tool's sides and far end stand out of it.
#[test]
fn box_union_box_prism() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = boxed(5.0, 2.0, 2.0, 15.0, 8.0, 8.0);
let r = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.02);
assert_eq!(h.faces.len(), 11);
let mut origins: Vec<FaceOrigin> = h
.faces
.iter()
.map(|f| match f.origins.as_slice() {
[o] => *o,
_ => panic!("one origin each: {}", show(h)),
})
.collect();
origins.sort();
// All of a's faces, and b's but the end inside a.
let mut want: Vec<FaceOrigin> = (0..6).map(|i| face(0, i)).chain((0..6).map(|i| face(1, i))).collect();
let inside = face(1, 4);
want.retain(|o| *o != inside);
assert_eq!(origins, want, "{}", show(h));
// b's four sides meet a's face at x = 10.
let rim: BTreeSet<_> = meets(h).iter().map(|(x, _)| *x).collect();
assert_eq!(meets(h).len(), 4);
assert_eq!(rim.len(), 1);
assert!(h.edges.iter().all(|e| e.origins.len() == 1));
}
/// A through hole: the box's sides are kept, top and bottom get the hole, the cylinder's two
/// half walls line it, meeting top and bottom along its rims, and meet each other along the
/// cylinder's own seams.
#[test]
fn box_minus_cylinder_prism() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = crate::cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 12.0).unwrap();
let r = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.01);
assert_eq!(changes(h), [4, 4, 0, 0, 0], "{}", show(h));
let walls = from(h, face(1, 1)).len() + from(h, face(1, 2)).len();
assert_eq!(walls, 2, "{}", show(h));
// Rims: each wall meets top and bottom.
assert_eq!(meets(h).len(), 4, "{}", show(h));
let seams = h.edges.iter().filter(|e| matches!(e.origins.as_slice(), [EdgeOrigin::Edge { operand: 1, .. }])).count();
assert_eq!(seams, 2);
// Every other edge is one of the box's twelve.
let boxed_edges: BTreeSet<_> = h.edges.iter().flat_map(|e| &e.origins).filter(|o| matches!(o, EdgeOrigin::Edge { operand: 0, .. })).collect();
assert_eq!(boxed_edges.len(), 12);
}
/// A slot across the top (a stepped prism along y): the top and the box's ends come apart in
/// pieces (Split); the slot's floor and sides are the tool's.
#[test]
fn slot_splits_a_face() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = boxed(4.0, -1.0, 5.0, 6.0, 11.0, 11.0);
let r = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.02);
let top = (0..6).find(|i| a.planar_face(*i).is_some_and(|p| p.origin.z > 9.0 && p.normal().z > 0.9)).unwrap();
let pieces = from(h, face(0, top));
assert!(pieces.len() >= 2, "{}", show(h));
assert!(pieces.iter().all(|i| h.faces[*i].change == Change::Split));
// Three of the tool's faces bound the slot.
let tool: BTreeSet<_> = h.faces.iter().flat_map(|f| &f.origins).filter(|o| matches!(o, FaceOrigin::Face { operand: 1, .. })).collect();
assert_eq!(tool.len(), 3, "{}", show(h));
}
/// Bodies clear of each other: a union keeps both whole, a cut keeps the target.
#[test]
fn trivial_paths() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = crate::sphere(Vec3::new(30.0, 0.0, 0.0), 3.0).unwrap();
let u = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
let h = verified(&u, &[&a, &b], 0.02);
let want: Vec<(Change, Vec<FaceOrigin>)> =
(0..a.face_count()).map(|i| (Change::Kept, vec![face(0, i)])).chain((0..b.face_count()).map(|i| (Change::Kept, vec![face(1, i)]))).collect();
assert_eq!(faces_of(h), want);
let c = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
let h = verified(&c, &[&a, &b], 0.02);
assert_eq!(faces_of(h), (0..a.face_count()).map(|i| (Change::Kept, vec![face(0, i)])).collect::<Vec<_>>());
// Wholly inside: the intersection is the tool, the union the target.
let inner = crate::sphere(Vec3::new(5.0, 5.0, 5.0), 2.0).unwrap();
let i = boolean(&a, &inner, BoolOp::Intersect).unwrap().unwrap();
let h = verified(&i, &[&a, &inner], 0.02);
assert!(h.faces.iter().all(|f| f.change == Change::Kept && matches!(f.origins.as_slice(), [FaceOrigin::Face { operand: 1, .. }])));
let u = boolean(&a, &inner, BoolOp::Union).unwrap().unwrap();
let h = verified(&u, &[&a, &inner], 0.02);
assert!(h.faces.iter().all(|f| matches!(f.origins.as_slice(), [FaceOrigin::Face { operand: 0, .. }])));
// Cut out of the middle: a void, the tool's faces turned inward.
let v = boolean(&a, &inner, BoolOp::Cut).unwrap().unwrap();
let h = verified(&v, &[&a, &inner], 0.02);
assert_eq!(v.shell_count(), 2);
assert_eq!(h.faces.len(), a.face_count() + inner.face_count());
assert!(h.faces.iter().all(|f| f.change == Change::Kept));
}
/// A body in two pieces, cut a piece at a time: each piece's faces are the body's own.
#[test]
fn by_lumps_composes_the_pieces() {
let left = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let right = boxed(20.0, 0.0, 0.0, 30.0, 10.0, 10.0);
let a = boolean(&left, &right, BoolOp::Union).unwrap().unwrap();
assert_eq!(a.shell_count(), 2);
let b = crate::sphere(Vec3::new(15.0, 5.0, 10.0), 7.0).unwrap();
let r = crate::ops::by_lumps(&a, &b, a.lumps().unwrap(), b.lumps().unwrap(), BoolOp::Cut).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.02);
// Both pieces are bitten: the sphere's patches inside each line its bite.
let bites: BTreeSet<_> = h.faces.iter().flat_map(|f| &f.origins).filter(|o| matches!(o, FaceOrigin::Face { operand: 1, .. })).collect();
assert!(bites.len() >= 2, "{}", show(h));
let targets: BTreeSet<_> = h.faces.iter().flat_map(|f| &f.origins).filter(|o| matches!(o, FaceOrigin::Face { operand: 0, .. })).collect();
assert_eq!(targets.len(), 12);
}
/// Two bodies touching along one whole face, back to back: glued, the two faces gone, the
/// coplanar sides each side of the seam merged.
#[test]
fn glue_merges_across_the_seam() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = boxed(10.0, 0.0, 0.0, 20.0, 10.0, 10.0);
let r = crate::coplanar::glue(&a, &b).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.02);
assert_eq!(changes(h), [2, 0, 0, 4, 0], "{}", show(h));
// The four seam edges went with the merged faces; the ends' edges are each box's.
assert_eq!(h.edges.len(), 12);
assert!(h.edges.iter().all(|e| !e.origins.is_empty()));
}
/// Coincident faces pushed apart: a cut flush with the target's top, and a union of bodies
/// standing on one floor (the floor that is cut back is both operands' floors).
#[test]
fn faces_pushed_apart() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let tool = boxed(2.0, 2.0, 5.0, 8.0, 8.0, 10.0);
let r = crate::coplanar::boolean_apart(&a, &tool, BoolOp::Cut).unwrap().unwrap().unwrap();
let h = verified(&r, &[&a, &tool], 0.02);
// The pocket: its floor and four sides are the tool's.
let tool_faces: BTreeSet<_> = h.faces.iter().flat_map(|f| &f.origins).filter(|o| matches!(o, FaceOrigin::Face { operand: 1, .. })).collect();
assert_eq!(tool_faces.len(), 5, "{}", show(h));
let left = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let right = boxed(5.0, 3.0, 0.0, 15.0, 7.0, 4.0);
let r = crate::coplanar::boolean_apart(&left, &right, BoolOp::Union).unwrap().unwrap().unwrap();
let h = verified(&r, &[&left, &right], 0.02);
assert!(h.faces.iter().any(|f| f.change == Change::Merged && f.origins.len() == 2), "{}", show(h));
}
/// The exact polygon boolean: pieces of planar faces joined back into faces.
#[test]
fn planar_fallback() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = boxed(5.0, 5.0, 5.0, 15.0, 15.0, 15.0);
for (op, count) in [(BoolOp::Cut, 9), (BoolOp::Union, 12), (BoolOp::Intersect, 6)] {
let r = crate::polybool::planar_boolean(&a, &b, op).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.02);
assert_eq!(h.faces.len(), count, "{op:?}\n{}", show(h));
assert!(h.faces.iter().all(|f| f.origins.len() == 1));
// Three faces of each meet three of the other's.
assert_eq!(meets(h).len(), 6, "{op:?}\n{}", show(h));
assert!(h.edges.iter().all(|e| e.origins.len() == 1), "{op:?}\n{}", show(h));
}
}
/// The nudged retry: a boolean with the tool moved a hair, composed with the move.
#[test]
fn nudged_tool_composes() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = crate::sphere(Vec3::new(10.0, 10.0, 10.0), 4.0).unwrap();
let nb = crate::transform(&b, Vec3::new(1e-5, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0).unwrap();
let r = boolean(&a, &nb, BoolOp::Cut).unwrap().unwrap();
let r = crate::ops::nudged(r, &nb, &crate::record::counts(&[&a, &b]));
verified(&r, &[&a, &b], 0.02);
}
/// A boolean with a triangle mesh: the mesh's faces are its triangles (and it has no edges).
#[test]
fn faceted_tool() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let tool = boxed(5.0, 5.0, 5.0, 15.0, 15.0, 15.0).tessellate(0.1).unwrap();
let r = crate::faceted_boolean(&a, &tool, BoolOp::Cut, 0.01).unwrap().unwrap();
let h = r.history().expect("the faceted boolean reports its history");
h.check_counts((r.face_count(), r.edge_count()), &[(a.face_count(), a.edge_count()), (tool.triangles.len(), 0)]).unwrap();
// The box's six faces (three notched) and the three tool faces lining the notch.
let from_a: BTreeSet<_> = h.faces.iter().flat_map(|f| &f.origins).filter(|o| matches!(o, FaceOrigin::Face { operand: 0, .. })).collect();
assert_eq!(from_a.len(), 6);
assert_eq!(h.faces.iter().filter(|f| matches!(f.origins.first(), Some(FaceOrigin::Face { operand: 1, .. }))).count(), 3);
}
/// A partial-depth cut of stepped prisms: walls over one segment through successive slabs are
/// sewn as one face (coalesced), which comes from the operand walls under it in every slab.
#[test]
fn coalesced_stepped_walls() {
use solvecraft_geom::{Loop2, Plane, Region2, Vec2};
for curved in [true, false] {
let (outer, inner) = if curved {
(Loop2::circle(Vec2::ZERO, 10.0), Loop2::circle(Vec2::ZERO, 5.0))
} else {
let square = |h: f64| Loop2::polygon(&[Vec2::new(-h, -h), Vec2::new(h, -h), Vec2::new(h, h), Vec2::new(-h, h)]);
(square(10.0), square(5.0))
};
let stock = crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 20.0).unwrap().pop().unwrap();
let cutter = crate::extrude(&Plane::XY, &[Region2 { outer: inner, holes: vec![] }], 2.0, 22.0).unwrap().pop().unwrap();
let r = boolean(&stock, &cutter, BoolOp::Cut).unwrap().unwrap();
let h = verified(&r, &[&stock, &cutter], 0.01);
// The stock's walls run whole from 0 to 20, one face each, past the cutter's floor.
let walls: Vec<usize> = (0..r.face_count()).filter(|i| r.face_was_coalesced(*i)).collect();
assert_eq!(walls.len(), if curved { 2 } else { 4 }, "{}", show(h));
for i in walls {
assert!(matches!(h.faces[i].origins.as_slice(), [FaceOrigin::Face { operand: 0, .. }]), "{}", show(h));
assert_eq!(h.faces[i].change, Change::Modified, "{}", show(h));
}
}
}
/// A step cut into a box: the stepped prism path sews the box's face 3 in pieces at the
/// slab stations, and the joining of generated walls on their common supports makes it one face
/// again; the joined body keeps the sewn body's history composed through the join. A certified
/// box (whose planar regularization would join the same pieces) gets the same history.
#[test]
fn regularized_step_cut() {
let (pa, pb) = (boxed(0.0, 0.0, 0.0, 30.0, 35.0, 7.5), boxed(0.0, 30.0, 2.5, 22.5, 50.0, 25.0));
let (a, b) = (pa.clone().with_box_certificate(1).unwrap(), pb.clone().with_box_certificate(2).unwrap());
let r = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.01);
let plain = boolean(&pa, &pb, BoolOp::Cut).unwrap().unwrap();
let ph = verified(&plain, &[&pa, &pb], 0.01);
let joined = from(ph, face(0, 3));
assert_eq!(joined.len(), 1, "{}", show(ph));
assert_eq!(faces_of(ph)[joined[0]], (Change::Modified, vec![face(0, 3)]));
assert_eq!(faces_of(h), faces_of(ph), "{}", show(h));
}
/// A ring joined under a floor, its bore on the floor's hole (coincident cylinders): the ring's
/// bore is narrowed, the bodies joined, and the bore cut out again. The ring's bore is a circle
/// built in two arcs, and the recut wall is split where the cutting cylinder's own construction
/// splits it, so which arc each piece of it restores is not known: no history.
#[test]
fn bore_union_of_a_bore_in_two_arcs_has_no_history() {
let hole = crate::cylinder(Vec3::new(50.0, 40.0, -5.0), Vec3::Z, 10.0, 30.0).unwrap();
let floor = boolean(&boxed(0.0, 0.0, 0.0, 100.0, 80.0, 10.0), &hole, BoolOp::Cut).unwrap().unwrap();
let centre = solvecraft_geom::Vec2::new(50.0, 40.0);
let annulus = solvecraft_geom::Region2 {
outer: solvecraft_geom::Loop2::circle(centre, 20.0),
holes: vec![solvecraft_geom::Loop2::circle(centre, 10.0).reversed()],
};
let ring = crate::extrude(&solvecraft_geom::Plane::XY, &[annulus], -5.0, 2.0).unwrap().remove(0);
let r = crate::coplanar::bore_union(&floor, &ring, &|_| true).expect("the bores are recut");
assert!(r.history().is_none());
}
/// Bodies clearly apart, joined as one body of several pieces (`union_apart`): every face and
/// edge is its piece's own, kept.
#[test]
fn bodies_apart_joined_keep_their_faces() {
let pieces = [boxed(0.0, 0.0, 0.0, 2.0, 2.0, 2.0), boxed(5.0, 0.0, 0.0, 7.0, 2.0, 2.0), boxed(10.0, 0.0, 0.0, 12.0, 2.0, 2.0)];
let r = crate::union_apart(&pieces).unwrap().unwrap();
let operands: Vec<&Body> = pieces.iter().collect();
let h = verified(&r, &operands, 0.01);
assert_eq!(h.operands, 3);
for (k, piece) in pieces.iter().enumerate() {
for f in 0..piece.face_count() {
assert_eq!(from(h, face(k, f)).len(), 1, "{}", show(h));
}
}
assert!(faces_of(h).iter().all(|(change, origins)| *change == Change::Kept && origins.len() == 1), "{}", show(h));
}
+182
View File
@@ -0,0 +1,182 @@
//! Recording the exact history of a boolean's result, whichever path made it (see
//! [`crate::ops::boolean`]): for each result face, the operand faces it is a region of; for each
//! result edge, the operand edges it lies along or the operand faces whose meeting it is. The
//! operands are `[a, b]`: the target, then the tool. Paths compose the histories of the steps
//! they take ([`History::over`], [`History::substitute`]); a path that cannot leaves the result
//! without history.
use std::collections::HashMap;
use solvecraft_geom::Vec3;
use truck_modeling as mt;
use crate::Result;
use crate::body::{Body, Solid, edge_order};
use crate::history::{Change, EdgeOrigin, FaceOrigin, FaceProv, History};
use crate::record;
/// A composition step's history, or none (logged) where the lineage can't be expressed.
pub(crate) fn composed(h: Result<History>) -> Option<History> {
h.map_err(|e| log::debug!("no history: {e}")).ok()
}
/// `h`, the history of an operation over one operand, as over `operands` with that one at
/// `operand`.
pub(crate) fn placed(h: Option<History>, operand: usize, operands: usize) -> Option<History> {
composed(h?.renumbered(&[Some(operand)], operands))
}
/// The history of `body` as operand `operand` of `operands`: every face and edge kept.
pub(crate) fn kept(body: &Body, operand: usize, operands: usize) -> Option<History> {
placed(Some(History::identity(body)), operand, operands)
}
/// The history of `result`, made from operands of which operand `k` was made by the operation
/// `inner` describes, with `inner`'s operands in `k`'s place.
pub(crate) fn through(result: &Body, k: usize, inner: Option<&History>) -> Option<History> {
composed(result.history()?.clone().substituted(k, inner?))
}
/// Operand `operand` (with `counts` giving every operand's faces and edges) as the result, with
/// the history that says so.
pub(crate) fn operand(body: &Body, operand: usize, counts: &[(usize, usize)]) -> Body {
body.clone().with_checked_history(kept(body, operand, counts.len()), counts)
}
/// The histories of `parts`, each made of shells of `whole` (its lumps), over `[whole]`: their
/// faces and edges are the whole's own (by identity).
pub(crate) fn parts_of(parts: &[Body], whole: &Body) -> Vec<Option<History>> {
let (faces, edges) = (record::face_index(&whole.solid), record::edge_origins(&whole.solid, 0));
parts
.iter()
.map(|part| {
let faces = part.solid.face_iter().map(|f| Some(record::from_face(0, *faces.get(&f.id())?, Change::Kept))).collect::<Option<Vec<_>>>()?;
Some(History { operands: 1, faces, edges: record::edge_provs(part, &edges)? })
})
.collect()
}
/// A body with its history over the operation's operands (`None`: not known).
pub(crate) type Traced = (Body, Option<History>);
/// The history over the operation's operands of `result`, made from `x` and `y` (in that order)
/// whose histories over them are given.
pub(crate) fn of_pair(result: &Body, x: &Option<History>, y: &Option<History>) -> Option<History> {
composed(result.history()?.clone().over(&[x.as_ref()?, y.as_ref()?]))
}
/// Bodies apart from each other, each with its history over the operation's operands (with
/// `counts` faces and edges), as one body of several pieces whose history is theirs side by side.
pub(crate) fn join(pieces: Vec<Traced>, counts: &[(usize, usize)]) -> Result<Option<Body>> {
if pieces.len() < 2 {
return Ok(pieces.into_iter().next().map(|(b, h)| b.with_checked_history(h, counts)));
}
// The pieces' shells in order: their faces in order, their edges themselves.
let mut faces = Some(Vec::new());
let mut edges = HashMap::new();
for (p, h) in &pieces {
let (Some(h), Some(all)) = (h, faces.as_mut()) else {
faces = None;
break;
};
all.extend(h.faces.iter().cloned());
edges.extend(record::edges_by_identity(p, h));
}
if let Some(faces) = faces.as_mut() {
record::mark_shared_split(faces);
}
let joined = crate::ops::join_pieces(pieces.into_iter().map(|(p, _)| p).collect())?;
Ok(joined.map(|body| record::traced(body, counts, faces, Some(&edges))))
}
/// A truck boolean's history in our terms, in the order of the solid it made: each face a
/// region of one operand face (Kept when that very face), each edge's origins.
pub(crate) type Shapeops = (Vec<FaceProv>, Vec<Vec<EdgeOrigin>>);
/// A truck boolean's history in our terms ([`Shapeops`]). `tool` is the
/// tool as passed (after a cut turned it inside out, which keeps its faces' order but walks each
/// face's edges the other way, so its edge order changes) and `tool_edges` its edges by identity
/// in its own order (before turning).
pub(crate) fn shapeops(h: &truck_shapeops::History, tool: &Solid, tool_edges: Option<&HashMap<mt::EdgeID, Vec<EdgeOrigin>>>) -> Option<Shapeops> {
let face = |f: truck_shapeops::FaceIndex| FaceOrigin::Face { operand: f.operand, face: f.face };
let faces =
h.faces.iter().map(|o| record::from_face(o.source.operand, o.source.face, if o.kept { Change::Kept } else { Change::Modified })).collect();
let passed: Vec<mt::EdgeID> = edge_order(tool.face_iter()).0.iter().map(|e| e.id()).collect();
let edges = h
.edges
.iter()
.map(|o| match *o {
truck_shapeops::EdgeOrigin::Edge { operand: 1, edge } if tool_edges.is_some() => {
tool_edges.and_then(|t| t.get(passed.get(edge)?)).cloned()
}
truck_shapeops::EdgeOrigin::Edge { operand, edge } => Some(vec![EdgeOrigin::Edge { operand, edge }]),
truck_shapeops::EdgeOrigin::Meet { a, b } => Some(vec![EdgeOrigin::meet(face(a), face(b))]),
})
.collect::<Option<Vec<_>>>()?;
Some((faces, edges))
}
/// The body made of `solid` healed (keeping faces apart along `keep`), with the history whose
/// faces and edges (in `solid`'s orders) are given before healing, carried through it.
/// Operands with `counts` faces and edges.
pub(crate) fn heal_build(
solid: Solid,
size: f64,
keep: &[Vec<Vec3>],
faces: Option<Vec<FaceProv>>,
edges: Option<Vec<Vec<EdgeOrigin>>>,
counts: &[(usize, usize)],
) -> Result<Body> {
let (healed, map) = crate::heal::heal_traced(solid, size, keep);
let carried = faces.zip(edges).zip(map).and_then(|((f, e), m)| m.carry(&f, &e));
let (faces, edges) = carried.unzip();
record::build(healed, counts, faces, edges.as_ref())
}
/// The body truck's result `s` makes, healed (see [`heal_build`]), with its history `h`.
pub(crate) fn truck_result(s: Solid, h: Option<Shapeops>, size: f64, keep: &[Vec<Vec3>], counts: &[(usize, usize)]) -> Result<Body> {
let (faces, edges) = h.unzip();
heal_build(s, size, keep, faces, edges, counts)
}
/// `body` healed (see [`heal_build`]), its history carried through: `solid` is `body`'s solid
/// or a copy of it (`Body::deep_copy`, which keeps its order).
pub(crate) fn heal_body(body: &Body, solid: Solid, size: f64, keep: &[Vec<Vec3>], counts: &[(usize, usize)]) -> Result<Body> {
let (faces, edges) = body.history().map(|h| (h.faces.clone(), h.edges.iter().map(|e| e.origins.clone()).collect())).unzip();
heal_build(solid, size, keep, faces, edges, counts)
}
/// `body` healed as the local modifiers take it (coplanar neighbours as one face, as after a
/// join; faces split on purpose kept apart), with its history over `[body]`.
pub(crate) fn healed(body: &Body) -> Result<Body> {
let counts = record::counts(&[body]);
heal_body(&operand(body, 0, &counts), body.deep_copy(), body.size(), body.split_keep(), &counts)
}
/// `body` copied as it is, unhealed (`Body::deep_copy`, which keeps its face and edge order),
/// with its history over `[body]`: every face and edge kept.
pub(crate) fn copied(body: &Body) -> Result<Body> {
let copy = Body::new(body.deep_copy())?;
Ok(operand(&copy, 0, &record::counts(&[body])))
}
/// The faces and edges of `a`, then of the tool `b` turned inside out as a void (`inner`, a
/// copy of `b`, with `inner_edges` its edges by identity before turning): the history of
/// `a` with `b`'s inside taken out where `b` lies wholly within it.
pub(crate) fn void(
a: &Body,
inner_edges: HashMap<mt::EdgeID, Vec<EdgeOrigin>>,
b_faces: usize,
) -> (Vec<FaceProv>, HashMap<mt::EdgeID, Vec<EdgeOrigin>>) {
let faces = (0..a.face_count())
.map(|i| record::from_face(0, i, Change::Kept))
.chain((0..b_faces).map(|i| record::from_face(1, i, Change::Kept)))
.collect();
let mut edges = record::edge_origins(&a.solid, 0);
edges.extend(inner_edges);
(faces, edges)
}
#[cfg(test)]
#[path = "boolean_history_tests.rs"]
pub(crate) mod tests;
+285 -183
View File
@@ -5,6 +5,9 @@ use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, Solid, from_p3, p3, v3};
use crate::history::FaceProv;
use crate::numbered::{self, NumberedLoop, NumberedRegion};
use crate::record::{self, Side};
use crate::{KernelError, Result, guard};
const MAX_SEGS: usize = 20_000;
@@ -18,7 +21,9 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
wire_sources(plane, lp).map(|(wire, _)| wire)
}
fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, Seg2)>)> {
/// A closed wire in 3D from a sketch loop, with each edge's segment and that segment's
/// position in the loop (a big arc makes two edges).
fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, Seg2, usize)>)> {
let n = lp.segs.len();
if n == 0 || n > MAX_SEGS {
return Err(KernelError::Invalid(format!("loop with {n} segments")));
@@ -41,7 +46,7 @@ fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, S
if sweep.abs() > std::f64::consts::PI * 1.5 {
let vm = builder::vertex(p3(plane.to_world(s.mid())));
let first = builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25))));
sources.push((first.clone(), *s));
sources.push((first.clone(), *s, i));
edges.push(first);
builder::circle_arc(&vm, b, p3(plane.to_world(s.point_at(0.75))))
} else {
@@ -52,19 +57,71 @@ fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, S
crate::freeform::edge(plane, s, a, b).ok_or_else(|| KernelError::Invalid("profile curve".into()))?
}
};
sources.push((e.clone(), *s));
sources.push((e.clone(), *s, i));
edges.push(e);
}
Ok((edges.into(), sources))
}
/// An edge of a profile wire: the segment it was built from and the profile segments under it.
pub(crate) struct ProfileEdge {
pub edge: mt::Edge,
pub segment: Seg2,
pub numbers: Vec<usize>,
}
/// A wire built from a profile loop, with its edges in wire order.
pub(crate) struct ProfileWire {
pub wire: mt::Wire,
pub edges: Vec<ProfileEdge>,
}
impl ProfileWire {
/// The profile segments under each edge, in wire order.
fn numbers(&self) -> Vec<Vec<usize>> {
self.edges.iter().map(|e| e.numbers.clone()).collect()
}
}
/// The wires of a region oriented as the builders build it ([`NumberedRegion::oriented`]), each
/// with what its edges were built from.
fn region_wires(plane: &Plane, r: &NumberedRegion) -> Result<Vec<ProfileWire>> {
r.loops()
.map(|lp| {
let (wire, s) = wire_sources(plane, &lp.lp)?;
let edges = s.into_iter().map(|(edge, segment, i)| ProfileEdge { edge, segment, numbers: lp.numbers(i).to_vec() }).collect();
Ok(ProfileWire { wire, edges })
})
.collect()
}
/// The wires alone.
fn wires_of(profile: &[ProfileWire]) -> Vec<mt::Wire> {
profile.iter().map(|p| p.wire.clone()).collect()
}
/// The profile edges as sweep seeds: each edge with the profile segments under it.
fn seeds(profile: &[ProfileWire]) -> Vec<(mt::EdgeID, Vec<usize>)> {
profile.iter().flat_map(|p| &p.edges).map(|e| (e.edge.id(), e.numbers.clone())).collect()
}
/// A region numbered on its own, as given.
fn numbered_region(r: &Region2) -> Result<NumberedRegion> {
numbered::number_regions(std::slice::from_ref(r)).pop().ok_or_else(|| KernelError::Invalid("profile".into()))
}
/// A region numbered on its own and oriented as the builders build it.
fn oriented(r: &Region2) -> Result<NumberedRegion> {
numbered_region(r).map(|n| n.oriented())
}
/// A planar face from a region (outer loop with holes).
pub(crate) fn face(plane: &Plane, r: &Region2) -> Result<mt::Face> {
let mut wires = vec![wire(plane, &r.outer.ccw())?];
for h in &r.holes {
wires.push(wire(plane, &h.ccw().reversed())?);
}
guard("face", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))
attach_profile(&wires_of(&region_wires(plane, &oriented(r)?)?))
}
fn attach_profile(wires: &[mt::Wire]) -> Result<mt::Face> {
guard("face", || builder::try_attach_plane(wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))
}
fn region_ok(r: &Region2) -> Result<()> {
@@ -112,82 +169,81 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
for r in regions {
region_ok(r)?;
let base = plane.offset(lo);
let (outer, mut sources) = wire_sources(&base, &r.outer.ccw())?;
let mut wires = vec![outer];
for hole in &r.holes {
let (wire, mut hole_sources) = wire_sources(&base, &hole.ccw().reversed())?;
wires.push(wire);
sources.append(&mut hole_sources);
}
let profile = region_wires(&base, &oriented(r)?)?;
let wires = wires_of(&profile);
let f = guard("extrude profile", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))?;
let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?;
let upper = guard("extrude cap identities", || {
let inputs: std::collections::HashSet<_> = sources.iter().map(|(edge, _)| edge.id()).collect();
let body = Body::new(solid)?;
// tsweep retains each actual input boundary edge on its generated side: one pass over
// the faces matches topology identity (never face order, surface samples or
// centroids) for both the face sources and the history. The profile face is the start
// cap, the one face holding no profile edge the end cap.
let (sources, history) = guard("extrude source identities", || {
let by_edge: std::collections::HashMap<mt::EdgeID, &ProfileEdge> =
profile.iter().flat_map(|p| &p.edges).map(|e| (e.edge.id(), e)).collect();
let mut faces: Vec<(mt::FaceID, Vec<&ProfileEdge>)> = Vec::new();
let mut visits = 0usize;
let mut candidate = None;
for (index, generated) in solid.face_iter().enumerate() {
if index >= 50_000 {
return Ok(None);
}
let mut side = false;
for generated in body.solid.face_iter() {
let mut matching = Vec::new();
for edge in generated.absolute_boundaries().iter().flatten() {
visits += 1;
if visits > 200_000 {
return Ok(None);
if let Some(e) = by_edge.get(&edge.id()) {
matching.push(*e);
}
side |= inputs.contains(&edge.id());
}
if generated.id() != f.id() && !side {
if candidate.is_some() {
return Ok(None);
}
candidate = Some(generated.id());
}
faces.push((generated.id(), matching));
}
Ok(candidate)
})?;
let body = Body::new(solid)?;
// tsweep retains each actual input boundary edge on its generated side.
// Match topology identity, never face order, surface samples or centroids.
let provenance = guard("extrude source identities", || {
let source_edges: std::collections::HashMap<_, _> = sources.iter().map(|(edge, segment)| (edge.id(), *segment)).collect();
let mut result = Vec::new();
for generated in body.solid.face_iter() {
let matching: Vec<_> = generated.absolute_boundaries().iter().flatten().filter_map(|edge| source_edges.get(&edge.id())).collect();
let source =
if generated.id() == f.id() {
upper.and_then(|_| {
(r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096).then(|| {
crate::FaceSource::ExtrudeCap {
high: false,
region: r.clone(),
plane: Plane { origin: base.origin, x: plane.x, y: -plane.y },
}
})
let mut ends = faces.iter().filter(|(id, matching)| *id != f.id() && matching.is_empty()).map(|(id, _)| *id);
let end = match (ends.next(), ends.next()) {
(Some(id), None) => Some(id),
_ => None,
};
let upper = end.filter(|_| faces.len() <= 50_000 && visits <= 200_000);
let small = r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096;
let mut sources = Vec::with_capacity(faces.len());
let mut history = Some(Vec::with_capacity(faces.len()));
for (id, matching) in &faces {
let (source, prov) = if *id == f.id() {
let source = upper.and_then(|_| {
small.then(|| crate::FaceSource::ExtrudeCap {
high: false,
region: r.clone(),
plane: Plane { origin: base.origin, x: plane.x, y: -plane.y },
})
} else if Some(generated.id()) == upper {
(r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096)
.then(|| crate::FaceSource::ExtrudeCap { high: true, region: r.clone(), plane: plane.offset(hi) })
} else if let [segment] = matching.as_slice() {
let basis = match **segment {
Seg2::Line { a, b } => {
let d = b - a;
d.normalized().and_then(|direction| {
let foot = a - direction * a.dot(direction);
Plane::new(plane.to_world(foot), plane.dir_to_world(direction), n)
})
}
_ => None,
};
Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis, coalesced: false })
} else {
None
});
(source, Some(record::cap(false)))
} else if Some(*id) == upper {
(small.then(|| crate::FaceSource::ExtrudeCap { high: true, region: r.clone(), plane: plane.offset(hi) }), Some(record::cap(true)))
} else if let [side] = matching.as_slice() {
let basis = match side.segment {
Seg2::Line { a, b } => {
let d = b - a;
d.normalized().and_then(|direction| {
let foot = a - direction * a.dot(direction);
Plane::new(plane.to_world(foot), plane.dir_to_world(direction), n)
})
}
_ => None,
};
result.push(source);
(
Some(crate::FaceSource::ExtrudeSide { segment: side.segment, plane: basis, coalesced: false }),
Some(record::profile(&side.numbers)),
)
} else if Some(*id) == end {
(None, Some(record::cap(true)))
} else {
(None, None)
};
sources.push(source);
history = history.zip(prov).map(|(mut h, p)| {
h.push(p);
h
});
}
Ok(result)
Ok((sources, history))
})?;
out.push(body.with_face_sources(Some(std::sync::Arc::new(provenance))));
let body = body.with_face_sources(Some(std::sync::Arc::new(sources)));
out.push(record::attach(body, &[], history, Default::default())?);
}
Ok(out)
}
@@ -242,19 +298,17 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
let q = axis_origin + d * along;
if p.dist(q) < 1e-7 { q } else { p }
};
let snap_loop = |l: &Loop2| Loop2 {
segs: l
.segs
.iter()
.map(|s| match *s {
Seg2::Line { a, b } => Seg2::Line { a: snap(a), b: snap(b) },
arc => arc,
})
.collect(),
let snap_loop = |l: &NumberedLoop| {
l.map_segs(|s| match *s {
Seg2::Line { a, b } => Seg2::Line { a: snap(a), b: snap(b) },
arc => arc,
})
};
// Full circles start at an odd angle: their seam points then don't lie in the planes
// through the profile's centre that other bodies tend to cut along.
let reseam = |l: Loop2| -> Loop2 {
// through the profile's centre that other bodies tend to cut along. Each new arc comes
// from every given arc it overlaps.
let reseam = |nl: NumberedLoop| -> NumberedLoop {
let l = &nl.lp;
let c = match l.segs.first() {
Some(Seg2::Arc { center, radius, .. }) => Some((*center, *radius)),
_ => None,
@@ -282,30 +336,41 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
{
let ccw = l.signed_area() > 0.0;
let n = Loop2::circle_from(center, radius, 0.4142);
if ccw { n } else { n.reversed() }
nl.resplit_circle(if ccw { n } else { n.reversed() })
}
_ => l,
_ => nl,
}
};
let r = &Region2 { outer: reseam(snap_loop(&r.outer)), holes: r.holes.iter().map(|h| reseam(snap_loop(h))).collect() };
// A half disc on the axis revolved fully is a sphere: build it without poles.
let given = numbered_region(r)?.map_loops(|l| reseam(snap_loop(l)));
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.
if angle.abs() >= std::f64::consts::TAU - 1e-9
&& r.holes.is_empty()
&& let Some((c, rad)) = half_disc(&r.outer, axis_origin, d)
{
out.push(sphere_patches(plane.to_world(c), rad)?);
let arcs: Vec<usize> = r
.outer
.segs
.iter()
.enumerate()
.filter(|(_, s)| matches!(s, Seg2::Arc { .. }))
.flat_map(|(i, _)| given.outer.numbers(i).to_vec())
.collect();
out.push(sphere_solid(plane.to_world(c), rad, [Vec3::X, Vec3::Y, Vec3::Z], &arcs)?);
continue;
}
// A full revolution of a profile with one edge on the axis: sweep the rest of the loop
// as a cone-like shell (no zero-area faces, which booleans can't handle).
if angle.abs() >= std::f64::consts::TAU - 1e-9
&& r.holes.is_empty()
&& let Some(b) = revolve_touching_axis(plane, &r.outer, axis_origin, d)?
&& let Some(b) = revolve_touching_axis(plane, &given.outer, axis_origin, d)?
{
out.push(b);
continue;
}
let f = face(plane, r)?;
let profile = region_wires(plane, &given.oriented())?;
let f = attach_profile(&wires_of(&profile))?;
// Partial revolves sweep toward the side the sketch normal points to (as features
// extrude toward it).
let c = plane.to_world(r.centroid());
@@ -314,13 +379,15 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
let angle = if toward < 0.0 { -angle } else { angle };
// A profile with an edge on the axis: no zero-area face where that edge turns on itself.
if r.holes.is_empty()
&& let Some(b) = revolve_partial_on_axis(plane, &r.outer, axis_origin, d, angle)?
&& let Some(b) = revolve_partial_on_axis(plane, &given.outer, axis_origin, d, angle)?
{
out.push(b);
continue;
}
let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?;
out.push(Body::new(solid)?);
let closed = angle.abs() >= std::f64::consts::TAU;
let history = guard("revolve history", || Ok(record::swept_face(&solid, &f, &seeds(&profile), closed)))?;
out.push(record::attach(Body::new(solid)?, &[], history, Default::default())?);
}
Ok(out)
}
@@ -467,16 +534,22 @@ pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f
region_ok(region)?;
let d = length * taper.tan();
let top_plane = plane.offset(length * dir_sign.signum());
let loops0: Vec<Loop2> = std::iter::once(region.outer.ccw()).chain(region.holes.iter().map(|h| h.ccw().reversed())).collect();
let loops1: Vec<Loop2> = loops0.iter().map(|l| offset_loop(l, d)).collect::<Result<_>>()?;
let loops0 = oriented(region)?;
let loops1: Vec<Loop2> = loops0.loops().map(|l| offset_loop(&l.lp, d)).collect::<Result<_>>()?;
guard("tapered extrude", || {
let w0: Vec<mt::Wire> = loops0.iter().map(|l| wire(plane, l)).collect::<Result<_>>()?;
let p0 = region_wires(plane, &loops0)?;
let w0 = wires_of(&p0);
let w1: Vec<mt::Wire> = loops1.iter().map(|l| wire(&top_plane, l)).collect::<Result<_>>()?;
let mut faces: Vec<mt::Face> = Vec::new();
for (a, b) in w0.iter().zip(&w1) {
let sides = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("taper sides: {e}")))?;
faces.extend(sides.face_iter().cloned());
let mut sides: Vec<Side> = Vec::new();
for (a, b) in p0.iter().zip(&w1) {
let shell = builder::try_wire_homotopy(&a.wire, b).map_err(|e| KernelError::Failed(format!("taper sides: {e}")))?;
// The offset loop keeps the loop's segments, edge for edge.
let numbers = a.numbers();
sides.extend(record::ruled(&shell, &a.wire, b, &numbers, &numbers));
}
let (faces, prov): (Vec<mt::Face>, Vec<Option<FaceProv>>) = sides.into_iter().unzip();
// The sides, then the bottom and top as pushed below.
let history: Option<Vec<FaceProv>> = prov.into_iter().chain([Some(record::cap(false)), Some(record::cap(true))]).collect();
let bottom = builder::try_attach_plane(&w0.iter().map(|w| w.inverse()).collect::<Vec<_>>())
.map_err(|e| KernelError::Failed(format!("taper bottom: {e}")))?;
let top = builder::try_attach_plane(&w1).map_err(|e| KernelError::Failed(format!("taper top: {e}")))?;
@@ -489,7 +562,7 @@ pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f
all.push(t);
let shell: mt::Shell = all.into();
match Solid::try_new(vec![shell]) {
Ok(s) => return Body::new(s),
Ok(s) => return record::attach(Body::new(s)?, &[], history, Default::default()),
Err(e) => last = e.to_string(),
}
}
@@ -518,8 +591,9 @@ fn half_disc(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Option<(Vec2, f64)> {
/// A partial revolve of a loop with one edge on the axis: the other edges sweep their faces,
/// the two caps (the profile at the start and at the end) share the axis edge. `None` when the
/// loop isn't like that.
fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2, angle: f64) -> Result<Option<Body>> {
fn revolve_partial_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2, angle: f64) -> Result<Option<Body>> {
use mt::{BoundedCurve, Invertible, ParametricCurve, ParametricSurface3D, SearchNearestParameter, Transformed};
let lp = &numbered.lp;
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
let n = lp.segs.len();
let axis_segs: Vec<usize> =
@@ -574,8 +648,9 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2
*slot = Some(builder::circle_arc(a, b, p3(turn(&half, *p))));
}
}
// Side faces: each edge's swept surface, oriented to agree with its boundary.
let mut faces: Vec<mt::Face> = Vec::new();
// Side faces: each edge's swept surface, oriented to agree with its boundary, from the
// profile segment the edge was built from.
let mut faces: Vec<(mt::Face, FaceProv)> = Vec::new();
for (i, sg) in chain.iter().enumerate() {
let (Some(ei), Some(eri)) = (e.get(i), er.get(i)) else { return Err(fail("edges")) };
let mut ws: Vec<mt::Edge> = vec![ei.clone()];
@@ -615,7 +690,7 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2
}
mt::Face::try_new(vec![wire], surface).map_err(|er| fail(&er.to_string()))?
};
faces.push(f);
faces.push((f, record::profile(numbered.numbers((k + 1 + i) % n))));
}
// Caps: the profile at the start and at the end, sharing the axis edge.
let (Some(a0), Some(an)) = (pv.first(), pv.get(last)) else { return Err(fail("ends")) };
@@ -624,6 +699,8 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2
let end: mt::Wire = er.iter().cloned().chain(std::iter::once(axis_edge.clone())).collect::<Vec<_>>().into();
let cs = builder::try_attach_plane(&[start]).map_err(|er| fail(&er.to_string()))?;
let ce = builder::try_attach_plane(&[end]).map_err(|er| fail(&er.to_string()))?;
let (faces, mut history): (Vec<mt::Face>, Vec<FaceProv>) = faces.into_iter().unzip();
history.extend([record::cap(false), record::cap(true)]);
let mut last_err = String::new();
for flip in 0..4 {
let mut all = faces.clone();
@@ -632,7 +709,7 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2
match Solid::try_new(vec![all.into()]) {
Ok(solid) => {
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
return Body::new(solid).map(Some);
return record::attach(Body::new(solid)?, &[], Some(history), Default::default()).map(Some);
}
Err(er) => last_err = er.to_string(),
}
@@ -641,14 +718,17 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2
})
}
fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Result<Option<Body>> {
fn revolve_touching_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2) -> Result<Option<Body>> {
let lp = &numbered.lp;
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
let n = lp.segs.len();
let axis_segs: Vec<usize> =
(0..n).filter(|i| lp.segs.get(*i).is_some_and(|s| matches!(s, Seg2::Line { a, b } if on_axis(*a) && on_axis(*b)))).collect();
let [k] = axis_segs[..] else { return Ok(None) };
// The open chain after the axis segment, back around to it.
// The open chain after the axis segment, back around to it, with the profile segments
// under each piece.
let mut chain: Vec<Seg2> = (1..n).filter_map(|j| lp.segs.get((k + j) % n).copied()).collect();
let mut chain_src: Vec<&[usize]> = (1..n).map(|j| numbered.numbers((k + j) % n)).collect();
// A single arc (a sphere) is split off-centre: a seam on the equator would lie in the very
// planes other bodies are often cut with.
if let [Seg2::Arc { center, radius, start, sweep }] = chain[..] {
@@ -657,6 +737,7 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
Seg2::Arc { center, radius, start, sweep: sweep * f },
Seg2::Arc { center, radius, start: start + sweep * f, sweep: sweep * (1.0 - f) },
];
chain_src = vec![numbered.numbers((k + 1) % n); 2];
}
if chain.is_empty() || chain.iter().skip(1).take(chain.len().saturating_sub(2)).any(|s| on_axis(s.start()) && on_axis(s.end())) {
return Ok(None);
@@ -669,11 +750,18 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
// Radial lines at the ends become planar caps (no degenerate disc centres).
let radial = |sg: &Seg2| matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9));
let mut mid: &[Seg2] = &chain;
let mut mid_src: &[&[usize]] = &chain_src;
// The segments of the radial lines that become caps.
let (mut start_cap, mut end_cap) = (None, None);
if mid.first().is_some_and(|f| radial(f) && on_axis(f.start())) {
mid = mid.get(1..).unwrap_or(&[]);
start_cap = mid_src.first().copied();
mid_src = mid_src.get(1..).unwrap_or(&[]);
}
if mid.last().is_some_and(|l| radial(l) && on_axis(l.end())) {
mid = mid.get(..mid.len().saturating_sub(1)).unwrap_or(&[]);
end_cap = mid_src.last().copied();
mid_src = mid_src.get(..mid_src.len().saturating_sub(1)).unwrap_or(&[]);
}
if mid.is_empty() {
return Ok(None);
@@ -698,6 +786,8 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
},
});
}
// Each edge with the profile segments under it, before the wire may be turned round.
let seeds: Vec<(mt::EdgeID, Vec<usize>)> = edges.iter().map(|e| e.id()).zip(mid_src.iter().map(|s| s.to_vec())).collect();
let mut w: mt::Wire = edges.into();
let axis = plane.dir_to_world(d);
let mut shell = if apex_start || apex_end {
@@ -710,7 +800,29 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
builder::rsweep(&w, p3(plane.to_world(axis_origin)), v3(axis), mt::Rad(std::f64::consts::TAU))
};
let sides = shell.len();
// History. truck sweeps each wire edge round into a column of faces (triangles at an
// apex, which stays put); each cap is the disc a trimmed radial line sweeps, found by
// the profile vertex on its rim.
let fixed: Vec<mt::VertexID> =
[(apex_start, verts.first()), (apex_end, verts.last())].into_iter().filter_map(|(apex, v)| v.filter(|_| apex).map(|v| v.id())).collect();
let mut history: Option<Vec<FaceProv>> = (seeds.len() == mid.len())
.then(|| record::columns(shell.face_iter(), &seeds, None, record::Column::ToEnd, &fixed))
.flatten()
.and_then(|sides| sides.into_iter().collect());
let (first_vertex, last_vertex) = (verts.first().map(|v| v.id()), verts.last().map(|v| v.id()));
for b in shell.extract_boundaries() {
let at = |v: Option<mt::VertexID>| v.is_some_and(|v| b.vertex_iter().any(|x| x.id() == v));
let src = if at(first_vertex) {
start_cap
} else if at(last_vertex) {
end_cap
} else {
None
};
history = history.zip(src).map(|(mut h, numbers)| {
h.push(record::profile(numbers));
h
});
let cap = builder::try_attach_plane(&[b]).map_err(|e| KernelError::Failed(format!("revolve cap: {e}")))?;
shell.push(cap);
}
@@ -724,12 +836,16 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
});
let solid = solid.map_err(|e| KernelError::Failed(format!("revolve: {e}")))?;
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
Body::new(solid).map(Some)
record::attach(Body::new(solid)?, &[], history, Default::default()).map(Some)
})
}
/// Close a ruled shell (side faces between matching wires) with planar caps into a body.
pub(crate) fn cap_and_close(mut faces: Vec<mt::Face>, w0: &[mt::Wire], w1: &[mt::Wire]) -> Result<Body> {
/// Close a ruled shell (side faces between matching wires, with their provenance as built)
/// with planar caps into a body, whose history is the sides' and the caps': the profile at
/// `w0` is the start cap and at `w1` the end cap (the other way round when `from_end`).
pub(crate) fn cap_and_close(sides: Vec<Side>, w0: &[mt::Wire], w1: &[mt::Wire], from_end: bool) -> Result<Body> {
let (mut faces, prov): (Vec<mt::Face>, Vec<Option<FaceProv>>) = sides.into_iter().unzip();
let history: Option<Vec<FaceProv>> = prov.into_iter().chain([Some(record::cap(from_end)), Some(record::cap(!from_end))]).collect();
let bottom =
builder::try_attach_plane(&w0.iter().map(|w| w.inverse()).collect::<Vec<_>>()).map_err(|e| KernelError::Failed(format!("cap: {e}")))?;
let top = builder::try_attach_plane(w1).map_err(|e| KernelError::Failed(format!("cap: {e}")))?;
@@ -741,7 +857,7 @@ pub(crate) fn cap_and_close(mut faces: Vec<mt::Face>, w0: &[mt::Wire], w1: &[mt:
all.push(t);
let shell: mt::Shell = all.into();
match Solid::try_new(vec![shell]) {
Ok(s) => return Body::new(s),
Ok(s) => return record::attach(Body::new(s)?, &[], history, Default::default()),
Err(e) => last = e.to_string(),
}
faces = keep;
@@ -749,42 +865,6 @@ pub(crate) fn cap_and_close(mut faces: Vec<mt::Face>, w0: &[mt::Wire], w1: &[mt:
Err(KernelError::Failed(format!("closing the shell: {last}")))
}
/// Re-split a loop into exactly `n` segments: a full circle gets breaks at `angles` (or evenly),
/// other loops split their longest segments in half.
fn resample(lp: &Loop2, n: usize, angles: &[f64]) -> Loop2 {
let circle = lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. }))
&& lp.segs.windows(2).all(|w| match (w[0], w[1]) {
(Seg2::Arc { center: c1, radius: r1, .. }, Seg2::Arc { center: c2, radius: r2, .. }) => c1.dist(c2) < 1e-9 && (r1 - r2).abs() < 1e-9,
_ => false,
});
if circle && let Some(Seg2::Arc { center, radius, .. }) = lp.segs.first().copied() {
let mut a: Vec<f64> = if angles.len() == n { angles.to_vec() } else { (0..n).map(|i| std::f64::consts::TAU * i as f64 / n as f64).collect() };
// Keep the given order but make them increase.
for i in 1..a.len() {
while a[i] <= a[i - 1] {
a[i] += std::f64::consts::TAU;
}
}
let segs = (0..n)
.map(|i| {
let s = a[i];
let e = if i + 1 < n { a[i + 1] } else { a[0] + std::f64::consts::TAU };
Seg2::Arc { center, radius, start: s, sweep: e - s }
})
.collect();
return Loop2 { segs };
}
let mut segs = lp.segs.clone();
while segs.len() < n {
let Some((k, _)) = segs.iter().enumerate().max_by(|a, b| a.1.length().total_cmp(&b.1.length())) else { break };
let s = segs.remove(k);
let (p, q) = s.split_half();
segs.insert(k, q);
segs.insert(k, p);
}
Loop2 { segs }
}
/// Ruled loft through two or more planar sections (outer loops only). The sections and the
/// same sections in reverse order give the same body.
pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
@@ -804,58 +884,67 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
let (Some(first), Some(last)) = (sections.first(), sections.last()) else { return Err(KernelError::Invalid("no sections".into())) };
let step = centre(last) - centre(first);
let big = [step.x, step.y, step.z].into_iter().max_by(|a, b| a.abs().total_cmp(&b.abs())).unwrap_or(0.0);
let ordered: Vec<&(Plane, Loop2)> = if big < 0.0 { sections.iter().rev().collect() } else { sections.iter().collect() };
let reversed = big < 0.0;
// The sections' segments are numbered in the order given.
let mut ordered: Vec<(Plane, NumberedLoop)> =
sections.iter().map(|(p, _)| *p).zip(numbered::number_loops(sections.iter().map(|(_, l)| l))).collect();
if reversed {
ordered.reverse();
}
// Orient every section counter-clockwise about the first section's normal (`Body::new`
// turns the solid outward), then match segment counts and starting points to the section
// with the most segments.
let n0 = ordered.first().map(|(p, _)| p.normal()).unwrap_or(Vec3::Z);
let oriented: Vec<(Plane, Loop2)> = ordered
let oriented: Vec<(Plane, NumberedLoop)> = ordered
.iter()
.map(|(p, l)| {
let l = if p.normal().dot(n0) >= 0.0 { l.ccw() } else { l.ccw().reversed() };
(*p, l)
let ccw = p.normal().dot(n0) >= 0.0;
(*p, l.oriented(!ccw))
})
.collect();
let lead = oriented.iter().max_by_key(|(_, l)| l.segs.len()).cloned().ok_or_else(|| KernelError::Invalid("no sections".into()))?;
let lead_c = lead.0.to_world(lead.1.centroid());
let lead_dirs: Vec<Vec3> = lead.1.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect();
let lead = oriented.iter().max_by_key(|(_, l)| l.lp.segs.len()).ok_or_else(|| KernelError::Invalid("no sections".into()))?;
let lead_c = lead.0.to_world(lead.1.lp.centroid());
let lead_dirs: Vec<Vec3> = lead.1.lp.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect();
let mut wires: Vec<mt::Wire> = Vec::new();
// Per section, the profile segments under each of its wire's edges.
let mut edge_sources: Vec<Vec<Vec<usize>>> = Vec::new();
for (p, l) in &oriented {
let c = l.centroid();
let c = l.lp.centroid();
// Break angles: the lead section's vertex directions seen in this plane.
let angles: Vec<f64> = lead_dirs.iter().map(|d| Vec2::new(d.dot(p.x), d.dot(p.y)).angle()).collect();
let mut r = resample(l, n, &angles);
let mut r = l.resample(n, &angles);
// Start at the vertex closest in direction to the lead's first vertex.
if let Some(d0) = lead_dirs.first() {
let target = Vec2::new(d0.dot(p.x), d0.dot(p.y)).angle();
let best = (0..r.segs.len()).min_by(|a, b| {
let ang = |k: usize| {
let v = r.segs[k].start() - c;
let d = (v.angle() - target).rem_euclid(std::f64::consts::TAU);
d.min(std::f64::consts::TAU - d)
};
ang(*a).total_cmp(&ang(*b))
});
let ang = |s: &Seg2| {
let d = ((s.start() - c).angle() - target).rem_euclid(std::f64::consts::TAU);
d.min(std::f64::consts::TAU - d)
};
let best = r.lp.segs.iter().enumerate().min_by(|a, b| ang(a.1).total_cmp(&ang(b.1))).map(|(k, _)| k);
if let Some(k) = best {
r.segs.rotate_left(k);
r.rotate_left(k);
}
}
wires.push(uniform_wire(p, &r)?);
wires.push(uniform_wire(p, &r.lp)?);
edge_sources.push(r.from);
}
// The wires run from the first section given, or (turned round) the last.
// Three sections or more: one smooth surface through them all for each segment.
if wires.len() >= 3
&& let Ok(b) = guard("loft", || smooth_loft(&wires))
&& let Ok(b) = guard("loft", || smooth_loft(&wires, &edge_sources, reversed))
{
return Ok(b);
}
guard("loft", || {
let mut faces: Vec<mt::Face> = Vec::new();
for w in wires.windows(2) {
let sides = builder::try_wire_homotopy(&w[0], &w[1]).map_err(|e| KernelError::Failed(format!("loft: {e}")))?;
faces.extend(sides.face_iter().cloned());
// Each ruled face lies between two sections: it comes from the section edges it spans.
let mut sides: Vec<Side> = Vec::new();
for (w, src) in wires.windows(2).zip(edge_sources.windows(2)) {
let (Some(a), Some(b), Some(na), Some(nb)) = (w.first(), w.get(1), src.first(), src.get(1)) else { continue };
let shell = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("loft: {e}")))?;
sides.extend(record::ruled(&shell, a, b, na, nb));
}
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("loft".into())) };
cap_and_close(faces, std::slice::from_ref(first), std::slice::from_ref(last))
cap_and_close(sides, std::slice::from_ref(first), std::slice::from_ref(last), reversed)
})
}
@@ -873,8 +962,10 @@ fn across(pts: &[Vec3]) -> Option<mt::BSplineCurve<mt::Point3>> {
/// A loft whose side faces are B-spline surfaces through every section (smooth across the
/// middle sections, which leave no edges). Every section wire comes from `uniform_wire`, so the
/// k-th edges of all sections share one knot vector and their control points pair up.
fn smooth_loft(wires: &[mt::Wire]) -> Result<Body> {
/// k-th edges of all sections share one knot vector and their control points pair up. Face k
/// comes from the profile segments under the k-th edge of every section (`numbers`: by
/// section, by edge); the caps as [`cap_and_close`] takes them (`from_end`).
fn smooth_loft(wires: &[mt::Wire], numbers: &[Vec<Vec<usize>>], from_end: bool) -> Result<Body> {
let fail = |m: &str| KernelError::Failed(format!("smooth loft: {m}"));
let edges: Vec<Vec<mt::Edge>> = wires.iter().map(|w| w.edge_iter().cloned().collect()).collect();
let n = edges.first().map(Vec::len).ok_or_else(|| fail("no sections"))?;
@@ -921,10 +1012,12 @@ fn smooth_loft(wires: &[mt::Wire]) -> Result<Body> {
};
// u along the section edge, v across the sections: counter-clockwise in (u, v).
let w: mt::Wire = vec![e0.clone(), s1.clone(), e1.inverse(), s0.inverse()].into();
faces.push(mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))?);
let face = mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))?;
let under: Vec<&usize> = numbers.iter().filter_map(|s| s.get(k)).flatten().collect();
faces.push((face, (under.len() >= numbers.len()).then(|| record::profile(under))));
}
let (Some(w0), Some(w1)) = (wires.first(), wires.last()) else { return Err(fail("sections")) };
cap_and_close(faces, std::slice::from_ref(w0), std::slice::from_ref(w1))
cap_and_close(faces, std::slice::from_ref(w0), std::slice::from_ref(w1), from_end)
}
/// A 3D path segment for sweeps.
@@ -1006,10 +1099,8 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
return crate::sweep_path::sweep_path(plane, region, &pts);
}
// The outer loop and each hole, swept along the path; the ends are capped with holes.
let mut starts = vec![wire(plane, &region.outer.ccw())?];
for h in &region.holes {
starts.push(wire(plane, &h.ccw().reversed())?);
}
let profile = region_wires(plane, &oriented(region)?)?;
let starts = wires_of(&profile);
guard("sweep", || {
let mut shell = mt::Shell::new();
let mut ends = Vec::new();
@@ -1033,8 +1124,13 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
}
ends.push(cur);
}
let faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
cap_and_close(faces, &starts, &ends)
// truck sweeps the faces: each profile edge's run along the whole path, path segment
// after segment.
let sides: Vec<Side> = match record::columns(shell.face_iter(), &seeds(&profile), None, record::Column::ToEnd, &[]) {
Some(prov) => shell.face_iter().cloned().zip(prov).collect(),
None => shell.face_iter().map(|f| (f.clone(), None)).collect(),
};
cap_and_close(sides, &starts, &ends, false)
})
}
@@ -1136,6 +1232,12 @@ pub fn sphere_patches(center: Vec3, radius: f64) -> Result<Body> {
/// [`sphere_patches`] with the patch layout turned to the frame `rot` (orthonormal columns).
pub fn sphere_patches_rotated(center: Vec3, radius: f64, rot: [Vec3; 3]) -> Result<Body> {
// Every patch is swept from the arc (segment 0) of the half disc a sphere revolves.
sphere_solid(center, radius, rot, &[0])
}
/// [`sphere_patches_rotated`], every patch swept from profile segments `arcs`.
fn sphere_solid(center: Vec3, radius: f64, rot: [Vec3; 3], arcs: &[usize]) -> Result<Body> {
let radius = finite(radius, "radius")?;
let turn = |v: Vec3| rot[0] * v.x + rot[1] * v.y + rot[2] * v.z;
if radius <= 1e-6 || !center.is_finite() {
@@ -1197,6 +1299,6 @@ pub fn sphere_patches_rotated(center: Vec3, radius: f64, rot: [Vec3; 3]) -> Resu
}
let shell: mt::Shell = out.into();
let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sphere: {e}")))?;
Body::new(solid)
record::attach(Body::new(solid)?, &[], Some(vec![record::profile(arcs); 6]), Default::default())
})
}
+140 -13
View File
@@ -17,7 +17,8 @@ use solvecraft_geom::Vec3;
use truck_modeling::{self as mt, builder};
use crate::body::{Body, Solid, from_p3, p3, v3};
use crate::{BoolOp, KernelError, Result, guard};
use crate::history::{Change, EdgeOrigin, FaceOrigin, FaceProv, History};
use crate::{BoolOp, KernelError, Result, guard, record};
#[derive(Clone, Copy, Debug)]
struct PlaneFace {
@@ -41,7 +42,8 @@ fn planes(b: &Body) -> Vec<(usize, PlaneFace)> {
}
/// Push the planar faces of `b` lying in the plane (n, d) — n their outward normal — along n
/// by `delta` (negative: inward). Their neighbours must stand perpendicular to the plane.
/// by `delta` (negative: inward). Their neighbours must stand perpendicular to the plane. The
/// result's history is over `[b]`.
pub(crate) fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
// The exact rebuild, unless it came out unsound (a curved side it couldn't re-sweep).
match push_faces_exact(b, n, d, delta)
@@ -75,6 +77,9 @@ pub(crate) fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<
let fail = |m: &str| KernelError::Failed(format!("pushing a face: {m}"));
guard("push face", || {
let solid = b.deep_copy();
// The copy's edges are b's, in order; the faces come out in place, one for one.
let mut edge_origins = record::edge_origins(&solid, 0);
let mut faces_from = Vec::new();
let shift = n * delta;
let mat = mt::Matrix4::from_translation(v3(shift));
let mut shells = Vec::new();
@@ -89,7 +94,9 @@ pub(crate) fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<
_ => false,
};
let moving: Vec<usize> = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(on_plane)).collect();
let first = faces_from.len();
if moving.is_empty() {
faces_from.extend((first..first + faces.len()).map(|i| record::from_face(0, i, Change::Kept)));
shells.push(sh.clone());
continue;
}
@@ -161,10 +168,18 @@ pub(crate) fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<
})
.collect()
};
// Each moved or stretched edge stands for the one it replaces.
for (old, new) in &emap {
if let Some(o) = edge_origins.get(old).cloned() {
edge_origins.insert(new.id(), o);
}
}
use mt::{ParametricSurface3D, SearchNearestParameter};
let mut out: Vec<mt::Face> = Vec::new();
for (i, f) in faces.iter().enumerate() {
let touches = f.edge_iter().any(|e| emap.contains_key(&e.id()));
let change = if touches { Change::Modified } else { Change::Kept };
faces_from.push(record::from_face(0, first + i, change));
if !touches {
out.push(f.clone());
continue;
@@ -208,7 +223,7 @@ pub(crate) fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<
shells.push(out.into());
}
let s = Solid::try_new(shells).map_err(|e| fail(&e.to_string()))?;
Body::new(s)
record::build(s, &record::counts(&[b]), Some(faces_from), Some(&edge_origins))
})
}
@@ -257,13 +272,18 @@ pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
return None;
}
let (d1, d2) = (size * 0.0137, size * 0.0229);
let counts = record::counts(&[a, b]);
let run = || -> Result<Option<Body>> {
let (mut a2, mut b2) = (a.clone(), b.clone());
// What the pushed bodies are made of: their histories over [a] and [b].
let (mut ha, mut hb) = (crate::boolean_record::kept(a, 0, 1), crate::boolean_record::kept(b, 0, 1));
for (n, d) in &same {
// b's face points along n too.
b2 = push_faces(&b2, *n, *d, d2)?;
hb = crate::boolean_record::through(&b2, 0, hb.as_ref());
if op == BoolOp::Union {
a2 = push_faces(&a2, *n, *d, d1)?;
ha = crate::boolean_record::through(&a2, 0, ha.as_ref());
}
}
for (n, d) in &back {
@@ -285,22 +305,78 @@ pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
-d2
};
b2 = push_faces(&b2, -*n, -*d, shift)?;
hb = crate::boolean_record::through(&b2, 0, hb.as_ref());
}
let Some(mut r) = crate::ops::boolean(&a2, &b2, op)? else { return Ok(None) };
// The pushed bodies' histories as over [a, b], a's and b's in their places.
let (ha, hb) = (crate::boolean_record::placed(ha, 0, 2), crate::boolean_record::placed(hb, 1, 2));
let mut h = crate::boolean_record::of_pair(&r, &ha, &hb);
if op == BoolOp::Union {
for (n, d) in &same {
let slab = slab_beyond(*n, *d, d1.max(d2) * 3.0, size * 4.0)?;
match crate::ops::boolean(&r, &slab, BoolOp::Cut)? {
Some(x) => r = x,
Some(x) => {
h = h.and_then(|h| trimmed(&x, h, &slab, [a, b], *n, *d, tol));
r = x;
}
None => return Ok(None),
}
}
}
Ok(Some(r))
Ok(Some(r.with_checked_history(h, &counts)))
};
Some(run())
}
/// The history over `[a, b]` of `x`, the union `r` of `a` and `b` (with history `h` over them),
/// pushed out past the plane (n, d), with the slab beyond it (`slab`, whose start cap lies in
/// the plane) cut off. The slab's cap in `x` is where the operands' faces in that plane were
/// (all of them, so only a cap that stays one face says which), and where it meets a side of
/// an operand is that side's edge along one of those faces. `None` where that is not so.
fn trimmed(x: &Body, h: History, slab: &Body, operands: [&Body; 2], n: Vec3, d: f64, tol: f64) -> Option<History> {
let mut cut = x.history()?.clone();
cut.substitute(0, &h).ok()?;
let cap = slab.history()?.faces.iter().position(|f| f.origins == [FaceOrigin::Cap { end: false }])?;
let cap = FaceOrigin::Face { operand: 2, face: cap };
// The operands' faces in the plane, facing along n, and their edges with each other face.
let in_plane: Vec<Vec<usize>> = operands
.iter()
.map(|o| planes(o).into_iter().filter(|(_, p)| p.n.dot(n) > 1.0 - 1e-9 && (p.d - d).abs() < tol * 10.0).map(|(i, _)| i).collect())
.collect();
let bounds: Vec<Vec<Vec<usize>>> = operands.iter().map(|o| crate::history::edge_faces(&o.solid)).collect();
if cut.faces.iter().filter(|f| f.origins.contains(&cap)).count() != 1 {
return None;
}
for f in &mut cut.faces {
if f.origins.contains(&cap) {
let mut origins: std::collections::BTreeSet<FaceOrigin> = f.origins.iter().copied().filter(|o| *o != cap).collect();
for (operand, faces) in in_plane.iter().enumerate() {
origins.extend(faces.iter().map(|face| FaceOrigin::Face { operand, face: *face }));
}
f.change = if origins.len() > 1 { Change::Merged } else { Change::Modified };
f.origins = origins.into_iter().collect();
}
}
let cap = crate::history::Side::from(cap);
for e in &mut cut.edges {
for o in &mut e.origins {
let side = match o {
EdgeOrigin::Meet { a, b } if *a == cap => b,
EdgeOrigin::Meet { a, b } if *b == cap => a,
_ => continue,
};
let Some(FaceOrigin::Face { operand, face }) = side.single() else { return None };
let (plane_faces, bounds) = (in_plane.get(operand)?, bounds.get(operand)?);
let mut along = (0..bounds.len())
.filter(|k| bounds.get(*k).is_some_and(|fs| fs.len() == 2 && fs.contains(&face) && fs.iter().any(|f| plane_faces.contains(f))));
let (Some(edge), None) = (along.next(), along.next()) else { return None };
*o = EdgeOrigin::Edge { operand, edge };
}
}
cut.renumber(&[Some(0), Some(1), None], 2).ok()?;
Some(cut)
}
/// Has `a` material behind every part of b's faces in the plane (m, d) (m their outward
/// normal, pointing into `a`), to `depth`? Checked at points inside the faces' triangles.
fn backed(a: &Body, b: &Body, m: Vec3, d: f64, depth: f64) -> bool {
@@ -398,6 +474,17 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
edges.insert(e.id(), mt::Edge::new(&v0, &v1, e.curve()));
}
let mut faces: Vec<mt::Face> = fa_list.iter().enumerate().filter(|(i, _)| i != ia).map(|(_, f)| (*f).clone()).collect();
// Every face but the two glued is kept; an edge along the seam is an edge of both.
let kept = |operand: usize, n: usize, skip: usize| {
(0..n).filter(move |i| *i != skip).map(move |i| record::from_face(operand, i, Change::Kept))
};
let from: Vec<FaceProv> = kept(0, fa_list.len(), *ia).chain(kept(1, fb_list.len(), *ib)).collect();
let mut origins = record::edge_origins(&a.solid, 0);
for (old, o) in record::edge_origins(&b.solid, 1) {
if let Some(new) = edges.get(&old) {
origins.entry(new.id()).or_default().extend(o);
}
}
for (i, f) in fb_list.iter().enumerate() {
if i == *ib {
continue;
@@ -419,7 +506,9 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
faces.push(nf);
}
let solid = Solid::try_new(vec![faces.into()]).map_err(|e| fail(&e.to_string()))?;
Body::new(crate::heal::heal(solid, size))
// Each edge's origins in the solid's edge order.
let origins = crate::body::edge_order(solid.face_iter()).0.iter().map(|e| origins.get(&e.id()).cloned()).collect();
crate::boolean_record::heal_build(solid, size, &[], Some(from), origins, &record::counts(&[a, b]))
}));
}
}
@@ -450,6 +539,33 @@ fn same_cylinder(x: &CylFace, y: &CylFace, tol: f64) -> bool {
x.axis.dot(y.axis).abs() > 1.0 - 1e-9 && (x.radius - y.radius).abs() < tol && (off - x.axis * off.dot(x.axis)).len() < tol
}
/// The history over `[a, b]` of `r`, made in [`bore_union`]: `joined` (the target joined with
/// `narrowed`, the tool with its bore narrowed) less `cut`, the cylinder whose wall restores the
/// tool's bore faces `bore` (the tool is operand `tool_operand`). Nothing of the cylinder but its
/// wall may be left. Only a bore of one face: the cylinder's wall faces are split where its own
/// construction splits them, so which of several bore faces a piece of it lies over is not known.
fn recut(r: &Body, joined: &Body, narrowed: &Body, cut: &Body, bore: &[usize], tool_operand: usize) -> Option<History> {
if bore.len() != 1 {
return None;
}
// Over [target, tool].
let joined = crate::boolean_record::through(joined, 1, narrowed.history())?;
let wall = || record::region(bore.iter().map(|&face| FaceOrigin::Face { operand: 1, face }).collect(), false);
let faces = cut
.history()?
.faces
.iter()
.map(|f| match f.origins.first() {
Some(FaceOrigin::Profile { .. }) => wall(),
_ => record::generated(Vec::new()),
})
.collect();
let cut = record::from_faces(&cut.solid, 2, faces);
let h = crate::boolean_record::of_pair(r, &Some(joined), &Some(cut))?;
let map = if tool_operand == 1 { [Some(0), Some(1)] } else { [Some(1), Some(0)] };
crate::boolean_record::composed(h.renumbered(&map, 2))
}
/// Do the bodies have cylindrical faces on a common cylinder?
pub(crate) fn has_coincident_cylinders(a: &Body, b: &Body) -> bool {
let tol = (a.size().max(b.size()) * 1e-7).max(1e-9) * 10.0;
@@ -465,7 +581,8 @@ pub(crate) fn has_coincident_cylinders(a: &Body, b: &Body) -> bool {
/// samples are plausibility checks; a whole-boundary guard rejects additional boundaries
/// crossing the recut. Original common-bore trims retain the legacy eligibility policy.
/// This is not a general emptiness or Boolean-equivalence certificate. Each direction
/// returns a result only when `accept` takes it; otherwise this returns `None`.
/// returns a result only when `accept` takes it; otherwise this returns `None`. The result's
/// history is over `[a, b]`.
pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) -> Option<Body> {
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
@@ -474,9 +591,10 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) ->
let (ia, ib) = (ma.inside_index(), mb.inside_index());
let empty = |p: Vec3| !ia.contains(p) && !ib.contains(p);
let (ca, cb) = (cylinders(a), cylinders(b));
// (body to narrow, its mesh and inside test, the other body, their faces on the cylinder).
let ways = [(b, &mb, &ib, a, &cb, &ca), (a, &ma, &ia, b, &ca, &cb)];
for (tool, mesh, inside, target, ct, co) in ways {
// (body to narrow, its operand number, its mesh and inside test, the other body, their faces
// on the cylinder).
let ways = [(b, 1, &mb, &ib, a, &cb, &ca), (a, 0, &ma, &ia, b, &ca, &cb)];
for (tool, tool_operand, mesh, inside, target, ct, co) in ways {
for c in ct {
if !co.iter().any(|o| same_cylinder(c, o, tol)) {
continue;
@@ -555,10 +673,19 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) ->
}
let Some(joined) = crate::ops::boolean(target, &narrowed, BoolOp::Union)? else { return Ok(None) };
let cut = crate::build::cylinder(at(lo, 0.0, 0.0), c.axis, c.radius, hi - lo)?;
crate::ops::boolean(&joined, &cut, BoolOp::Cut)
let Some(r) = crate::ops::boolean(&joined, &cut, BoolOp::Cut)? else { return Ok(None) };
let h = recut(&r, &joined, &narrowed, &cut, &bore, tool_operand);
Ok(Some(r.with_checked_history(h, &record::counts(&[a, b]))))
};
match run() {
Ok(Some(r)) if accept(&r) => return Some(r),
let (result, diagnostics) = crate::capture_boolean_diagnostics(run);
match result {
Ok(Some(r)) if accept(&r) => {
crate::boolean_diagnostics::merge(diagnostics);
let mut event = crate::BooleanFallback::new(crate::BooleanStrategy::CommonBore);
event.face_shift_mm = d;
crate::boolean_diagnostics::record(event);
return Some(r);
}
_ => continue,
}
}
+98 -10
View File
@@ -3,7 +3,8 @@
//! blend's radius rolls along the edge touching both sides; its centre traces the spine and its
//! contacts the curves where the blend meets each side. The blend face is a rational B-spline
//! through the circular cross-sections (exact arcs at the samples), the side faces are trimmed
//! back to the contact curves, and seams that met the edge are shortened to the contacts.
//! back to the contact curves, and seams that met the edge (lines, or arcs of an end face) are
//! shortened to the contacts.
use std::collections::HashMap;
@@ -11,6 +12,7 @@ use solvecraft_geom::Vec3;
use truck_modeling::{self as mt, builder};
use crate::body::{Solid, from_p3, p3};
use crate::record::Rebuild;
use crate::{KernelError, Result};
fn unsupported(msg: &str) -> KernelError {
@@ -176,14 +178,47 @@ fn project(f: &mt::Face, p: Vec3, hint: Option<(f64, f64)>) -> Option<(Vec3, Vec
Some((q, Vec3::new(n.x, n.y, n.z).normalized()?, uv))
}
/// Whether `uv` lies within the parameters of the face's surface (unbounded directions pass).
fn in_range(f: &mt::Face, uv: (f64, f64)) -> bool {
use mt::ParametricSurface;
use std::ops::Bound;
let inside = |range: (Bound<f64>, Bound<f64>), x: f64| {
let slack = |a: f64, b: f64| (b - a).abs() * 1e-6 + 1e-9;
match range {
(Bound::Included(a) | Bound::Excluded(a), Bound::Included(b) | Bound::Excluded(b)) => x >= a - slack(a, b) && x <= b + slack(a, b),
(Bound::Included(a) | Bound::Excluded(a), Bound::Unbounded) => x >= a - 1e-9,
(Bound::Unbounded, Bound::Included(b) | Bound::Excluded(b)) => x <= b + 1e-9,
(Bound::Unbounded, Bound::Unbounded) => true,
}
};
let (ru, rv) = f.oriented_surface().parameter_range();
uv.0.is_finite() && uv.1.is_finite() && inside(ru, uv.0) && inside(rv, uv.1)
}
/// Solve for the ball touching both sides near `p`. `extra` adds a third condition (a plane the
/// side-k contact must lie in); without it the centre stays in the plane through `p` square to
/// `t`. `sigma` is +1 when the ball sits outside the material (concave edge), −1 inside.
#[allow(clippy::too_many_arguments)]
fn solve(f1: &[&mt::Face], f2: &[&mt::Face], p: Vec3, t: Vec3, r: f64, sigma: f64, extra: Option<(usize, Vec3, Vec3)>, tol: f64) -> Option<Section> {
// The nearest face of a side (several faces on one smooth surface group).
// The nearest face of a side (several faces on one smooth surface group). Each face's
// search starts from its last answer: the centre moves little between iterations. An
// answer outside the surface's parameters, or farther than that last point (the search
// ran away or settled on the far side), is searched again cold.
let last = std::cell::RefCell::new(HashMap::<mt::FaceID, ((f64, f64), Vec3)>::new());
let near = |fs: &[&mt::Face], c: Vec3| -> Option<(Vec3, Vec3)> {
fs.iter().filter_map(|f| project(f, c, None).map(|(q, n, _)| (q, n))).min_by(|a, b| a.0.dist(c).total_cmp(&b.0.dist(c)))
fs.iter()
.filter_map(|f| {
let prev = last.borrow().get(&f.id()).copied();
let warm =
prev.and_then(|(uv, q)| project(f, c, Some(uv)).filter(|(x, _, uv)| in_range(f, *uv) && x.dist(c) <= q.dist(c) + tol * 1e-3));
let (q, n, uv) = match warm {
Some(x) => x,
None => project(f, c, None)?,
};
last.borrow_mut().insert(f.id(), (uv, q));
Some((q, n))
})
.min_by(|a, b| a.0.dist(c).total_cmp(&b.0.dist(c)))
};
let (q1, n1) = near(f1, p)?;
let (q2, n2) = near(f2, p)?;
@@ -238,12 +273,57 @@ fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> {
Some([h(s.q1, 1.0), h(m, half), h(s.q2, 1.0)])
}
/// Blend the closed chain of edges (ids in any order) with radius `r`.
pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<Solid> {
/// A curved seam (an arc of an end face) shortened to the blend's contacts: the part of its
/// curve between the new ends `a` and `b` (`None` keeps that end), which must lie on it, in
/// order, short of each other.
fn trim_curved_seam(seam: &mt::Edge, a: Option<mt::Vertex>, b: Option<mt::Vertex>, size: f64) -> Result<mt::Edge> {
// A closed seam (a full circle starting and ending at one vertex) has no "between": which
// end a contact replaces, and which way round the kept piece runs, are both ambiguous.
if seam.front() == seam.back() {
return Err(unsupported("a closed curved edge where the blend ends"));
}
use mt::{BoundedCurve, Cut, SearchNearestParameter};
let mut curve = seam.curve();
let (lo, hi) = curve.range_tuple();
let at = |v: &Option<mt::Vertex>, end: f64| -> Result<f64> {
let Some(v) = v else { return Ok(end) };
// Start from the nearest of a few samples: from the far end of a long arc
// the search can settle on the opposite side of the circle.
let q = from_p3(v.point());
let hint = (0..=32)
.map(|i| lo + (hi - lo) * i as f64 / 32.0)
.min_by(|x, y| {
let d = |t: f64| from_p3(mt::ParametricCurve::subs(&curve, t)).dist(q);
d(*x).total_cmp(&d(*y))
})
.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"));
}
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"));
}
if tb < hi {
let _ = curve.cut(tb);
}
let piece = if ta > lo { curve.cut(ta) } else { curve };
let a = a.unwrap_or_else(|| seam.front().clone());
let b = b.unwrap_or_else(|| seam.back().clone());
mt::Edge::try_new(&a, &b, piece).map_err(|e| KernelError::Failed(format!("curve blend: seam: {e}")))
}
/// Blend the closed chain of edges (ids in any order) with radius `r`; with what it built from
/// `solid` (each blend face in place of its chain edge).
pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<(Solid, Rebuild)> {
curve_blend_with_limit(solid, ids, size, r, round, MAX_SAMPLES)
}
fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, max_samples: usize) -> Result<Solid> {
fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, max_samples: usize) -> Result<(Solid, Rebuild)> {
if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) {
return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into()));
}
@@ -598,7 +678,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let mut seam_subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
for (id, (seam, a, b)) in ends {
if !matches!(seam.curve(), mt::Curve::Line(_)) {
return Err(unsupported("a curved edge where the blend ends"));
seam_subst.insert(id, trim_curved_seam(&seam, a, b, size)?);
continue;
}
// The new ends must stay on the seam, in order, short of each other.
let (p0, p1) = (from_p3(seam.front().point()), from_p3(seam.back().point()));
@@ -617,6 +698,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
seam_subst.insert(id, builder::line(&a, &b));
}
let mut out: Vec<mt::Face> = Vec::new();
let mut rec = Rebuild::default();
rec.pieces(&seam_subst);
for (fi, f) in faces.iter().enumerate() {
let corners: Vec<usize> = end_faces.iter().filter(|(_, g)| *g == fi).map(|(k, _)| *k).collect();
if !corners.is_empty() {
@@ -655,7 +738,9 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
wires.push(mt::Wire::from(es));
}
out.push(crate::heal::absolute_face(f, wires).ok_or_else(|| fail("an end face could not be rebuilt"))?);
let rebuilt = crate::heal::absolute_face(f, wires).ok_or_else(|| fail("an end face could not be rebuilt"))?;
rec.face(&rebuilt, f);
out.push(rebuilt);
continue;
}
let on1 = side1.contains(&fi);
@@ -682,7 +767,9 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
wires.push(mt::Wire::from(es));
}
out.push(crate::heal::absolute_face(f, wires).ok_or_else(|| fail("a side face could not be rebuilt"))?);
let rebuilt = crate::heal::absolute_face(f, wires).ok_or_else(|| fail("a side face could not be rebuilt"))?;
rec.face(&rebuilt, f);
out.push(rebuilt);
}
// Blend faces: each runs its side-1 contact the other way from side 1's face.
for (k, bf) in blends.iter().enumerate() {
@@ -706,13 +793,14 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
vec![e1.clone(), ak1.clone(), e2.inverse(), ak.inverse()].into()
};
let face = mt::Face::try_new(vec![wire], bf.clone()).map_err(|e| fail(&e.to_string()))?;
rec.blend(&face, [chain_e.id()]);
out.push(face);
}
let mut shells = solid.boundaries().clone();
if let Some(sh) = shells.get_mut(si) {
*sh = out.into();
}
Solid::try_new(shells).map_err(|e| fail(&format!("invalid solid: {e}")))
Ok((Solid::try_new(shells).map_err(|e| fail(&format!("invalid solid: {e}")))?, rec))
})
}
+107
View File
@@ -192,3 +192,110 @@ fn rolling_ball_refinement_cap_returns_an_error_and_preserves_input() {
assert_eq!(crate::step_export(&[&body], "unchanged").unwrap(), before);
assert_eq!(crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap(), input_before);
}
/// Plan-view construction of a round between two overlapping circles (a concave vertical edge
/// where two Z cylinders meet): the ball centre `f`, the circle and fillet arc angles, and the
/// area the round fills in, all independent of the kernel.
struct Gap {
/// Points along the round's arc in plan, contacts included.
arc: Vec<Vec3>,
/// Arc angles on circle 1, circle 2 and the fillet between the corner and the contacts.
angles: [f64; 3],
area: f64,
}
fn gap(c1: Vec3, r1: f64, c2: Vec3, r2: f64, corner: Vec3, r: f64) -> Gap {
// The ball centre is r from both circles, on the corner's side of the centre line.
let (a, b) = (r1 + r, r2 + r);
let d = c1.dist(c2);
let u = (c2 - c1) * (1.0 / d);
let along = (a * a - b * b + d * d) / (2.0 * d);
let mut side = Vec3::new(-u.y, u.x, 0.0);
if (corner - c1).dot(side) < 0.0 {
side = side * -1.0;
}
let f = c1 + u * along + side * (a * a - along * along).sqrt();
let (t1, t2) = (c1 + (f - c1) * (r1 / a), c2 + (f - c2) * (r2 / b));
let angle = |c: Vec3, p: Vec3, q: Vec3| (p - c).normalized().unwrap().dot((q - c).normalized().unwrap()).clamp(-1.0, 1.0).acos();
let angles = [angle(c1, t1, corner), angle(c2, corner, t2), angle(f, t2, t1)];
// Shoelace over the three short arcs: circle 1 to the corner, circle 2 on, fillet back.
let mut ring = Vec::new();
for (c, p, q) in [(c1, t1, corner), (c2, corner, t2), (f, t2, t1)] {
let (p0, q0) = (p - c, q - c);
let (a0, a1) = (p0.y.atan2(p0.x), q0.y.atan2(q0.x));
let mut sweep = a1 - a0;
while sweep > std::f64::consts::PI {
sweep -= std::f64::consts::TAU;
}
while sweep < -std::f64::consts::PI {
sweep += std::f64::consts::TAU;
}
let rr = p0.len();
for i in 0..20_000 {
let t = a0 + sweep * i as f64 / 20_000.0;
ring.push(c + Vec3::new(t.cos(), t.sin(), 0.0) * rr);
}
}
let area = ring.iter().zip(ring.iter().cycle().skip(1)).map(|(p, q)| p.x * q.y - q.x * p.y).sum::<f64>().abs() * 0.5;
let (p, q) = ((t1 - f).normalized().unwrap(), (t2 - f).normalized().unwrap());
let arc = (0..=8).map(|i| angles[2] * i as f64 / 8.0).map(|t| f + (p * ((angles[2] - t).sin()) + q * t.sin()) * (r / angles[2].sin())).collect();
Gap { arc, angles, area }
}
#[test]
fn rolling_ball_along_a_straight_edge_between_cylinders_trims_curved_end_seams() {
// Two Z cylinders crossing square to each other: the vertical lines where they meet are
// concave edges between two curved faces, and the blend ends on the planar caps, where the
// seams are arcs.
let (r1, r2, h, r) = (17.0, 9.5, 10.0, 6.0);
let (c1, c2) = (Vec3::ZERO, Vec3::new(r1, r2, 0.0));
let a = cylinder(c1, Vec3::Z, r1, h).unwrap();
let b = cylinder(c2, Vec3::Z, r2, h).unwrap();
let body = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
// The corner: one of the two points where the circles cross.
let d = c1.dist(c2);
let u = (c2 - c1) * (1.0 / d);
let along = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
let corner = c1 + u * along + Vec3::new(-u.y, u.x, 0.0) * (r1 * r1 - along * along).sqrt();
let g = gap(c1, r1, c2, r2, corner, r);
let blend = fillet(&body, &[corner + Vec3::Z * (h / 2.0)], r).unwrap();
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
// One blend face; the edge is replaced by two contact lines and two end arcs.
let (before, after) = (crate::measure(&body).unwrap(), crate::measure(&blend).unwrap());
assert_eq!((after.faces, after.edges, after.vertices), (before.faces + 1, before.edges + 3, before.vertices + 2), "{before:?} -> {after:?}");
// The round fills the gap over the full height. The caps gain it twice; the walls lose
// their arcs to the contacts and gain the round.
let (m0, m1) = (body.tessellate_with(0.0005, true).unwrap().measure(), blend.tessellate_with(0.0005, true).unwrap().measure());
let volume = h * g.area;
assert!((m1.volume - m0.volume - volume).abs() < volume * 3e-3, "added volume {} vs {volume}", m1.volume - m0.volume);
let [a1, a2, af] = g.angles;
let area = 2.0 * g.area + h * (r * af - r1 * a1 - r2 * a2);
assert!((m1.area - m0.area - area).abs() < area.abs() * 3e-3, "added area {} vs {area}", m1.area - m0.area);
// The round is the analytic arc at every height, from contact to contact.
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");
}
#[test]
fn closed_curved_seam_is_not_supported() {
// A closed seam (one edge starting and ending at the same vertex, as a full cap circle can
// be in imported geometry) has no "between" two contacts, so trimming it is refused rather
// than guessed.
let body = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 5.0).unwrap();
let arc = body.solid.edge_iter().find(|e| !matches!(e.curve(), mt::Curve::Line(_))).expect("a cap arc");
let circle = mt::Edge::new_unchecked(arc.front(), arc.front(), arc.curve());
assert_eq!(circle.front(), circle.back());
// Contacts on the circle itself: a third and two thirds of the way round.
let (lo, hi) = circle.curve().range_tuple();
let on = |f: f64| mt::Vertex::new(circle.curve().subs(lo + (hi - lo) * f));
for (a, b) in [(Some(on(1.0 / 3.0)), None), (None, Some(on(2.0 / 3.0))), (Some(on(1.0 / 3.0)), Some(on(2.0 / 3.0))), (None, None)] {
let err = trim_curved_seam(&circle, a, b, 20.0).unwrap_err();
assert!(err.to_string().contains("not supported yet: a closed curved edge"), "{err}");
}
}

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