Compare commits

..

31 Commits

Author SHA1 Message Date
bherbruck 6090e89b3f Release under the SolidCraft name: binaries, assets and installers (#383)
* Release under the SolidCraft name: binaries, assets and installers

The desktop binary is now `solidcraft` and the CLI `solidcraft-cli` (the two app packages
are renamed; their folders move with the rest of the rename later). Every release asset is
named solidcraft-<ver>-… / SolidCraft-<ver>-windows-x64.exe, and the installers show the
SolidCraft product name.

Kept so existing installs keep updating (docs/compatibility.md):
- `solvecraft-cli` alias binary in every package, forwarding to solidcraft-cli with a
  deprecation note on stderr (tests/legacy_alias.rs).
- Linux app ID ai.storyteller.solvecraft (Flatpak ID, desktop entry, Wayland app_id).
- MSI UpgradeCode, so the MSI upgrades in place.
- deb Replaces/Conflicts/Provides and rpm Obsoletes/Provides solvecraft.
- An old-named .zsync copy so earlier AppImages find updates; a legacy .flatpakrepo copy.
- .solvecraft file associations; SOLVECRAFT_* fallbacks in the packaging scripts.

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

* MSI: register the legacy .solvecraft extension without a duplicate open verb

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 16:05:35 -04:00
Jonas Wrede f06b76e14f fix: number a profile added after a named multi-profile extrude after body_name (#373) (#385) 2026-10-12 16:04:54 -04:00
Jonas Wrede 4b8872e4d0 test: failing regression test for #374 (ignored until fixed) (#382)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-12 15:43:56 -04:00
Jonas Wrede e7b5b2618e test: failing regression test for #373 (ignored until fixed) (#381)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-12 15:35:32 -04:00
Jonas Wrede ef9bb0db09 test: failing regression test for #371 (ignored until fixed) (#379)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-12 15:34:41 -04:00
Jonas Wrede f538eeb5fa test: failing regression test for #372 (ignored until fixed) (#380) 2026-10-12 15:25:36 -04:00
Jonas Wrede d470bfc52a test: failing regression test for #370 (ignored until fixed) (#378) 2026-10-12 15:25:32 -04:00
Jonas Wrede f82b6beedb test: failing regression test for #368 (ignored until fixed) (#377) 2026-10-12 15:25:27 -04:00
bherbruck 80672460ed Version 0.5.0 (#376)
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:17:00 -04:00
bherbruck 57e6396fbb Tangent hole contacts split no faces, consistently (#307) (#364)
* Tangent hole contacts: every symmetric contact gives the same faces (#307)

Three things made the four symmetric contacts of reference case 42 differ:

- The 2D prism boolean's ray-parity test miscounted a ray grazing the bottom
  of a patterned hole, whose two halves are read back with centres a few ulps
  apart: one half counted the touch, the other missed it. The loop chaining
  then failed and those cuts fell back to the general boolean, which treats
  circles tangent within its tolerance as overlapping and splits the hole.
  The ray is now moved off vertices and arc extremes by far less than any
  modelling tolerance.
- The prism result's volume check used a coarser mesh than the operands, so
  a plate with many holes read too large and correct results were rejected.
- The merged topology did not join a hole's two pieces across the line where
  two tangent holes touch (four faces meet there).

Case 42 now gives 39 faces (33 holes, 6 planes); its expected.json pins it.

Fixes #307

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

* Kernel test: tangent hole pinch no longer splits the wall (#307)

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 15:01:03 -04:00
Iraklis Kourtis a5cc1243e3 fix(doc): a pattern repeats a fillet on the edges it found, not its picks (#363)
A rectangular or circular pattern of a fillet or chamfer repeated it at the
points picked when the feature was made. The feature itself finds its edges
by name on every evaluation, but a parameter that moves the edges (a taller
pocket) leaves the old points on other geometry, and the copies then
blended the wrong edges or failed ("the faces at the edge ends must be
planar"). The model state now records, per fillet or chamfer, the edges it
blended in this evaluation (part of the state's digest), and the pattern
repeats those.

Fixes #219


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:58:52 -04:00
Iraklis Kourtis afcec4d0bd fix(kernel): fillets that end in faces at an angle or across two faces (#362)
A straight-edge fillet required each end of the edge to meet exactly one more
face, square to the edge. Now an end may meet one planar face at any angle
(the round ends in it along an exact ellipse, the cross-section slid along
the edge onto the plane) or two planar faces meeting at the end vertex: the
round ends across both and cuts back the edge between them where its
cylinder crosses it (Vida, Martin and Varady 1994: trimlines joined across
existing faces). The cylinder is swept to cover both trimmed ends. Square
ends are built as before.

This rounds the mitres where two chamfers meet at a plate's corner, and a
concave edge where a post stands on a plate (the round's end caps extend
the post's end faces); both were refused.

Fixes #215


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:56:00 -04:00
Vitali M. 6efbd6ff14 Place a dimension that would over-constrain the sketch as a driven one (#366)
Sketch Dimension refused a dimension the sketch couldn't take, with
"that dimension would over-constrain the sketch; add it as a driven
(reference) dimension with driven: true", which the tool gave no way to
do. It now places it as a driven dimension instead: while placing, the
preview shows it in parentheses in the driven colour, and the click adds
it at once (it has no parameter and no value to edit), with a status line
saying why. Other refusals, such as an unsupported pick, are reported as
before, since the driven dimension is refused too.

Refs #320

Co-authored-by: Vitali M. <6042958+vitalibr@users.noreply.github.com>
2026-10-12 14:54:34 -04:00
Pablo López c51735c885 Toolbar drop-downs scroll when taller than the window (#360)
A panel drop-down taller than the window ran past its bottom edge, so the
last rows (Sketch → Create → Intersect and below) could not be reached. The
rows now scroll inside the window, and a row reached with the arrow keys
scrolls into view. Row ids no longer depend on the enclosing Ui, so the
keyboard focus still finds them inside the scroll area.
2026-10-12 14:38:27 -04:00
Nathan Dane 4eaadf3099 fix(macos): package the app as SolidCraft (#347) (#354) 2026-10-12 14:37:35 -04:00
bherbruck 882ef0e9c6 Face counts: classify sliver faces from their exact surface (#308) (#357)
Since 4e595de a partial-depth cut that ends inside a cylinder runs through the
exact stacked-section path, which leaves 0.3 mm wide strips of the cylinder wall
beside the cut. Their tessellation is one triangle wide, too few distinct
samples for the cylinder fit, so the merged topology reported them as a sphere
and a freeform face. Where the mesh fit finds no plane or cylinder, use the
face's exact surface (plane, cylinder or sphere) instead.

Fixes #308


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:35:13 -04:00
bherbruck d15869ab85 CI: shard tests across 4 runners; heavy tests on main and nightly (#350)
* CI: build tests once and run them in 4 nextest shards

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

* CI: quote the shard arguments

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

* CI: extract the test archive into the checkout; skip the heaviest tests on pull requests

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

* Docs: CI shards and the tests pull requests skip

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 14:30:35 -04:00
Pablo López 511d105dcb Project faces split by hidden seams as a whole (#352)
Projecting a cylinder's wall or the face of a round along a circular edge
failed with "face projection has an open or invalid boundary": these
surfaces are stored in halves joined by hidden seams, and the projection
dropped the seams but kept only the picked half. It now projects the whole
patch, the face as drawn, through the same surface_patch the viewport uses
to highlight it.
2026-10-12 14:29:08 -04:00
bherbruck 2ebceb6918 Sheet metal: sew the folded-material guard per piece so adjacent flanges cut (#296) (#355)
The guard that refuses machining through bends sewed every bend zone and
nonparallel panel into one solid. Two bends meeting at an open corner relief
share only an edge, so that sew failed with "nonmanifold edge contact" and any
cut on the base was refused. Sew and test each piece on its own instead.

Reference case 75-sm-bracket-holes now passes; its known_failure mark is removed.

Fixes #296


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:27:40 -04:00
Vitali M. 7db66bb824 Pick a horizontal or vertical dimension from where its text is placed (#345)
Between two points, or on a line, that don't line up with an axis, the
Sketch Dimension tool only offered the distance between them: an auto
dimension ignored where its text went. Now the placement picks what it
measures: across from the two (between the perpendiculars through them)
the distance between them, as before; past that, above or below them the
horizontal distance, beside them the vertical one. With no text, an
explicit type, or two ends already on an axis, nothing changes.

The tool previews the dimension from the cursor, so the choice follows
the pointer while placing it.

Fixes #257

Co-authored-by: Vitali M. <6042958+vitalibr@users.noreply.github.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:18:11 -04:00
NOWN-DEV a7e47fdae6 fix: unfold sheets shrunk by a parameter change (#349)
A sheet-metal tray built at 2000 x 2500 mm with an edge flange on all four
edges (72° or 90°) and unfolded failed to rebuild when W and H went to
400 x 300, 600 x 450, or W alone to 400: "planar region arrangement failed
or exceeded its bounded processing budget". The same tray drawn at 400 x 300
unfolded, and growing it worked.

The sketch solved again after the change leaves its corners ~1e-13 mm off,
so the flanges inherit a hair of tilt and their panels and bend zones meet
the trimmed base within 1e-13 mm instead of exactly. The flat pattern is a
balanced union of those pieces, and the arrangement chains its output loops
within 10 tol. In a later round, ray parity classified a bend zone's edge
midpoint that lay level with a corner whose two lines did not quite meet:
the ray slipped through the gap without a crossing, the zone's edges counted
as inside the other operand and were dropped, and the outline no longer
closed. A rectangle turned by 1e-14 rad failed the same way.

inside_loop now closes the gap where one line ends and the next begins with
a straight connector while it counts crossings; exactly closed joints are
unaffected. Joints with an arc keep the arc's own endpoint rule, which
treats ends within its angular tolerance as on the ray (a connector there
double counted at a circle split into two arcs).

Co-authored-by: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:15:55 -04:00
bherbruck ce226a88eb Regression tests for the XZ plane orientation (#78): a positive extrude from XZ and from an XZ offset plane goes +Y; a format 5 design with XZ sketches, an offset plane and extrudes opens, and resaves, with identical volumes and bounding boxes (#353)
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:10:48 -04:00
Jochen DA2JH 0b122e6d4a Support 3D mice on Linux and devices that send all six axes in report 1 (#343)
The space_mouse module was Windows-only (hidapi). On Linux it now finds the
multi-axis HID interface through /sys/class/hidraw and reads /dev/hidrawN with
plain std I/O, so there is no new dependency and no libudev needed.

A small report descriptor parser tells devices apart: a top-level Multi-Axis
Controller collection marks the 3D mouse, and report 1 declaring all six axes
selects the combined layout (X Y Z Rx Ry Rz in one report) that some homemade
devices use. Real split devices (report 1 translation, report 2 rotation) keep
their existing decoding. Windows reads the descriptor through hidapi and falls
back to the split layout if it is unavailable.

Co-authored-by: jochenhammes <15869208+jochenhammes@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 13:55:54 -04:00
Vitali M. b2fc6d1fac Keep a zoomed-in profile's hover fill: count the slabs each edge crosses (#336)
fill_triangles bounded the slab triangulator by edges x slabs, as if every
edge crossed every slab. A ring sampled finely at a close zoom has hundreds
of edges and slabs but only four edges in each slab, so the estimate ran a
hundred times over the hover highlight's budget and the fill was dropped,
leaving only the outline. Count the slabs each edge actually spans instead.

Region2::triangulate tested every edge against every slab, so its work was
the overestimate. It now sweeps upward with the edges crossing the current
slab, giving the same triangles in time proportional to them.

Fixes #164

Co-authored-by: Vitali M. <6042958+vitalibr@users.noreply.github.com>
2026-10-12 13:47:11 -04:00
Tobias Breckle e6a95615a0 fix(ui): start with an anti-aliasing the graphics device can't do (#329)
Choosing 8x anti-aliasing in Preferences › Graphics on a device that
only allows 1x and 4x for the window's format made every later start
panic in wgpu's pipeline validation (`Sample count 8 is not supported
by format Bgra8Unorm on this device`), so the app could not be opened
again without editing graphics.json by hand.

- egui-wgpu (vendored): request TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
  when the adapter has it (WebGPU only guarantees 1x and 4x), and clamp
  the sample count to the largest one the device supports for both the
  colour target and the depth/stencil format. The supported counts are
  exposed as `RenderState::msaa_supported`.
- At start, a saved count the device can't do goes back to the default
  (4x, or the largest supported count below it), is written back, and a
  "Settings were changed" notice says what changed.
- The Anti-aliasing list greys out unsupported counts, `ui.prefs`
  rejects them, and an invalid saved `msaa` loads as the default.
- New messages translated in en, de, ja and ko.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:46:54 -04:00
palaniraja 6dab0fb2c1 openscad navigation (#211)
* openscad navigation

* addressed pr feedback: fixing box selection conflict with openscad and use constant for openscad
2026-10-12 13:46:40 -04:00
Iraklis Kourtis a04ef4ca59 T-spline core library and sheet bodies (#250, step 1) (#337)
* Add B-spline basis, knot insertion and NURBS surfaces to geom

Freeform faces need local refinement and gap-free joins between patches
with different knot vectors, which tensor B-spline grids cannot give;
T-splines can, and the first user is an N-sided Boundary Fill of
non-planar loops. This lays the univariate groundwork they rest on, in
the L0 geometry crate so it stays independent of the B-rep kernel:

- Cox-de Boor basis of one function from its local knot vector, on
  half-open spans with a left-limit side for the domain end, with
  derivatives (NURBS Book eq. 2.5, 2.9)
- knot insertion into a single basis function (Sederberg et al. 2004
  section 3) and Bezier extraction of one function by repeated insertion
- Boehm knot insertion for curves (NURBS Book A5.1), de Casteljau
  evaluation and sub-segments
- a rational tensor NURBS surface with evaluation, knot insertion and
  clamping, and a rational Bezier patch type

All input is checked (finite, monotone knots, positive weights, size
caps) and returns SplineError; nothing indexes unchecked.

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

* Add T-spline surfaces: T-mesh, evaluation, AS check, refinement, extraction

Tensor B-spline faces refine only by whole rows and columns and cannot
join patches with different knot vectors without gaps. T-splines allow
T-junctions in the control net, which fixes both; the first user is an
N-sided Boundary Fill. This adds the maths only (no command, no change
in behaviour):

- T-mesh in index space with knot values per line, validated (outer
  rectangle, rectangular faces, no empty lines, size caps); local knot
  vectors read off rays from each vertex, rays leaving the mesh repeat
  the boundary (Sederberg et al. 2003 section 3)
- rational T-spline evaluation and first derivatives for odd bi-degree,
  with a complete (p-1)/2-line frame and the domain inside it
- T-junction extensions and the analysis-suitability check (Scott et al.
  2012 section 2)
- local refinement that keeps the surface exactly: requested knot lines
  are cut in, then every original blending function is carried as a
  record and split (violation 1) or the mesh gains edges (violations 2
  and 3) until each record is a blending function of the refined mesh
  (Sederberg et al. 2004 section 3); analysis-suitable input stays
  analysis-suitable by carrying conflicting T-junctions across faces
- NURBS <-> T-spline conversion for meshes without T-junctions, and
  Bezier extraction over the rectangles no blending-function knot line
  crosses

Measured: regular T-spline vs NURBS < 1e-12, round trip exact, partition
of unity with T-junctions < 1e-14, refinement deviation <= 1.4e-14 over
40 random refinements, Bezier patches vs surface < 1e-14.

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

* Convert T-splines to sewn NURBS sheet bodies in the kernel

A T-spline is only useful to the modeller once it is a B-rep face set.
tspline_sheet builds an open shell: a mesh without T-junctions becomes
one clamped NURBS face; otherwise each Bezier element is a face, and
sides are split at the T-points where neighbours' corners land, so all
faces share vertices and edges and the sheet is one oriented shell
(V - E + F = 1). Every truck call runs under guard. No command yet;
Boundary Fill will be the first caller.

Tests: one face for a regular surface, sewn Bezier faces with
T-junctions, tessellated points on the surface to ~1e-9 mm, and hostile
control nets (coincident, 1e300, needle-thin) never panic.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:46:22 -04:00
Iraklis Kourtis 8e87a37511 feat(sketch): constructive solving ahead of the numeric solver (#339)
Part 1 of splitting #251: the sketch crate only. A graph-constructive front
end (Owen 1991; Fudos & Hoffmann 1997) places what it can by ruler-and-compass
loci and cluster decomposition, Levenberg-Marquardt finishes and verifies the
rest, and the rank analysis stays authoritative for degrees of freedom. Solves
can follow a reference (the previous solution), so edits and drags keep their
branch instead of flipping.

New API: solve_with (hold, reference, analysis, budget) and
solve_after_value_edit; solve and solve_holding keep their signatures as
wrappers, so no caller changes here.

When the seed moves an arc's centre, the arc's unconstructed ends start carried
along (the arc translates) instead of left behind for LM to turn; found by the
reference test 20-slot-constrained.

Refs #203


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:46:08 -04:00
Iraklis Kourtis fe4d171e22 feat(sketch): live drag that keeps its solution, ended by sketch.drag_end (#341)
* feat(sketch): constructive solving ahead of the numeric solver

Part 1 of splitting #251: the sketch crate only. A graph-constructive front
end (Owen 1991; Fudos & Hoffmann 1997) places what it can by ruler-and-compass
loci and cluster decomposition, Levenberg-Marquardt finishes and verifies the
rest, and the rank analysis stays authoritative for degrees of freedom. Solves
can follow a reference (the previous solution), so edits and drags keep their
branch instead of flipping.

New API: solve_with (hold, reference, analysis, budget) and
solve_after_value_edit; solve and solve_holding keep their signatures as
wrappers, so no caller changes here.

When the seed moves an arc's centre, the arc's unconstructed ends start carried
along (the arc translates) instead of left behind for LM to turn; found by the
reference test 20-slot-constrained.

Refs #203

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

* feat(doc): keep a sketch's solved state across value edits

Part 2 of splitting #251 (builds on part 1, the sketch crate):
- Sketch edits solve for the document's values continuing from the stored
  solved state (Document::solve_sketch), so a dimension edit keeps the
  sketch's branch instead of re-solving from scratch.
- A value edit (parameters, a configuration, the units) that would make a
  sketch that solved before fail or turn over is refused with the
  conflicting constraints named; otherwise the solved sketches are written
  back into the document (Model::adopt_solved_sketches), which changes what
  saved designs store.
- Later features are keyed by a sketch's solved geometry (to a 1e-6 mm
  quantum), so setting a value back reuses their earlier results.
- Solved sketches in a model state are shared (Arc) between states.

Refs #203

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

* feat(sketch): live drag that keeps its solution, ended by sketch.drag_end

Part 3 of splitting #251 (builds on part 2):
- sketch.drag follows a long move in short held steps, so the geometry keeps
  its branch, and reports how far it got (reached, at).
- live: true runs one frame of an interactive drag: the sketch is solved
  once and shown, later features keep their last result (marked stale)
  until sketch.drag_end or any command that reads the model evaluates it
  (CommandSpec::live, reads_model, doc_only).
- sketch.drag_end adds the constraint the drag let go on and evaluates the
  model once.
- Undo and redo evaluate a new document once (execute does) instead of
  twice.
- The viewport drives drags through live frames and drag_end; dimension
  text drags start only off sketch geometry.

Refs #203

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>
2026-10-12 13:45:48 -04:00
bherbruck 75a5609b23 docs/releasing.md: refresh Cargo.lock with cargo update --workspace --offline (#348)
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:43:42 -04:00
Pablo López fc8a5b21cf Loop blends: accept straight wall seams that are not lines (#335)
* Loop blends accept straight wall seams that are not lines

Filleting or chamfering the top outline of two overlapping cylinders whose
concave edges were rounded failed with "the wall seams must be straight":
the round's contact lines with the cylinders are straight NURBS curves, and
the loop blend only accepted seams stored as lines. A seam now counts as
straight when every sample lies on its chord; it is trimmed as a line as
before.

* Fillet and chamfer arrows point into the body

The radius arrow pointed out of the body along the edge's bisector, so
dragging it away from the edge grew the round. It now points into the
material, like the shell's thickness arrow: dragging toward the body grows
the radius or distance, dragging out of it shrinks it.
2026-10-12 13:15:30 -04:00
152 changed files with 15932 additions and 1092 deletions
+29
View File
@@ -0,0 +1,29 @@
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
# PR runs build the tests once and run them in 4 shards (.github/workflows/ci.yml).
[profile.ci]
# Heavy tests (one to four minutes each on a CI runner) that pull requests skip. They run on
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`.
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
default-filter = """
not (
(package(solvecraft-engine) & (
test(=tests::hostile_params_never_panic)
| test(/^tests::robustness::/)
| test(=pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen)
| test(=pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs)
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
))
| (package(solvecraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
| (package(solvecraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
)
"""
fail-fast = false
# Every test's time in the log, so the slow ones are easy to find.
status-level = "pass"
final-status-level = "slow"
failure-output = "immediate-final"
slow-timeout = { period = "60s" }
[profile.ci.junit]
path = "junit.xml"
+26 -15
View File
@@ -3,41 +3,52 @@ parameter to watch the timeline rebuild. [Documentation](https://bherbruck.githu
· [try it in the browser](https://bherbruck.github.io/solidcraft/app/) ·
[contributing](https://github.com/bherbruck/solidcraft/blob/main/CONTRIBUTING.md) ·
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
Compatibility names are unchanged: existing `.solvecraft` designs, `solvecraft-cli`, package
IDs and archive filenames still work. Published 0.2.0 and 0.2.1 releases keep their original
SolveCraft branding. Documentation, the browser app and the Flatpak feed use the new
SolidCraft project Pages URLs; old-name Pages addresses are not permanent aliases.
**Renamed to SolidCraft.** Downloads, installers and programs now carry the SolidCraft name:
the desktop app is `solidcraft`, the command line `solidcraft-cli`. `solvecraft-cli` is still
installed as a deprecated alias that forwards to `solidcraft-cli` (it prints a one-line note on
stderr), so scripts and MCP setups such as `claude mcp add solvecraft -- solvecraft-cli mcp`
keep working; new setups use `claude mcp add solidcraft -- solidcraft-cli mcp`. Existing designs
open as before. Upgrades: the `.msi` upgrades an earlier install in place (it now installs to
`Program Files\SolidCraft`); the `solidcraft` `.deb`/`.rpm` replaces the old `solvecraft`
package; the Flatpak app ID `ai.storyteller.solvecraft` is unchanged, so Flatpak updates
continue; earlier AppImages still find updates.
**Which file?**
- **Windows:** `SolveCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
- **Windows:** `SolidCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
`-portable.zip` (x64, x86 and arm64).
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolveCraft.app` onto Applications.
The CLI is in `solvecraft-cli-{version}-macos-universal.zip`.
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolidCraft.app` onto Applications.
The CLI is in `solidcraft-cli-{version}-macos-universal.zip`.
- **Linux** (x86_64 and aarch64, glibc 2.35+): the `.AppImage` (`chmod +x`, then run it), the
`.deb` or `.rpm`, the `.flatpak` (`flatpak install --user <file>`), or the `.tar.gz`.
- **FreeBSD:** `tar -xzf solvecraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
- **Web:** `solvecraft-web-{version}.zip` is a static site to host yourself.
- **FreeBSD:** `tar -xzf solidcraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
- **Web:** `solidcraft-web-{version}.zip` is a static site to host yourself.
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`.
**Updating macOS from SolveCraft.app:** quit the app, rename the existing app in
Applications to `SolidCraft.app`, then drag the new app from the DMG onto Applications
and choose **Replace**. If both names are already installed, move the old `SolveCraft.app`
to the Trash. Remove any Dock shortcut with the old name and pin the new app. Designs
and settings are unchanged. The DMG includes these instructions.
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 remote-add --user --if-not-exists solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo
flatpak install --user solidcraft 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`).
The builds aren't signed yet. On macOS, right-click `SolidCraft.app` in Applications and choose
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolidCraft.app`).
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
Licences are built in (Help ▸ About ▸ Licences, `solvecraft-cli licences`); the packages also
Licences are built in (Help ▸ About ▸ Licences, `solidcraft-cli licences`); the packages also
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
[ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
+91 -13
View File
@@ -1,9 +1,15 @@
# Fast checks always run; native builds run for code/CI changes.
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
# skipped on pull requests and run on pushes to main and nightly.
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
name: CI
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "17 6 * * *"
workflow_dispatch:
inputs:
cross_compile:
@@ -16,7 +22,8 @@ permissions:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# Every push to main gets its full run; a new PR push replaces the old run.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
@@ -39,7 +46,7 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
native=false
if [ "$EVENT_NAME" = workflow_dispatch ]; then
if [ "$EVENT_NAME" != pull_request ]; then
native=true
else
# Compare both old and new paths of renames; unknown paths run native checks.
@@ -92,12 +99,12 @@ jobs:
key: native-clippy
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
tests:
name: Native Linux tests
build-tests:
name: Build tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 90
timeout-minutes: 45
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
@@ -113,15 +120,82 @@ jobs:
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- name: Compile tests
run: cargo test --workspace --profile ci --locked --no-run
- name: Run tests
# Every test binary at once (cargo test runs them one after another).
run: cargo xtask test --locked
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- name: Compile tests into a nextest archive
run: cargo nextest archive --workspace --cargo-profile ci --locked --archive-file tests.tar.zst
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nextest-archive
path: tests.tar.zst
retention-days: 1
compression-level: 0
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
tests:
name: Native Linux tests (${{ matrix.shard }}/4)
needs: build-tests
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Run tests
env:
EVENT_NAME: ${{ github.event_name }}
SHARD: ${{ matrix.shard }}
run: |
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
all=()
if [ "$EVENT_NAME" != pull_request ]; then all=(--ignore-default-filter); fi
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
# the build job's path, which is the same checkout path here.
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --partition "slice:$SHARD/4" "${all[@]}"
- name: Upload test timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: junit-${{ matrix.shard }}
path: target/nextest/ci/junit.xml
retention-days: 7
doc-tests:
name: Doc tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
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: doc-tests
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# nextest does not run doc tests.
- run: cargo test --workspace --profile ci --locked --doc
oracle:
name: Reference tests
needs: changes
@@ -144,9 +218,9 @@ jobs:
with:
version: v0.16.0
- name: Build the command line
run: cargo build -p solvecraft-cli --profile ci --locked
run: cargo build -p solidcraft-cli --profile ci --locked
- name: Replay tests/oracle and compare with expected.json
run: cargo xtask oracle --cli target/ci/solvecraft-cli
run: cargo xtask oracle --cli target/ci/solidcraft-cli
- name: Upload the report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
@@ -156,7 +230,7 @@ jobs:
# Preserve the existing check name for branch protection, including docs-only PRs.
ci:
needs: [changes, checks, clippy, tests, oracle]
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle]
if: always()
runs-on: ubuntu-24.04
steps:
@@ -166,14 +240,18 @@ jobs:
CHECKS: ${{ needs.checks.result }}
NATIVE: ${{ needs.changes.outputs.native }}
CLIPPY: ${{ needs.clippy.result }}
BUILD: ${{ needs.build-tests.result }}
TESTS: ${{ needs.tests.result }}
DOCS: ${{ needs.doc-tests.result }}
ORACLE: ${{ needs.oracle.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
if [ "$NATIVE" = true ]; then
test "$CLIPPY" = success
test "$BUILD" = success
test "$TESTS" = success
test "$DOCS" = success
test "$ORACLE" = success
fi
+2 -2
View File
@@ -52,8 +52,8 @@ jobs:
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/solvecraft.wxs packaging/windows/solvecraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
xmllint --noout packaging/windows/solidcraft.wxs packaging/windows/solidcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.solvecraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solvecraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solvecraft'"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solidcraft'"
+28 -23
View File
@@ -79,7 +79,7 @@ jobs:
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
# The app icon in every size, drawn by solidcraft-cli (no image files are committed), for the
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
icons:
needs: version
@@ -91,10 +91,10 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-icons
- run: cargo build --release --locked -p solvecraft-cli
- run: cargo build --release --locked -p solidcraft-cli
- run: |
. packaging/env.sh
make_icons target/release/solvecraft-cli icons
make_icons target/release/solidcraft-cli icons
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: icons
@@ -114,8 +114,8 @@ jobs:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -135,17 +135,22 @@ jobs:
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft$|usr/bin/solvecraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solidcraft$|usr/bin/solidcraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
# Compatibility shims (docs/compatibility.md): the old CLI name and the package rename.
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft-cli$'
dpkg-deb --field dist/release/*.deb Replaces Conflicts Provides | grep -F solvecraft
rpm -qip dist/release/*.rpm
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
ldd target/release/solvecraft
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
ldd target/release/solidcraft
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
# Legacy AppImage update alias (docs/compatibility.md).
ls dist/release/solvecraft-*-linux-*.AppImage.zsync
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
@@ -166,8 +171,8 @@ jobs:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
@@ -181,7 +186,7 @@ jobs:
# 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
run: packaging/linux/flatpak-bundle.sh "dist/linux/solvecraft-$SOLVECRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
run: packaging/linux/flatpak-bundle.sh "dist/linux/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
@@ -198,8 +203,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 180
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build and package in a FreeBSD VM (tar.gz)
@@ -211,7 +216,7 @@ jobs:
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "SOLVECRAFT_VERSION SOLVECRAFT_BUILD_DATE"
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_BUILD_DATE"
prepare: |
pkg install -y bash git curl pkgconf libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
@@ -221,10 +226,10 @@ jobs:
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
# runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/solvecraft-target CARGO_BUILD_JOBS=4
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
tar -tzf dist/release/solvecraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/solvecraft$|/bin/solvecraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solvecraft/THIRD-PARTY-LICENSES\.txt$'
tar -tzf dist/release/solidcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
@@ -237,8 +242,8 @@ jobs:
if: needs.version.outputs.build == 'true'
runs-on: macos-14
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
@@ -269,7 +274,7 @@ jobs:
# Windows on ARM: cross-compiled on the x64 runner.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
@@ -290,7 +295,7 @@ jobs:
- name: Build and package (msi, portable zip)
shell: pwsh
env:
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
@@ -304,7 +309,7 @@ jobs:
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+2 -2
View File
@@ -115,7 +115,7 @@ version bumps solely from commit prefixes, overwrite published versions, or push
before the draft is verified.
## Running and looking at the app
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
@@ -128,7 +128,7 @@ before the draft is verified.
- 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`.
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
+2 -2
View File
@@ -92,7 +92,7 @@ version bumps solely from commit prefixes, overwrite published versions, or push
before the draft is verified.
## Running and looking at the app
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
@@ -105,7 +105,7 @@ before the draft is verified.
- 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`.
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
+18 -5
View File
@@ -17,23 +17,23 @@ build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](htt
```sh
git clone https://github.com/bherbruck/solidcraft && cd solidcraft
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
cargo run --release -p solidcraft -- --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`
Build the desktop app with `cargo build --release -p solidcraft`, or the CLI with
`cargo build --release -p solidcraft-cli`. Run the CLI with
`cargo run --release -p solidcraft-cli -- commands` (or `target/release/solidcraft-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.
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
### NixOS: `nix develop`
@@ -153,6 +153,19 @@ 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.
**CI shards the tests.** One job builds them into a cargo-nextest archive and four jobs run a
quarter each, so every test runs in its own process: don't rely on state left by another test.
Pull requests skip a few heavy tests (one to four minutes each), which run on every push to
`main` and nightly; check the `main` run after merging. They are listed in
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
- `solvecraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness`,
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`
- `solvecraft-kernel`: `tests::boolean_identity_on_tangent_primitives`
- `solvecraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
**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`,
Generated
+16 -15
View File
@@ -3289,8 +3289,8 @@ dependencies = [
]
[[package]]
name = "solvecraft"
version = "0.4.0"
name = "solidcraft"
version = "0.5.0"
dependencies = [
"eframe",
"egui",
@@ -3306,8 +3306,8 @@ dependencies = [
]
[[package]]
name = "solvecraft-cli"
version = "0.4.0"
name = "solidcraft-cli"
version = "0.5.0"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3316,7 +3316,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"log",
"serde",
@@ -3331,7 +3331,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"log",
"serde",
@@ -3347,15 +3347,16 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "solvecraft-io"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
@@ -3367,7 +3368,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"log",
"serde",
@@ -3382,7 +3383,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3390,7 +3391,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"image",
"serde",
@@ -3400,7 +3401,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"serde",
"serde_json",
@@ -3411,7 +3412,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -3426,7 +3427,7 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"eframe",
"egui",
@@ -4849,7 +4850,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.4.0"
version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+25 -8
View File
@@ -33,6 +33,9 @@ Cargo package names, settings/recovery locations and package IDs keep their exis
Published 0.2.0 and 0.2.1 packages and demo recordings retain their original SolveCraft branding
and filenames.
On macOS, the DMG contains `SolidCraft.app`. To replace an older `SolveCraft.app`
without installing a second copy, follow the [macOS upgrade instructions](https://bherbruck.github.io/solidcraft/guide/getting-started.html#macos).
Use the SolidCraft documentation and web-app URLs above. GitHub redirects links to the
renamed repository; old-name Pages and Flatpak URLs are not maintained as permanent aliases.
@@ -97,22 +100,36 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
## Build and run
```sh
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
cargo run --release -p solvecraft-cli -- commands # the command registry
cargo run --release -p solvecraft-cli -- run examples/bracket.json --out bracket.step
cargo run --release -p solvecraft-cli -- snapshot examples/bracket.json --out bracket.png
cargo run --release -p solvecraft -- part.step # open a STEP file as a new design
cargo run --release -p solvecraft-cli -- eval part.step # measure an imported STEP file
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
cargo run --release -p solidcraft-cli -- commands # the command registry
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png
cargo run --release -p solidcraft -- part.step # open a STEP file as a new design
cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file
cargo xtask ci # fmt, clippy, tests, assets, layers, wasm
```
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
On Windows, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
page in Preferences has an on/off switch and a speed control for the 3D mouse.
On Linux SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the
device. A udev rule that adds the `uaccess` tag does that, for example
`/etc/udev/rules.d/70-3d-mouse.rules` for devices with 3Dconnexion's vendor id:
```
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess"
```
Older SpaceNavigators use Logitech's vendor id `046d`; add a rule with their product id (see `lsusb`)
instead of matching every Logitech device. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then replug the device. The Flatpak
sandbox does not expose hidraw devices, so the 3D mouse only works with native builds there for now.
Devices that send all six axes in HID report 1 (some homemade ones do) are recognised from their
report descriptor, so they work as well.
In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or
WebGL2 with `?webgl`; `?sample` opens the sample part).
+4 -2
View File
@@ -9,7 +9,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|---|---|
| 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). |
| Reference tests | **76 / 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. |
| 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) |
@@ -44,7 +44,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| # | Milestone | Status | Hours left |
|---|---|---|---|
| M0 | Vertical slice | done | — |
| 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 |
| 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. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. 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 — [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 |
@@ -137,6 +137,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## View navigation
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
+9 -2
View File
@@ -1,17 +1,24 @@
[package]
name = "solvecraft-cli"
name = "solidcraft-cli"
description = "SolidCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
version.workspace = true
edition.workspace = true
license.workspace = true
default-run = "solidcraft-cli"
[lints]
workspace = true
[[bin]]
name = "solvecraft-cli"
name = "solidcraft-cli"
path = "src/main.rs"
# Compatibility alias for the CLI's former name: forwards to solidcraft-cli with a deprecation
# note on stderr. Shipped in every package for at least one release.
[[bin]]
name = "solvecraft-cli"
path = "src/alias.rs"
[dependencies]
solvecraft-engine = { workspace = true }
solvecraft-mcp = { workspace = true }
+30
View File
@@ -0,0 +1,30 @@
//! `solvecraft-cli`: the CLI's former name, kept for at least one release so existing scripts
//! and MCP client setups (`claude mcp add solvecraft -- solvecraft-cli mcp`) keep working.
//! Compatibility shim: it runs `solidcraft-cli` (next to this executable, else on PATH) with the
//! same arguments and standard streams, after a one-line deprecation note on stderr (stdout
//! stays clean for MCP's JSON-RPC), and exits with its status.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
use std::process::{Command, ExitCode};
const TARGET: &str = "solidcraft-cli";
fn main() -> ExitCode {
eprintln!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
let sibling = std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join(format!("{TARGET}{}", std::env::consts::EXE_SUFFIX))))
.filter(|p| p.is_file());
let program = sibling.map(|p| p.into_os_string()).unwrap_or_else(|| TARGET.into());
match Command::new(&program).args(std::env::args_os().skip(1)).status() {
Ok(status) => match status.code() {
Some(code) => ExitCode::from(u8::try_from(code).unwrap_or(1)),
None => ExitCode::FAILURE,
},
Err(e) => {
eprintln!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
ExitCode::FAILURE
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! `solvecraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
//! `solidcraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
//!
//! Each seed runs in its own process (`fuzz-one`) with a time limit, so a hang or a hard crash
//! is recorded like any other failure.
+28 -28
View File
@@ -1,17 +1,17 @@
//! SolveCraft headless command line.
//!
//! ```text
//! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
//! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
//! solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only] loading timings as JSON lines
//! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! 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 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)
//! solidcraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
//! solidcraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
//! solidcraft-cli bench-open <design.solvecraft | part.step> [--parse-only] loading timings as JSON lines
//! solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
//! solidcraft-cli commands the command registry as JSON
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
//! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
//! solidcraft-cli licences SolveCraft's licence and the third-party licences
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -31,19 +31,19 @@ use solvecraft_engine::Session;
use solvecraft_engine::render::{StandardView, render_png};
const USAGE: &str = "usage:
solvecraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
solvecraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]
solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
solvecraft-cli exec <command> [json-params]
solvecraft-cli commands
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 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
solidcraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
solidcraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
solidcraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
solidcraft-cli bench-open <design.solvecraft | part.step> [--parse-only]
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
solidcraft-cli exec <command> [json-params]
solidcraft-cli commands
solidcraft-cli recipe <recipe.json>
solidcraft-cli oracle <case-dir>... [--json]
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
solidcraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
solidcraft-cli licences
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
";
fn main() -> ExitCode {
@@ -87,7 +87,7 @@ fn main() -> ExitCode {
Some("bench-edit") => cmd_bench_edit(&args[1..]),
Some("bench-open") => cmd_bench_open(&args[1..]),
Some("--version" | "-V") => {
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
_ => Err(USAGE.to_string()),
@@ -305,7 +305,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
/// parameter `save_round` set to the round number each time.
fn cmd_save_stress(args: &[String]) -> Result<(), String> {
let (Some(path), Some(rounds)) = (args.first(), args.get(1).and_then(|n| n.parse::<u64>().ok())) else {
return Err("usage: solvecraft-cli save-stress <path> <rounds>".into());
return Err("usage: solidcraft-cli save-stress <path> <rounds>".into());
};
let mut s = Session::default();
let filler = (0..3000).map(|i| solvecraft_engine::doc::Param {
@@ -327,7 +327,7 @@ fn cmd_save_stress(args: &[String]) -> Result<(), String> {
/// parameter edit, setting it back, undo and redo (ms and features recomputed each).
fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
let (Some(path), Some(param), Some(expr)) = (args.first(), args.get(1), args.get(2)) else {
return Err("usage: solvecraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
return Err("usage: solidcraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
};
let v = read_json(path)?;
let script = if v.get("features").is_some() { recipe::to_script(&v)? } else { v };
@@ -355,7 +355,7 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or("usage: solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]")?;
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.solvecraft | part.step> [--parse-only]")?;
let t = std::time::Instant::now();
let step = solvecraft_engine::io::is_step_path(path);
let limit = if step { solvecraft_engine::io::MAX_STEP_BYTES } else { solvecraft_engine::io::MAX_DESIGN_BYTES };
+2 -2
View File
@@ -4,7 +4,7 @@ use std::process::{Command, Stdio};
#[test]
fn registry_output_is_complete_json() {
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").output().unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let registry: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let commands = registry.as_array().unwrap();
@@ -16,7 +16,7 @@ fn registry_output_is_complete_json() {
#[test]
fn closed_registry_pipe_exits_without_panicking() {
let mut child = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
// Close our read end before waiting. No sleeps or timing threshold are needed:
// the registry is larger than the pipe buffer, so it cannot all be written first.
drop(child.stdout.take());
+3 -3
View File
@@ -5,7 +5,7 @@ use std::process::Command;
#[test]
fn overflowing_seed_range_is_an_error_not_a_panic() {
let output =
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("seed range exceeds u64::MAX"), "{stderr}");
@@ -15,14 +15,14 @@ fn overflowing_seed_range_is_an_error_not_a_panic() {
#[test]
fn empty_seed_range_at_maximum_is_valid() {
let output =
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).starts_with("0 seeds, 0 design steps,"));
}
#[test]
fn maximum_seed_runs_once_without_wrapping() {
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["fuzz", "--from", "18446744073709551615", "--count", "1", "--steps", "0", "--timeout", "0"])
.output()
.unwrap();
+1 -1
View File
@@ -12,7 +12,7 @@ fn round_of(path: &std::path::Path) -> Option<String> {
#[test]
fn killed_mid_save_the_old_file_survives() {
let exe = env!("CARGO_BIN_EXE_solvecraft-cli");
let exe = env!("CARGO_BIN_EXE_solidcraft-cli");
let dir = std::env::temp_dir().join(format!("solvecraft-kill-mid-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
+20
View File
@@ -0,0 +1,20 @@
//! The `solvecraft-cli` compatibility alias forwards to `solidcraft-cli`.
use std::process::Command;
#[test]
fn legacy_alias_forwards_with_a_deprecation_note() {
let out = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("--version").output().unwrap();
assert!(out.status.success(), "{out:?}");
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(stdout.trim(), format!("solidcraft-cli {}", env!("CARGO_PKG_VERSION")));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("deprecated") && stderr.contains("solidcraft-cli"), "{stderr}");
}
#[test]
fn legacy_alias_passes_the_exit_status_through() {
let alias = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("no-such-command").output().unwrap();
let real = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("no-such-command").output().unwrap();
assert_eq!(alias.status.code(), real.status.code());
assert_eq!(alias.stdout, real.stdout);
}
+1 -1
View File
@@ -7,7 +7,7 @@ use std::process::{Command, Stdio};
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"))
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--connect", &format!("127.0.0.1:{port}")])
.stdin(Stdio::null())
.output()
+1 -1
View File
@@ -31,7 +31,7 @@ impl Drop for Scratch {
}
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
}
fn json_output(output: Output) -> Value {
+5 -3
View File
@@ -1,11 +1,11 @@
[package]
name = "solvecraft"
name = "solidcraft"
description = "SolidCraft desktop app"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
default-run = "solvecraft"
default-run = "solidcraft"
[lints]
workspace = true
@@ -19,9 +19,11 @@ serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
quick-xml = { 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"] }
+1 -1
View File
@@ -56,6 +56,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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()?;
embed_resource::compile_for(&rc, ["solidcraft"], embed_resource::NONE).manifest_required()?;
Ok(())
}
+9 -7
View File
@@ -15,7 +15,7 @@
#[path = "../../app_icon.rs"]
mod app_icon;
mod control_server;
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
mod space_mouse;
use solvecraft_engine::Session;
@@ -23,7 +23,7 @@ use solvecraft_ui_egui::{Services, SolveApp};
struct App {
solve: SolveApp,
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
space_mouse: space_mouse::SpaceMouse,
}
@@ -36,7 +36,7 @@ impl eframe::App for App {
solvecraft_ui_egui::documents::request_quit(&mut self.solve);
}
self.solve.logic(ctx);
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
self.space_mouse.tick(&mut self.solve, ctx);
if self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
@@ -155,11 +155,11 @@ fn main() -> eframe::Result {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--version" | "-V" => {
println!("solvecraft {}", env!("CARGO_PKG_VERSION"));
println!("solidcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
"--help" | "-h" => {
println!("usage: solvecraft [--control PORT] [--sample] [design.solvecraft | part.step]");
println!("usage: solidcraft [--control PORT] [--sample] [design.solvecraft | part.step]");
return Ok(());
}
_ => files.push(a),
@@ -200,6 +200,8 @@ fn main() -> eframe::Result {
if let Some(rs) = &cc.wgpu_render_state {
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);
// A saved anti-aliasing the device can't do: back to the default, with a notice.
solvecraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
@@ -227,11 +229,11 @@ fn main() -> eframe::Result {
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
}
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
Ok(Box::new(App {
solve: app,
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
space_mouse,
}))
}),
+131 -19
View File
@@ -1,10 +1,17 @@
//! Windows 3Dconnexion HID navigation. The device reader never touches the document or UI;
//! it publishes the latest axes, and the egui thread applies them once per frame.
//! 3Dconnexion HID navigation on Windows (through hidapi) and Linux (through hidraw). The device
//! reader never touches the document or UI; it publishes the latest axes, and the egui thread
//! applies them once per frame.
mod hid_descriptor;
#[cfg(target_os = "linux")]
mod hidraw;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use egui::Context;
use hid_descriptor::Layout;
#[cfg(target_os = "windows")]
use hidapi::{DeviceInfo, HidApi};
use quick_xml::Reader;
use quick_xml::events::Event;
@@ -208,9 +215,14 @@ impl Reading {
}
/// The old SpaceNavigator uses Logitech's VID; newer 3Dconnexion devices use their own.
fn is_3d_mouse_vendor(vendor_id: u16) -> bool {
matches!(vendor_id, 0x046d | 0x256f)
}
/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice.
#[cfg(target_os = "windows")]
fn is_3d_mouse(info: &DeviceInfo) -> bool {
matches!(info.vendor_id(), 0x046d | 0x256f) && info.usage_page() == 0x01 && info.usage() == 0x08
is_3d_mouse_vendor(info.vendor_id()) && info.usage_page() == 0x01 && info.usage() == 0x08
}
fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
@@ -220,8 +232,18 @@ fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
/// SpaceNavigator reports 1 (translation) and 2 (rotation), each with three signed
/// little-endian 16-bit values. The HID layer may pad either report to its maximum size.
fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool {
/// Some homemade devices send all six values in report 1 instead (`Layout::Combined`): only the
/// device's report descriptor can tell the two apart, because a padded split report 1 is as long.
fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Instant) -> bool {
match report.first().copied() {
Some(1) if layout == Layout::Combined => {
let Some(body) = report.get(1..) else { return false };
let (Some(translation), Some(rotation)) = (body.get(..6).and_then(three_axes), body.get(6..).and_then(three_axes)) else { return false };
reading.axes = Axes { translation, rotation };
reading.translation_at = now;
reading.rotation_at = now;
true
}
Some(1) => {
let Some(translation) = report.get(1..).and_then(three_axes) else { return false };
reading.axes.translation = translation;
@@ -238,6 +260,24 @@ fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool {
}
}
/// Publishes one input report from the device to the egui thread.
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
if let Ok(mut reading) = shared.lock()
&& decode_report(report, layout, &mut reading, Instant::now())
{
ctx.request_repaint();
}
}
/// The device is gone: stop reporting its last position.
fn reset(ctx: &Context, shared: &Mutex<Reading>) {
if let Ok(mut reading) = shared.lock() {
*reading = Reading::new(Instant::now());
}
ctx.request_repaint();
}
#[cfg(target_os = "windows")]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
let Ok(mut api) = HidApi::new() else {
log::warn!("3D mouse: HID initialization failed");
@@ -247,27 +287,72 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
if let Err(error) = api.refresh_devices() {
log::warn!("3D mouse: device scan failed: {error}");
} else if let Some(device) = api.device_list().find(|info| is_3d_mouse(info)).and_then(|info| info.open_device(&api).ok()) {
let layout = device_layout(&device);
let mut report = [0_u8; 64];
loop {
match device.read_timeout(&mut report, 100) {
Ok(0) => {}
Ok(size) => {
if let Ok(mut reading) = shared.lock()
&& decode_report(&report[..size], &mut reading, Instant::now())
{
ctx.request_repaint();
}
}
Ok(size) => publish(&ctx, &shared, layout, report.get(..size).unwrap_or_default()),
Err(error) => {
log::info!("3D mouse disconnected: {error}");
break;
}
}
}
if let Ok(mut reading) = shared.lock() {
*reading = Reading::new(Instant::now());
reset(&ctx, &shared);
}
std::thread::sleep(RETRY_AFTER);
}
}
/// How the device packs its axes, from its report descriptor; the usual split layout if the
/// descriptor cannot be read.
#[cfg(target_os = "windows")]
fn device_layout(device: &hidapi::HidDevice) -> Layout {
let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE];
match device.get_report_descriptor(&mut descriptor) {
Ok(size) => hid_descriptor::Descriptor::parse(descriptor.get(..size).unwrap_or_default()).layout(),
Err(error) => {
log::info!("3D mouse: no report descriptor, assuming separate translation and rotation reports: {error}");
Layout::Split
}
}
}
/// Reads `/dev/hidrawN` with plain blocking reads, which end with an error when the device is
/// unplugged. Opening needs permission: most distributions grant it to the logged-in user for
/// devices that carry the `uaccess` udev tag, which a rule for the 3D mouse has to add.
#[cfg(target_os = "linux")]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
use std::io::Read;
let mut denied = None;
loop {
if let Some(device) = hidraw::find() {
match std::fs::File::open(&device.path) {
Ok(mut file) => {
denied = None;
log::info!("3D mouse: reading {} ({:?} reports)", device.path.display(), device.layout);
let mut report = [0_u8; 64];
loop {
match file.read(&mut report) {
Ok(0) => break,
Ok(size) => publish(&ctx, &shared, device.layout, report.get(..size).unwrap_or_default()),
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(error) => {
log::info!("3D mouse disconnected: {error}");
break;
}
}
}
reset(&ctx, &shared);
}
// Say so once per device node, not every retry.
Err(error) if denied.as_ref() != Some(&device.path) => {
log::warn!("3D mouse: cannot open {}: {error}. A udev rule granting the logged-in user access is needed.", device.path.display());
denied = Some(device.path);
}
Err(_) => {}
}
ctx.request_repaint();
}
std::thread::sleep(RETRY_AFTER);
}
@@ -371,19 +456,46 @@ mod tests {
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
let now = Instant::now();
let mut reading = Reading::new(now);
assert!(!decode_report(&[1, 1, 2], &mut reading, now));
assert!(!decode_report(&[1, 1, 2], Layout::Split, &mut reading, now));
assert_eq!(reading.current(now), Axes::default());
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], &mut reading, now));
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], Layout::Split, &mut reading, now));
assert_eq!(reading.current(now).translation, [-100, 300, 0]);
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], &mut reading, now));
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], Layout::Split, &mut reading, now));
assert_eq!(reading.current(now).rotation, [0, 1, -1]);
let later = now + STALE_AFTER + Duration::from_millis(1);
assert_eq!(reading.current(later), Axes::default());
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], &mut reading, later));
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], &mut reading, later));
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], Layout::Split, &mut reading, later));
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], Layout::Split, &mut reading, later));
assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
}
#[test]
fn combined_report_one_carries_translation_and_rotation_together() {
let now = Instant::now();
let mut reading = Reading::new(now);
// Report id 1, then X Y Z Rx Ry Rz as signed little-endian 16-bit values.
let report = [1, 0x9c, 0xff, 0x2c, 0x01, 0, 0, 1, 0, 0, 0, 0xff, 0xff];
assert!(decode_report(&report, Layout::Combined, &mut reading, now));
assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [1, 0, -1] });
// The same bytes on a split device are only the translation report; its padding is not rotation.
let mut split = Reading::new(now);
assert!(decode_report(&report, Layout::Split, &mut split, now));
assert_eq!(split.current(now), Axes { translation: [-100, 300, 0], rotation: [0; 3] });
}
#[test]
fn combined_layout_ignores_short_and_other_reports() {
let now = Instant::now();
let mut reading = Reading::new(now);
assert!(!decode_report(&[1, 1, 0, 2, 0, 3, 0], Layout::Combined, &mut reading, now));
assert!(!decode_report(&[3, 1, 0, 0, 0, 0], Layout::Combined, &mut reading, now));
assert!(!decode_report(&[], Layout::Combined, &mut reading, now));
assert_eq!(reading.current(now), Axes::default());
// Extra padding after the six values is fine.
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 0, 0], Layout::Combined, &mut reading, now));
assert_eq!(reading.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
}
#[test]
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
let mut cam = Camera::default();
@@ -0,0 +1,253 @@
//! Just enough of a HID report descriptor reader to recognise a 3D mouse and to see how it packs
//! its motion axes. Written from the public USB HID specification. Malformed input never panics;
//! it simply yields less information.
/// Generic Desktop usage page, and the usages in it that matter here.
const PAGE_GENERIC_DESKTOP: u32 = 0x01;
const USAGE_MULTI_AXIS_CONTROLLER: u32 = 0x08;
const FIRST_AXIS_USAGE: u32 = 0x30; // X, Y, Z, Rx, Ry, Rz are 0x30..=0x35
const AXIS_COUNT: u32 = 6;
/// Motion reports are report id 1 (translation, or everything) and report id 2 (rotation).
const MOTION_REPORT_ID: u8 = 1;
/// Descriptors are small (hidapi reserves 4 KiB). Anything past this is ignored.
const MAX_DESCRIPTOR_BYTES: usize = 4096;
/// Bound on the usages one main item can declare through a usage range.
const MAX_USAGES_PER_ITEM: u32 = 256;
/// How a device spreads its six motion axes over input reports.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Layout {
/// Report 1 carries translation and report 2 carries rotation, three 16-bit values each.
/// This is how 3Dconnexion devices report, and it is the layout assumed when nothing is known.
#[default]
Split,
/// Report 1 carries all six axes: translation, then rotation. Homemade devices do this.
Combined,
}
#[derive(Clone, Copy)]
struct Item {
/// 0 main, 1 global, 2 local (3 is reserved).
kind: u8,
tag: u8,
value: u32,
}
/// Walks the short items of a descriptor. Long items are skipped; a truncated tail ends the walk.
fn items(descriptor: &[u8]) -> impl Iterator<Item = Item> + '_ {
let descriptor = descriptor.get(..MAX_DESCRIPTOR_BYTES).unwrap_or(descriptor);
let mut pos = 0_usize;
std::iter::from_fn(move || {
loop {
let prefix = *descriptor.get(pos)?;
pos += 1;
if prefix == 0xFE {
// Long item: a data length byte, a tag byte, then the data.
let length = usize::from(*descriptor.get(pos)?);
pos = pos.checked_add(2 + length)?;
continue;
}
let size = match prefix & 0b11 {
0 => 0,
1 => 1,
2 => 2,
_ => 4,
};
let data = descriptor.get(pos..pos + size)?;
pos += size;
let value = data.iter().enumerate().fold(0_u32, |value, (index, byte)| value | (u32::from(*byte) << (8 * index)));
return Some(Item { kind: (prefix >> 2) & 0b11, tag: prefix >> 4, value });
}
})
}
/// What a descriptor says about a device, as far as 3D mouse support needs to know.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Descriptor {
multi_axis_controller: bool,
/// Distinct axes (X, Y, Z, Rx, Ry, Rz as bits 0..=5) that input report 1 carries.
motion_report_axes: u8,
}
impl Descriptor {
pub fn parse(descriptor: &[u8]) -> Self {
let mut summary = Self::default();
let mut page = 0_u32;
let mut report_id = 0_u8;
let mut depth = 0_u32;
// Local items belong to the next main item only.
let mut usages: Vec<u32> = Vec::new();
let mut usage_min = None;
for item in items(descriptor) {
match (item.kind, item.tag) {
// Global: usage page, report id.
(1, 0) => page = item.value,
(1, 8) => report_id = u8::try_from(item.value).unwrap_or(0),
// Local: usage, usage minimum, usage maximum.
(2, 0) => usages.push(Self::full_usage(page, item)),
(2, 1) => usage_min = Some(Self::full_usage(page, item)),
(2, 2) => {
if let Some(min) = usage_min.take() {
let max = Self::full_usage(page, item);
let count = max.saturating_sub(min).min(MAX_USAGES_PER_ITEM - 1);
usages.extend((0..=count).map(|offset| min.saturating_add(offset)));
}
}
// Main: input, collection, end collection.
(0, 8) => {
if report_id == MOTION_REPORT_ID || (report_id == 0 && depth > 0) {
summary.note_axes(&usages);
}
}
(0, 0xA) => {
// An application collection (1) at the top level names the device.
if depth == 0 && item.value == 1 && usages.first() == Some(&((PAGE_GENERIC_DESKTOP << 16) | USAGE_MULTI_AXIS_CONTROLLER)) {
summary.multi_axis_controller = true;
}
depth += 1;
}
(0, 0xC) => depth = depth.saturating_sub(1),
_ => {}
}
if item.kind == 0 {
usages.clear();
usage_min = None;
}
}
summary
}
/// Usage items of up to two bytes name a usage on the current page, four bytes carry the page.
fn full_usage(page: u32, item: Item) -> u32 {
if item.value > 0xFFFF { item.value } else { (page << 16) | item.value }
}
fn note_axes(&mut self, usages: &[u32]) {
for usage in usages {
let axis = usage.wrapping_sub((PAGE_GENERIC_DESKTOP << 16) | FIRST_AXIS_USAGE);
if axis < AXIS_COUNT {
self.motion_report_axes |= 1 << axis;
}
}
}
/// True for a top-level Generic Desktop "Multi-Axis Controller" collection. This tells a
/// SpaceMouse's motion interface apart from its keyboard interface and from ordinary mice.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn is_multi_axis_controller(&self) -> bool {
self.multi_axis_controller
}
/// `Combined` when input report 1 declares all six axes, otherwise `Split`.
pub fn layout(&self) -> Layout {
if self.motion_report_axes.count_ones() == AXIS_COUNT { Layout::Combined } else { Layout::Split }
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Report 1 = X Y Z, report 2 = Rx Ry Rz, each as three signed 16-bit values.
const SPLIT: &[u8] = &[
0x05, 0x01, // usage page: generic desktop
0x09, 0x08, // usage: multi-axis controller
0xA1, 0x01, // collection: application
0x85, 0x01, // report id 1
0x16, 0xA2, 0xFE, // logical minimum -350
0x26, 0x5E, 0x01, // logical maximum 350
0x75, 0x10, // report size 16
0x95, 0x03, // report count 3
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, // X Y Z
0x81, 0x06, // input: data, variable, relative
0x85, 0x02, // report id 2
0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // Rx Ry Rz
0x81, 0x06, // input
0xC0, // end collection
];
/// Report 1 = X Y Z Rx Ry Rz.
const COMBINED: &[u8] = &[
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, // multi-axis controller, report id 1
0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, // 6 x 16 bit, -350..350
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // X Y Z Rx Ry Rz
0x81, 0x06, 0xC0,
];
/// The same six axes declared as a usage range, with report id 1 after the collection opens.
const COMBINED_RANGE: &[u8] = &[
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x75, 0x10, 0x95, 0x06, //
0x19, 0x30, 0x29, 0x35, // usage minimum X, usage maximum Rz
0x81, 0x06, 0xC0,
];
/// A boot keyboard-like interface: usage page 7 usages must not count as axes.
const KEYBOARD: &[u8] =
&[0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0xC0];
/// An ordinary mouse: Generic Desktop X, Y but top-level usage is Mouse (2).
const MOUSE: &[u8] = &[
0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x09, 0x01, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02, 0x81,
0x06, 0xC0, 0xC0,
];
#[test]
fn split_descriptor_is_a_multi_axis_controller_with_three_axes_in_report_one() {
let descriptor = Descriptor::parse(SPLIT);
assert!(descriptor.is_multi_axis_controller());
assert_eq!(descriptor.motion_report_axes, 0b000_111);
assert_eq!(descriptor.layout(), Layout::Split);
}
#[test]
fn six_axes_in_report_one_are_a_combined_layout() {
for bytes in [COMBINED, COMBINED_RANGE] {
let descriptor = Descriptor::parse(bytes);
assert!(descriptor.is_multi_axis_controller());
assert_eq!(descriptor.layout(), Layout::Combined);
}
}
#[test]
fn other_interfaces_are_not_3d_mice() {
for bytes in [KEYBOARD, MOUSE, &[][..]] {
let descriptor = Descriptor::parse(bytes);
assert!(!descriptor.is_multi_axis_controller());
assert_eq!(descriptor.layout(), Layout::Split);
}
}
#[test]
fn four_byte_usages_carry_their_own_page() {
// Usage (4 bytes) 0x0007_0030 is on page 7, not an axis.
let descriptor = Descriptor::parse(&[0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x0B, 0x30, 0x00, 0x07, 0x00, 0x81, 0x06, 0xC0]);
assert!(descriptor.is_multi_axis_controller());
assert_eq!(descriptor.motion_report_axes, 0);
}
#[test]
fn truncated_and_hostile_descriptors_never_panic() {
for end in 0..=COMBINED.len() {
let _ = Descriptor::parse(&COMBINED[..end]);
}
// Long item with a length running past the end, huge usage ranges, deep nesting.
let _ = Descriptor::parse(&[0xFE, 0xFF, 0x00, 0x01]);
let _ = Descriptor::parse(&[0x05, 0x01, 0x1B, 0, 0, 0, 0, 0x2B, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x06]);
let _ = Descriptor::parse(&[0xC0; 64]);
let _ = Descriptor::parse(&[0xA1; 64]);
let mut state = 0x2545_F491_4F6C_DD1D_u64;
for _ in 0..200 {
let bytes: Vec<u8> = (0..64)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state.to_le_bytes()[0]
})
.collect();
let _ = Descriptor::parse(&bytes);
}
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Finding a 3D mouse on Linux. The kernel exposes every HID interface as `/dev/hidrawN`, with
//! its identity and report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading both is
//! plain file I/O, so no udev or hidapi library is needed.
use std::path::{Path, PathBuf};
use super::hid_descriptor::{Descriptor, Layout};
const SYSFS: &str = "/sys/class/hidraw";
const DEV: &str = "/dev";
pub struct Device {
pub path: PathBuf,
pub layout: Layout,
}
/// The vendor id from `HID_ID=<bus>:<vendor>:<product>` (all hexadecimal) in a sysfs `uevent`.
fn vendor_id(uevent: &str) -> Option<u16> {
let id = uevent.lines().find_map(|line| line.strip_prefix("HID_ID="))?;
let vendor = id.split(':').nth(1)?;
u32::from_str_radix(vendor.trim(), 16).ok().and_then(|vendor| u16::try_from(vendor).ok())
}
/// The first hidraw interface that is a 3D mouse's multi-axis controller.
pub fn find() -> Option<Device> {
let mut names: Vec<_> = std::fs::read_dir(SYSFS).ok()?.filter_map(Result::ok).map(|entry| entry.file_name()).collect();
names.sort();
names.into_iter().find_map(|name| probe(&Path::new(SYSFS).join(&name), Path::new(DEV).join(&name)))
}
fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
let uevent = std::fs::read_to_string(sysfs.join("device/uevent")).ok()?;
if !super::is_3d_mouse_vendor(vendor_id(&uevent)?) {
return None;
}
let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?);
descriptor.is_multi_axis_controller().then(|| Device { path, layout: descriptor.layout() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_the_vendor_from_the_hid_id() {
let uevent = "DRIVER=hid-generic\nHID_ID=0003:0000256F:0000C631\nHID_NAME=Some Device\n";
assert_eq!(vendor_id(uevent), Some(0x256f));
assert_eq!(vendor_id("HID_ID=0003:0000046D:0000C077"), Some(0x046d));
}
#[test]
fn malformed_ids_are_rejected() {
for uevent in ["", "HID_NAME=x", "HID_ID=", "HID_ID=0003", "HID_ID=0003:zzzz:0001", "HID_ID=0003:FFFFFFFF:0001"] {
assert_eq!(vendor_id(uevent), None, "{uevent:?}");
}
}
}
+26 -1
View File
@@ -1544,7 +1544,8 @@ pub struct Feature {
pub name: String,
#[serde(default)]
pub suppressed: bool,
/// Names for the bodies this feature creates (in order); missing ones get `BodyN`.
/// Names for the bodies this feature creates (in order); missing ones are numbered after the
/// first (`Ribs (4)`), or get `BodyN` when there is none (or see `shell_piece_order`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub body_names: Vec<String>,
/// Parameter names of the feature's inputs (see `FeatureKind::inputs`), e.g. `d3`.
@@ -1570,6 +1571,8 @@ pub struct Feature {
/// 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.
/// Such features also name the bodies past their `body_names` `BodyN`, as those builds did,
/// where newer ones number them after the first name (`Ribs (4)`, #373).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub shell_piece_order: bool,
#[serde(flatten)]
@@ -2181,6 +2184,28 @@ impl Document {
}
Ok(())
}
/// Apply parameter values to a sketch's dimensions and solve it. `stored` are the values
/// its geometry is solved for (the stored sketch's): what the solve cannot reach in one
/// jump it reaches by stepping the changed values from there, and an edit that would turn
/// the sketch over is refused. Offsets whose sources moved are refreshed and solved again:
/// from the stored state again after a refused or failed first solve (the geometry is still
/// the stored one), from the solved one otherwise.
pub fn solve_sketch(
&self,
vals: &std::collections::BTreeMap<String, expr::Value>,
sk: &mut Sketch,
stored: &solvecraft_sketch::DrivingValues,
) -> Result<solvecraft_sketch::SolveReport> {
self.apply_dimension_values(vals, sk)?;
let mut report = solvecraft_sketch::solve_after_value_edit(sk, stored);
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
// Source dimensions may have moved curves during the solve.
sk.refresh_offsets()?;
report = if report.ok() { solvecraft_sketch::solve(sk) } else { solvecraft_sketch::solve_after_value_edit(sk, stored) };
}
Ok(report)
}
}
/// Density of the default material (g/cm³), as Fusion's default.
+259 -55
View File
@@ -7,7 +7,7 @@ use std::sync::{Arc, OnceLock};
use serde::Serialize;
use solvecraft_geom::{Mesh, Plane, Region2, Vec2, Vec3};
use solvecraft_kernel::{self as kernel, Body, BoolOp};
use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles, solve};
use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles};
use crate::expr::{self, Kind, Value};
use crate::lineage::Traced;
@@ -262,7 +262,8 @@ pub struct ModelState {
/// Cosmetic threads (from Thread features and threaded holes).
pub threads: Vec<ThreadInfo>,
pub bodies: Vec<ModelBody>,
pub sketches: Vec<SolvedSketch>,
/// Shared between states: a feature that does not change a sketch keeps it as it was.
pub sketches: Vec<Arc<SolvedSketch>>,
pub body_counter: usize,
/// Sheet metal bodies (by body name) and their flat patterns.
pub sheets: Vec<crate::sheet::SheetBody>,
@@ -270,6 +271,10 @@ pub struct ModelState {
pub construct: Vec<crate::construct::Construct>,
/// Evaluation-cache keys of the sketches (by sketch feature id).
sketch_keys: BTreeMap<u64, u64>,
/// The edges each fillet or chamfer blended, as found in this evaluation (by feature id):
/// a pattern of the feature repeats these, not the points picked when it was made, which a
/// parameter change may have moved off the edges.
blend_edges: BTreeMap<u64, Vec<Vec3>>,
}
impl ModelState {
@@ -289,13 +294,20 @@ impl ModelState {
self.sketch_keys.get(&s.feature).hash(&mut h);
}
self.body_counter.hash(&mut h);
for (feature, points) in &self.blend_edges {
feature.hash(&mut h);
for p in points {
[p.x, p.y, p.z].map(f64::to_bits).hash(&mut h);
}
}
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
h.finish()
}
/// After a feature evaluated to this state from `before` with cache key `key`: bodies it
/// made or changed, and sketches it made, take that key; the rest keep theirs.
/// made or changed take that key, sketches it made a digest of their solved geometry (see
/// [`quantize`]); the rest keep theirs.
fn stamp(&mut self, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
let feature = f.id;
let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1;
@@ -342,8 +354,16 @@ impl ModelState {
}
}
}
if self.sketches.iter().any(|s| s.feature == feature) {
self.sketch_keys.insert(feature, key);
// Later features read a sketch's solved geometry: they are keyed by it (to the
// quantum), so a value set back that solves to the same geometry reuses their results.
if let Some(s) = self.sketches.iter().find(|s| s.feature == feature) {
let mut h = std::collections::hash_map::DefaultHasher::new();
serde_json::to_string(&s.plane).unwrap_or_default().hash(&mut h);
let mut sketch = s.sketch.clone();
quantize(&mut sketch);
serde_json::to_string(&sketch).unwrap_or_default().hash(&mut h);
s.report.ok().hash(&mut h);
self.sketch_keys.insert(feature, h.finish());
}
}
}
@@ -394,7 +414,7 @@ impl ModelState {
self.bodies.iter().find(|b| b.name == name)
}
pub fn sketch(&self, feature: u64) -> Option<&SolvedSketch> {
self.sketches.iter().find(|s| s.feature == feature)
self.sketches.iter().find(|s| s.feature == feature).map(|s| &**s)
}
}
@@ -408,6 +428,11 @@ pub struct FeatureResult {
pub boolean_diagnostics: kernel::BooleanDiagnostics,
pub ms: f64,
pub skipped: bool,
/// Not evaluated on the document's current inputs: a feature at or after a sketch shown live
/// ([`Model::show_live_sketch`]). It keeps its last result until the next `evaluate`, which
/// never reuses it.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub stale: bool,
#[serde(skip)]
fingerprint: u64,
#[serde(skip)]
@@ -448,7 +473,7 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_ba
prev.hash(&mut h);
rolled_back.hash(&mut h);
vals.get(DESIGN_LEN).map(|v| v.v.to_bits()).hash(&mut h);
serde_json::to_string(f).unwrap_or_default().hash(&mut h);
feature_json(f).hash(&mut h);
for n in param_names(f) {
n.hash(&mut h);
match vals.get(&n) {
@@ -463,6 +488,123 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_ba
h.finish()
}
/// Positions and radii are fingerprinted to this quantum (mm): far below anything a design
/// can show, far above the solver's rounding, so a sketch solved again to the same geometry
/// reads as the same input.
const QUANTUM: f64 = 1e-6;
/// Put what a solve writes into a sketch on the [`QUANTUM`] grid: positions, radii and the
/// measured values of driven dimensions.
fn quantize(sk: &mut solvecraft_sketch::Sketch) {
let q = |v: f64| (v / QUANTUM).round() * QUANTUM;
for p in &mut sk.points {
p.pos = Vec2::new(q(p.pos.x), q(p.pos.y));
}
for c in &mut sk.curves {
if let CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. } = &mut c.kind {
*r = q(*r);
}
}
for c in sk.constraints.iter_mut().filter(|c| c.driven) {
if let Some(v) = c.kind.value() {
c.kind.set_value(q(v));
}
}
}
/// A feature as fingerprinted: its definition, a sketch's stored solve quantized.
fn feature_json(f: &Feature) -> String {
if let FeatureKind::Sketch { .. } = &f.kind {
let mut g = f.clone();
if let FeatureKind::Sketch { sketch, .. } = &mut g.kind {
quantize(sketch);
}
return serde_json::to_string(&g).unwrap_or_default();
}
serde_json::to_string(f).unwrap_or_default()
}
/// Store in `stored` the solution `solved` holds for it: the positions, the circle and ellipse
/// radii, the arcs' end order and orientation (a signed sweep can swap them) and the
/// constraints' values. Nothing else: what evaluation derives for its own copy (an offset's
/// upgraded topology, refreshed links) stays out of the stored sketch. A `solved` that does not
/// match `stored` entity for entity is not adopted.
fn adopt_solution(stored: &mut solvecraft_sketch::Sketch, solved: &solvecraft_sketch::Sketch) {
if stored.points.len() != solved.points.len()
|| stored.curves.len() != solved.curves.len()
|| stored.constraints.len() != solved.constraints.len()
|| stored.constraints.iter().zip(&solved.constraints).any(|(a, b)| a.id != b.id)
{
return;
}
for (p, q) in stored.points.iter_mut().zip(&solved.points) {
p.pos = q.pos;
}
for (c, d) in stored.curves.iter_mut().zip(&solved.curves) {
match (&mut c.kind, &d.kind) {
(CurveKind::Circle { r, .. }, CurveKind::Circle { r: s, .. }) | (CurveKind::Ellipse { r, .. }, CurveKind::Ellipse { r: s, .. }) => {
*r = *s
}
(CurveKind::Arc { a, b, .. }, CurveKind::Arc { a: sa, b: sb, .. }) => {
(*a, *b) = (*sa, *sb);
c.reversed = d.reversed;
}
_ => {}
}
}
for (c, d) in stored.constraints.iter_mut().zip(&solved.constraints) {
if let Some(v) = d.kind.value() {
c.kind.set_value(v);
}
}
}
/// The values features are evaluated with: the parameters, and the design's length unit.
fn evaluation_values(doc: &Document) -> (BTreeMap<String, Value>, BTreeMap<String, String>) {
let (mut vals, perr) = doc.param_values();
if let Some((Kind::Length, scale)) = expr::unit_info(doc.units.trim())
&& scale != 1.0
{
vals.insert(DESIGN_LEN.into(), Value { v: scale, len: 0, ang: 0 });
}
(vals, perr)
}
/// A feature's fingerprint in the chain after `prev`: its own inputs, where it is placed, and
/// the sheet metal or plastic rules it takes defaults from.
fn chain_fingerprint(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, prev: u64, rolled_back: bool) -> u64 {
let mut fp = fingerprint(prev, f, vals, rolled_back) ^ placement_digest(doc, f);
// Sheet metal features also depend on the rules (and the parameters they use).
if f.kind.is_sheet() {
let mut h = std::collections::hash_map::DefaultHasher::new();
fp.hash(&mut h);
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
for r in &doc.sheet.rules {
for e in [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap] {
for n in expr::references(e) {
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
}
}
}
fp = h.finish();
}
// Plastic features take defaults from the plastic rules.
if f.kind.is_plastic() && !doc.plastic.is_empty() {
let mut h = std::collections::hash_map::DefaultHasher::new();
fp.hash(&mut h);
serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h);
for r in &doc.plastic.rules {
for (_, e, _) in r.fields() {
for n in expr::references(e) {
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
}
}
}
fp = h.finish();
}
fp
}
/// Document-level inputs any feature may read: construction planes (resolved by name), sheet
/// metal and plastic rules (and the parameters they use), components and body placement.
fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> u64 {
@@ -579,14 +721,79 @@ impl Model {
self.results.iter().find(|r| r.id == id)
}
/// Show a sketch's new solution before the model is evaluated again (the frames of an
/// interactive drag): `sketch`, already solved with `report`, on the plane it was last
/// evaluated on. The sketch's own result and the final state show it, and the sketch and
/// every feature after it are marked stale, keeping their last geometry. Nothing else is
/// recomputed; the next [`Model::evaluate`] recomputes from the sketch on. Returns `false`,
/// changing nothing, when the sketch has no evaluated result or a feature before it is
/// already stale (only a full evaluation brings that up to date).
pub fn show_live_sketch(&mut self, feature: u64, sketch: solvecraft_sketch::Sketch, report: SolveReport) -> bool {
let Some(i) = self.results.iter().position(|r| r.id == feature) else { return false };
if self.results.iter().take(i).any(|r| r.stale) {
return false;
}
let Some(last) = self.results.get(i).filter(|r| !r.skipped && r.error.is_none()).and_then(|r| r.state.sketch(feature)) else {
return false;
};
let solved = Arc::new(SolvedSketch { feature, name: last.name.clone(), plane: last.plane, profiles: find_profiles(&sketch), sketch, report });
let shown = self.results.iter().rposition(|r| !r.skipped);
for (k, r) in self.results.iter_mut().enumerate().skip(i) {
r.stale = true;
// The sketch's own state and the final one show it: only that entry changes (the
// states are copied on the first frame; later frames own them and swap in place).
if (k == i || Some(k) == shown)
&& let Some(s) = Arc::make_mut(&mut r.state).sketches.iter_mut().find(|s| s.feature == feature)
{
*s = solved.clone();
}
}
true
}
/// Some results are stale ([`Model::show_live_sketch`]): the model needs an `evaluate`.
pub fn is_stale(&self) -> bool {
self.results.iter().any(|r| r.stale)
}
/// Store in `doc` the sketches whose last evaluation solved them for new driving values
/// (see `Document::solve_sketch`), so the stored sketch is the solved state the next value
/// edit continues from (edits depend on their history, as a user editing dimensions one
/// after another expects) and later evaluations repeat no continuation. Their results
/// stand for the updated document: the solved sketch is what evaluating it gives.
/// Returns how many sketches were stored.
pub fn adopt_solved_sketches(&mut self, doc: &mut Document) -> usize {
let solved: Vec<SolvedSketch> = self.edited_sketches(doc).cloned().collect();
let Some(first) = solved.iter().filter_map(|s| doc.features.iter().position(|f| f.id == s.feature)).min() else { return 0 };
for s in &solved {
if let Ok(stored) = doc.sketch_mut(s.feature) {
adopt_solution(stored, &s.sketch);
}
}
// The fingerprints from the first stored sketch on.
let vals = evaluation_values(doc).0;
let marker = doc.marker.unwrap_or(usize::MAX);
let mut prev_fp = first.checked_sub(1).and_then(|i| self.results.get(i)).map_or(0, |r| r.fingerprint);
for (i, f) in doc.features.iter().enumerate().skip(first) {
prev_fp = chain_fingerprint(doc, &vals, f, prev_fp, i >= marker);
if let Some(r) = self.results.get_mut(i).filter(|r| r.id == f.id) {
r.fingerprint = prev_fp;
}
}
solved.len()
}
/// The sketches the last evaluation solved for driving values other than the ones `doc`
/// stores them with (a value edit since they were stored).
pub fn edited_sketches<'a>(&'a self, doc: &'a Document) -> impl Iterator<Item = &'a SolvedSketch> + 'a {
self.results.iter().filter(|r| !r.skipped && r.error.is_none()).filter_map(|r| r.state.sketch(r.id)).filter(|s| {
s.report.keeps_orientation() && doc.sketch(s.feature).is_ok_and(|stored| stored.driving_values() != s.sketch.driving_values())
})
}
/// Re-evaluate the document, reusing results up to the first changed feature.
pub fn evaluate(&mut self, doc: &Document) {
let (mut vals, perr) = doc.param_values();
if let Some((Kind::Length, scale)) = expr::unit_info(doc.units.trim())
&& scale != 1.0
{
vals.insert(DESIGN_LEN.into(), Value { v: scale, len: 0, ang: 0 });
}
let (vals, perr) = evaluation_values(doc);
self.param_errors = perr;
let mut prev_fp = 0u64;
let mut state = self.empty.clone();
@@ -598,40 +805,13 @@ impl Model {
let ctx = context_digest(doc, &vals);
for (i, f) in doc.features.iter().enumerate() {
let rolled_back = i >= marker;
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back) ^ placement_digest(doc, f);
// Sheet metal features also depend on the rules (and the parameters they use).
if f.kind.is_sheet() {
let mut h = std::collections::hash_map::DefaultHasher::new();
fp.hash(&mut h);
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
for r in &doc.sheet.rules {
for e in [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap] {
for n in expr::references(e) {
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
}
}
}
fp = h.finish();
}
// Plastic features take defaults from the plastic rules.
if f.kind.is_plastic() && !doc.plastic.is_empty() {
let mut h = std::collections::hash_map::DefaultHasher::new();
fp.hash(&mut h);
serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h);
for r in &doc.plastic.rules {
for (_, e, _) in r.fields() {
for n in expr::references(e) {
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
}
}
}
fp = h.finish();
}
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back);
prev_fp = fp;
if reuse
&& let Some(old) = self.results.get(i)
&& old.id == f.id
&& old.fingerprint == fp
&& !old.stale
{
if !old.skipped {
state = old.state.clone();
@@ -649,6 +829,7 @@ impl Model {
boolean_diagnostics: Default::default(),
ms: 0.0,
skipped: true,
stale: false,
fingerprint: fp,
state: state.clone(),
});
@@ -671,6 +852,7 @@ impl Model {
boolean_diagnostics: c.boolean_diagnostics,
ms: c.ms,
skipped: false,
stale: false,
fingerprint: fp,
state: state.clone(),
});
@@ -775,6 +957,7 @@ impl Model {
boolean_diagnostics,
ms,
skipped: false,
stale: false,
fingerprint: fp,
state: state.clone(),
});
@@ -871,6 +1054,16 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String {
{
return n.clone();
}
// Bodies past the stored names (a profile added after the feature was made) are numbered
// after the first name: `Ribs (4)` (#373). Features saved before format 14 keep `BodyN`,
// which later features of those designs may name.
if !f.shell_piece_order
&& k >= f.body_names.len()
&& let Some(base) = f.body_names.first().map(|n| n.trim()).filter(|n| !n.is_empty())
{
let n = format!("{base} ({})", k.saturating_add(1));
return if state.body(&n).is_none() { n } else { unique_body_name(state, base) };
}
loop {
state.body_counter += 1;
let n = format!("Body{}", state.body_counter);
@@ -2562,8 +2755,11 @@ fn replay(
// A fillet or chamfer repeats at the copied edges.
if let FeatureKind::Fillet { edges, radius, .. } | FeatureKind::Chamfer { edges, distance: radius, .. } = &src.kind {
let r = val(vals, radius, Kind::Length)?;
// The edges the feature blended in this evaluation (found by name), falling back to
// its picked points.
let found = st.blend_edges.get(&src.id).cloned().unwrap_or_else(|| edges.clone());
for m in mats {
let moved: Vec<Vec3> = edges.iter().map(|p| crate::apply_point(m, *p)).collect();
let moved: Vec<Vec3> = found.iter().map(|p| crate::apply_point(m, *p)).collect();
let ti = blend_target(st, &None, &moved)?;
let Some(mb) = st.bodies.get(ti).cloned() else { continue };
let nb = match &src.kind {
@@ -2920,15 +3116,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
if let Some(w) = face_warning {
warns.insert(0, w);
}
doc.apply_dimension_values(vals, &mut sk)?;
let mut report = solve(&mut sk);
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
// Source dimensions may have moved curves during the solve.
sk.refresh_offsets()?;
report = solve(&mut sk);
}
if !report.ok() {
warns.insert(0, format!("the sketch constraints conflict ({})", report.failing.join(", ")));
// The stored sketch is solved for its stored values.
let stored = sk.driving_values();
let report = doc.solve_sketch(vals, &mut sk, &stored)?;
if !report.keeps_orientation() {
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
}
if !warns.is_empty() {
*warning = Some(warns.join("; "));
@@ -2936,7 +3128,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
sk.validate_periodic_splines()?;
let profiles = find_profiles(&sk);
st.sketches.retain(|s| s.feature != f.id);
st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles });
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles }));
Ok(())
}
FeatureKind::Extrude { operation, targets, .. }
@@ -3297,6 +3489,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
} else {
resolve_edges(&mb.body, edges, &f.edge_refs, warning)?
};
st.blend_edges.insert(f.id, edges.clone());
let input = mb.traced();
// The blended body, and the lineage of what the last blend started from.
let (nb, from) = match &f.kind {
@@ -3580,6 +3773,12 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
}
}
for (s, m) in out.sketches.iter_mut().zip(&sketch_m) {
// Only a sketch the frame moves is copied.
let maps_wire = |w: &solvecraft_sketch::Wire| !crate::is_identity(if w.space == solvecraft_sketch::WireSpace::Component { m } else { &inv });
if crate::is_identity(m) && !s.sketch.wires.iter().any(maps_wire) {
continue;
}
let s = Arc::make_mut(s);
if !crate::is_identity(m)
&& let Some(np) = Plane::new(crate::apply_point(m, s.plane.origin), crate::apply_vector(m, s.plane.x), crate::apply_vector(m, s.plane.y))
{
@@ -3646,7 +3845,12 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
}
if primary {
for s in st.sketches.iter().filter(|s| comp_of_feature(s.feature) == comp) {
let mut s2 = s.clone();
if ident {
// In place: shared as it is.
out.sketches.push(s.clone());
continue;
}
let mut s2 = SolvedSketch::clone(s);
if !ident {
let p = &s.plane;
if let Some(np) = Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)) {
@@ -3658,7 +3862,7 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
map_wire(wire, &m);
}
}
out.sketches.push(s2);
out.sketches.push(Arc::new(s2));
}
for t in st
.threads
+1 -1
View File
@@ -687,7 +687,7 @@ pub(super) fn sheet_cut(
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
continue;
}
if let Some(bends) = sh.unmodeled_solid(&ss.plane)? {
for bends in sh.unmodeled_solids(&ss.plane)? {
for tool in tools {
if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() {
return Err(DocError::Invalid(
+6
View File
@@ -9,7 +9,13 @@ use crate::{DocError, Result};
type Key = (i64, i64, i64);
/// Boundary of the picked face as drawn: a closed surface cut into pieces (a cylinder or a
/// round's torus in halves) projects as the whole patch, since its seams are hidden.
pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
let patch = mesh.surface_patch(face as usize);
if patch.len() > 1 {
return family_edges(mesh, &patch.into_iter().collect(), plane);
}
let edges = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect();
boundary_edges(mesh, edges, plane)
}
+40 -7
View File
@@ -879,10 +879,13 @@ impl SheetBody {
Ok(body)
}
/// Actual curved material, used to certify unsupported folded machining before mutation.
pub(crate) fn unmodeled_solid(&self, plane: &Plane) -> Result<Option<Body>> {
/// Actual curved material, used to certify unsupported folded machining before mutation:
/// one solid per bend zone or nonparallel panel. Each is sewn on its own, because the
/// selected pieces need not form a manifold solid together (two bends meeting at an open
/// corner relief touch only along an edge).
pub(crate) fn unmodeled_solids(&self, plane: &Plane) -> Result<Vec<Body>> {
if self.flat || self.flanges.is_empty() {
return Ok(None);
return Ok(Vec::new());
}
let mut work = FlatWork::new();
let cells: Vec<_> = self
@@ -893,10 +896,7 @@ impl SheetBody {
Piece::Panel(pi) => apply_vector(&self.world(pi), Vec3::Z).cross(plane.normal()).len() > 1e-6,
})
.collect();
if cells.is_empty() {
return Ok(None);
}
Ok(Some(kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?))
cells.iter().map(|c| Ok(kernel::sew(&self.faces(std::slice::from_ref(c), 0.0, self.t, &mut work)?)?)).collect()
}
fn through_pocket_regions(&self, work: &mut FlatWork) -> Result<Vec<Region2>> {
@@ -2250,4 +2250,37 @@ mod tests {
s.pockets.push(pocket.clone());
assert!(s.record_pocket(pocket.region, 0.0, 0.5, &mut work).is_err());
}
/// A sketch solved again after a parameter change leaves its corners off by ~1e-13 mm, so
/// its edges are not quite square to the axes. Such a tray (four 72° flanges, shrunk to
/// 400 x 300 from a larger size) failed to unfold in the planar arrangement while the same
/// tray drawn at that size unfolded.
#[test]
fn nearly_square_base_unfolds_like_the_exact_one() {
let rv = RuleValues { thickness: 2.0, k_factor: 0.5, bend_radius: 1.0, ..steel() };
let tray = |[a, b, c, d]: [Vec2; 4]| {
let mut s = base_flange("Body1", "AL2", &rv, Plane::XY, rect(a, b, c, d));
let picks = [Vec3::new(200.0, 0.0, 2.0), Vec3::new(400.0, 150.0, 2.0), Vec3::new(200.0, 300.0, 2.0), Vec3::new(0.0, 150.0, 2.0)];
s.add_flanges(&picks, 38.6, 72f64.to_radians(), 1.0, "inside", false).unwrap();
s
};
let flat_area = |s: &SheetBody| s.flat_regions().unwrap().iter().map(|r| r.area().abs()).sum::<f64>();
let square = [Vec2::new(0.0, 0.0), Vec2::new(400.0, 0.0), Vec2::new(400.0, 300.0), Vec2::new(0.0, 300.0)];
let exact = flat_area(&tray(square));
// The corners the solver left after the parameter change, and the rectangle turned by a hair.
let solved = [
Vec2::new(0.0, 0.0),
Vec2::new(400.0000000000001, 4.369543929613394e-13),
Vec2::new(400.00000000000017, 300.0000000000008),
Vec2::new(0.0, 300.0000000000009),
];
let (c, s) = (1e-14f64.cos(), 1e-14f64.sin());
let turned = square.map(|p| Vec2::new(p.x * c - p.y * s, p.x * s + p.y * c));
for corners in [solved, turned] {
let mut sheet = tray(corners);
assert!((flat_area(&sheet) - exact).abs() < 1e-6, "{} {exact}", flat_area(&sheet));
sheet.flat = true;
assert!((vol(&sheet.solid().unwrap()) - exact * 2.0).abs() < 1e-3);
}
}
}
+3 -1
View File
@@ -32,7 +32,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"),
CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"),
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
CommandSpec::new("view.list", "List Named Views", view_list).noundo(),
CommandSpec::new("view.list", "List Named Views", view_list).noundo().doc_only(),
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
@@ -312,6 +312,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
// What the geometry reads: every feature's length inputs.
let matched_configuration = s.doc.configuration_matches().then(|| s.doc.configs.active.clone()).flatten();
let before = s.doc.length_inputs();
let solved = s.solved_sketches();
s.doc_mut().units = u.to_string();
// Unit-less user parameters holding a bare number read it in the design's units where they
// stand for lengths. If any input moved, those numbers keep the old unit, written out.
@@ -346,6 +347,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(row) = matched_configuration {
s.doc_mut().apply_configuration(&row)?;
}
s.settle_value_edit(&solved)?;
Ok(json!({ "units": u, "kept": kept }))
}
+2 -1
View File
@@ -393,8 +393,9 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("configuration `{r}` would fail to rebuild: {}", new_errors.join("; "))));
}
let styles = trial.doc.configuration_style_status(&trial.model.state()).map_err(|error| bad(cmd, format!("configuration `{r}`: {error}")))?;
let solved = s.solved_sketches();
s.doc = trial.doc;
s.refresh();
s.settle_value_edit(&solved)?;
let errors: Vec<String> = s.model.results.iter().filter_map(|x| x.error.clone()).collect();
let mut out = show(s, p)?;
out["errors"] = json!(errors);
+1 -2
View File
@@ -19,8 +19,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.delete", "De
/// Run another command's body inside this one (so the whole delete is one undo step).
fn sub(s: &mut Session, id: &str, p: Value) -> Result<Value> {
let spec = super::find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
(spec.enabled)(s).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
let r = (spec.run)(s, &p)?;
let r = spec.run_nested(s, &p)?;
s.refresh();
Ok(r)
}
+24 -18
View File
@@ -8,8 +8,8 @@ use crate::params::{bad, bool_, num, str_, string_list};
use crate::{EngineError, Result, Sel, Session, Snapshot};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo().doc_only(),
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(),
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
.noundo()
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
@@ -51,28 +51,35 @@ pub static COMMANDS: &[CommandSpec] = &[
fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) };
let label = prev.label.clone();
s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
s.doc = prev.doc;
s.active_sketch = prev.active_sketch;
s.pending_moves = prev.pending_moves;
s.selection.clear();
s.refresh();
let now = go_to(s, prev);
let label = now.label.clone();
s.redo.push(now);
Ok(json!({"undone": label}))
}
fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) };
let label = next.label.clone();
s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
s.doc = next.doc;
s.active_sketch = next.active_sketch;
s.pending_moves = next.pending_moves;
s.selection.clear();
s.refresh();
let now = go_to(s, next);
let label = now.label.clone();
s.undo.push(now);
Ok(json!({"redone": label}))
}
/// Put the session at `snap`; returns where it was, under the same label.
fn go_to(s: &mut Session, snap: Snapshot) -> Snapshot {
let was = Snapshot { label: snap.label, doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() };
let same = std::sync::Arc::ptr_eq(&s.doc, &snap.doc);
s.doc = snap.doc;
s.active_sketch = snap.active_sketch;
s.pending_moves = snap.pending_moves;
s.selection.clear();
// A new document is evaluated by `execute`.
if same {
s.refresh();
}
was
}
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"checkpoint": s.checkpoint()}))
}
@@ -477,8 +484,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
}
trial.doc_mut().marker = Some(idx + 1);
trial.refresh();
(spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
let r = (spec.run)(&mut trial, &params)?;
let r = spec.run_nested(&mut trial, &params)?;
let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
let mut new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
if let (FeatureKind::Emboss { face: Some(before), .. }, FeatureKind::Emboss { face: Some(after), .. }) = (&old.kind, &mut new.kind)
+44
View File
@@ -185,3 +185,47 @@ fn repeated_source_face_profile_requires_and_uses_the_exact_active_occurrence()
assert!(!state.bodies.iter().any(|body| body.body.contains(Vec3::new(x, 5.0, 16.0))));
}
}
/// #307: four pairs of holes touching at one line each (a ring pattern against a grid). Every
/// contact is the same up to rounding, so every hole is one face: 33 holes and 6 planes.
#[test]
fn tangent_hole_contacts_split_no_faces() {
let recipe: Value = serde_json::from_str(RECIPE_307).unwrap();
let mut s = Session::default();
for c in recipe["commands"].as_array().unwrap() {
s.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
}
let m = s.execute("inspect.measure", &json!({})).unwrap();
let body = &m["bodies"][0];
assert_eq!(body["face_types"], json!({"cylinder": 33, "plane": 6}), "{m}");
assert_eq!(body["faces"], 39, "{m}");
}
const RECIPE_307: &str = r#"{"commands": [{"command": "sketch.create", "params": {"name": "Plate_sketch", "plane": "XY"}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l1"], "points": [[-50, -50], [50, -50]]}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l2"], "points": ["l1.end", [50, 50]]}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l3"], "points": ["l2.end", [-50, 50]]}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l4"], "points": ["l3.end", "l1.start"]}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"body_names": ["Plate"], "direction": "positive", "distance": 5, "name": "Plate", "operation": "new", "profiles": [{"loops": [{"curves": ["l1", "l2", "l3", "l4"], "outer": true}], "sketch": "Plate_sketch"}], "sketch": "Plate_sketch"}}, {"command": "sketch.create", "params": {"name": "HoleA", "plane": "XY"}}, {"command": "sketch.circle.center", "params": {"center": [-30, -30], "construction": false, "id": "c1", "radius": 5}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"body_names": ["Plate"], "direction": "positive", "name": "HoleA", "operation": "cut", "profiles": [{"loops": [{"curves": ["c1"], "outer": true}], "sketch": "HoleA"}], "sketch": "HoleA", "through_all": true}}, {"command": "solid.pattern.rectangular", "params": {"count1": 5, "count2": 5, "dir1": [1, 0, 0], "dir2": [0, 1, 0], "features": ["HoleA"], "name": "GridPattern", "spacing1": 15, "spacing2": 15}}, {"command": "sketch.create", "params": {"name": "HoleB", "plane": "XY"}}, {"command": "sketch.circle.center", "params": {"center": [25, 0], "construction": false, "id": "c1", "radius": 5}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"body_names": ["Plate"], "direction": "positive", "name": "HoleB", "operation": "cut", "profiles": [{"loops": [{"curves": ["c1"], "outer": true}], "sketch": "HoleB"}], "sketch": "HoleB", "through_all": true}}, {"command": "solid.pattern.circular", "params": {"angle": 360, "axis": {"dir": [0, 0, 1], "origin": [0, 0, 0]}, "count": 8, "features": ["HoleB"], "name": "RingPattern"}}]}"#;
/// #308: a blind cut ending inside a cylinder leaves thin strips of the cylinder wall beside
/// the cut; they must count as cylinder, not as a sphere or a freeform face.
#[test]
fn blind_cut_inside_a_cylinder_keeps_only_planes_and_cylinders() {
let mut s = Session::default();
let side = json!({"origin":[0,0,0],"x_dir":[0,0,-1],"y_dir":[0,1,0]});
s.execute("solid.box", &json!({"length":40,"width":30,"height":3,"body_name":"Hinge"})).unwrap();
for (name, radius, operation, extent) in
[("Knuckle", 4, "join", json!({"distance":40})), ("Pin", 2, "cut", json!({"distance":100,"direction":"symmetric"}))]
{
s.execute("sketch.create", &json!({"name":name,"plane":side})).unwrap();
s.execute("sketch.circle.center", &json!({"center":[-4,33],"radius":radius})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
let mut p = json!({"sketch":name,"operation":operation,"body_names":["Hinge"]});
p.as_object_mut().unwrap().extend(extent.as_object().unwrap().clone());
s.execute("solid.extrude", &p).unwrap();
}
s.execute("sketch.create", &json!({"name":"Gap","plane":"XY"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0":[12,29],"p1":[28,40]})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.extrude", &json!({"sketch":"Gap","operation":"cut","distance":3.3,"body_names":["Hinge"]})).unwrap();
let m = s.execute("inspect.measure", &json!({})).unwrap();
let body = &m["bodies"][0];
assert_eq!(body["face_types"], json!({"cylinder": 2, "plane": 12}), "{m}");
assert_eq!(body["faces"], 14, "{m}");
}
+9 -20
View File
@@ -453,26 +453,15 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
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();
// `body_name`) has the rest numbered after the first by the evaluator: `Ribs`, `Ribs (2)`,
// `Ribs (3)`… (#266, #373). They are stored on the feature, so recomputes and saved designs
// keep them even when the profiles are later picked in another order.
if names.first().is_some_and(|n| !n.trim().is_empty()) {
let made: Vec<String> = s.model.state().bodies.iter().filter(|b| b.feature == id).map(|b| b.name.clone()).collect();
if made.len() > names.len()
&& let Some(f) = s.doc_mut().feature_mut(id)
{
f.body_names = made;
}
}
let r = s.model.result(id).cloned();
+1 -1
View File
@@ -34,7 +34,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("appearance.list", "List Appearances", appearance_list)
.noundo()
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
CommandSpec::new("material.list", "List Materials", material_list).noundo(),
CommandSpec::new("material.list", "List Materials", material_list).noundo().doc_only(),
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
];
+40 -1
View File
@@ -57,6 +57,10 @@ use serde_json::Value;
use crate::{Result, Session};
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
/// A command's form for one frame of an interactive gesture: its result and the sketch it
/// changed, for [`Session::execute`] to show without evaluating the rest of the model (`None`:
/// evaluate as usual).
pub type LiveRun = fn(&mut Session, &Value) -> Result<(Value, Option<crate::LiveEdit>)>;
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
pub struct CommandSpec {
@@ -74,11 +78,40 @@ pub struct CommandSpec {
pub enabled: Enabled,
pub run: Run,
pub undoable: bool,
/// What runs instead of `run` when the command is given `live: true` (a frame of an
/// interactive gesture, see [`Session::execute`]).
pub live: Option<LiveRun>,
/// Reads the evaluated model (the default). A command that reads only the document and the
/// session's history runs on a model left stale by a live gesture without evaluating it first.
pub reads_model: bool,
}
impl CommandSpec {
pub const fn new(id: &'static str, label: &'static str, run: Run) -> Self {
CommandSpec { id, label, tab: "", panel: "", icon: "", shortcut: None, params: "", enabled: always, run, undoable: true }
CommandSpec {
id,
label,
tab: "",
panel: "",
icon: "",
shortcut: None,
params: "",
enabled: always,
run,
undoable: true,
live: None,
reads_model: true,
}
}
/// Takes `live: true`, run by `run`.
pub const fn live(mut self, run: LiveRun) -> Self {
self.live = Some(run);
self
}
/// Reads only the document (and history), never the evaluated model.
pub const fn doc_only(mut self) -> Self {
self.reads_model = false;
self
}
pub const fn at(mut self, tab: &'static str, panel: &'static str) -> Self {
self.tab = tab;
@@ -105,6 +138,12 @@ impl CommandSpec {
self.undoable = false;
self
}
/// Run inside another command: when it is enabled, its `run` (the caller's command owns the
/// undo step and the evaluation).
pub(crate) fn run_nested(&self, s: &mut Session, p: &Value) -> Result<Value> {
(self.enabled)(s).map_err(|why| crate::EngineError::Disabled(self.id.to_string(), why))?;
(self.run)(s, p)
}
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
CommandInfo {
+1 -1
View File
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("joint")
.params("occurrences: [ids or names] (two or more); name?"),
CommandSpec::new("contact.edit", "Edit Contact Set", edit_set).params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"),
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().params("→ sets, enabled, all"),
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().doc_only().params("→ sets, enabled, all"),
CommandSpec::new("contact.check", "Check Contact", check).noundo().params("→ pairs in contact sets that pass through each other now"),
CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params(
"name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \
+12 -3
View File
@@ -15,7 +15,7 @@ pub static COMMANDS: &[CommandSpec] = &[
favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \
restored: features whose earlier result for the same inputs was reused (e.g. a value set back)",
),
CommandSpec::new("parameters.list", "List Parameters", list).noundo().params(
CommandSpec::new("parameters.list", "List Parameters", list).noundo().doc_only().params(
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
source, feature/input, comment, favorite, uses, used_by, error) and cycles",
),
@@ -38,9 +38,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("text: CSV (header with name, expression, unit?, comment?) or JSON [{name, expression, unit?, comment?}] | path; all or nothing"),
CommandSpec::new("expr.evaluate", "Evaluate Expression", evaluate)
.noundo()
.doc_only()
.params("expression, kind?: length|angle|unitless (or unit?: mm|in|deg…) → ok, value (mm/rad), display_value, references, error"),
CommandSpec::new("parameters.complete", "Complete Expression", complete)
.noundo()
.doc_only()
.params("prefix?: the text before the cursor → word, suggestions [{text, kind: parameter|function|unit|constant, detail}]"),
];
@@ -67,8 +69,10 @@ fn known(s: &Session, name: &str) -> bool {
fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parameters.change";
let solved = s.solved_sketches();
if let Some(n) = str_(p, "delete") {
s.doc_mut().remove_param(n.trim())?;
s.settle_value_edit(&solved)?;
return Ok(json!({"deleted": n.trim()}));
}
let mut name = param_name(cmd, p)?.to_string();
@@ -101,7 +105,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = bool_(p, "favorite") {
s.doc_mut().set_favorite(&name, f)?;
}
s.refresh();
s.settle_value_edit(&solved)?;
let (vals, _) = s.doc.param_values();
out["name"] = json!(name);
out["expression"] = json!(e);
@@ -140,7 +144,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parameters.add";
let name = param_name(cmd, p)?.to_string();
let e = expr_arg(p, "expression").or_else(|| expr_arg(p, "value")).ok_or_else(|| bad(cmd, "`expression` must be a number or text"))?;
let solved = s.solved_sketches();
s.doc_mut().add_user_param(&name, &e, str_(p, "unit"), str_(p, "comment"))?;
s.settle_value_edit(&solved)?;
if let Some(f) = bool_(p, "favorite") {
s.doc_mut().set_favorite(&name, f)?;
}
@@ -160,7 +166,9 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = param_name("parameters.delete", p)?.to_string();
let solved = s.solved_sketches();
s.doc_mut().remove_param(&name)?;
s.settle_value_edit(&solved)?;
Ok(json!({"deleted": name}))
}
@@ -238,8 +246,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => return Err(bad(cmd, "give `text` or `path`")),
};
let solved = s.solved_sketches();
let names = s.doc_mut().import_params(&text)?;
s.refresh();
s.settle_value_edit(&solved)?;
Ok(json!({"imported": names, "errors": failing(s)}))
}
+305 -4
View File
@@ -67,9 +67,11 @@ fn width_follows_an_expression() {
assert!(e.contains("expected a length, got an angle"), "{e}");
}
/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the
/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken
/// for "nothing changed". timeline.edit reports the same.
/// Setting a value back reuses the earlier results and says so: the features it restored are
/// reported next to `recomputed`, so `recomputed: 0` is not mistaken for "nothing changed". A
/// sketch dimension set back solves the sketch again from its current state (value edits
/// continue from the last solved state); the features after it see the same geometry and are
/// restored.
#[test]
fn setting_a_parameter_back_reports_restored_features() {
let mut s = Session::default();
@@ -77,7 +79,7 @@ fn setting_a_parameter_back_reports_restored_features() {
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}");
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}");
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(1)), "{r}");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}");
@@ -243,3 +245,302 @@ fn csv_and_json_round_trip() {
assert_eq!(v.doc.param("len").map(|p| p.expr.as_str()), Some("80 mm"));
let _ = std::fs::remove_dir_all(&dir);
}
/// Two triangles joined by three links (a b c, d e f; links a–d, b–e, c–f), a on the origin,
/// a–b horizontal, every distance a dimension: the construction leaves it to the iteration.
/// Returns the session, the sketch feature and the nine dimensions' parameters.
fn linkage_session() -> (Session, u64, Vec<String>) {
let layout = [[0.0, 0.0], [40.0, 0.0], [18.0, 30.0], [8.0, 55.0], [52.0, 48.0], [30.0, 80.0]];
let pairs = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)];
let ids = ["a", "b", "c", "d", "e", "f"];
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Linkage"}));
for (id, at) in ids.iter().zip(layout) {
run(&mut s, "sketch.point", json!({"point": at, "id": id}));
}
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"points": ["a", "b"], "mode": "horizontal"}));
let params = pairs
.iter()
.map(|&(i, j)| run(&mut s, "sketch.dimension", json!({"entities": [ids[i], ids[j]]}))["param"].as_str().unwrap_or_default().to_string())
.collect();
let feature = s.doc.find_feature("Linkage").map(|f| f.id).unwrap_or_default();
(s, feature, params)
}
/// Edited values the iteration cannot reach in one jump from the layout.
const REACHED_BY_CONTINUATION: [f64; 9] = [
49.75798473660107,
44.249179252138724,
28.66332553713371,
49.725624597008775,
45.81086166277159,
35.887660702567544,
45.64700450204954,
50.756441510188495,
63.72063313963373,
];
/// Turn of both triangles in the evaluated sketch.
fn linkage_turns(s: &Session, feature: u64) -> [bool; 2] {
let state = s.model.state();
let sk = &state.sketch(feature).unwrap().sketch;
let p = |id: &str| sk.point(sk.resolve_point(id).unwrap()).unwrap();
[(p("b") - p("a")).cross(p("c") - p("a")) > 0.0, (p("e") - p("d")).cross(p("f") - p("d")) > 0.0]
}
/// Set the parameters in the document only (as an edit that does not store the sketch).
fn set_params(s: &mut Session, params: &[String], values: &[f64]) {
let changes: Vec<(String, String)> = params.iter().zip(values).map(|(name, value)| (name.clone(), format!("{value} mm"))).collect();
s.doc_mut().change_params_atomic(&changes).unwrap();
}
#[test]
fn dimension_edits_of_a_triconnected_linkage_continue_and_store_the_solved_sketch() {
// The edit steps to the new values from the stored sketch, keeping both triangles' turn,
// and stores what it reached: evaluating the document afresh needs no continuation.
let (mut s, feature, params) = linkage_session();
run(&mut s, "sketch.finish", json!({}));
let before = linkage_turns(&s, feature);
// All nine at once: one at a time, some intermediate values have no solution.
let batch: Vec<_> = params.iter().zip(REACHED_BY_CONTINUATION).map(|(n, v)| json!({"name": n, "expression": format!("{v} mm")})).collect();
run(&mut s, "parameters.import", json!({"text": serde_json::to_string(&batch).unwrap()}));
let solved = s.model.state().sketch(feature).unwrap().report.clone();
assert!(solved.ok(), "{solved:?}");
assert_eq!(linkage_turns(&s, feature), before);
let mut fresh = solvecraft_doc::Model::default();
fresh.evaluate(&s.doc);
let again = fresh.state().sketch(feature).unwrap().report.clone();
assert!(again.ok() && again.continuations == 0, "{again:?}");
// The kept results are what evaluating the stored document gives.
let (kept, afresh) = (s.model.state(), fresh.state());
let pairs = kept.sketch(feature).unwrap().sketch.points.iter().zip(&afresh.sketch(feature).unwrap().sketch.points);
assert!(pairs.into_iter().all(|(a, b)| a.pos.dist(b.pos) < 1e-9));
// In one go (one evaluation from the layout) it needs continuation.
let (mut t, feature, params) = linkage_session();
run(&mut t, "sketch.finish", json!({}));
set_params(&mut t, &params, &REACHED_BY_CONTINUATION);
t.refresh();
let once = t.model.state().sketch(feature).unwrap().report.clone();
assert!(once.ok() && once.continuations > 0, "{once:?}");
}
#[test]
fn extending_after_a_parameter_edit_continues_from_the_stored_sketch() {
let (mut s, feature, params) = linkage_session();
run(&mut s, "sketch.line", json!({"points": [[100, 0], [110, 0]], "ids": ["ext"]}));
run(&mut s, "sketch.line", json!({"points": [[130, -20], [130, 20]], "ids": ["wall"]}));
let before = linkage_turns(&s, feature);
set_params(&mut s, &params, &REACHED_BY_CONTINUATION);
run(&mut s, "sketch.extend", json!({"curve": "ext", "at": [110, 0]}));
let solved = s.model.state().sketch(feature).unwrap().report.clone();
assert!(solved.ok(), "{solved:?}");
assert_eq!(linkage_turns(&s, feature), before);
}
#[test]
fn a_failed_edit_keeps_the_stored_sketch_solved_for_its_values() {
// Parameters no geometry satisfies, then an edit that does not insist on a solution: the
// stored dimensions keep the values its geometry is solved for.
let (mut s, feature, params) = linkage_session();
let mut values = REACHED_BY_CONTINUATION;
values[7] = 500.0;
set_params(&mut s, &params, &values);
run(&mut s, "sketch.point", json!({"point": [200, 200]}));
let stored = s.doc.sketch(feature).unwrap();
for c in stored.constraints.iter().filter(|c| c.param.is_some()) {
let measured = solvecraft_sketch::measure_dimension(stored, &c.kind).unwrap();
assert!((measured - c.kind.value().unwrap()).abs() < 1e-6, "{}: {measured} vs {:?}", c.id, c.kind.value());
}
}
/// Values the linkage reaches only with its top triangle mirrored.
const FLIPPING: [f64; 9] = [
26.483003374917082,
90.0253808040292,
84.67215428223084,
54.580784880976275,
68.12871394439186,
30.98508537669166,
116.94895853350752,
103.75535113133466,
32.83103173640168,
];
/// All nine parameters set by one command.
fn import_values(params: &[String], values: &[f64]) -> Value {
let batch: Vec<_> = params.iter().zip(values).map(|(n, v)| json!({"name": n, "expression": format!("{v} mm")})).collect();
json!({"text": serde_json::to_string(&batch).unwrap()})
}
#[test]
fn an_edit_that_would_flip_the_sketch_is_refused_and_named() {
let (mut s, feature, params) = linkage_session();
run(&mut s, "sketch.finish", json!({}));
let (points, before) = (stored_points(&s, feature), params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>());
let why = err(&mut s, "parameters.import", import_values(&params, &FLIPPING));
assert!(why.contains("Linkage") && why.contains("flip the sketch over"), "{why}");
assert_eq!(params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>(), before);
assert_eq!(stored_points(&s, feature), points);
assert!(s.model.state().sketch(feature).unwrap().report.keeps_orientation());
}
#[test]
fn a_design_stored_with_other_values_still_rebuilds() {
// A design whose stored sketch is not solved for its parameters (saved before value edits
// stored their solve): evaluating it never refuses. The flipped solve is kept, with a
// warning, the flipped constraints listed as conflicts, and the solid built from it.
let (mut s, feature, params) = linkage_session();
for line in [[[8.0, 55.0], [52.0, 48.0]], [[52.0, 48.0], [30.0, 80.0]], [[30.0, 80.0], [8.0, 55.0]]] {
run(&mut s, "sketch.line", json!({"points": line}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Linkage", "distance": 5}));
set_params(&mut s, &params, &FLIPPING);
let s = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
let state = s.model.state();
let solved = state.sketch(feature).unwrap();
assert!(solved.report.ok() && !solved.report.folded.is_empty(), "{:?}", solved.report);
let warning = s.model.result(feature).and_then(|r| r.warning.clone()).unwrap_or_default();
assert!(warning.contains("flip the sketch over") && solved.report.folded.iter().all(|id| warning.contains(id.as_str())), "{warning}");
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results.iter().map(|r| &r.error).collect::<Vec<_>>());
assert_eq!(state.bodies.len(), 1);
// Listed as conflicts wherever conflicts are shown.
let mut s = s;
s.active_sketch = Some(feature);
let si = run(&mut s, "sketch.inspect", json!({}));
let glyphs = run(&mut s, "sketch.glyphs", json!({}));
for id in &solved.report.folded {
assert!(si["failing"].as_array().unwrap().contains(&json!(id)), "{si}");
assert!(glyphs["conflicts"].as_array().unwrap().contains(&json!(id)), "{glyphs}");
}
}
#[test]
fn an_edit_that_breaks_a_projecting_sketch_downstream_applies_with_a_warning() {
// `w` places a guide line; a second sketch projects it and ends a 12 mm line on it. Moving
// the guide out of reach is the guide sketch's edit, not a value edit of the projecting
// sketch: it applies, and the projecting sketch carries a warning.
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "w", "expression": "10 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Guide"}));
run(&mut s, "sketch.line", json!({"points": [[10, -30], [10, 30]], "ids": ["guide"], "connect": false}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "guide", "mode": "vertical"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "guide.start"], "type": "horizontal", "value": "w"}));
run(&mut s, "sketch.finish", json!({}));
let id = run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Reach"}))["sketch"].as_u64().unwrap();
let projected = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "Guide", "curve": "guide"}]}))["links"][0]["curves"][0]
.as_str()
.unwrap()
.to_string();
let line = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, -(44.0_f64).sqrt()]]}))["curves"][0].as_str().unwrap().to_string();
run(&mut s, "sketch.dimension", json!({"entities": [line], "value": 12}));
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{line}.end"), "b": projected}));
run(&mut s, "sketch.finish", json!({}));
assert!(s.model.state().sketch(id).unwrap().report.ok());
let r = run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
assert_eq!(r["value"], json!(20.0), "the edit applies: {r}");
assert!(!s.model.state().sketch(id).unwrap().report.ok());
assert!(s.model.result(id).and_then(|r| r.warning.clone()).is_some_and(|w| w.contains("conflict")));
}
#[test]
fn a_failed_auto_constrain_keeps_the_stored_sketch_consistent() {
// Auto-constrain works on the current values: reached by continuation, its result is
// stored solved for them; values no geometry satisfies refuse it and change nothing.
let consistent = |s: &Session, feature: u64| {
let stored = s.doc.sketch(feature).unwrap();
stored.constraints.iter().filter(|c| c.param.is_some()).all(|c| {
let measured = solvecraft_sketch::measure_dimension(stored, &c.kind).unwrap();
(measured - c.kind.value().unwrap()).abs() < 1e-6
})
};
let (mut s, feature, params) = linkage_session();
let mut values = REACHED_BY_CONTINUATION;
values[7] = 500.0;
set_params(&mut s, &params, &values);
let before = s.doc.sketch(feature).unwrap().clone();
let why = err(&mut s, "sketch.auto_constrain", json!({}));
assert!(why.contains("conflict"), "{why}");
assert_eq!(*s.doc.sketch(feature).unwrap(), before);
assert!(consistent(&s, feature));
set_params(&mut s, &params, &REACHED_BY_CONTINUATION);
run(&mut s, "sketch.auto_constrain", json!({}));
assert!(consistent(&s, feature));
let stored = s.doc.sketch(feature).unwrap();
for (c, v) in stored.constraints.iter().filter(|c| c.param.is_some()).zip(REACHED_BY_CONTINUATION) {
assert!((c.kind.value().unwrap() - v).abs() < 1e-9, "{}", c.id);
}
}
/// The stored sketch's points, by id.
fn stored_points(s: &Session, feature: u64) -> Vec<solvecraft_geom::Vec2> {
s.doc.sketch(feature).unwrap().points.iter().map(|p| p.pos).collect()
}
#[test]
fn an_unsolvable_value_edit_is_refused_and_changes_nothing() {
let (mut s, feature, params) = linkage_session();
run(&mut s, "sketch.finish", json!({}));
let (expr, points) = (s.doc.param(&params[7]).map(|p| p.expr.clone()), stored_points(&s, feature));
let why = err(&mut s, "parameters.change", json!({"name": params[7], "expression": "500 mm"}));
assert!(why.contains("Linkage") && why.contains("conflict"), "{why}");
assert_eq!(s.doc.param(&params[7]).map(|p| p.expr.clone()), expr);
assert_eq!(stored_points(&s, feature), points);
assert!(s.model.state().sketch(feature).unwrap().report.ok());
// A batch with one such value: none of it.
let before: Vec<_> = params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect();
let batch: Vec<_> = params
.iter()
.enumerate()
.map(|(i, n)| json!({"name": n, "expression": if i == 7 { "500 mm".to_string() } else { format!("{} mm", REACHED_BY_CONTINUATION[i]) }}))
.collect();
let why = err(&mut s, "parameters.import", json!({"text": serde_json::to_string(&batch).unwrap()}));
assert!(why.contains("Linkage"), "{why}");
assert_eq!(params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>(), before);
assert_eq!(stored_points(&s, feature), points);
}
#[test]
fn value_edits_continue_from_the_last_solved_state() {
// A → B → C, each a small edit solved in one jump; C is solved from B (the stored state),
// and setting both values back reproduces A (the linkage is fully constrained).
let close = |a: &[solvecraft_geom::Vec2], b: &[solvecraft_geom::Vec2], tol: f64| a.iter().zip(b).all(|(p, q)| p.dist(*q) < tol);
let (mut s, feature, params) = linkage_session();
run(&mut s, "sketch.finish", json!({}));
let a = stored_points(&s, feature);
let (p7, p8) = (s.doc.param(&params[7]).unwrap().expr.clone(), s.doc.param(&params[8]).unwrap().expr.clone());
run(&mut s, "parameters.change", json!({"name": params[8], "expression": "53 mm"}));
let b_sketch = s.doc.sketch(feature).unwrap().clone();
let b = stored_points(&s, feature);
assert!(!close(&a, &b, 1e-3) && s.model.state().sketch(feature).unwrap().report.continuations == 0);
run(&mut s, "parameters.change", json!({"name": params[7], "expression": "50 mm"}));
let c = stored_points(&s, feature);
// The same edit solved from B directly.
let mut from_b = b_sketch.clone();
let stored = from_b.driving_values();
let (vals, _) = s.doc.param_values();
s.doc.apply_dimension_values(&vals, &mut from_b).unwrap();
assert!(solvecraft_sketch::solve_after_value_edit(&mut from_b, &stored).ok());
assert!(close(&c, &from_b.points.iter().map(|p| p.pos).collect::<Vec<_>>(), 1e-6) && !close(&c, &b, 1e-3));
run(&mut s, "parameters.change", json!({"name": params[7], "expression": p7}));
run(&mut s, "parameters.change", json!({"name": params[8], "expression": p8}));
assert!(close(&stored_points(&s, feature), &a, 1e-6));
}
#[test]
fn switching_configurations_back_reuses_the_later_features() {
// Rows A and B of `len`: back on A, the sketch solves again from B's state to A's
// geometry, and the extrude after it is restored from the cache.
let mut s = Session::default();
block(&mut s);
run(&mut s, "config.configure", json!({"params": ["len"]}));
run(&mut s, "config.row", json!({"name": "A", "values": {"len": "60 mm"}}));
run(&mut s, "config.row", json!({"name": "B", "values": {"len": "100 mm"}}));
run(&mut s, "config.activate", json!({"row": "B"}));
assert!(rel(volume(&mut s), 40.0 * 50.0 * 10.0) < 1e-9);
let hits = |s: &Session| s.model.cache.lock().map(|c| c.hits).unwrap_or(0);
let before = hits(&s);
let r = run(&mut s, "config.activate", json!({"row": "A"}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
assert!(hits(&s) > before, "{r}");
}
+39
View File
@@ -252,3 +252,42 @@ fn pattern_of_512_hole_cuts_finishes_in_reasonable_time() {
// 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:?}");
}
/// A plate with `n` Ø2 circles drawn in one sketch (16 a row, 6 mm apart) and cut by one extrude:
/// how long the cut took. It runs on its own thread so a hang fails the test instead of CI.
fn sketch_hole_cut(n: usize) -> 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"}));
for i in 0..n {
run(&mut s, "sketch.circle.center", json!({"center": [4 + (i % 16) * 6, 4 + (i / 16) * 6], "diameter": 2}));
}
run(&mut s, "sketch.finish", json!({}));
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let t = Instant::now();
let r =
s.execute("solid.extrude", &json!({"sketch": "H", "distance": "4", "direction": "negative", "operation": "cut", "targets": ["Plate"]}));
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 cut did not finish within 900 s");
let n = n as f64;
let v = v.expect("cut");
// 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}");
took
}
/// Many holes drawn in one sketch and cut by one extrude (#368), the usual way to draw a vent
/// field: 32 took 2 s and 128 took 60 s, though a feature pattern of the same holes is near
/// linear since #261. Linear is 4×. A ratio, not a time (see the 512-hole pattern test).
#[test]
#[ignore = "fails until #368 is fixed"]
fn one_extrude_of_many_sketch_circles_scales_linearly() {
let t32 = sketch_hole_cut(32);
let t128 = sketch_hole_cut(128);
assert!(t128.as_secs_f64() < 8.0 * t32.as_secs_f64() + 2.0, "32 circles took {t32:?}, 128 took {t128:?}");
}
+1 -1
View File
@@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's); active?: bool; delete?: bool",
),
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().params("→ rules with their expressions and values, and the active one"),
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
.at("SHEET METAL", "CREATE")
.icon("flat")
+107
View File
@@ -702,6 +702,45 @@ fn fold_keeps_the_larger_side_either_way() {
}
}
/// A tray shrunk by a parameter change unfolds like the same tray built at that size. The
/// sketch solved again leaves its corners ~1e-13 mm off, and the flat pattern's planar
/// arrangement used to drop a bend zone through a gap that small: "planar region arrangement
/// failed", for W alone or both sizes, at 72° or 90°, while growing the tray worked.
#[test]
fn tray_shrunk_by_a_parameter_unfolds_like_one_built_at_that_size() {
for angle in [72.0, 90.0] {
let tray = |s: &mut Session, w: f64, h: f64| {
run(s, "parameters.add", json!({"name": "W", "expression": format!("{w} mm")}));
run(s, "parameters.add", json!({"name": "H", "expression": format!("{h} mm")}));
run(s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Tray"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [w, h]}));
run(s, "sketch.constraint.fix", json!({"entities": ["p1"]}));
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "W"}));
run(s, "sketch.dimension", json!({"entities": ["l2"], "value": "H"}));
run(s, "sketch.finish", json!({}));
run(s, "sheet.flange", json!({"type": "base", "sketch": "Tray"}));
let edges = json!([[w / 2.0, 0, 2], [w, h / 2.0, 2], [w / 2.0, h, 2], [0, h / 2.0, 2]]);
run(s, "sheet.flange", json!({"type": "edge", "edges": edges, "height": 38.6, "angle": angle}));
run(s, "sheet.unfold", json!({}));
};
let mut direct = Session::default();
tray(&mut direct, 400.0, 300.0);
let want = flat(&mut direct)["flat_size_mm"].clone();
let mut shrunk = Session::default();
tray(&mut shrunk, 2000.0, 2500.0);
for (name, expr) in [("W", "400 mm"), ("H", "300 mm")] {
let r = run(&mut shrunk, "parameters.change", json!({"name": name, "expression": expr}));
assert_eq!(r["errors"], json!([]), "{angle}° {name} = {expr}: {r}");
}
let got = flat(&mut shrunk)["flat_size_mm"].clone();
for k in 0..3 {
assert!((got[k].as_f64().unwrap() - want[k].as_f64().unwrap()).abs() < 1e-9, "{angle}°: {got} {want}");
}
assert!(rel(volume(&mut shrunk), volume(&mut direct)) < 1e-9, "{angle}°");
}
}
#[test]
fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() {
let mut s = Session::default();
@@ -1646,3 +1685,71 @@ fn fold_at_a_tab_root_names_the_real_cause() {
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]}));
}
/// #296: two bends meeting at an open corner relief touch only along an edge, so the folded
/// material the machining guard checks must not be sewn into one solid.
#[test]
fn hole_beside_two_adjacent_edge_flanges_cuts_and_bends_stay_guarded() {
let build = |s: &mut Session| {
base(s, 80.0, 40.0);
run(s, "sheet.flange", json!({"edges":[[40.0, 0.0, 2.5]],"height":30,"angle":90}));
run(s, "sheet.flange", json!({"edges":[[80.0, 22.5, 2.5]],"height":15,"angle":90}));
};
let hole = |s: &mut Session, name: &str, centre: [f64; 2]| {
run(s, "sketch.create", json!({"plane":"XY","name":name}));
run(s, "sketch.circle.center", json!({"center":centre,"radius":3}));
run(s, "sketch.finish", json!({}));
s.execute("solid.extrude", &json!({"sketch":name,"through_all":true,"operation":"cut"}))
};
let mut s = Session::default();
build(&mut s);
let before = volume(&mut s);
assert!((before - 14057.2).abs() < 0.1, "{before}");
if let Err(e) = hole(&mut s, "Hole", [25.0, 20.0]) {
panic!("hole beside two flanges: {e}");
}
let after = volume(&mut s);
assert!((before - after - 9.0 * PI * 2.5).abs() < 0.05, "{before} -> {after}");
let mut bend = Session::default();
build(&mut bend);
let error = hole(&mut bend, "Bend", [78.5, 20.0]).unwrap_err().to_string();
assert!(error.contains("bend"), "{error}");
}
/// A fold whose stored `fixed` point ends up off the sheet after a parameter change keeps working:
/// the point still says which side stays (#370). Only a new pick off the sheet is refused (#263).
#[test]
#[ignore = "fails until #370 is fixed"]
fn fold_keeps_working_when_a_parameter_moves_the_sheet_off_its_fixed_point() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "w", "expression": "100 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Blank"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "w"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Blank"}));
run(&mut s, "sheet.fold", json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": [90, 30, 2.5], "name": "Fold1"}));
run(&mut s, "parameters.change", json!({"name": "w", "expression": "80 mm"}));
let fold = run(&mut s, "timeline.inspect", json!({"feature": "Fold1"}));
assert!(fold["error"].is_null(), "the fold fails after the width change: {}", fold["error"]);
// The +x side (where the point is) stays flat out to the new width; the -x side stands up.
let bb = &run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"];
assert!((bb["max"][0].as_f64().unwrap_or(0.0) - 80.0).abs() < 1e-6, "{bb}");
assert!(bb["max"][2].as_f64().unwrap_or(0.0) > 30.0, "{bb}");
}
/// `fixed` 20 mm above a 2.5 mm sheet touches no material: refuse it or say so, like a point in a
/// cut-out (#371).
#[test]
#[ignore = "fails until #371 is fixed"]
fn fold_fixed_point_above_the_sheet_is_not_silently_accepted() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
match s.execute("sheet.fold", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": [90, 30, 20]})) {
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.to_string().contains("fixed")));
assert!(warned, "fixed [90, 30, 20] is 17.5 mm above the sheet, yet the fold succeeded silently: {r}");
}
}
}
+237 -115
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind};
use solvecraft_geom::{Plane, Vec2, Vec3};
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
use solvecraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_with};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3};
@@ -11,9 +11,12 @@ use crate::{EngineError, Result, Session};
#[path = "sketch_dimensioned.rs"]
mod dimensioned;
#[path = "sketch_drag.rs"]
mod drag;
#[path = "sketch_face.rs"]
pub(super) mod face;
use dimensioned::draw as draw_dimensioned;
use drag::drag_entity;
/// The `connect` parameter every drawing command takes (see `connect_points`).
macro_rules! connect_doc {
@@ -103,7 +106,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; 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)",
"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 points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. 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")
@@ -170,8 +173,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.enabled(in_sketch)
.params("entities: [curve, point or constraint ids]"),
CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).enabled(in_sketch).params("point: ref, to: [x,y]"),
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).params(
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y]: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can",
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).live(drag_live).params(
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can and keeps its solution branch (a long move is followed in short steps, each solved with the entity held; a step that cannot be solved that way is approached as far as it can be and the next ones tried; when none past it solves, the last one is left to the sketch's ordinary solve, which may pull it back or leave the sketch unsolved; a step that would sweep an arc through its start is undone and the drag ends there). Returns `reached`: how much of the way from `from` to `to` the geometry followed (0..1), and `at`: where the grabbed spot went (`from` moved as the dragged points did), the `from` of the next move. `live: true` (each frame of an interactive drag) solves the sketch once and leaves the features after it as they were (stale) until `sketch.drag_end` or any command that reads the model; `dof` is then the one from before the drag",
),
CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).enabled(in_sketch).doc_only().params(
"constraint?: {command: a sketch.constraint.* id, params}: ends a drag made of `sketch.drag` `live: true` frames: adds the constraint it let go on (not kept if it over-constrains the sketch), then evaluates the whole model once; returns the constraint's result and the sketch's solved state and degrees of freedom",
),
CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text)
.enabled(in_sketch)
@@ -219,25 +225,143 @@ pub(super) fn edit<T>(
f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result<T>,
) -> Result<(T, Value)> {
let id = target_sketch(s, p, cmd)?;
let (out, rep) = edit_solved(s, id, strict, |sk, doc| Ok((f(sk, doc)?, Left::Unsolved)))?;
Ok((out, rep.map(|rep| report_json(&rep)).unwrap_or(Value::Null)))
}
/// A solve's outcome as commands return it.
pub(super) fn report_json(rep: &SolveReport) -> Value {
json!({"solved": rep.keeps_orientation(), "dof": rep.dof})
}
/// What an [`edit_solved`] step leaves for the edit's own solve.
pub(super) enum Left {
/// The sketch, solved from where it is.
Unsolved,
/// The sketch, solved keeping the orientation of these positions (the last solved ones,
/// before a drag left a step placed but unsolved).
MovedFrom(Vec<Vec2>),
/// The sketch, solved with the dimensions' values (from the parameters) applied.
Solved,
}
impl Left {
/// Solved again by the edit even when already solved (for its report).
pub(super) fn solve_again(self) -> Left {
match self {
Left::Solved => Left::Unsolved,
left => left,
}
}
}
/// `edit` of sketch `id` for a step that may solve the sketch itself: when it returns
/// [`Left::Solved`] (and offsets need not be recomputed) the sketch is not solved again and the
/// report is `None`.
pub(super) fn edit_solved<T>(
s: &mut Session,
id: u64,
strict: bool,
f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result<(T, Left)>,
) -> Result<(T, Option<SolveReport>)> {
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
super::sketch_project::refresh(s, &doc, id, &mut sk);
let out = f(&mut sk, &mut doc)?;
let (vals, _) = doc.param_values();
doc.apply_dimension_values(&vals, &mut sk)?;
sk.refresh_offsets()?;
let mut rep = solve(&mut sk);
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
sk.refresh_offsets()?;
rep = solve(&mut sk);
}
if strict && !rep.ok() {
return Err(EngineError::Other(format!("that would over-constrain the sketch (conflicts with {})", rep.failing.join(", "))));
}
// The stored sketch is solved for its stored values.
let stored = sk.driving_values();
let (out, left) = f(&mut sk, &mut doc)?;
let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty();
let rep = match left {
// A drag step solved it (held, with the dimensions' values applied).
Left::Solved if !offsets => None,
// A drag left a step placed but unsolved: solve it keeping the orientation of the last
// solved positions. A drag changes no values, so there is nothing to continue.
Left::MovedFrom(reference) => {
doc.apply_dimension_values(&doc.param_values().0, &mut sk)?;
sk.refresh_offsets()?;
let unsolved = sk.clone();
let mut rep = counted_solve(&mut sk, &Hold::default(), Some(&reference), Analysis::Full, None);
if offsets {
sk.refresh_offsets()?;
rep = counted_solve(&mut sk, &Hold::default(), Some(&reference), Analysis::Full, None);
}
Some(settle(strict, rep, &mut sk, unsolved)?)
}
// Solved for the document's values, continuing from the stored solved state.
Left::Solved | Left::Unsolved => {
let (vals, _) = doc.param_values();
sk.refresh_offsets()?;
let unsolved = sk.clone();
#[cfg(test)]
solve_counts::record(Analysis::Full);
let rep = doc.solve_sketch(&vals, &mut sk, &stored)?;
Some(settle(strict, rep, &mut sk, unsolved)?)
}
};
sk.validate_periodic_splines()?;
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
Ok((out, json!({"solved": rep.ok(), "dof": rep.dof})))
Ok((out, rep))
}
/// An edit's solve outcome: with `strict`, a solve that fails or turns the sketch over is an
/// error; otherwise such a sketch is stored as the edit left it before the solve, its values
/// and geometry together, so the stored sketch stays solved for its stored values.
fn settle(strict: bool, rep: SolveReport, sk: &mut Sketch, unsolved: Sketch) -> Result<SolveReport> {
if strict && !rep.keeps_orientation() {
return Err(EngineError::Other(rep.failure(|f| format!("that would over-constrain the sketch (conflicts with {f})"))));
}
if !rep.keeps_orientation() {
*sk = unsolved;
}
Ok(rep)
}
/// The sketch solves of `edit` and drags, counted in tests (see [`solve_with`]).
pub(super) fn counted_solve(
sk: &mut Sketch,
hold: &Hold,
reference: Option<&[Vec2]>,
analysis: Analysis,
budget: Option<&mut Budget>,
) -> SolveReport {
#[cfg(test)]
solve_counts::record(analysis);
solve_with(sk, hold, reference, analysis, budget)
}
/// Inside an `edit` closure: solve the sketch for the document's parameter values from its
/// stored solved state, as the edit's own solve does, for commands that work on the current
/// geometry.
pub(super) fn solve_current(sk: &mut Sketch, doc: &solvecraft_doc::Document) -> Result<SolveReport> {
let stored = sk.driving_values();
let (values, _) = doc.param_values();
Ok(doc.solve_sketch(&values, sk, &stored)?)
}
/// Solves run on this thread by [`counted_solve`] and the edit's own solve: (all of them, those
/// with a rank analysis).
#[cfg(test)]
pub(super) mod solve_counts {
use std::cell::Cell;
use solvecraft_sketch::Analysis;
thread_local! {
static COUNTS: Cell<(usize, usize)> = const { Cell::new((0, 0)) };
}
pub fn record(analysis: Analysis) {
COUNTS.with(|c| {
let (n, full) = c.get();
c.set((n + 1, full + usize::from(analysis == Analysis::Full)));
});
}
/// The counts since the last call.
pub fn take() -> (usize, usize) {
COUNTS.with(|c| c.replace((0, 0)))
}
}
/// How close (mm) a new point must land to an existing one to be connected to it.
@@ -1165,7 +1289,7 @@ pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent],
driven: false,
text: None,
}));
let ok = solve_holding(&mut trial, &h).ok();
let ok = solve_with(&mut trial, &h, None, Analysis::Full, None).ok();
trial.constraints.retain(|c| c.id != TEMP);
if ok && !collapsed(sk, &trial) {
*sk = trial;
@@ -1240,7 +1364,7 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
&& let Some(c) = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a)))
{
let rep = solve(&mut sk.clone());
return Ok(json!({"constraint": c.id, "existing": true, "sketch": {"solved": rep.ok(), "dof": rep.dof}}));
return Ok(json!({"constraint": c.id, "existing": true, "sketch": report_json(&rep)}));
}
constrain(s, p, cmd, |sk| {
if let Some((a, b)) = line_points {
@@ -1486,7 +1610,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
}
((None, Some(c)), None) if is_line(c) => {
let (a, b) = line_pts(sk, c).unwrap_or_default();
match ty.as_str() {
match auto_orientation(&ty, a, b, text_at) {
"angle" => {
let angle = (b - a).angle();
(ConstraintKind::LineAngle { l: c, value: angle }, angle)
@@ -1523,7 +1647,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
}
((Some(a), None), Some((Some(b), None))) => {
let (pa, pb) = (sk.point(a).unwrap_or_default(), sk.point(b).unwrap_or_default());
match ty.as_str() {
match auto_orientation(&ty, pa, pb, text_at) {
"horizontal" => (ConstraintKind::DistanceX { p: a, q: b, value: (pb.x - pa.x).abs() }, (pb.x - pa.x).abs()),
"vertical" => (ConstraintKind::DistanceY { p: a, q: b, value: (pb.y - pa.y).abs() }, (pb.y - pa.y).abs()),
_ => (ConstraintKind::Distance { p: a, q: b, value: pa.dist(pb) }, pa.dist(pb)),
@@ -1643,6 +1767,28 @@ fn solved_measure(s: &Session, p: &Value, cmd: &str) -> Option<(f64, &'static st
Some(measured(k.is_angle(), solvecraft_sketch::measure_dimension(sk, k)?))
}
/// The type of a dimension between `a` and `b` (two points or a line's ends). `auto` takes it
/// from where the text is placed: across from the two (between the perpendiculars through
/// them) the distance between them; past that, above or below them the horizontal distance,
/// beside them the vertical one (#257). With no text, or when the two already line up with
/// an axis, `auto` stays the distance between them; other types are returned unchanged.
fn auto_orientation(ty: &str, a: Vec2, b: Vec2, text_at: Option<Vec2>) -> &str {
let Some(t) = text_at.filter(|_| ty == "auto") else { return ty };
let d = b - a;
let len = d.len();
let along = (t - a).dot(d) / len;
let between = |v: f64, p: f64, q: f64| p.min(q) <= v && v <= p.max(q);
if d.x.abs() <= 1e-9 * len || d.y.abs() <= 1e-9 * len || !along.is_finite() || (0.0..=len).contains(&along) {
ty
} else if between(t.x, a.x, b.x) {
"horizontal"
} else if between(t.y, a.y, b.y) {
"vertical"
} else {
ty
}
}
/// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the
/// dimension so it follows the geometry.
fn place_text(sk: &mut Sketch, id: &str, at: Option<Vec2>) {
@@ -1823,109 +1969,82 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.move_point";
let to = req_vec2(cmd, p, "to")?;
let (_, info) = edit(s, p, cmd, false, |sk, _| {
let id = target_sketch(s, p, cmd)?;
let (_, rep) = edit_solved(s, id, false, |sk, _| {
let q = point_ref(sk, p.get("point"), cmd, "point")?;
drag_entity(sk, Ent::Point(q), to, to);
Ok(())
Ok(((), drag_entity(sk, Ent::Point(q), to, to, false).1.solve_again()))
})?;
Ok(json!({"sketch": info}))
Ok(json!({"sketch": rep.as_ref().map(report_json)}))
}
fn drag(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.drag";
let to = req_vec2(cmd, p, "to")?;
let from = p.get("from").and_then(vec2).unwrap_or(to);
let (_, info) = edit(s, p, cmd, false, |sk, _| {
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?;
drag_entity(sk, e, from, to);
Ok(())
})?;
Ok(json!({"sketch": info}))
drag_frame(s, p, false).map(|(v, _)| v)
}
/// Drag like Fusion: the grabbed entity follows the cursor exactly (a point goes to it; a line,
/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes
/// radius about its centre) and the rest of the sketch moves as little as it can. Fixed and
/// fully constrained geometry stays (the solve that follows pulls it back).
fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2) {
let d = to - from;
let mut hold = Hold::default();
match e {
Ent::Point(q) => {
if sk.point_locked(q) {
return;
}
if let Some(pt) = sk.points.get_mut(q) {
pt.pos = to;
}
hold.points.push(q);
}
Ent::Curve(c) => {
if sk.curve_locked(c) {
return;
}
let Some(kind) = sk.curves.get(c).map(|c| c.kind.clone()) else { return };
let locked = sk.locked_points();
let set = |sk: &mut Sketch, q: usize, at: Vec2| {
if !locked.get(q).copied().unwrap_or(true)
&& let Some(pt) = sk.points.get_mut(q)
{
pt.pos = at;
}
};
match kind {
CurveKind::Circle { c: cc, .. } => {
let r = sk.point(cc).map(|o| o.dist(to)).unwrap_or(0.0);
if let Some(CurveKind::Circle { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind)
&& r > 1e-9
{
*slot = r;
}
hold.points.push(cc);
hold.radii.push(c);
}
CurveKind::Arc { c: cc, a, b } => {
let Some(o) = sk.point(cc) else { return };
let r = o.dist(to);
if r > 1e-9 {
for q in [a, b] {
if let Some(u) = sk.point(q).and_then(|pq| (pq - o).normalized()) {
set(sk, q, o + u * r);
}
}
}
hold.points.extend([cc, a, b]);
}
CurveKind::Ellipse { c: cc, m, .. } => {
// Grabbed on the outline: the minor radius follows (the major stays).
let (Some(o), Some(pm)) = (sk.point(cc), sk.point(m)) else { return };
let u = (pm - o).normalized().unwrap_or(Vec2::X);
let minor = u.cross(to - o).abs();
if minor > 1e-9
&& let Some(CurveKind::Ellipse { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind)
{
*slot = minor;
}
hold.points.extend([cc, m]);
hold.radii.push(c);
}
k => {
// Lines, splines and conics slide with the cursor.
for q in k.point_ids().into_iter().collect::<std::collections::BTreeSet<_>>() {
if let Some(at) = sk.point(q) {
set(sk, q, at + d);
}
}
hold.points.extend(k.point_ids());
}
}
}
fn drag_live(s: &mut Session, p: &Value) -> Result<(Value, Option<crate::LiveEdit>)> {
drag_frame(s, p, true)
}
/// `sketch.drag`; `live`: one frame of an interactive drag, returned with the sketch to show.
fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<crate::LiveEdit>)> {
let cmd = "sketch.drag";
let to = req_vec2(cmd, p, "to")?;
let from = match p.get("from") {
None | Some(Value::Null) => to,
Some(v) => vec2(v).ok_or_else(|| bad(cmd, "`from` must be a point [x, y]"))?,
};
let id = target_sketch(s, p, cmd)?;
// The sketch's report as last evaluated: a live frame keeps its degrees of freedom.
let last = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| ss.report.clone());
let (d, rep) = edit_solved(s, id, false, |sk, doc| {
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?;
// The held solves work on the dimensions' current values (a solved step applies them).
doc.apply_dimension_values(&doc.param_values().0, sk)?;
let (d, left) = drag_entity(sk, e, from, to, live);
// A live frame's held solve is its only one; otherwise the sketch's own solve reports
// its degrees of freedom.
Ok((d, if live && last.is_some() { left } else { left.solve_again() }))
})?;
// Where the grabbed spot went: `from` moved as the dragged points did on average (with the
// whole step when it followed; otherwise as the sketch's own solve left them).
let sk = s.doc.sketch(id)?;
let moves: Vec<Vec2> = d.moving.iter().filter_map(|&(q, was)| Some(sk.point(q)? - was)).collect();
let at = from + moves.iter().fold(Vec2::ZERO, |a, m| a + *m) * (1.0 / moves.len().max(1) as f64);
let rep = rep.or_else(|| last.map(|last| SolveReport { status: solvecraft_sketch::SolveStatus::Solved, failing: Vec::new(), ..last }));
let out = json!({"sketch": rep.as_ref().map(report_json), "reached": d.reached, "at": [at.x, at.y]});
Ok((out, rep.filter(|_| live).map(|report| crate::LiveEdit { sketch: id, report })))
}
/// Ends a live drag: the snap constraint it let go on, if any, then one evaluation of the model.
/// It reads the model only before the sketch it edits (never stale), so `execute` does not
/// evaluate first.
fn drag_end(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.drag_end";
let id = target_sketch(s, p, cmd)?;
let start = s.doc.clone();
let mut constraint = Value::Null;
if let Some(c) = p.get("constraint").filter(|c| !c.is_null()) {
let name = c.get("command").and_then(Value::as_str).unwrap_or_default();
let spec = super::find_command(name)
.filter(|spec| spec.id.starts_with("sketch.constraint."))
.ok_or_else(|| bad(cmd, "`constraint.command` must be a sketch.constraint.* command"))?;
let params = c.get("params").cloned().unwrap_or(Value::Null);
// A constraint that cannot be added (it would over-constrain the sketch) is not kept.
constraint = spec.run_nested(s, &params).unwrap_or_else(|e| {
s.doc = start.clone();
json!({"error": e.to_string()})
});
}
let mut trial = sk.clone();
if solve_holding(&mut trial, &hold).ok() {
*sk = trial;
// A new constraint changed the document: `execute` evaluates the model after this.
if !std::sync::Arc::ptr_eq(&start, &s.doc) {
return Ok(json!({"constraint": constraint, "sketch": constraint.get("sketch")}));
}
if s.model.is_stale() {
s.refresh();
}
let report = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| report_json(&ss.report));
Ok(json!({"constraint": constraint, "sketch": report}))
}
/// The Sketch Palette options of a sketch, by their palette names (true = shown / on).
@@ -2000,6 +2119,9 @@ mod rect_tests;
#[path = "sketch_ux_tests.rs"]
mod ux_tests;
#[cfg(test)]
#[path = "sketch_drag_live_tests.rs"]
mod drag_live_tests;
#[cfg(test)]
#[path = "sketch_ids_tests.rs"]
mod ids_tests;
+7 -3
View File
@@ -249,9 +249,13 @@ fn auto(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
Some(r) => sk.resolve_point(r).ok_or_else(|| bad(cmd, format!("unknown point `{r}`")))?,
None => 0,
};
// Values of existing dimensions must be applied before judging degrees of freedom.
let (vals, _) = doc.param_values();
doc.apply_dimension_values(&vals, sk)?;
// Values of existing dimensions must be applied (and reached from the stored solved
// state) before judging degrees of freedom; a sketch they would turn over keeps its
// stored geometry, as the edit's own solve then refuses them.
let rep = super::sketch::solve_current(sk, doc)?;
if !rep.keeps_orientation() {
return Err(bad(cmd, rep.failure(|f| format!("the sketch constraints conflict ({f})"))));
}
Ok(run_auto(sk, doc, p, datum))
})?;
Ok(json!({"added": added, "dof": dof, "sketch": info}))
+545
View File
@@ -0,0 +1,545 @@
//! Dragging sketch geometry without jumping to another solution.
//!
//! A constrained sketch usually has several solutions for one cursor position (a two-link arm's
//! elbow up or down, a fillet inside or outside its corner). Each held solve of a drag is seeded
//! on the positions from before it: the points the solver's construction places (from distances
//! and alignments to placed points) keep the orientation they had there however far the step.
//! The rest is left to the iteration, which from far away can land on any solution, so a long
//! drag still walks from where the geometry is to where the cursor puts it in short steps, each
//! solved from the previous step's result (continuation). Continuation is what keeps lengths the
//! construction does not read (an Equal to another line, a rigid shape hanging off a held point);
//! it also lets the arc sweep guard see every step, and lets a path that crosses where the
//! geometry cannot reach stop short at the edge of that stretch and go on past it. It steps only
//! when the move is long next to the dragged geometry, so a normal mouse frame is a single solve.
use std::f64::consts::{PI, TAU};
use solvecraft_geom::Vec2;
use solvecraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch};
use super::{Ent, Left, counted_solve};
/// Longest step, as a fraction of the smallest driving length or radius of the dragged group.
/// Each step is seeded on its pre-step positions, so what the seed constructs keeps its branch
/// whatever the step; the steps are for what it does not (an arc's sweep guard, paths across
/// unreachable stretches). The drag tests first fail at 1 (an arc end wraps through its start):
/// this keeps a factor of four. It holds where the seed placed what it could: once a held
/// solve's decomposition runs out of budget (see [`Budget::exhausted`]), the rest of the drag
/// takes `SHORT_STEP_FRACTION` steps, and continuation keeps the branch.
const STEP_FRACTION: f64 = 0.25;
/// The steps of a drag whose seed did not place everything it could (as before seeding steps).
const SHORT_STEP_FRACTION: f64 = 0.125;
/// Shortest step (mm), so a tiny feature cannot make a drag crawl.
const MIN_STEP: f64 = 1e-3;
/// Most steps one drag is split into.
const MAX_STEPS: usize = 64;
/// After a step fails, the way to it from the last solved state is halved until what is left is
/// shorter than a step over this; at most `MAX_STEPS` such solves per drag.
const HALVINGS: usize = 16;
/// An arc whose sweep gets this close (radians) to 0 or a full turn has run into its start.
const SWEEP_EPS: f64 = 1e-6;
/// What a drag moves, from where it is (`t = 0`) to where the cursor puts it (`t = 1`).
enum Path {
/// Points slide by `d` (a dragged point by its way to the cursor).
Slide { pts: Vec<(usize, Vec2)>, d: Vec2 },
/// A circle's radius or an ellipse's minor radius follows the cursor.
Radius { c: usize, from: f64, to: f64 },
/// An arc's ends move along their rays from the centre `o` to the cursor's radius.
ArcRadius { o: Vec2, ends: Vec<(usize, Vec2)>, from: f64, to: f64 },
}
impl Path {
fn length(&self) -> f64 {
match self {
Path::Slide { d, .. } => d.len(),
Path::Radius { from, to, .. } | Path::ArcRadius { from, to, .. } => (to - from).abs(),
}
}
/// The points the drag moves.
fn points(&self) -> impl Iterator<Item = usize> + '_ {
let pts = match self {
Path::Slide { pts, .. } | Path::ArcRadius { ends: pts, .. } => pts.as_slice(),
Path::Radius { .. } => &[],
};
pts.iter().map(|(q, _)| *q)
}
/// Put the dragged geometry where it is at `t` along the drag.
fn apply(&self, sk: &mut Sketch, t: f64) {
let mut set = |q: usize, at: Vec2| {
if let Some(pt) = sk.points.get_mut(q) {
pt.pos = at;
}
};
match self {
Path::Slide { pts, d } => {
for &(q, at) in pts {
set(q, at + *d * t);
}
}
Path::ArcRadius { o, ends, from, to } => {
let r = from + (to - from) * t;
for &(q, u) in ends {
set(q, *o + u * r);
}
}
Path::Radius { c, from, to } => {
if let Some(CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. }) = sk.curves.get_mut(*c).map(|x| &mut x.kind) {
*r = from + (to - from) * t;
}
}
}
}
}
/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line,
/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes
/// radius about its centre) and the rest of the sketch moves as little as it can, staying on the
/// solution it started on. Fixed geometry stays. A step that cannot be solved with the entity
/// held (the path crosses where the geometry cannot reach) is approached by halving the way to
/// it, and the next steps are tried from the last solved state, the last of them being the whole
/// remaining move. When none past it solves, the first failed step stays placed but unsolved:
/// the edit's ordinary, non-strict solve that follows (seeded on the last solved state, see
/// [`Left::MovedFrom`]) then pulls it back (fully constrained geometry) or leaves the sketch
/// unsolved for the user to see. A step that would sweep an arc through its own start is undone
/// and the drag ends there. Returns how far it got and what it leaves for the edit's solve
/// ([`Left::Solved`]: the held solve of the last step it kept left the sketch solved).
///
/// `live`: one frame of an interactive drag, whose held solves share one frame's decomposition
/// budget ([`Budget::frame`]); otherwise each has a solve's own.
pub(super) fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2, live: bool) -> (Dragged, Left) {
let Some((path, hold, n, short)) = prepare(sk, e, from, to) else {
return (Dragged { reached: 0.0, moving: Vec::new() }, Left::Unsolved);
};
#[cfg(test)]
let (n, short) = (n.min(step_cap::get()), short.min(step_cap::get()));
let moving = path.points().filter_map(|q| Some((q, sk.point(q)?))).collect();
let mut frame = live.then(frame_budget);
// Only success matters to these solves: the degrees of freedom of a sketch with the entity
// held are not the sketch's. Each is seeded on the last solved state (`last`), so what the
// constraints pin down keeps the orientation it had there. A failed or wrapping step is
// undone (a failed solve leaves the step placed). Also returns whether the decomposition ran
// out of budget.
let mut solves = Solves::default();
let mut step = |sk: &mut Sketch, last: &Geometry, t: f64, solves: &mut Solves| {
path.apply(sk, t);
let mut own = Budget::full();
let budget = frame.as_mut().unwrap_or(&mut own);
let spent = budget.spent();
let ok = counted_solve(sk, &hold, Some(&last.pos), Analysis::StatusOnly, Some(&mut *budget)).ok();
solves.count += 1;
solves.work += budget.spent() - spent;
solves.cut |= budget.exhausted();
let outcome = if !ok {
Step::Failed
} else if last.sweep_wrapped(sk) {
Step::Wrapped
} else {
return Step::Solved;
};
last.restore(sk);
outcome
};
let mut last = Geometry::default();
last.take(sk);
// How far along the last solved state is; the first step since it that failed.
let (mut good, mut failed) = (0.0, None);
let mut spare = MAX_STEPS;
// The steps: `k` of `dt` from `base`; once a seed ran out of budget, the rest of the way in
// short steps.
let (mut base, mut dt, mut k, mut t) = (0.0, 1.0 / n as f64, 0, 0.0);
let mut shortened = false;
'walk: while t < 1.0 {
if solves.cut && !shortened {
shortened = true;
let rest = 1.0 - t;
let m = steps(rest * short as f64, 1.0);
(base, dt, k) = (t, rest / m as f64, 0);
}
k += 1;
t = base + dt * k as f64;
// The last step ends exactly at the cursor.
if 1.0 - t < 0.5 * dt {
t = 1.0;
}
match step(sk, &last, t, &mut solves) {
Step::Solved => {
(good, failed) = (t, None);
if t < 1.0 {
last.take(sk);
}
continue;
}
Step::Wrapped => {
failed = None;
break 'walk;
}
Step::Failed => {}
}
if failed.is_none() {
// Halve the way to the failed step, so the state the next steps start from (and are
// seeded on) is as close to it as the geometry follows. The shortest move is tried
// first: geometry that cannot move at all (pinned) costs one solve, not every halving.
let fine = dt / HALVINGS as f64;
let mut miss = t;
let mut first = true;
while spare > 0 && miss - good > fine {
spare -= 1;
let mid = if std::mem::take(&mut first) { good + fine } else { 0.5 * (good + miss) };
match step(sk, &last, mid, &mut solves) {
Step::Solved => {
good = mid;
last.take(sk);
}
Step::Wrapped => break 'walk,
Step::Failed => miss = mid,
}
}
failed = Some(t);
}
}
#[cfg(test)]
last_drag::set(solves);
let left = match failed {
// The failed step is left placed for the edit's solve, seeded on the last solved state.
Some(t) => {
path.apply(sk, t);
Left::MovedFrom(last.pos)
}
None if good > 0.0 => Left::Solved,
None => Left::Unsolved,
};
(Dragged { reached: good, moving }, left)
}
/// The held solves of one drag: how many, the decomposition work they did, and whether one ran
/// out of budget.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct Solves {
pub count: usize,
pub work: usize,
pub cut: bool,
}
/// One live frame's decomposition budget (smaller in tests that set one).
fn frame_budget() -> Budget {
#[cfg(test)]
if let Some(work) = last_drag::frame_work() {
return Budget::new(work);
}
Budget::frame()
}
/// What the last drag on this thread did, and a frame budget for tests to set.
#[cfg(test)]
pub(super) mod last_drag {
use std::cell::Cell;
use super::Solves;
thread_local! {
static LAST: Cell<Solves> = const { Cell::new(Solves { count: 0, work: 0, cut: false }) };
static FRAME: Cell<Option<usize>> = const { Cell::new(None) };
}
pub fn set(s: Solves) {
LAST.with(|c| c.set(s));
}
/// The held solves of the last drag.
pub fn get() -> Solves {
LAST.with(Cell::get)
}
pub fn frame_work() -> Option<usize> {
FRAME.with(Cell::get)
}
/// Restores the frame budget when dropped, also when the test panics.
struct Restore(Option<usize>);
impl Drop for Restore {
fn drop(&mut self) {
FRAME.with(|c| c.set(self.0));
}
}
/// Run `f` with live frames given `work` units of decomposition budget.
pub fn with_frame_work<R>(work: usize, f: impl FnOnce() -> R) -> R {
let _restore = Restore(FRAME.with(|c| c.replace(Some(work))));
f()
}
}
/// How one held solve of a drag went.
enum Step {
Solved,
Failed,
/// Solved, but it swept an arc through its own start.
Wrapped,
}
/// How far a drag got.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Dragged {
/// The part of the way from where it was grabbed to the cursor the geometry followed (0..1).
pub reached: f64,
/// The points the drag moves (not the fixed ones it holds), where they were before it.
pub moving: Vec<(usize, Vec2)>,
}
/// The geometry a step changes (point positions; curves, for radii and the arc ends a solve
/// may swap), kept to undo a step. Its buffers are reused from step to step.
#[derive(Default)]
struct Geometry {
pos: Vec<Vec2>,
curves: Vec<(CurveKind, bool)>,
}
impl Geometry {
fn take(&mut self, sk: &Sketch) {
self.pos.clear();
self.pos.extend(sk.points.iter().map(|p| p.pos));
self.curves.clear();
self.curves.extend(sk.curves.iter().map(|c| (c.kind.clone(), c.reversed)));
}
fn restore(&self, sk: &mut Sketch) {
for (p, at) in sk.points.iter_mut().zip(&self.pos) {
p.pos = *at;
}
for (c, (kind, reversed)) in sk.curves.iter_mut().zip(&self.curves) {
c.kind = kind.clone();
c.reversed = *reversed;
}
}
/// Did the step drive some arc's end through its start: the sweep jumped through 0 / a full
/// turn, or ran from clear of them onto one? An arc already that close may still move.
/// Measured on the arc's ends as they were before the step (a solve may swap them).
fn sweep_wrapped(&self, sk: &Sketch) -> bool {
let was = |q: usize| self.pos.get(q).copied();
let now = |q: usize| sk.point(q);
let clear = |s: f64| (SWEEP_EPS..=TAU - SWEEP_EPS).contains(&s);
self.curves.iter().any(|(kind, _)| {
let CurveKind::Arc { c, a, b } = *kind else { return false };
let (Some(s0), Some(s1)) = (sweep(was(c), was(a), was(b)), sweep(now(c), now(a), now(b))) else { return false };
(s1 - s0).abs() > PI || (clear(s0) && !clear(s1))
})
}
}
/// The held solves dragging `e` from `from` to `to` takes (0 when it cannot move): the plan
/// `drag_entity` runs, for tests.
#[cfg(test)]
pub(super) fn drag_steps(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> usize {
prepare(sk, e, from, to).map_or(0, |(_, _, n, _)| n)
}
/// A drag's path, what its solves hold and its number of steps (and of short steps, see
/// `SHORT_STEP_FRACTION`), or `None` if it cannot move.
fn prepare(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold, usize, usize)> {
if !(from.is_finite() && to.is_finite()) {
return None;
}
let (path, hold) = plan(sk, e, from, to)?;
let n = step_count(sk, &hold, path.length(), STEP_FRACTION);
let short = step_count(sk, &hold, path.length(), SHORT_STEP_FRACTION);
Some((path, hold, n, short))
}
/// A cap on the steps `drag_entity` takes on this thread, for tests that measure what fewer
/// steps keep (the planned count, `drag_steps`, is not capped).
#[cfg(test)]
pub(super) mod step_cap {
use std::cell::Cell;
thread_local! {
static CAP: Cell<usize> = const { Cell::new(super::MAX_STEPS) };
}
pub fn get() -> usize {
CAP.with(Cell::get)
}
/// Restores the cap when dropped, also when the test panics.
struct Restore(usize);
impl Drop for Restore {
fn drop(&mut self) {
CAP.with(|c| c.set(self.0));
}
}
/// Run `f` with drags split into at most `cap` steps (at least one).
pub fn with<R>(cap: usize, f: impl FnOnce() -> R) -> R {
let _restore = Restore(CAP.with(|c| c.replace(cap.max(1))));
f()
}
}
/// Steps of at most `step` covering `length`: at least one, at most `MAX_STEPS`.
fn steps(length: f64, step: f64) -> usize {
let n = (length / step).ceil();
if n.is_finite() && n >= 1.0 { (n as usize).min(MAX_STEPS) } else { 1 }
}
/// The dragged geometry's path and what the solves hold, or `None` if it cannot move.
fn plan(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold)> {
let mut hold = Hold::default();
let path = match e {
Ent::Point(q) => {
if sk.point_locked(q) {
return None;
}
hold.points.push(q);
let at = sk.point(q)?;
Path::Slide { pts: vec![(q, at)], d: to - at }
}
Ent::Curve(c) => {
if sk.curve_locked(c) {
return None;
}
let kind = sk.curves.get(c).map(|x| x.kind.clone())?;
let locked = sk.locked_points();
let free = |q: &usize| !locked.get(*q).copied().unwrap_or(true);
// A cursor on the centre leaves the radius as it is.
let radius_to = |o: Vec2, r: f64| Some(o.dist(to)).filter(|x| *x > 1e-9).unwrap_or(r);
match kind {
CurveKind::Circle { c: cc, r } => {
let to_r = sk.point(cc).map_or(r, |o| radius_to(o, r));
hold.points.push(cc);
hold.radii.push(c);
Path::Radius { c, from: r, to: to_r }
}
CurveKind::Arc { c: cc, a, b } => {
let o = sk.point(cc)?;
let r = sk.radius(c).unwrap_or(0.0);
hold.points.extend([cc, a, b]);
let ends = [a, b].into_iter().filter(free).filter_map(|q| Some((q, (sk.point(q)? - o).normalized()?))).collect();
Path::ArcRadius { o, ends, from: r, to: radius_to(o, r) }
}
CurveKind::Ellipse { c: cc, m, r } => {
// Grabbed on the outline: the minor radius follows (the major stays).
let (o, pm) = (sk.point(cc)?, sk.point(m)?);
let u = (pm - o).normalized().unwrap_or(Vec2::X);
let minor = u.cross(to - o).abs();
hold.points.extend([cc, m]);
hold.radii.push(c);
Path::Radius { c, from: r, to: if minor > 1e-9 { minor } else { r } }
}
k => {
// Lines, splines and conics slide with the cursor.
let mut ids = k.point_ids();
ids.sort_unstable();
ids.dedup();
let pts = ids.iter().filter(|q| free(q)).filter_map(|&q| Some((q, sk.point(q)?))).collect();
hold.points.extend(ids);
Path::Slide { pts, d: to - from }
}
}
}
};
Some((path, hold))
}
/// Steps for a move of `length`: enough that none is longer than `fraction` of the smallest
/// size of the geometry the solve moves with what it holds (at least `MIN_STEP`), at most
/// `MAX_STEPS`. A move short next to every size in the sketch (a normal mouse frame) is one
/// step, found without grouping the sketch.
fn step_count(sk: &Sketch, hold: &Hold, length: f64, fraction: f64) -> usize {
let step = |size: f64| (size * fraction).max(MIN_STEP);
if length <= step(smallest_size(sk, hold, None)) {
return 1;
}
let (points, radii) = sk.coupled(&hold.points, &hold.radii);
steps(length, step(smallest_size(sk, hold, Some(Group { points: &points, radii: &radii }))))
}
/// What a solve moves together: per point and per curve radius.
#[derive(Clone, Copy)]
struct Group<'a> {
points: &'a [bool],
radii: &'a [bool],
}
impl Group<'_> {
/// Does an entity with these points and curves belong to the group?
fn has(&self, points: &[usize], curves: &[usize]) -> bool {
let any = |flags: &[bool], ids: &[usize]| ids.iter().any(|i| flags.get(*i).copied().unwrap_or(false));
any(self.points, points) || any(self.radii, curves)
}
}
/// The smallest size that can make a constrained shape take another solution (of the geometry
/// in `group`, or of the whole sketch), or infinity when there is none: every length the
/// constraints hold. That is driving length dimensions (offsets along an axis or from a line
/// too: the solver holds their size, either side), radii and the current lengths of lines held
/// equal to another (a link's length can come from an Equal as well as a dimension). A line that
/// only keeps a direction (a rectangle's side) holds no length: it may pass through zero and turn
/// over, so it does not shrink the steps. Nor does a distance between two held points (the
/// dragged line's own length): held geometry moves as one. A circle's radius counts when the
/// solve moves the radius itself (not merely its centre).
fn smallest_size(sk: &Sketch, hold: &Hold, group: Option<Group>) -> f64 {
// Point lists are built only to test a size smaller than the smallest so far against the
// group; without a group nothing is built.
let in_group = |points: &dyn Fn() -> Vec<usize>, curves: &[usize]| group.is_none_or(|g| g.has(&points(), curves));
// A radius is the circle's own variable, or (an arc's) the distance between its points.
let radius_in_group = |c: usize| match sk.curves.get(c).map(|x| &x.kind) {
Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. }) => in_group(&Vec::new, &[c]),
Some(k) => in_group(&|| k.point_ids(), &[]),
None => false,
};
let line_length = |l: usize| super::line_pts(sk, l).map(|(a, b)| a.dist(b));
let points = |k: &ConstraintKind| sk.constraint_points(k);
let mut min = f64::INFINITY;
let mut take = |size: f64, counts: &dyn Fn() -> bool| {
let size = size.abs();
if size.is_finite() && size > 1e-9 && size < min && counts() {
min = size;
}
};
for k in sk.constraints.iter().filter(|k| !k.driven).map(|k| &k.kind) {
match *k {
ConstraintKind::Equal { a, b } => {
if let Some(v) = line_length(a).into_iter().chain(line_length(b)).reduce(f64::min) {
take(v, &|| in_group(&|| points(k), &[]));
}
}
ConstraintKind::Radius { c, value } => take(value, &|| radius_in_group(c)),
ConstraintKind::Diameter { c, value } => take(value / 2.0, &|| radius_in_group(c)),
_ => {
if let Some(v) = driving_length(k) {
// Held points move as one: a length between them never changes.
take(v, &|| {
let pts = points(k);
!pts.iter().all(|p| hold.points.contains(p)) && in_group(&|| pts.clone(), &k.indices().1)
});
}
}
}
}
for c in 0..sk.curves.len() {
if let Some(r) = sk.radius(c) {
take(r, &|| radius_in_group(c));
}
}
min
}
/// The length a driving dimension sets (a linear diameter's radius), if it sets one.
fn driving_length(k: &ConstraintKind) -> Option<f64> {
if k.is_angle() {
return None;
}
let v = k.value()?;
Some(if matches!(k, ConstraintKind::LinearDiameter { .. }) { v / 2.0 } else { v })
}
/// Counter-clockwise sweep (radians, `[0, 2π)`) from `a` to `b` about `c`.
fn sweep(c: Option<Vec2>, a: Option<Vec2>, b: Option<Vec2>) -> Option<f64> {
let (o, pa, pb) = (c?, a?, b?);
Some(((pb - o).angle() - (pa - o).angle()).rem_euclid(TAU))
}
@@ -0,0 +1,290 @@
//! Live drags (`sketch.drag` with `live: true`): each frame solves the sketch once, without a
//! rank analysis, and evaluates nothing after it; the gesture's end evaluates the model once.
use serde_json::{Value, json};
use solvecraft_geom::Vec3;
use super::solve_counts;
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}"),
}
}
/// A dimensioned 20 x 15 rectangle from (5, 5) extruded 5 mm, its sketch being edited again;
/// returns the session and the extrude's id.
fn extruded() -> (Session, u64) {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 20}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 15}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
let extrude = s.doc.features.last().map(|f| f.id).expect("the extrude");
run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"}));
(s, extrude)
}
/// Counters from here on.
fn reset(s: &mut Session) {
s.evaluations = 0;
solve_counts::take();
}
/// Frames moving the rectangle's corner (5, 5) (and so the whole rectangle) left by 1 mm each.
fn frames(s: &mut Session, n: usize, live: bool) -> Vec<Value> {
(1..=n)
.map(|k| {
let (from, to) = ([6.0 - k as f64, 5.0], [5.0 - k as f64, 5.0]);
run(s, "sketch.drag", json!({"entity": "l1.start", "from": from, "to": to, "live": live}))
})
.collect()
}
fn body_contains(s: &Session, p: Vec3) -> bool {
s.model.state().bodies.first().expect("a body").body.contains(p)
}
fn pt(s: &Session, id: &str) -> solvecraft_geom::Vec2 {
let sk = s.active_sketch.and_then(|i| s.doc.sketch(i).ok()).expect("sketch mode");
sk.resolve_point(id).and_then(|i| sk.point(i)).expect("the point")
}
fn corner(s: &Session) -> solvecraft_geom::Vec2 {
let id = s.active_sketch.expect("sketch mode");
let st = s.world_state();
let sk = &st.sketch(id).expect("the shown sketch").sketch;
sk.resolve_point("l1.start").and_then(|i| sk.point(i)).expect("the corner")
}
#[test]
fn a_live_frame_solves_once_and_leaves_the_features_after_the_sketch_stale() {
let (mut s, extrude) = extruded();
let body = s.model.state().bodies.first().expect("a body").body.clone();
reset(&mut s);
let r = frames(&mut s, 1, true).remove(0);
assert_eq!(s.evaluations, 0, "a live frame evaluates no feature");
assert_eq!(solve_counts::take(), (1, 0), "one held solve, no rank analysis");
assert_eq!(r["reached"], json!(1.0));
assert_eq!(r["sketch"]["solved"], json!(true));
assert_eq!(r["sketch"]["dof"], json!(2), "the degrees of freedom from before the drag");
// The sketch shows its new place; the extrude keeps its last result.
assert!(corner(&s).dist(solvecraft_geom::Vec2::new(4.0, 5.0)) < 1e-9);
let result = s.model.result(extrude).expect("the extrude's result");
assert!(result.stale);
assert!(s.model.state().bodies.first().expect("a body").body.same(&body), "the body is not rebuilt");
}
#[test]
fn the_drag_end_evaluates_once_to_the_scripted_drags_result() {
let (mut live, extrude) = extruded();
let (mut scripted, _) = extruded();
frames(&mut live, 3, true);
reset(&mut live);
let end = run(&mut live, "sketch.drag_end", json!({}));
assert_eq!(live.evaluations, 1, "one full evaluation at the end");
assert_eq!(solve_counts::take(), (0, 0));
assert_eq!(end["sketch"], json!({"solved": true, "dof": 2}));
assert!(!live.model.is_stale());
frames(&mut scripted, 3, false);
assert_eq!(*live.doc, *scripted.doc, "the same design as dragging without `live`");
let (a, b) = (live.model.state(), scripted.model.state());
let (sa, sb) = (&a.sketches[0], &b.sketches[0]);
assert_eq!(sa.sketch, sb.sketch);
assert_eq!((sa.report.dof, &sa.report.point_determined), (sb.report.dof, &sb.report.point_determined));
assert!(body_contains(&live, Vec3::new(2.5, 6.0, 2.5)) && !body_contains(&live, Vec3::new(22.5, 6.0, 2.5)), "the body followed");
assert!(live.model.result(extrude).is_some_and(|r| !r.stale && r.error.is_none()));
// One undo step for the gesture is the UI's business; each frame is still undoable.
run(&mut live, "edit.undo", json!({}));
assert!(corner(&live).dist(solvecraft_geom::Vec2::new(3.0, 5.0)) < 1e-9);
}
#[test]
fn an_abandoned_live_drag_is_evaluated_before_the_next_command() {
let (mut s, extrude) = extruded();
frames(&mut s, 2, true);
assert!(s.model.is_stale());
reset(&mut s);
// A command that changes nothing still sees (and leaves) a fully evaluated model.
run(&mut s, "sketch.solve", json!({}));
assert_eq!(s.evaluations, 1);
assert!(!s.model.is_stale());
assert!(s.model.result(extrude).is_some_and(|r| !r.stale));
assert!(body_contains(&s, Vec3::new(3.5, 6.0, 2.5)));
// Leaving the sketch does too.
frames(&mut s, 1, true);
run(&mut s, "sketch.finish", json!({}));
assert!(!s.model.is_stale());
}
#[test]
fn a_scripted_drag_still_evaluates_the_whole_model() {
let (mut s, extrude) = extruded();
reset(&mut s);
let r = frames(&mut s, 1, false).remove(0);
assert_eq!(s.evaluations, 1);
assert_eq!(solve_counts::take(), (2, 1), "the held solve, then the sketch's own solve with its analysis");
assert_eq!(r["sketch"], json!({"solved": true, "dof": 2}));
assert!(!s.model.is_stale());
assert!(s.model.result(extrude).is_some_and(|r| !r.stale));
assert!(body_contains(&s, Vec3::new(4.5, 6.0, 2.5)));
}
#[test]
fn a_drag_reports_how_far_the_geometry_followed() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "sketch.line", json!({"points": [[10, 30], [40, 30]]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
for live in [false, true] {
// A line of fixed length with a fixed end cannot slide: the sketch's own solve only
// turns it a little toward the cursor, and the grab follows its free end.
let before = pt(&s, "l1.end");
let r = run(&mut s, "sketch.drag", json!({"entity": "l1", "from": [25, 10], "to": [25, 20], "live": live}));
assert_eq!(r["reached"], json!(0.0), "{r}");
let moved = pt(&s, "l1.end") - before;
let at = crate::params::vec2(&r["at"]).expect("`at`");
assert!(at.dist(solvecraft_geom::Vec2::new(25.0, 10.0) + moved) < 1e-9 && at.y < 20.0, "{r}");
let to = 40 + 10 * usize::from(live);
let r = run(&mut s, "sketch.drag", json!({"entity": "l2", "from": [25, to - 10], "to": [25, to]}));
assert_eq!((&r["reached"], &r["at"]), (&json!(1.0), &json!([25.0, to as f64])), "{r}");
}
// A point that cannot move at all.
let r = run(&mut s, "sketch.drag", json!({"entity": "l1.start", "from": [10, 10], "to": [0, 0], "live": true}));
assert_eq!(r["reached"], json!(0.0));
}
#[test]
fn a_live_drag_of_a_lone_sketch_shows_it_without_evaluating() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]}));
reset(&mut s);
frames(&mut s, 2, true);
assert_eq!(s.evaluations, 0);
assert_eq!(solve_counts::take(), (2, 0));
// The free rectangle's corner moves alone; its profile follows.
assert!(corner(&s).dist(solvecraft_geom::Vec2::new(3.0, 5.0)) < 1e-9);
let st = s.model.state();
let profile = st.sketches[0].profiles.first().expect("a profile");
assert!((profile.area - 22.0 * 15.0).abs() < 1e-9, "{}", profile.area);
run(&mut s, "sketch.drag_end", json!({}));
assert_eq!(s.evaluations, 1);
}
/// Frames of a UI drag of `entity` grabbed at `grab`, the cursor moving by `moves` in turn: each
/// frame's `from` is the previous frame's `at`, as the viewport does.
fn ui_frames(s: &mut Session, entity: &str, grab: [f64; 2], moves: &[[f64; 2]]) {
let (mut from, mut cursor) = (grab, grab);
for m in moves {
cursor = [cursor[0] + m[0], cursor[1] + m[1]];
let r = run(s, "sketch.drag", json!({"entity": entity, "from": from, "to": cursor, "live": true}));
let at = crate::params::vec2(&r["at"]).expect("`at`");
from = [at.x, at.y];
}
}
#[test]
fn the_grab_follows_the_points_that_move() {
// A line with a fixed end, dragged by its middle: only the free end moves, and it keeps the
// cursor's offset frame after frame.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [30, 10]]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
ui_frames(&mut s, "l1", [20.0, 10.0], &[[0.0, 1.0]; 6]);
assert!(pt(&s, "l1.end").dist(solvecraft_geom::Vec2::new(30.0, 16.0)) < 1e-9, "{:?}", pt(&s, "l1.end"));
assert!(pt(&s, "l1.start").dist(solvecraft_geom::Vec2::new(10.0, 10.0)) < 1e-12);
}
#[test]
fn a_query_during_a_live_drag_does_not_evaluate_the_model() {
let (mut s, _) = extruded();
frames(&mut s, 1, true);
reset(&mut s);
for (id, p) in
[("parameters.complete", json!({"prefix": "d"})), ("parameters.list", json!({})), ("expr.evaluate", json!({"expression": "d1 * 2"}))]
{
run(&mut s, id, p);
}
assert_eq!(s.evaluations, 0);
assert!(s.model.is_stale(), "the drag goes on");
frames(&mut s, 1, true);
assert_eq!(s.evaluations, 0);
}
fn sketch_of(s: &Session) -> solvecraft_sketch::Sketch {
s.active_sketch.and_then(|i| s.doc.sketch(i).ok()).cloned().expect("sketch mode")
}
#[test]
fn a_live_frame_solves_the_dimensions_current_values() {
let (mut s, _) = extruded();
// The bottom side's parameter now says 25 while the sketch still holds 20 mm.
let sk = sketch_of(&s);
let l1 = sk.curve_index("l1").expect("l1");
let param = sk
.constraints
.iter()
.find(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::Length { l, .. } if l == l1))
.and_then(|c| c.param.clone())
.expect("l1's dimension");
if let Some(p) = s.doc_mut().params.iter_mut().find(|p| p.name == param) {
p.expr = "25".into();
}
let r = frames(&mut s, 1, true).remove(0);
assert_eq!(r["sketch"]["solved"], json!(true));
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
assert!((a.dist(b) - 25.0).abs() < 1e-6, "l1 is {} mm", a.dist(b));
}
#[test]
fn a_live_frame_swaps_only_the_dragged_sketch() {
let (mut s, _) = extruded();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
run(&mut s, "sketch.circle.center", json!({"center": [50, 50], "radius": 4}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"}));
let id = s.active_sketch.expect("sketch mode");
let other = |s: &Session| s.model.state().sketches.iter().find(|x| x.feature != id).cloned().expect("the other sketch");
let before = other(&s);
frames(&mut s, 1, true);
assert!(std::sync::Arc::ptr_eq(&before, &other(&s)), "the other sketch is shared, not copied");
// Later frames change the state they own in place.
let state = std::sync::Arc::as_ptr(&s.model.state());
frames(&mut s, 1, true);
assert_eq!(state, std::sync::Arc::as_ptr(&s.model.state()));
}
#[test]
fn letting_go_on_a_snap_or_undoing_evaluates_once() {
let (mut s, _) = extruded();
run(&mut s, "sketch.point", json!({"point": [-10, 5], "connect": false}));
let point = sketch_of(&s).points.last().map(|p| p.id.clone()).expect("the point");
frames(&mut s, 3, true);
reset(&mut s);
let snap = json!({"command": "sketch.constraint.coincident", "params": {"a": "l1.start", "b": point}});
let r = run(&mut s, "sketch.drag_end", json!({ "constraint": snap }));
assert_eq!(s.evaluations, 1, "{r}");
assert!(r["constraint"].get("error").is_none(), "{r}");
assert!(!s.model.is_stale());
// Undo after an abandoned drag.
frames(&mut s, 1, true);
reset(&mut s);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.evaluations, 1);
assert!(!s.model.is_stale());
// A snap that over-constrains the sketch is not kept; the drag still ends.
frames(&mut s, 1, true);
let bad = json!({"command": "sketch.constraint.coincident", "params": {"a": "l1.start", "b": "l1.end"}});
let r = run(&mut s, "sketch.drag_end", json!({ "constraint": bad }));
assert!(r["constraint"]["error"].is_string(), "{r}");
assert!(!s.model.is_stale());
}
+11 -18
View File
@@ -8,7 +8,7 @@ use solvecraft_doc::ProfileSel;
use solvecraft_geom::Vec2;
use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, intersections};
use super::sketch::{add_c, edit, ids_of};
use super::sketch::{add_c, edit, ids_of, solve_current};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list};
use crate::{Result, Session};
@@ -378,10 +378,9 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.extend";
let at = req_vec2(cmd, p, "at")?;
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
let (values, _) = doc.param_values();
doc.apply_dimension_values(&values, sk)?;
if !solvecraft_sketch::solve(sk).ok() {
return Err(bad(cmd, "solve the existing sketch constraints before extending"));
let rep = solve_current(sk, doc)?;
if !rep.keeps_orientation() {
return Err(bad(cmd, rep.failure(|_| "solve the existing sketch constraints before extending".into())));
}
let ci = curve_arg(sk, p, cmd)?;
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
@@ -453,7 +452,7 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
pin(sk, end, oi)?;
let before = sk.points.iter().map(|p| p.pos).collect::<Vec<_>>();
let hold = solvecraft_sketch::Hold { points: (0..sk.points.len()).filter(|i| *i != end).collect(), radii: (0..sk.curves.len()).collect() };
if !solvecraft_sketch::solve_holding(sk, &hold).ok()
if !solvecraft_sketch::solve_with(sk, &hold, None, solvecraft_sketch::Analysis::Full, None).ok()
|| sk.point(end).is_none_or(|point| point.dist(q) > TOL)
|| sk.points.iter().enumerate().any(|(i, point)| i != end && point.pos.dist(before[i]) > TOL)
{
@@ -928,11 +927,10 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
let side = p.get("side").and_then(vec2);
let use_chain = bool_(p, "chain").unwrap_or(true);
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
let (values, _) = doc.param_values();
doc.apply_dimension_values(&values, sk)?;
sk.refresh_offsets()?;
if !solvecraft_sketch::solve(sk).ok() {
return Err(bad(cmd, "solve existing constraints before offsetting"));
let rep = solve_current(sk, doc)?;
if !rep.keeps_orientation() {
return Err(bad(cmd, rep.failure(|_| "solve existing constraints before offsetting".into())));
}
sk.refresh_offsets()?;
let sel: Vec<usize> = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::<Result<_>>()?;
@@ -1137,14 +1135,9 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
// A parameter edit may have changed the evaluated source without
// rewriting the stored fit points. Break must freeze the displayed,
// current dimensioned geometry, rather than its stale stored predecessor.
let (values, _) = doc.param_values();
doc.apply_dimension_values(&values, sk)?;
if !solvecraft_sketch::solve(sk).ok() {
return Err(bad(cmd, "cannot break an offset with conflicting source dimensions"));
}
sk.refresh_offsets()?;
if !solvecraft_sketch::solve(sk).ok() {
return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions"));
let rep = solve_current(sk, doc)?;
if !rep.keeps_orientation() {
return Err(bad(cmd, rep.failure(|_| "cannot break an offset with conflicting dimensions".into())));
}
sk.validate_periodic_splines()?;
if let Some(association) = sk
@@ -499,3 +499,30 @@ fn project_and_include_from_other_bodies() {
assert!(row["error"].is_null(), "{row}");
assert!(row["warning"].as_str().is_some_and(|w| w.contains("lost")), "{row}");
}
/// A cylinder's wall and the torus of a rounded rim are drawn as one face but stored in halves
/// joined by hidden seams; projecting one projects the whole face, not an open half.
#[test]
fn faces_split_by_hidden_seams_project_whole() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":20}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":10}));
run(&mut s, "sketch.create", json!({"plane":"XZ"}));
run(&mut s, "sketch.project", json!({"refs":[{"face":[20,0,5]}]}));
let wall = run(&mut s, "sketch.inspect", json!({}));
let heights: Vec<f64> =
wall["curves"].as_array().unwrap().iter().flat_map(|c| [c["start_at"][1].as_f64().unwrap(), c["end_at"][1].as_f64().unwrap()]).collect();
assert!(heights.iter().any(|y| y.abs() < 1e-6) && heights.iter().any(|y| (y.abs() - 10.).abs() < 1e-6), "{wall}");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.fillet", json!({"edges":[[20,0,10]],"radius":3}));
run(&mut s, "sketch.create", json!({"plane":"XY"}));
let t = 0.5f64.sqrt() * 3.;
run(&mut s, "sketch.project", json!({"refs":[{"face":[17. + t, 0, 7. + t]}]}));
let round = run(&mut s, "sketch.inspect", json!({}));
let mut radii: Vec<f64> = round["curves"].as_array().unwrap().iter().map(|c| c["radius"].as_f64().unwrap()).collect();
radii.sort_by(f64::total_cmp);
assert_eq!(radii.len(), 4, "{round}");
assert!(radii[..2].iter().all(|r| (r - 17.).abs() < 1e-6) && radii[2..].iter().all(|r| (r - 20.).abs() < 1e-6), "{round}");
}
+716
View File
@@ -552,6 +552,584 @@ fn constrained_geometry_resists_drags() {
assert!((a.y - b.y).abs() < 1e-6 && (a.dist(b) - 30.0).abs() < 1e-6, "{a:?} {b:?}");
}
/// How the arm's link lengths are set.
#[derive(Clone, Copy, Debug)]
enum Links {
/// Length dimensions on both links.
Dimensioned,
/// Equal to fixed reference lines (no length dimension anywhere).
EqualToFixed,
}
/// Two-link arm: shoulder `l1.start` fixed, |l1| = 30 and |l2| = 20, the hand `l2.end` dragged
/// in one `sketch.drag` call along a straight path that stays clear of the singular poses. The
/// elbow has two solutions for every hand position; a drag must keep the one it started on.
/// 1000 deterministic cases (fixed-seed LCG). Returns (elbow flips, drags that stopped short).
fn arm_drags(links: Links) -> (usize, usize) {
use std::f64::consts::TAU;
let (a, b) = (30.0, 20.0);
let (lo, hi) = ((a - b) + 3.0, a + b - 3.0);
let mut s = new_sketch();
// Away from the sketch origin so nothing connects to it.
let o = v(200.0, 100.0);
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + a, o.y], [o.x + a, o.y + b]], "ids": ["l1", "l2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
match links {
Links::Dimensioned => {
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": a}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": b}));
}
Links::EqualToFixed => {
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y - 100.0], [o.x + a, o.y - 100.0]], "ids": ["r1"]}));
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y - 120.0], [o.x + b, o.y - 120.0]], "ids": ["r2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["r1", "r2"]}));
run(&mut s, "sketch.constraint.equal", json!({"a": "r1", "b": "l1"}));
run(&mut s, "sketch.constraint.equal", json!({"a": "r2", "b": "l2"}));
}
}
let id = s.active_sketch.expect("sketch");
let sk0 = sketch(&s);
assert_eq!(sk0.constraints.iter().any(|c| c.kind.is_dimension()), matches!(links, Links::Dimensioned));
let ix = |r: &str| sk0.resolve_point(r).unwrap_or_else(|| panic!("no point {r}"));
let (p1, p2, p3) = (ix("l1.start"), ix("l1.end"), ix("l2.end"));
let p2b = ix("l2.start");
let side = |sk: &Sketch| {
let (x1, x2, x3) = (sk.points[p1].pos, sk.points[p2].pos, sk.points[p3].pos);
(x3 - x1).cross(x2 - x1).signum()
};
let mut seed = 11u64;
let mut rnd = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(seed >> 11) as f64 / (1u64 << 53) as f64
};
let (mut kept, mut flips, mut short) = (0, 0, 0);
while kept < 1000 {
let th1 = rnd() * TAU;
let bend = (0.4 + rnd() * 2.2) * if rnd() < 0.5 { 1.0 } else { -1.0 };
let v2 = o + v(a * th1.cos(), a * th1.sin());
let v3 = v2 + v(b * (th1 + bend).cos(), b * (th1 + bend).sin());
let (tr, tt) = (lo + rnd() * (hi - lo), rnd() * TAU);
let to = o + v(tr * tt.cos(), tr * tt.sin());
let clear = |p: solvecraft_geom::Vec2| (lo..=hi).contains(&p.dist(o));
if !(0..=100).all(|i| clear(v3 + (to - v3) * (i as f64 / 100.0))) {
continue;
}
kept += 1;
{
let sk = s.doc_mut().sketch_mut(id).expect("sketch");
for (p, at) in [(p1, o), (p2, v2), (p2b, v2), (p3, v3)] {
sk.points[p].pos = at;
}
}
let before = side(&sketch(&s));
drag(&mut s, "l2.end", [v3.x, v3.y], [to.x, to.y]);
let sk = sketch(&s);
if side(&sk) != before {
flips += 1;
}
if sk.points[p3].pos.dist(to) > 1e-6 {
short += 1;
}
}
(flips, short)
}
#[test]
fn a_dragged_two_link_arm_keeps_its_elbow_side() {
for links in [Links::Dimensioned, Links::EqualToFixed] {
let (flips, short) = arm_drags(links);
eprintln!("two-link arm ({links:?}), one-call drags: {flips}/1000 elbow flips, {short} stopped short");
assert_eq!(flips, 0, "{links:?}: elbow flipped in {flips}/1000 drags");
assert_eq!(short, 0, "{links:?}: {short}/1000 drags stopped short of the cursor");
}
}
/// Each step of a drag is seeded on the positions from before it, so even a drag made in one
/// step (one held solve from the grab to the cursor) keeps the elbow of an arm whose links are
/// dimensioned or held equal to fixed lines (the seed reads both).
#[test]
fn a_single_step_arm_drag_keeps_its_elbow_side() {
for links in [Links::Dimensioned, Links::EqualToFixed] {
let (flips, short) = super::drag::step_cap::with(1, || arm_drags(links));
eprintln!("two-link arm ({links:?}), one-step drags: {flips}/1000 elbow flips, {short} stopped short");
assert_eq!(flips, 0, "{links:?}: elbow flipped in {flips}/1000 one-step drags");
assert_eq!(short, 0, "{links:?}: {short}/1000 one-step drags stopped short of the cursor");
}
}
/// Two-link arm (shoulder fixed, |l1| = 30, |l2| = 20) with a thin triangle (sides 3.9, 2, 2)
/// hanging off the hand: pinned there at one point, free to turn about it. The hand is dragged
/// in one `sketch.drag` call along a straight path clear of the arm's singular poses, `n`
/// deterministic cases with the triangle at random turns and on either side. Returns the drags
/// that turned the triangle over.
fn hanging_triangle_flips(n: usize) -> usize {
use std::f64::consts::TAU;
let (a, b) = (30.0, 20.0);
let (lo, hi) = ((a - b) + 3.0, a + b - 3.0);
let o = v(200.0, 100.0);
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + a, o.y], [o.x + a, o.y + b]], "ids": ["l1", "l2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": a}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": b}));
let hand = pt(&s, "l2.end");
let (q, r) = (hand + v(3.9, 0.0), hand + v(1.95, (4.0f64 - 1.95 * 1.95).sqrt()));
run(&mut s, "sketch.line", json!({"points": [[hand.x, hand.y], [q.x, q.y], [r.x, r.y], [hand.x, hand.y]], "ids": ["t1", "t2", "t3"]}));
for (l, value) in [("t1", 3.9), ("t2", 2.0), ("t3", 2.0)] {
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": value}));
}
let id = s.active_sketch.expect("sketch");
let sk0 = sketch(&s);
let ix = |r: &str| sk0.resolve_point(r).unwrap_or_else(|| panic!("no point {r}"));
let (p1, p2, p2b, p3) = (ix("l1.start"), ix("l1.end"), ix("l2.start"), ix("l2.end"));
let (t1a, t1b, t2a, t2b, t3a, t3b) = (ix("t1.start"), ix("t1.end"), ix("t2.start"), ix("t2.end"), ix("t3.start"), ix("t3.end"));
let side = |s: &Session| (pt(s, "t1.end") - pt(s, "l2.end")).cross(pt(s, "t2.end") - pt(s, "l2.end")).signum();
let mut seed = 23u64;
let mut rnd = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(seed >> 11) as f64 / (1u64 << 53) as f64
};
let (mut kept, mut flips) = (0, 0);
while kept < n {
let th1 = rnd() * TAU;
let bend = (0.4 + rnd() * 2.2) * if rnd() < 0.5 { 1.0 } else { -1.0 };
let v2 = o + v(a * th1.cos(), a * th1.sin());
let v3 = v2 + v(b * (th1 + bend).cos(), b * (th1 + bend).sin());
let (tr, tt) = (lo + rnd() * (hi - lo), rnd() * TAU);
let to = o + v(tr * tt.cos(), tr * tt.sin());
let clear = |p: solvecraft_geom::Vec2| (lo..=hi).contains(&p.dist(o));
if !(0..=100).all(|i| clear(v3 + (to - v3) * (i as f64 / 100.0))) {
continue;
}
kept += 1;
let turn = rnd() * TAU;
let u = solvecraft_geom::Vec2::from_angle(turn);
let normal = u.perp() * if rnd() < 0.5 { 1.0 } else { -1.0 };
let (tq, tr) = (v3 + u * 3.9, v3 + u * 1.95 + normal * (4.0f64 - 1.95 * 1.95).sqrt());
{
let sk = s.doc_mut().sketch_mut(id).expect("sketch");
for (p, at) in [(p1, o), (p2, v2), (p2b, v2), (p3, v3), (t1a, v3), (t1b, tq), (t2a, tq), (t2b, tr), (t3a, tr), (t3b, v3)] {
sk.points[p].pos = at;
}
}
let before = side(&s);
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [v3.x, v3.y], "to": [to.x, to.y]}));
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
assert!(pt(&s, "l2.end").dist(to) < 1e-6, "{:?} vs {to:?}: {r}", pt(&s, "l2.end"));
if side(&s) != before {
flips += 1;
}
}
flips
}
/// A rigid cluster pinned at a single point is placed by its reference pose (turned about the
/// pin, never reflected): a thin triangle hanging off a dragged arm's hand never turns over,
/// however few steps the drag takes.
#[test]
fn a_triangle_hanging_off_a_dragged_point_keeps_its_side() {
let flips: Vec<(usize, usize)> =
[1, 2, 4, usize::MAX].into_iter().map(|cap| (cap, super::drag::step_cap::with(cap, || hanging_triangle_flips(100)))).collect();
eprintln!("thin triangle on a dragged hand, (step cap, turned over /100): {flips:?}");
assert!(flips.iter().all(|(_, f)| *f == 0), "{flips:?}");
}
/// Two-link arm with both ends fixed: the elbow is pinned to one of two places. Its drags (one
/// step each) to `n` cursors across the shoulder-hand line all fail their held solve and leave
/// the elbow placed there; returns how many of the sketch's own solves that follow put it on
/// the other side. `move_point`: moved by `sketch.move_point` rather than `sketch.drag`.
fn pinned_elbow_flips(n: usize, move_point: bool) -> usize {
let o = v(200.0, 100.0);
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + 30.0, o.y + 10.0], [o.x + 40.0, o.y]], "ids": ["l1", "l2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l2.end"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
let start = s.doc.clone();
let side = |s: &Session| (pt(s, "l2.end") - pt(s, "l1.start")).cross(pt(s, "l1.end") - pt(s, "l1.start")).signum();
let before = side(&s);
let mut flips = 0;
for i in 0..n {
s.doc = start.clone();
let elbow = pt(&s, "l1.end");
// Across the line, spread along it.
let to = o + v(-10.0 + 60.0 * i as f64 / n as f64, -2.0 - 20.0 * ((i * 7) % n) as f64 / n as f64);
let r = if move_point {
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [to.x, to.y]}))
} else {
run(&mut s, "sketch.drag", json!({"entity": "l1.end", "from": [elbow.x, elbow.y], "to": [to.x, to.y]}))
};
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
if side(&s) != before {
flips += 1;
}
}
flips
}
/// A drag whose held solve fails leaves the step placed for the sketch's own solve, which keeps
/// the orientation of the last solved state: an elbow pinned by both ends of its arm, dragged
/// across the shoulder-hand line in one step, stays on its side.
#[test]
fn a_failed_drag_step_keeps_the_last_solved_orientation() {
for move_point in [false, true] {
let flips = super::drag::step_cap::with(1, || pinned_elbow_flips(50, move_point));
eprintln!("pinned elbow, one-step moves across the line (move_point {move_point}): {flips}/50 flips");
assert_eq!(flips, 0, "move_point {move_point}");
}
}
/// After a failed step the drag follows as far as the geometry can, and `reached` says how far:
/// an arm's hand (reach 50) dragged in one step from 49 to 50.5 from its shoulder gets about
/// two thirds of the way.
#[test]
fn a_drag_follows_up_to_where_its_step_fails() {
let o = v(200.0, 100.0);
let mut s = new_sketch();
// Elbow bent so the hand is 49 from the shoulder, along +x.
let c = (30.0f64 * 30.0 + 49.0 * 49.0 - 20.0 * 20.0) / (2.0 * 30.0 * 49.0);
let elbow = o + v(30.0 * c, 30.0 * (1.0 - c * c).sqrt());
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [elbow.x, elbow.y], [o.x + 49.0, o.y]], "ids": ["l1", "l2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
assert_eq!(drag_steps(&s, "l2.end", [o.x + 49.0, o.y], [o.x + 50.5, o.y]), 1);
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [o.x + 49.0, o.y], "to": [o.x + 50.5, o.y]}));
let reached = r["reached"].as_f64().unwrap_or(f64::NAN);
eprintln!("arm hand past its reach in one step: {r}");
assert!((1.0 / 1.5 - 1.0 / 16.0..=1.0 / 1.5).contains(&reached), "{r}");
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
}
/// The test-only step cap limits the steps a drag takes, not the plan tests read, and is
/// restored when the capped code panics.
#[test]
fn the_step_cap_leaves_the_plan_and_is_restored() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[100, 0], [120, 0]], "ids": ["l1"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 20}));
let uncapped = super::drag::step_cap::get();
let plan = drag_steps(&s, "l1.end", [120.0, 0.0], [100.0, 20.0]);
assert!(plan > 1);
assert_eq!(super::drag::step_cap::with(1, || drag_steps(&s, "l1.end", [120.0, 0.0], [100.0, 20.0])), plan);
assert_eq!(super::drag::step_cap::get(), uncapped);
assert!(std::panic::catch_unwind(|| super::drag::step_cap::with(1, || panic!("capped"))).is_err());
assert_eq!(super::drag::step_cap::get(), uncapped);
}
/// Stored (counter-clockwise) sweep of an arc, degrees in [0, 360).
fn sweep_deg(s: &Session, arc: &str) -> f64 {
let sk = sketch(s);
let Some(solvecraft_sketch::CurveKind::Arc { c, a, b }) = sk.curve_index(arc).map(|i| sk.curves[i].kind.clone()) else { panic!("no arc {arc}") };
let (c, a, b) = (sk.points[c].pos, sk.points[a].pos, sk.points[b].pos);
((b - c).angle() - (a - c).angle()).rem_euclid(std::f64::consts::TAU).to_degrees()
}
/// A free arc of radius 10 about a fixed centre (50, 50), from angle 0 sweeping `sweep`°.
fn free_arc(sweep: f64) -> Session {
let mut s = new_sketch();
run(&mut s, "sketch.arc.center_point", json!({"center": [50, 50], "start": [60, 50], "sweep": sweep, "id": "a1"}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["a1.center"]}));
s
}
fn on_arc(deg: f64) -> [f64; 2] {
let t = deg.to_radians();
[50.0 + 10.0 * t.cos(), 50.0 + 10.0 * t.sin()]
}
/// An arc's end dragged through its start never wraps the sweep (15° → 0° → 345°, or
/// 330° → 360° → 30°): the arc stops short, in one call or in many.
#[test]
fn a_dragged_arc_end_never_wraps_through_its_start() {
// One call, clockwise through the start: 90° → -60°.
let mut s = free_arc(90.0);
drag(&mut s, "a1.end", on_arc(90.0), on_arc(-60.0));
let sw = sweep_deg(&s, "a1");
eprintln!("free arc, CW one call 90° → -60°: sweep {sw:.1}°");
assert!(sw > 0.0 && sw < 30.0, "CW one call: sweep {sw}°");
// One call, counter-clockwise past a full turn: 330° → 390°.
let mut s = free_arc(330.0);
drag(&mut s, "a1.end", on_arc(330.0), on_arc(390.0));
let sw = sweep_deg(&s, "a1");
eprintln!("free arc, CCW one call 330° → 390°: sweep {sw:.1}°");
assert!(sw > 330.0 && sw < 360.0, "CCW one call: sweep {sw}°");
// 30° calls, clockwise: 90 → -60.
let mut s = free_arc(90.0);
let mut last = 90.0;
let mut log = Vec::new();
for deg in [60.0, 30.0, 0.0, -30.0, -60.0] {
drag(&mut s, "a1.end", on_arc(last), on_arc(deg));
last = deg;
log.push(sweep_deg(&s, "a1"));
}
eprintln!("free arc, CW 30° calls: sweeps {log:.1?}");
assert!(log.windows(2).all(|w| w[1] <= w[0] + 1e-9) && log.iter().all(|&x| x > 0.0), "CW calls: {log:?}");
// 30° calls, counter-clockwise: 90 → 450.
let mut s = free_arc(90.0);
let mut last = 90.0;
let mut log = Vec::new();
for deg in (4..=15).map(|i| (i * 30) as f64) {
drag(&mut s, "a1.end", on_arc(last), on_arc(deg));
last = deg;
log.push(sweep_deg(&s, "a1"));
}
eprintln!("free arc, CCW 30° calls: sweeps {log:.1?}");
assert!(log.windows(2).all(|w| w[1] >= w[0] - 1e-9) && log.iter().all(|&x| x < 360.0), "CCW calls: {log:?}");
assert!(log.last().is_some_and(|&x| x > 330.0), "CCW calls: {log:?}");
}
/// A fillet (radius 5, tangent to both legs) whose second leg is dragged round the corner
/// 90° → 174° → 14° → 90° in 2° calls stays inside the corner; with a 40 mm leg the leg turns
/// over near 14° (the tangent length passes 40) and comes back on the way up.
#[test]
fn a_dragged_fillet_stays_in_its_corner() {
for (leg, strict) in [(80.0, true), (40.0, false)] {
let mut s = new_sketch();
let o = v(100.0, 100.0);
run(&mut s, "sketch.line", json!({"points": [[o.x + 40.0, o.y], [o.x, o.y], [o.x, o.y + leg]], "ids": ["l1", "l2"]}));
run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 5}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "horizontal"}));
let sk0 = sketch(&s);
let arc = sk0.curves.iter().position(|c| matches!(c.kind, solvecraft_sketch::CurveKind::Arc { .. })).expect("fillet arc");
let arc_id = sk0.curves[arc].id.clone();
let inside = |s: &Session| {
let sk = sketch(s);
let side = |p: solvecraft_geom::Vec2, a: solvecraft_geom::Vec2, b: solvecraft_geom::Vec2| (b - a).cross(p - a).signum();
let c = sk.center(arc).expect("centre");
let (p0, k1, k2, p3) = (pt(s, "l1.start"), pt(s, "l1.end"), pt(s, "l2.start"), pt(s, "l2.end"));
let r = sk.radius(arc).unwrap_or(0.0);
side(c, p0, k1) == side(p3, p0, k1) && side(c, k2, p3) == side(p0, k2, p3) && (r - 5.0).abs() < 1e-6
};
assert!(inside(&s));
let mut path: Vec<f64> = (45..=87).map(|i| (i * 2) as f64).collect();
path.extend((7..=87).rev().map(|i| (i * 2) as f64));
path.extend((8..=45).map(|i| (i * 2) as f64));
let corner = o;
let at = |deg: f64| {
let t = deg.to_radians();
[corner.x + leg * t.cos(), corner.y + leg * t.sin()]
};
let mut bad = Vec::new();
let mut last = at(90.0);
for deg in path {
let to = at(deg);
drag(&mut s, "l2.end", last, to);
last = to;
let sw = sweep_deg(&s, &arc_id);
if !(inside(&s) && sw < 180.0) {
bad.push(deg);
}
}
if strict {
assert!(bad.is_empty(), "leg {leg}: bad states at {bad:?}");
}
assert!(inside(&s) && sweep_deg(&s, &arc_id) < 180.0, "leg {leg}: did not recover (bad at {bad:?})");
}
}
/// Hostile `from` / `to` are refused and change nothing; a zero-length drag changes nothing.
#[test]
fn hostile_drags_change_nothing() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 20]]}));
let l0 = snap(&s, "l1");
for bad in [json!(f64::NAN), json!(f64::INFINITY), json!(1e300), json!(-1e300), json!("x"), json!([1.0]), json!(null)] {
for (from, to) in [(json!([10, 10]), json!([bad.clone(), 0])), (json!([bad.clone(), 0]), json!([10, 10]))] {
for e in ["l1", "l1.end"] {
let r = s.execute("sketch.drag", &json!({"entity": e, "from": from.clone(), "to": to.clone()}));
assert!(r.is_err(), "{e} {from} {to}: {r:?}");
assert!(same(&snap(&s, "l1"), &l0));
}
}
assert!(s.execute("sketch.move_point", &json!({"point": "l1.end", "to": [bad.clone(), 0]})).is_err());
assert!(same(&snap(&s, "l1"), &l0));
}
drag(&mut s, "l1", [20.0, 10.0], [20.0, 10.0]);
drag(&mut s, "l1.end", [40.0, 20.0], [40.0, 20.0]);
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [40, 20]}));
assert!(same(&snap(&s, "l1"), &l0));
// A huge (but finite, in-range) jump on a constrained group is capped and never panics.
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
assert_eq!(drag_steps(&s, "l1.end", [40.0, 20.0], [9.0e8, -9.0e8]), 64);
drag(&mut s, "l1.end", [40.0, 20.0], [9.0e8, -9.0e8]);
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
assert!(a.is_finite() && b.is_finite(), "{a:?} {b:?}");
}
/// The solves a drag of `entity` from `from` to `to` would take.
fn drag_steps(s: &Session, entity: &str, from: [f64; 2], to: [f64; 2]) -> usize {
let sk = sketch(s);
let e = super::ent(&sk, entity).unwrap_or_else(|| panic!("no entity {entity}"));
super::drag::drag_steps(&sk, e, v(from[0], from[1]), v(to[0], to[1]))
}
/// Continuation costs nothing on a normal mouse frame: a short move next to the geometry is one
/// held solve, as it was before; only a long one is split.
#[test]
fn a_short_drag_is_a_single_solve() {
let mut s = new_sketch();
// Ten dimensioned 20 × 10 rectangles, free to move (clear of the origin).
for i in 0..10 {
let x = 100.0 + 30.0 * i as f64;
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 20.0, 10]}));
let c = curve_ids(&r);
run(&mut s, "sketch.dimension", json!({"entities": [c[0]], "value": 20}));
run(&mut s, "sketch.dimension", json!({"entities": [c[1]], "value": 10}));
}
assert_eq!(drag_steps(&s, "l1.end", [120.0, 0.0], [120.5, 0.3]), 1);
assert_eq!(drag_steps(&s, "l1", [110.0, 0.0], [110.5, 0.5]), 1);
drag(&mut s, "l1.end", [120.0, 0.0], [120.5, 0.3]);
assert!(pt(&s, "l1.end").dist(v(120.5, 0.3)) < 1e-9);
// A long move (more than an eighth of the 10 mm side) is split into steps.
assert!(drag_steps(&s, "l1.end", [120.5, 0.3], [140.0, -20.0]) > 1);
drag(&mut s, "l1.end", [120.5, 0.3], [140.0, -20.0]);
assert!(pt(&s, "l1.end").dist(v(140.0, -20.0)) < 1e-9);
}
/// The drag group stops at fixed points, as the solve does: a tiny dimension on geometry that
/// only shares a fixed point with the dragged geometry does not shrink its steps.
#[test]
fn the_drag_step_ignores_geometry_beyond_a_fixed_point() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[100, 100], [120, 100]], "ids": ["la"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["la.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 20}));
let before = drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]);
run(&mut s, "sketch.line", json!({"points": [[100, 100], [100.01, 100]], "ids": ["lb"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["lb"], "value": 0.01}));
assert_eq!(drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]), before);
assert_eq!(before, 1);
// A free radius couples like a point: a line tangent to a circle whose centre is fixed
// steps by the circle's radius (r 2: steps of 0.5 mm), its only link to the line.
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [100, 100], "radius": 2, "connect": false}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1.center"]}));
run(&mut s, "sketch.line", json!({"points": [[90, 102], [110, 102]], "ids": ["lt"], "connect": false}));
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "lt"}));
assert_eq!(drag_steps(&s, "lt.end", [110.0, 102.0], [110.0, 105.0]), 6);
}
/// The step counts every size the constraints hold, offsets along an axis too (the solver holds
/// their size on either side), except a length between two points the drag holds: a dimensioned
/// line slid by its middle keeps its length whatever the step.
#[test]
fn the_drag_step_counts_every_held_size_but_the_dragged_length() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[100, 100], [100.5, 100]], "ids": ["la"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 0.5}));
let steps = drag_steps(&s, "la", [100.25, 100.0], [105.25, 100.0]);
eprintln!("0.5 mm line slid 5 mm: {steps} steps");
assert_eq!(steps, 1);
// B 0.05 mm right of A (horizontal dimension); A dragged 1 mm right in one call: B stays
// right of A instead of jumping to the other side.
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[100, 100], [100, 110]], "ids": ["la"]}));
run(&mut s, "sketch.line", json!({"points": [[100.05, 120], [110, 120]], "ids": ["lb"], "connect": false}));
run(&mut s, "sketch.dimension", json!({"entities": ["la.start", "lb.start"], "type": "horizontal", "value": 0.05}));
assert!(sketch(&s).constraints.iter().any(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::DistanceX { .. })));
let steps = drag_steps(&s, "la.start", [100.0, 100.0], [101.0, 100.0]);
drag(&mut s, "la.start", [100.0, 100.0], [101.0, 100.0]);
let (a, b) = (pt(&s, "la.start"), pt(&s, "lb.start"));
eprintln!("1 mm drag with a 0.05 mm DistanceX: {steps} steps; A {a:?} B {b:?}");
assert!(steps > 1);
assert!(a.dist(v(101.0, 100.0)) < 1e-9 && (b.x - a.x - 0.05).abs() < 1e-6, "B went to the other side: {a:?} {b:?}");
}
/// A circle shares only its centre with a dragged line's end (coincident): the solve couples
/// its centre, never its radius, so a tiny circle does not shrink the step.
#[test]
fn a_circle_centred_on_a_dragged_end_does_not_set_the_step() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[100, 100], [120, 100]], "ids": ["la"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["la.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 20}));
run(&mut s, "sketch.circle.center", json!({"center": [120, 100], "radius": 0.01}));
assert!(sketch(&s).constraints.iter().any(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::Coincident { .. })));
let steps = drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]);
eprintln!("1 mm drag next to a 0.01 mm circle on the end: {steps} steps");
assert_eq!(steps, 1);
}
/// A one-call drag whose straight path crosses where the geometry cannot reach (an arm's hand
/// through the hole round its shoulder) still lands on a cursor it can reach.
#[test]
fn a_drag_across_an_unreachable_middle_still_reaches_the_cursor() {
let mut s = new_sketch();
let o = v(200.0, 100.0);
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + 30.0, o.y], [o.x + 30.0, o.y + 20.0]], "ids": ["l1", "l2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
// From (30, 20) to (-30, -20) about the shoulder: the path passes through it.
let to = [o.x - 30.0, o.y - 20.0];
let steps = drag_steps(&s, "l2.end", [o.x + 30.0, o.y + 20.0], to);
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [o.x + 30.0, o.y + 20.0], "to": to}));
eprintln!("arm hand across the hole: {steps} steps, {r}");
assert!(steps > 1);
assert!(pt(&s, "l2.end").dist(v(to[0], to[1])) < 1e-6, "{:?}", pt(&s, "l2.end"));
assert_eq!(r["reached"], json!(1.0));
}
/// Past the unreachable stretch round its shoulder, an arm's drag keeps stepping (no one jump to
/// the cursor), so a thin triangle hanging off the hand (it can turn over) keeps its side.
#[test]
fn a_drag_past_an_unreachable_stretch_keeps_stepping() {
let mut s = new_sketch();
let o = v(200.0, 100.0);
let elbow = o + v(19.702247520273062, 22.62347105662377);
let hand = o + v(39.654744430607906, 21.245840103473398);
let to = o + v(-28.82686238241851, -31.24580379872583);
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [elbow.x, elbow.y], [hand.x, hand.y]], "ids": ["l1", "l2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
// A triangle with sides 3.9, 2 and 2 on the hand.
let hand = pt(&s, "l2.end");
let (q, r) = (hand + v(3.9, 0.0), hand + v(1.95, (4.0f64 - 1.95 * 1.95).sqrt()));
run(&mut s, "sketch.line", json!({"points": [[hand.x, hand.y], [q.x, q.y], [r.x, r.y], [hand.x, hand.y]], "ids": ["t1", "t2", "t3"]}));
for (l, value) in [("t1", 3.9), ("t2", 2.0), ("t3", 2.0)] {
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": value}));
}
let side = |s: &Session| (pt(s, "t1.end") - pt(s, "l2.end")).cross(pt(s, "t2.end") - pt(s, "l2.end")).signum();
let before = side(&s);
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [hand.x, hand.y], "to": [to.x, to.y]}));
eprintln!("arm with a thin triangle past the hole: {r}, side {before} -> {}", side(&s));
assert!(pt(&s, "l2.end").dist(to) < 1e-6, "{:?}", pt(&s, "l2.end"));
assert_eq!(side(&s), before, "the triangle turned over");
assert_eq!(r["reached"], json!(1.0));
}
/// The sweep guard stops an arc's end crossing its start, never mere closeness: an arc at
/// 359.99999° or 1e-7 rad still lets its own end and geometry joined to it be dragged.
#[test]
fn arcs_near_a_full_turn_or_nothing_can_still_be_dragged() {
// (sweep, a nudge of the end towards the inside of the sweep that keeps it within 1e-6 rad
// of the limit).
for (sweep, nudge) in [(359.99999, -5e-7), (1e-7_f64.to_degrees(), 5e-7)] {
let mut s = free_arc(sweep);
// A dimensioned line hangs off the arc's end.
let end = pt(&s, "a1.end");
run(&mut s, "sketch.line", json!({"points": [[end.x, end.y], [end.x + 10.0, end.y + 10.0]], "ids": ["lc"], "connect": false}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "a1.end", "b": "lc.start"}));
run(&mut s, "sketch.dimension", json!({"entities": ["lc"], "value": 200f64.sqrt()}));
let sw0 = sweep_deg(&s, "a1");
// The arc's own end.
let t = (end - v(50.0, 50.0)).angle() + nudge;
let to = [50.0 + 10.0 * t.cos(), 50.0 + 10.0 * t.sin()];
drag(&mut s, "a1.end", [end.x, end.y], to);
assert!(pt(&s, "a1.end").dist(v(to[0], to[1])) < 1e-9, "sweep {sweep}°: the arc's end did not move");
// The joined line's far end, which pulls the arc's end along (inwards).
let far = pt(&s, "lc.end");
let target = v(far.x, far.y + 1e-5 * nudge.signum());
drag(&mut s, "lc.end", [far.x, far.y], [target.x, target.y]);
assert!(pt(&s, "lc.end").dist(target) < 1e-9, "sweep {sweep}°: the joined line did not move");
assert!((sweep_deg(&s, "a1") - sw0).abs() < 1.0, "sweep {sweep}° became {}°", sweep_deg(&s, "a1"));
}
}
#[test]
fn fully_constrained_feedback_is_per_entity() {
let mut s = new_sketch();
@@ -669,3 +1247,141 @@ fn pipe_and_sweep_follow_3d_sketch_curves() {
assert!(near(&lo[0], -2.0) && near(&lo[2], 0.0) && within(&hi[0], 50.0, 52.0) && within(&hi[2], 50.0, 52.0), "{p} {body}");
assert!(body["volume_mm3"].as_f64().unwrap_or(0.0) > 4.0 * std::f64::consts::PI * 50.0 * 0.95, "{p} {body}");
}
/// Between two points, or on a line, that don't line up with an axis, an `auto` dimension takes
/// its orientation from where its text goes: across from the two the distance between them,
/// past that above or below them the horizontal distance, beside them the vertical one (#257).
#[test]
fn auto_dimension_orientation_follows_where_its_text_is_placed() {
use solvecraft_sketch::ConstraintKind;
// The ends of a 30 × 40 line: 50 long, and the slope keeps "across" and "past" distinct.
let dimension = |end: [f64; 2], entities: Value, extra: Value| {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], end], "connect": false}));
let mut p = json!({"entities": entities});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
let r = run(&mut s, "sketch.dimension", p);
let sk = sketch(&s);
let kind = sk.constraints.iter().find(|c| c.kind.is_dimension()).map(|c| c.kind.clone()).expect("a dimension");
(kind, r["measured"].as_f64().unwrap_or(f64::NAN), sk)
};
let points = json!(["l1.start", "l1.end"]);
for entities in [points.clone(), json!(["l1"])] {
for (at, expected) in [
([15.0, 60.0], "horizontal"),
([15.0, -20.0], "horizontal"),
([-30.0, 5.0], "vertical"),
([55.0, 38.0], "vertical"),
([-20.0, 25.0], "aligned"),
([45.0, 10.0], "aligned"),
([-20.0, -20.0], "aligned"),
] {
let (kind, measured, _) = dimension([30.0, 40.0], entities.clone(), json!({"text_at": at}));
let (got, value) = match kind {
ConstraintKind::DistanceX { .. } => ("horizontal", 30.0),
ConstraintKind::DistanceY { .. } => ("vertical", 40.0),
ConstraintKind::Distance { .. } | ConstraintKind::Length { .. } => ("aligned", 50.0),
other => panic!("{entities} at {at:?}: {other:?}"),
};
assert_eq!(got, expected, "{entities} with its text at {at:?}");
assert!((measured - value).abs() < 1e-9, "{entities} at {at:?} measured {measured}");
}
// No text, an explicit type, or two ends already on an axis keep today's dimension.
let (kind, ..) = dimension([30.0, 40.0], entities.clone(), json!({}));
assert!(matches!(kind, ConstraintKind::Distance { .. } | ConstraintKind::Length { .. }), "{kind:?}");
let (kind, ..) = dimension([30.0, 40.0], entities.clone(), json!({"type": "distance", "text_at": [15, 60]}));
assert!(matches!(kind, ConstraintKind::Distance { .. } | ConstraintKind::Length { .. }), "{kind:?}");
let (kind, ..) = dimension([30.0, 40.0], entities.clone(), json!({"type": "vertical", "text_at": [15, 60]}));
assert!(matches!(kind, ConstraintKind::DistanceY { .. }), "{kind:?}");
let (kind, ..) = dimension([40.0, 0.0], json!(["l1.start", "l1.end"]), json!({"text_at": [60, 5]}));
assert!(matches!(kind, ConstraintKind::Distance { .. }), "{kind:?}");
}
// The chosen dimension drives that coordinate: 24 across X, the 40 in Y kept.
let (_, _, sk) = dimension([30.0, 40.0], points, json!({"text_at": [15, 60], "value": 24}));
let end = sk.point(sk.resolve_point("l1.end").expect("end")).expect("end point");
let start = sk.point(sk.resolve_point("l1.start").expect("start")).expect("start point");
assert!(((end.x - start.x).abs() - 24.0).abs() < 1e-6 && ((end.y - start.y).abs() - 40.0).abs() < 1e-6, "{start:?} {end:?}");
}
/// A two-link arm (B, elbow E, hand H) hanging off a rigid triangle A B C that only the cluster
/// decomposition places: pinned at F by two of its corners and held at G by the third. Every
/// line is dimensioned. Without decomposition budget the elbow's seed has nothing to build on.
fn hung_arm() -> Session {
let mut s = new_sketch();
let o = v(200.0, 100.0);
let at = |x: f64, y: f64| json!([o.x + x, o.y + y]);
run(&mut s, "sketch.line", json!({"points": [at(0.0, 0.0), at(5.0, 15.0), at(20.0, 25.0), at(15.0, 8.0)], "ids": ["fa", "ab", "bc"]}));
run(&mut s, "sketch.line", json!({"points": ["bc.end", "ab.start"], "ids": ["ca"]}));
run(&mut s, "sketch.line", json!({"points": ["fa.start", "bc.end"], "ids": ["fc"]}));
run(&mut s, "sketch.line", json!({"points": [at(40.0, 0.0), "ab.end"], "ids": ["gb"]}));
run(&mut s, "sketch.line", json!({"points": ["ab.end", at(35.0, 40.0), at(50.0, 30.0)], "ids": ["be", "eh"]}));
for id in ["fa", "ab", "bc", "ca", "fc", "gb", "be", "eh"] {
run(&mut s, "sketch.dimension", json!({"entities": [id]}));
}
run(&mut s, "sketch.constraint.fix", json!({"entities": ["fa.start", "gb.start"]}));
s
}
/// The elbow's side of the shoulder–hand line.
fn hung_elbow_side(s: &Session) -> f64 {
let b = pt(s, "ab.end");
(pt(s, "eh.end") - b).cross(pt(s, "be.end") - b).signum()
}
/// The hand of `hung_arm` swung by `angle` about the shoulder.
fn hung_hand_target(s: &Session, angle: f64, scale: f64) -> solvecraft_geom::Vec2 {
let (b, h) = (pt(s, "ab.end"), pt(s, "eh.end"));
let r = h - b;
let (c, si) = (angle.cos(), angle.sin());
b + v(r.x * c - r.y * si, r.x * si + r.y * c) * scale
}
/// A live drag whose seed runs out of decomposition budget takes the rest of the way in short
/// steps (continuation keeps the elbow the seed no longer places).
#[test]
fn a_drag_out_of_decomposition_budget_takes_short_steps() {
let start = hung_arm();
let before = hung_elbow_side(&start);
let h = pt(&start, "eh.end");
let mut flips = 0;
for k in [-3.0, -2.0, -1.0, 1.0, 2.0, 3.0] {
let mut s = hung_arm();
let to = hung_hand_target(&s, k * 0.7, 1.0);
let planned = drag_steps(&s, "eh.end", [h.x, h.y], [to.x, to.y]);
let r = super::drag::last_drag::with_frame_work(0, || {
run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [to.x, to.y], "live": true}))
});
let d = super::drag::last_drag::get();
assert!(d.cut, "{d:?}");
assert!(d.count > planned, "{planned} steps planned, {d:?}: the rest in short steps");
assert!(pt(&s, "eh.end").dist(to) < 1e-6, "{r}");
if hung_elbow_side(&s) != before {
flips += 1;
}
}
assert_eq!(flips, 0);
}
/// All the held solves of one live frame, halvings included, share one decomposition budget:
/// together they do at most a frame's work, and one solve's more.
#[test]
fn a_live_drag_shares_one_decomposition_budget() {
let mut s = hung_arm();
let h = pt(&s, "eh.end");
let near = hung_hand_target(&s, 0.01, 1.0);
run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [near.x, near.y], "live": true}));
let one = super::drag::last_drag::get();
assert!(one.count == 1 && one.work > 0 && !one.cut, "{one:?}");
// Swung round and out past the arm's reach: many steps, then halvings.
let mut s = hung_arm();
let far = hung_hand_target(&s, 2.0, 1.5);
let budget = 3 * one.work;
super::drag::last_drag::with_frame_work(budget, || {
run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [far.x, far.y], "live": true}))
});
let d = super::drag::last_drag::get();
assert!(d.count > 6, "{d:?}");
assert!(d.work <= budget + one.work, "{d:?} over a budget of {budget}");
}
+95 -3
View File
@@ -217,6 +217,19 @@ pub struct Session {
/// commands are undone however long the undo history is.
pub(crate) checkpoints: Vec<Checkpoint>,
next_checkpoint: u64,
/// Full model evaluations (`refresh`), for tests.
#[cfg(test)]
pub(crate) evaluations: usize,
}
/// A sketch changed by one frame of an interactive gesture (`live: true`).
#[derive(Clone, Debug)]
pub struct LiveEdit {
pub sketch: u64,
/// What the frame's solve found: a full solve's report, or, when the sketch was solved
/// without a rank analysis, its last report's degrees of freedom and determined flags (a
/// drag does not change them; the evaluation at the gesture's end brings them up to date).
pub report: solvecraft_sketch::SolveReport,
}
/// The design, active sketch, pending positions and undo history at an `edit.checkpoint`.
@@ -302,6 +315,8 @@ impl Session {
checkpoints: Vec::new(),
next_checkpoint: 1,
visibility: Visibility::default(),
#[cfg(test)]
evaluations: 0,
}
}
@@ -360,6 +375,7 @@ impl Session {
if let Some(inverse) = solvecraft_doc::mat_inverse(&first) {
let delta = solvecraft_doc::mat_mul(&self.active_component_frame(), &inverse);
for sketch in w.sketches.iter_mut().filter(|sk| self.doc.feature(sk.feature).is_some_and(|f| f.component == self.active_component)) {
let sketch = Arc::make_mut(sketch);
let plane = sketch.plane;
if let Some(mapped) = solvecraft_geom::Plane::new(
solvecraft_doc::apply_point(&delta, plane.origin),
@@ -394,6 +410,10 @@ impl Session {
pub fn refresh(&mut self) {
self.model.evaluate(&self.doc);
#[cfg(test)]
{
self.evaluations += 1;
}
self.revision += 1;
self.prune_occurrence_state();
// Joints follow the geometry their origins snap to.
@@ -407,10 +427,65 @@ impl Session {
}
}
/// Show the sketch a live frame changed, leaving the features after it as they were last
/// evaluated (marked stale); a full `refresh` when the model cannot show it that way.
fn refresh_live(&mut self, edit: LiveEdit) {
let sketch = self.doc.sketch(edit.sketch).ok().cloned();
if sketch.is_some_and(|sketch| self.model.show_live_sketch(edit.sketch, sketch, edit.report)) {
self.revision += 1;
} else {
self.refresh();
}
}
/// The sketches whose last evaluation solved, each with its own parameter-driven values
/// (taken before a value edit, for [`Session::settle_value_edit`]).
pub(crate) fn solved_sketches(&self) -> SketchValues {
self.model
.results
.iter()
.filter_map(|r| r.state.sketch(r.id).filter(|k| k.report.keeps_orientation()).map(|k| (r.id, own_values(&k.sketch))))
.collect()
}
/// After the document's values changed (parameters, a configuration, the units): evaluate,
/// and refuse the edit when a sketch that solved before no longer does, or turns over,
/// because its own parameter-driven values changed (the command fails, so `execute`
/// restores the document's values and geometry). A sketch broken by a change upstream
/// (a projected or linked edge moved) is not the edit's to refuse: it carries a warning,
/// as before. Otherwise store each sketch whose values changed as solved, so the next edit
/// continues from it (see `Model::adopt_solved_sketches`).
pub(crate) fn settle_value_edit(&mut self, solved_before: &SketchValues) -> Result<()> {
self.refresh();
for r in &self.model.results {
let (Some(before), Some(k)) = (solved_before.get(&r.id), r.state.sketch(r.id)) else { continue };
if !k.report.keeps_orientation() && own_values(&k.sketch) != *before {
let why = k.report.failure(|f| format!("its constraints would conflict ({f})"));
return Err(EngineError::Other(format!("sketch `{}`: {why}; the values were not changed", r.name)));
}
}
// Checked first: storing makes the document unique (a copy when the undo step shares it).
if self.model.edited_sketches(&self.doc).next().is_some() {
self.model.adopt_solved_sketches(Arc::make_mut(&mut self.doc));
}
Ok(())
}
/// Run a command with JSON parameters (no dialogs). Undoable commands record an undo step
/// when they change the document; a failing or panicking command leaves it unchanged.
///
/// A command that takes `live` ([`CommandSpec::live`]) run with `live: true` is one frame of
/// an interactive gesture: the model shows its sketch's new solution and keeps every later
/// feature's last result (stale) instead of evaluating them. Any other command that reads
/// the model ([`CommandSpec::reads_model`]) first evaluates a stale model in full, so the
/// gesture's end (`sketch.drag_end`, or whatever runs next after an abandoned gesture) brings
/// the model up to date before anything reads it.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
let live = spec.live.filter(|_| params.get("live").and_then(Value::as_bool) == Some(true));
if live.is_none() && spec.reads_model && self.model.is_stale() {
self.refresh();
}
(spec.enabled)(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
let before = Snapshot {
label: spec.label.to_string(),
@@ -425,7 +500,10 @@ impl Session {
let (_nested, outermost) = frames::Nested::enter(spec.id);
let mapped = if outermost { frames::to_local(self, spec.id, params) } else { None };
let params = mapped.as_ref().unwrap_or(params);
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match live {
Some(run) => run(self, params),
None => (spec.run)(self, params).map(|v| (v, None)),
}));
let r = match r {
Ok(r) => r,
Err(p) => {
@@ -435,7 +513,7 @@ impl Session {
};
let changed = !Arc::ptr_eq(&self.doc, &before.doc);
match r {
Ok(v) => {
Ok((v, live_edit)) => {
let v = if changed { self.with_created(spec.id, params, &before, v) } else { v };
if changed {
if spec.undoable {
@@ -445,7 +523,10 @@ impl Session {
}
self.redo.clear();
}
self.refresh();
match live_edit {
Some(edit) => self.refresh_live(edit),
None => self.refresh(),
}
}
Ok(v)
}
@@ -548,6 +629,8 @@ impl Session {
checkpoints: Vec::new(),
next_checkpoint: 1,
visibility: self.visibility.clone(),
#[cfg(test)]
evaluations: 0,
}
}
@@ -686,3 +769,12 @@ mod tests;
mod thread_tests;
#[cfg(test)]
mod world_placement_cache_tests;
/// Per sketch feature: its parameter-driven dimensions' values (see [`own_values`]).
pub(crate) type SketchValues = std::collections::BTreeMap<u64, std::collections::BTreeMap<String, f64>>;
/// The values of a sketch's driving dimensions that come from parameters: what a value edit
/// changes in it (as opposed to geometry it projects or links from upstream).
fn own_values(sk: &solvecraft_sketch::Sketch) -> std::collections::BTreeMap<String, f64> {
sk.constraints.iter().filter(|c| c.param.is_some() && !c.driven).filter_map(|c| Some((c.id.clone(), c.kind.value()?))).collect()
}
+154 -2
View File
@@ -145,6 +145,7 @@ mod component_frames;
mod mirror_planes;
mod primitive_points;
mod robustness;
mod xz_plane;
/// 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.
@@ -209,6 +210,42 @@ fn fillet_the_edges_between_two_overlapping_cylinders() {
assert!(rel(v, expect) < 2e-4, "{v} vs {expect}");
}
/// The top outline of two overlapping cylinders whose concave edges were rounded: the walls of
/// the rounds meet the cylinders along the rolling-ball contact lines, straight but not lines.
#[test]
fn fillet_and_chamfer_the_top_loop_of_rounded_overlapping_cylinders() {
use std::f64::consts::{PI, TAU};
let (big, round, h) = (25.0_f64, 5.0_f64, 10.0);
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": big}));
run(&mut s, "sketch.circle.center", json!({"center": [-big, 0], "radius": big}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": h, "profiles": [0, 1, 2]}));
// Plan view: ball centres at x = −12.5, |y| = yb; each round's arc turns by th, each big arc by
// TAU − 2·a1, and the loop turns by TAU in all.
let half = big / 2.0;
let yb = ((big + round).powi(2) - half * half).sqrt();
run(&mut s, "solid.fillet", json!({"edges": [[-half, half * 3f64.sqrt(), 5], [-half, -half * 3f64.sqrt(), 5]], "radius": round}));
let (a1, th) = (yb.atan2(half), 2.0 * (half / (big + round)).asin());
let base = volume(&mut s);
let top = [[big, 0.0, h], [-2.0 * big, 0.0, h], [-half, yb - round, h], [-half, round - yb, h]];
// Removed volume by Pappus: the corner's section swept along the loop at its centroid's offset.
let removed = |area: f64, xb: f64| area * (2.0 * (TAU - 2.0 * a1) * (big - xb) + 2.0 * th * (round + xb));
run(&mut s, "solid.fillet", json!({"edges": top, "radius": 2}));
let r: f64 = 2.0;
let expect = base - removed(r * r * (1.0 - PI / 4.0), (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)) * r);
let v = volume(&mut s);
assert!((v - expect).abs() < 1.5, "{v} vs {expect}");
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["bodies"][0]["kernel"]["faces"].as_u64(), Some(10), "{m}");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.chamfer", json!({"edges": top, "distance": 2}));
let expect = base - removed(2.0, 2.0 / 3.0);
let v = volume(&mut s);
assert!((v - expect).abs() < 1.5, "{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() {
@@ -1641,8 +1678,10 @@ fn cached_evaluation_matches_from_scratch() {
let before = hits(&s);
run(&mut s, id, p.clone());
same(&s, &format!("step {step}: {id} {p}"));
if step == 1 {
assert!(hits(&s) > before, "setting the width back reuses the earlier results");
// Setting the width back solves the sketch again from its current state; the
// features after it see the same geometry and reuse their results. So does undo.
if step == 1 || step == 3 {
assert!(hits(&s) > before, "step {step} reuses the earlier results");
}
}
}
@@ -2638,3 +2677,116 @@ fn feature_params_docs_list_shared_keys() {
assert!(params(id).contains("targets?"), "{id}: {}", params(id));
}
}
#[test]
fn a_pattern_repeats_a_fillet_on_the_edges_it_found() {
// #219: a filleted pocket patterned along the plate, its height from a parameter. A taller
// pocket moves its far corner edges away from the points picked for the fillet; the pattern
// repeats the edges the fillet found (by name), not those points.
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "n", "expression": "14", "unit": ""}));
run(&mut s, "parameters.add", json!({"name": "h", "expression": "(419.1 mm - 3.175 mm - (n - 1) * 3.175 mm) / n", "unit": "mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [150, 422.275]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 19.05, "name": "Plate"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 19.05, "name": "PocketPlane"}));
run(&mut s, "sketch.create", json!({"plane": "PocketPlane"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [3.175, 3.175], "p1": [146.825, 29.9357]}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p1"], "type": "horizontal", "value": "3.175"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p1"], "type": "vertical", "value": "3.175"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "type": "length", "value": "143.65"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l4"], "type": "length", "value": "h"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 12.7, "direction": "negative", "operation": "cut", "name": "Pocket"}));
run(
&mut s,
"solid.fillet",
json!({"edges": [[3.175, 3.175, 12.700000000000001], [146.825, 3.175, 12.700000000000001], [146.825, 29.9357, 12.700000000000001], [3.175, 29.9357, 12.700000000000001]], "radius": 3.2}),
);
run(
&mut s,
"solid.pattern.rectangular",
json!({"features": ["Pocket", "Fillet1"], "dir1": [0, 1, 0], "count1": "n", "spacing1": "h + 3.175 mm"}),
);
let r = run(&mut s, "parameters.change", json!({"name": "n", "expression": "10"}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
let (n, h) = (10.0, (419.1 - 3.175 - 9.0 * 3.175) / 10.0);
let pocket = 143.65 * h * 12.7 - 4.0 * (1.0 - PI / 4.0) * 3.2 * 3.2 * 12.7;
let expect = 150.0 * 422.275 * 19.05 - n * pocket;
assert!(rel(volume(&mut s), expect) < 1e-5, "{} vs {expect}", volume(&mut s));
}
/// A copy added by raising the count after a rename takes the next number, not the freed one (#372).
#[test]
#[ignore = "fails until #372 is fixed"]
fn raising_a_pattern_count_after_a_rename_numbers_the_new_copy_after_the_others() {
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, "name": "Pat"}));
run(&mut s, "body.rename", json!({"body": "X (2)", "name": "Second"}));
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "4"}}}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["X", "Second", "X (3)", "X (4)"]);
let fourth = run(&mut s, "inspect.measure", json!({"bodies": ["X (4)"]}));
assert_eq!(fourth["bodies"][0]["bbox"]["min"][0].as_f64().map(f64::round), Some(60.0), "{fourth}");
}
/// A profile added to the sketch after a named multi-profile extrude is named after `body_name`
/// too (#373).
#[test]
fn a_profile_added_later_is_named_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!({"sketch": "R", "distance": "10", "operation": "new_body", "body_name": "Ribs"}));
run(&mut s, "sketch.edit", json!({"sketch": "R"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [30, 0], "p1": [35, 30]}));
run(&mut s, "sketch.finish", json!({}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["Ribs", "Ribs (2)", "Ribs (3)", "Ribs (4)"]);
}
/// The reply names the bodies the feature created, apart from the ones already there (#374).
#[test]
#[ignore = "fails until #374 is fixed"]
fn extrude_reply_lists_the_bodies_it_created() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [0, 50, 0], "operation": "new_body", "body_name": "Base"}));
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!({}));
let r = run(&mut s, "solid.extrude", json!({"sketch": "R", "distance": "10", "operation": "new_body", "body_name": "Ribs"}));
assert_eq!(r["created_bodies"], json!(["Ribs", "Ribs (2)", "Ribs (3)"]), "{r}");
}
/// A design saved before format 14 names the bodies past its stored names `BodyN`, as it did when
/// it was saved, so later features that name them still find them (#373).
#[test]
fn a_design_saved_before_format_14_keeps_generic_names_past_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!({"sketch": "R", "distance": "10", "operation": "new_body", "body_name": "Ribs"}));
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
legacy["format"] = json!("solvecraft/13");
let extrude = legacy["features"].as_array_mut().unwrap().iter_mut().find(|f| f["type"] == "extrude").unwrap();
extrude["body_names"] = json!(["Ribs"]);
let dir = std::env::temp_dir().join(format!("solvecraft-373-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let design = dir.join("ribs.solvecraft");
std::fs::write(&design, legacy.to_string()).unwrap();
let mut old = Session::default();
run(&mut old, "doc.open", json!({"path": design.to_string_lossy()}));
let names: Vec<String> = old.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["Ribs", "Body1", "Body2"]);
let _ = std::fs::remove_dir_all(&dir);
}
+97
View File
@@ -0,0 +1,97 @@
//! The XZ origin plane (#78): sketch x = +X, y = −Z, normal +Y, so a positive extrude from XZ
//! (or from a plane offset from it) goes +Y. Designs saved before format 6 used y = +Z and
//! normal −Y; opening one keeps its geometry exactly where it was saved.
use serde_json::{Value, json};
use super::run;
use crate::*;
/// A 10 × 5 rectangle on XZ extruded 3, and a 4 × 4 square on a plane offset 7 from XZ
/// extruded 2.
fn build(s: &mut Session) {
run(s, "sketch.create", json!({"plane": "XZ", "name": "Base"}));
run(s, "sketch.line", json!({"points": [[0, 0], [10, 0], [10, 5], [0, 5]], "closed": true}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"sketch": "Base", "distance": 3, "body_name": "Slab"}));
run(s, "construct.plane.offset", json!({"base": "XZ", "offset": "7 mm", "name": "Shelf"}));
run(s, "sketch.create", json!({"plane": "Shelf", "name": "Top"}));
run(s, "sketch.line", json!({"points": [[20, 1], [24, 1], [24, 5], [20, 5]], "closed": true}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"sketch": "Top", "distance": 2, "body_name": "Post", "operation": "new"}));
}
/// Volume and world bounding box (min, max) of each body.
fn bodies(s: &mut Session) -> Vec<(String, f64, [f64; 3], [f64; 3])> {
let m = run(s, "inspect.measure", json!({}));
let v3 = |v: &Value| -> [f64; 3] { [0, 1, 2].map(|i| v[i].as_f64().unwrap()) };
m["bodies"]
.as_array()
.unwrap()
.iter()
.map(|b| (b["name"].as_str().unwrap().to_string(), b["volume_mm3"].as_f64().unwrap(), v3(&b["bbox"]["min"]), v3(&b["bbox"]["max"])))
.collect()
}
fn close(a: [f64; 3], b: [f64; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
}
fn expect(got: &[(String, f64, [f64; 3], [f64; 3])], want: &[(&str, f64, [f64; 3], [f64; 3])]) {
assert_eq!(got.len(), want.len(), "{got:?}");
for ((name, vol, min, max), (wname, wvol, wmin, wmax)) in got.iter().zip(want) {
assert_eq!(name, wname);
assert!((vol - wvol).abs() < 1e-6 * wvol, "{name}: volume {vol} vs {wvol}");
assert!(close(*min, *wmin) && close(*max, *wmax), "{name}: bbox {min:?}..{max:?}, expected {wmin:?}..{wmax:?}");
}
}
#[test]
fn positive_extrude_from_xz_goes_plus_y() {
let mut s = Session::default();
build(&mut s);
expect(
&bodies(&mut s),
&[
// Sketch y = −Z; extrude along +Y.
("Slab", 150.0, [0.0, 0.0, -5.0], [10.0, 3.0, 0.0]),
// The offset plane sits at Y = +7 and its extrude continues to +Y.
("Post", 32.0, [20.0, 7.0, -5.0], [24.0, 9.0, -1.0]),
],
);
}
#[test]
fn format_5_xz_design_opens_where_it_was_saved() {
let dir = std::env::temp_dir().join(format!("solvecraft-xz78-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("old-xz.solvecraft");
let mut s = Session::default();
build(&mut s);
run(&mut s, "file.save_as", json!({"path": path.to_string_lossy()}));
// Stamp the file as format 5: its XZ references then mean the old frame (sketch y = +Z,
// normal −Y), which is what a build before format 6 wrote and evaluated.
let mut v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(v.to_string().contains("\"XZ\""), "the saved design names the XZ plane");
v["format"] = Value::from("solvecraft/5");
std::fs::write(&path, v.to_string()).unwrap();
let mut old = Session::default();
run(&mut old, "doc.open", json!({"path": path.to_string_lossy()}));
let want = [
// The old frame: the same sketch coordinates land at +Z and the extrude goes −Y.
("Slab", 150.0, [0.0, -3.0, 0.0], [10.0, 0.0, 5.0]),
("Post", 32.0, [20.0, -9.0, 1.0], [24.0, -7.0, 5.0]),
];
expect(&bodies(&mut old), &want);
// Saving the migrated design writes the current format and keeps the geometry.
let resaved = dir.join("resaved.solvecraft");
run(&mut old, "file.save_as", json!({"path": resaved.to_string_lossy()}));
let text = std::fs::read_to_string(&resaved).unwrap();
assert!(text.contains(&format!("\"solvecraft/{}\"", solvecraft_doc::format::FORMAT)), "resaved at the current format");
let mut again = Session::default();
run(&mut again, "doc.open", json!({"path": resaved.to_string_lossy()}));
expect(&bodies(&mut again), &want);
let _ = std::fs::remove_dir_all(&dir);
}
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+812
View File
@@ -0,0 +1,812 @@
//! B-spline machinery shared by NURBS and T-spline surfaces: homogeneous points, the
//! univariate basis (Cox–de Boor on half-open intervals, with derivatives), Boehm knot
//! insertion, knot insertion into a single basis function, Bézier extraction, and a tensor
//! NURBS surface.
//!
//! References: Piegl & Tiller, *The NURBS Book* (2nd ed.) — §2.2 (basis definition, eq. 2.5),
//! §2.3 (derivatives, eq. 2.9), A2.1 (span search), §5.2 (knot insertion, eq. 5.10 / A5.1),
//! §1.4 (de Casteljau); Sederberg, Cardon, Finnigan, North, Zheng, Lyche 2004, "T-spline
//! simplification and local refinement", §3 (knot insertion into one blending function).
//!
//! Input is hostile: every constructor checks finiteness and sizes and returns
//! [`SplineError`] instead of panicking; no function here indexes with an unchecked number.
use std::ops::{Add, AddAssign, Mul, Sub};
use crate::Vec3;
/// Highest polynomial degree accepted in either direction.
pub const MAX_SPLINE_DEGREE: usize = 7;
/// Most knots accepted in one knot vector.
pub const MAX_KNOTS: usize = 1 << 14;
/// Most control points accepted in one tensor surface.
pub const MAX_SURFACE_POINTS: usize = 1 << 20;
/// Why a spline could not be built or used.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum SplineError {
#[error("invalid spline: {0}")]
Invalid(String),
#[error("not supported yet: {0}")]
Unsupported(String),
#[error("spline too large: {0}")]
TooLarge(String),
}
pub type SplineResult<T> = Result<T, SplineError>;
pub(crate) fn invalid<T>(msg: impl Into<String>) -> SplineResult<T> {
Err(SplineError::Invalid(msg.into()))
}
/// A homogeneous control point `(x·w, y·w, z·w, w)`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct HPoint {
pub x: f64,
pub y: f64,
pub z: f64,
pub w: f64,
}
impl HPoint {
pub const ZERO: HPoint = HPoint { x: 0.0, y: 0.0, z: 0.0, w: 0.0 };
/// The point `p` with weight `w`.
pub fn weighted(p: Vec3, w: f64) -> HPoint {
HPoint { x: p.x * w, y: p.y * w, z: p.z * w, w }
}
/// The Cartesian point, if the weight is usable.
pub fn project(self) -> Option<Vec3> {
let p = Vec3::new(self.x / self.w, self.y / self.w, self.z / self.w);
(self.w.abs() > f64::MIN_POSITIVE && p.is_finite()).then_some(p)
}
/// The weighted coordinates `(x·w, y·w, z·w)`.
pub fn xyz(self) -> Vec3 {
Vec3::new(self.x, self.y, self.z)
}
pub fn is_finite(self) -> bool {
self.x.is_finite() && self.y.is_finite() && self.z.is_finite() && self.w.is_finite()
}
}
impl Add for HPoint {
type Output = HPoint;
fn add(self, o: HPoint) -> HPoint {
HPoint { x: self.x + o.x, y: self.y + o.y, z: self.z + o.z, w: self.w + o.w }
}
}
impl Sub for HPoint {
type Output = HPoint;
fn sub(self, o: HPoint) -> HPoint {
HPoint { x: self.x - o.x, y: self.y - o.y, z: self.z - o.z, w: self.w - o.w }
}
}
impl Mul<f64> for HPoint {
type Output = HPoint;
fn mul(self, k: f64) -> HPoint {
HPoint { x: self.x * k, y: self.y * k, z: self.z * k, w: self.w * k }
}
}
impl AddAssign for HPoint {
fn add_assign(&mut self, o: HPoint) {
*self = *self + o;
}
}
/// A parameter direction: along s (u, the first) or along t (v, the second).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Dir {
S,
T,
}
impl Dir {
/// `s` for [`Dir::S`], `t` for [`Dir::T`].
pub fn pick<X>(self, s: X, t: X) -> X {
match self {
Dir::S => s,
Dir::T => t,
}
}
}
/// Homogeneous control points from finite points and positive weights, one each.
pub fn weighted_points(points: &[Vec3], weights: &[f64]) -> SplineResult<Vec<HPoint>> {
if points.len() != weights.len() {
return invalid("one weight per control point");
}
if points.iter().any(|p| !p.is_finite()) {
return invalid("a control point is not finite");
}
if weights.iter().any(|w| !(w.is_finite() && *w > 0.0)) {
return invalid("weights must be positive numbers");
}
Ok(points.iter().zip(weights).map(|(p, w)| HPoint::weighted(*p, *w)).collect())
}
/// Which one-sided limit a basis evaluation takes at a knot. Basis functions live on half-open
/// spans `[uᵢ, uᵢ₊₁)` ([`Side::Right`]); at the right end of a domain the left limit on
/// `(uᵢ, uᵢ₊₁]` ([`Side::Left`]) keeps the closing span.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
Right,
Left,
}
/// Check a knot vector: finite, non-decreasing, not too long.
pub fn check_knots(knots: &[f64]) -> SplineResult<()> {
if knots.len() > MAX_KNOTS {
return Err(SplineError::TooLarge(format!("{} knots (at most {MAX_KNOTS})", knots.len())));
}
if knots.iter().any(|k| !k.is_finite()) {
return invalid("a knot is not a finite number");
}
if knots.windows(2).any(|w| matches!(w, [a, b] if b < a)) {
return invalid("knots must not decrease");
}
Ok(())
}
/// One B-spline basis function of degree `p = knots.len() − 2` given by its local knot vector
/// `[ξ₀ … ξₚ₊₁]`, and its derivatives up to order `n`: element `k` is the `k`-th derivative at
/// `u`. Cox–de Boor recursion (NURBS Book eq. 2.5) with the 0/0 = 0 convention, derivatives by
/// eq. 2.9 applied repeatedly (`dᵏNᵢ,ₚ = p·(dᵏ⁻¹Nᵢ,ₚ₋₁/(uᵢ₊ₚ−uᵢ) − dᵏ⁻¹Nᵢ₊₁,ₚ₋₁/(uᵢ₊ₚ₊₁−uᵢ₊₁))`).
/// A knot vector shorter than two knots gives zeros.
pub fn local_basis(knots: &[f64], u: f64, side: Side, n: usize) -> Vec<f64> {
let mut out = vec![0.0; n + 1];
let Some(p) = knots.len().checked_sub(2) else { return out };
if p > MAX_SPLINE_DEGREE || !u.is_finite() {
return out;
}
let kn = |i: usize| knots.get(i).copied().unwrap_or(0.0);
let ratio = |num: f64, den: f64| if den > 0.0 { num / den } else { 0.0 };
// table[k][j]: k-th derivative of N_{j,q} for the current degree q (j = 0..=p−q), updated
// in place: k descending (level k reads level k−1 of the previous degree), j ascending
// (N_{j,q} reads N_{j+1,q−1}, not yet overwritten). Derivatives above p are zero.
const W: usize = MAX_SPLINE_DEGREE + 1;
let levels = n.min(p);
let mut table = [[0.0f64; W]; W];
for (j, slot) in table[0].iter_mut().enumerate().take(p + 1) {
let (a, b) = (kn(j), kn(j + 1));
let inside = match side {
Side::Right => a <= u && u < b,
Side::Left => a < u && u <= b,
};
*slot = if inside { 1.0 } else { 0.0 };
}
for q in 1..=p {
for k in (0..=levels).rev() {
for j in 0..=p - q {
let (d1, d2) = (kn(j + q) - kn(j), kn(j + q + 1) - kn(j + 1));
let at = |k: usize, j: usize| table.get(k).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
let v = if k == 0 {
ratio(u - kn(j), d1) * at(0, j) + ratio(kn(j + q + 1) - u, d2) * at(0, j + 1)
} else {
q as f64 * (ratio(at(k - 1, j), d1) - ratio(at(k - 1, j + 1), d2))
};
if let Some(slot) = table.get_mut(k).and_then(|r| r.get_mut(j)) {
*slot = v;
}
}
}
}
for (k, o) in out.iter_mut().enumerate().take(levels + 1) {
*o = table.get(k).map_or(0.0, |r| r[0]);
}
out
}
/// The coefficients `(c₁, c₂)` of knot insertion into one basis function: inserting `k`
/// (with `ξ₀ ≤ k ≤ ξₚ₊₁`) into `N[ξ₀…ξₚ₊₁]` gives `c₁·N[first p+2 merged knots] +
/// c₂·N[last p+2 merged knots]` (Sederberg et al. 2004, §3; the single-function case of the
/// NURBS Book eq. 5.10). `None` when `k` is outside the support or the knots are too few.
pub fn split_basis(knots: &[f64], k: f64) -> Option<(f64, f64)> {
let p = knots.len().checked_sub(2)?;
let (x0, x1, xp, xl) = (*knots.first()?, *knots.get(1)?, *knots.get(p)?, *knots.get(p + 1)?);
if !(k >= x0 && k <= xl) {
return None;
}
let c1 = if k >= xp { 1.0 } else { (k - x0) / (xp - x0) };
let c2 = if k <= x1 { 1.0 } else { (xl - k) / (xl - x1) };
Some((c1, c2))
}
/// The two knot vectors of a split basis function (see [`split_basis`]): `k` merged into
/// `knots` in order, then the first and the last `knots.len()` entries.
pub fn split_knots<T: Copy + PartialOrd>(knots: &[T], k: T) -> [Vec<T>; 2] {
let at = knots.iter().position(|&x| x > k).unwrap_or(knots.len());
let mut merged = knots.to_vec();
merged.insert(at, k);
let n = knots.len();
[merged.get(..n).map(<[T]>::to_vec).unwrap_or_default(), merged.get(1..).map(<[T]>::to_vec).unwrap_or_default()]
}
/// The Bernstein coefficients of one basis function `N[knots]` on `[a, b]`: `N(u) =
/// Σ cᵢ Bᵢ,ₚ((u−a)/(b−a))` there. `[a, b]` must not contain a knot in its interior (the
/// function is one polynomial on it). Found by inserting `a` and `b` into the function until
/// every surviving piece is a Bernstein polynomial `N[a…a, b…b]` (Sederberg et al. 2004, §3;
/// NURBS Book §5.4, decomposition into Bézier segments).
pub fn basis_bezier(knots: &[f64], a: f64, b: f64) -> SplineResult<Vec<f64>> {
let Some(p) = knots.len().checked_sub(2) else { return invalid("a basis function needs at least two knots") };
if p > MAX_SPLINE_DEGREE {
return Err(SplineError::Unsupported(format!("degree {p} (at most {MAX_SPLINE_DEGREE})")));
}
check_knots(knots)?;
if !(a.is_finite() && b.is_finite() && a < b) {
return invalid("a Bézier interval must be finite and non-empty");
}
if knots.iter().any(|&x| x > a && x < b) {
return invalid("a knot lies inside the Bézier interval");
}
let mut out = vec![0.0; p + 1];
let mut work = vec![(knots.to_vec(), 1.0)];
let mut steps = 0usize;
while let Some((kn, c)) = work.pop() {
steps += 1;
if steps > 1 << 12 {
return Err(SplineError::TooLarge("Bézier extraction did not converge".into()));
}
let (Some(&lo), Some(&hi)) = (kn.first(), kn.last()) else { continue };
if hi <= a || lo >= b || c == 0.0 {
continue;
}
let insert = if kn.iter().any(|&x| x < a) {
Some(a)
} else if kn.iter().any(|&x| x > b) {
Some(b)
} else {
None
};
match insert {
Some(k) => {
let (c1, c2) = split_basis(&kn, k).ok_or_else(|| SplineError::Invalid("knot insertion outside the support".into()))?;
let [first, second] = split_knots(&kn, k);
work.push((first, c * c1));
work.push((second, c * c2));
}
None => {
// Knots a×(p+1−i), b×(i+1): the Bernstein polynomial Bᵢ,ₚ.
let nb = kn.iter().filter(|&&x| x == b).count();
if let Some(slot) = nb.checked_sub(1).and_then(|i| out.get_mut(i)) {
*slot += c;
}
}
}
}
Ok(out)
}
/// The span index `k` with `knots[k] ≤ u < knots[k+1]` among the valid spans `p ≤ k < n`
/// (`n` control points), the last span closed at the domain end (NURBS Book A2.1).
pub fn find_span(knots: &[f64], p: usize, n: usize, u: f64) -> Option<usize> {
if n <= p || knots.len() != n + p + 1 || !u.is_finite() {
return None;
}
let (lo, hi) = (*knots.get(p)?, *knots.get(n)?);
if u < lo || u > hi {
return None;
}
// The last non-empty span in [p, n−1] that starts at or before u.
let mut k = p;
for i in p..n {
if let (Some(&a), Some(&b)) = (knots.get(i), knots.get(i + 1))
&& a <= u
&& a < b
{
k = i;
}
}
Some(k)
}
/// Insert the knot `u` once into a B-spline with degree `p`, knots and homogeneous control
/// points (Boehm; NURBS Book eq. 5.10 / A5.1). `u` must lie in the domain `[knots[p],
/// knots[n]]`; the curve is unchanged.
pub fn insert_knot(p: usize, knots: &[f64], ctrl: &[HPoint], u: f64) -> SplineResult<(Vec<f64>, Vec<HPoint>)> {
let n = ctrl.len();
if n <= p || knots.len() != n + p + 1 {
return invalid("knot and control point counts do not match the degree");
}
if knots.len() >= MAX_KNOTS {
return Err(SplineError::TooLarge("too many knots".into()));
}
check_knots(knots)?;
let k = find_span(knots, p, n, u).ok_or_else(|| SplineError::Invalid(format!("knot {u} is outside the domain")))?;
let kn = |i: usize| knots.get(i).copied().unwrap_or(0.0);
let pt = |i: usize| ctrl.get(i).copied().unwrap_or(HPoint::ZERO);
let mut q = Vec::with_capacity(n + 1);
for i in 0..=n {
q.push(if i + p <= k {
pt(i)
} else if i > k {
pt(i - 1)
} else {
let den = kn(i + p) - kn(i);
let alpha = if den > 0.0 { (u - kn(i)) / den } else { 1.0 };
pt(i) * alpha + pt(i - 1) * (1.0 - alpha)
});
}
let mut nk = knots.to_vec();
nk.insert(k + 1, u);
Ok((nk, q))
}
/// Bernstein polynomial `Bᵢ,ₚ(x)`.
pub fn bernstein(p: usize, i: usize, x: f64) -> f64 {
if i > p {
return 0.0;
}
let mut c = 1.0;
for m in 0..i {
c = c * (p - m) as f64 / (m + 1) as f64;
}
c * x.powi(i as i32) * (1.0 - x).powi((p - i) as i32)
}
/// A Bézier curve's point at `x ∈ [0, 1]` (de Casteljau, NURBS Book §1.4).
pub fn bezier_point(ctrl: &[HPoint], x: f64) -> HPoint {
subdivide(ctrl, x).0.last().copied().unwrap_or(HPoint::ZERO)
}
/// The control points of the piece of a Bézier curve over `[a, b] ⊂ [0, 1]` (two de Casteljau
/// subdivisions).
pub fn bezier_segment(ctrl: &[HPoint], a: f64, b: f64) -> SplineResult<Vec<HPoint>> {
if !(a.is_finite() && b.is_finite() && 0.0 <= a && a < b && b <= 1.0) {
return invalid("a Bézier segment must satisfy 0 ≤ a < b ≤ 1");
}
// Left part [0, b], then its right part from a/b.
let left = subdivide(ctrl, b).0;
Ok(subdivide(&left, a / b).1)
}
/// Split a Bézier curve at `x` (de Casteljau): control points of `[0, x]` and `[x, 1]`.
fn subdivide(ctrl: &[HPoint], x: f64) -> (Vec<HPoint>, Vec<HPoint>) {
let mut left = Vec::with_capacity(ctrl.len());
let mut right = Vec::with_capacity(ctrl.len());
let mut pts = ctrl.to_vec();
while let (Some(&f), Some(&l)) = (pts.first(), pts.last()) {
left.push(f);
right.push(l);
pts = pts.windows(2).map(|w| if let [a, b] = w { *a * (1.0 - x) + *b * x } else { HPoint::ZERO }).collect();
}
right.reverse();
(left, right)
}
/// A rational tensor Bézier patch over the parameter rectangle `s × t`: control point `(i, j)`
/// (i along s, j along t) at `ctrl[j·(degree[0]+1) + i]`.
#[derive(Clone, Debug, PartialEq)]
pub struct BezierPatch {
pub s: [f64; 2],
pub t: [f64; 2],
pub degree: [usize; 2],
pub ctrl: Vec<HPoint>,
}
impl BezierPatch {
/// Control point `(i, j)`.
pub fn point(&self, i: usize, j: usize) -> HPoint {
self.ctrl.get(j * (self.degree[0] + 1) + i).copied().unwrap_or(HPoint::ZERO)
}
/// Homogeneous point at global parameters `(s, t)`.
pub fn eval_h(&self, s: f64, t: f64) -> HPoint {
let x = (s - self.s[0]) / (self.s[1] - self.s[0]);
let y = (t - self.t[0]) / (self.t[1] - self.t[0]);
let mut acc = HPoint::ZERO;
for j in 0..=self.degree[1] {
let bj = bernstein(self.degree[1], j, y);
for i in 0..=self.degree[0] {
acc += self.point(i, j) * (bernstein(self.degree[0], i, x) * bj);
}
}
acc
}
/// Point at global parameters `(s, t)`.
pub fn eval(&self, s: f64, t: f64) -> Option<Vec3> {
self.eval_h(s, t).project()
}
/// The control points of one side as a Bézier curve: `0` t = t₀ (s rising), `1` s = s₁
/// (t rising), `2` t = t₁ (s rising), `3` s = s₀ (t rising).
pub fn side(&self, side: usize) -> Vec<HPoint> {
let [ps, pt] = self.degree;
match side {
0 => (0..=ps).map(|i| self.point(i, 0)).collect(),
1 => (0..=pt).map(|j| self.point(ps, j)).collect(),
2 => (0..=ps).map(|i| self.point(i, pt)).collect(),
_ => (0..=pt).map(|j| self.point(0, j)).collect(),
}
}
}
/// A rational tensor-product B-spline (NURBS) surface. Control point `(i, j)` (i along u, j
/// along v) is `ctrl[j·count_u + i]`; the domain is `[knots_u[p], knots_u[n]] × …`.
#[derive(Clone, Debug, PartialEq)]
pub struct NurbsSurface {
degree: [usize; 2],
knots: [Vec<f64>; 2],
count: [usize; 2],
ctrl: Vec<HPoint>,
}
impl NurbsSurface {
/// A surface from Cartesian points and positive weights (one per point, u fastest).
pub fn new(degree: [usize; 2], knots: [Vec<f64>; 2], points: &[Vec3], weights: &[f64]) -> SplineResult<NurbsSurface> {
Self::from_homogeneous(degree, knots, weighted_points(points, weights)?)
}
/// A surface from homogeneous control points (u fastest).
pub fn from_homogeneous(degree: [usize; 2], knots: [Vec<f64>; 2], ctrl: Vec<HPoint>) -> SplineResult<NurbsSurface> {
let mut count = [0; 2];
for d in 0..2 {
let (p, kn) = (degree[d], &knots[d]);
if p == 0 || p > MAX_SPLINE_DEGREE {
return Err(SplineError::Unsupported(format!("degree {p} (1 to {MAX_SPLINE_DEGREE})")));
}
check_knots(kn)?;
let n = kn.len().checked_sub(p + 1).filter(|&n| n > p).ok_or_else(|| SplineError::Invalid("too few knots for the degree".into()))?;
if !(kn.get(p) < kn.get(n)) {
return invalid("the surface's domain is empty");
}
count[d] = n;
}
if count[0].checked_mul(count[1]).is_none_or(|c| c > MAX_SURFACE_POINTS) {
return Err(SplineError::TooLarge(format!("more than {MAX_SURFACE_POINTS} control points")));
}
if ctrl.len() != count[0] * count[1] {
return invalid(format!("{} control points, expected {}", ctrl.len(), count[0] * count[1]));
}
if ctrl.iter().any(|h| !h.is_finite() || !(h.w > 0.0)) {
return invalid("control points must be finite with positive weights");
}
Ok(NurbsSurface { degree, knots, count, ctrl })
}
pub fn degree(&self) -> [usize; 2] {
self.degree
}
pub fn knots(&self) -> &[Vec<f64>; 2] {
&self.knots
}
/// Control point counts along u and v.
pub fn count(&self) -> [usize; 2] {
self.count
}
pub fn homogeneous(&self) -> &[HPoint] {
&self.ctrl
}
/// Control point `(i, j)`.
pub fn point(&self, i: usize, j: usize) -> HPoint {
self.ctrl.get(j * self.count[0] + i).copied().unwrap_or(HPoint::ZERO)
}
/// The parameter domain `[[u₀, u₁], [v₀, v₁]]`.
pub fn domain(&self) -> [[f64; 2]; 2] {
let d = |k: usize| {
let (p, n, kn) = (self.degree[k], self.count[k], &self.knots[k]);
[kn.get(p).copied().unwrap_or(0.0), kn.get(n).copied().unwrap_or(0.0)]
};
[d(0), d(1)]
}
/// The point at `(u, v)`, which must lie in the domain.
pub fn eval(&self, u: f64, v: f64) -> SplineResult<Vec3> {
let dom = self.domain();
let mut spans = [0; 2];
let mut basis: [Vec<f64>; 2] = [Vec::new(), Vec::new()];
for (d, x) in [u, v].into_iter().enumerate() {
let (p, n, kn) = (self.degree[d], self.count[d], &self.knots[d]);
let span = find_span(kn, p, n, x).ok_or_else(|| SplineError::Invalid(format!("({u}, {v}) is outside the domain")))?;
let side = if x >= dom[d][1] { Side::Left } else { Side::Right };
spans[d] = span;
basis[d] = (span - p..=span).map(|i| kn.get(i..i + p + 2).map_or(0.0, |k| local_basis(k, x, side, 0)[0])).collect();
}
let mut acc = HPoint::ZERO;
for (b, nj) in basis[1].iter().enumerate() {
for (a, ni) in basis[0].iter().enumerate() {
acc += self.point(spans[0] - self.degree[0] + a, spans[1] - self.degree[1] + b) * (ni * nj);
}
}
acc.project().ok_or_else(|| SplineError::Invalid("the surface's weights vanish there".into()))
}
/// The same surface with the knot `x` inserted once in direction `dir` ([`Dir::S`] = u).
pub fn insert_knot(&self, dir: Dir, x: f64) -> SplineResult<NurbsSurface> {
let d = dir.pick(0, 1);
let (p, kn) = (self.degree[d], &self.knots[d]);
let [nu, nv] = self.count;
let (lines, len) = if d == 0 { (nv, nu) } else { (nu, nv) };
let at = |line: usize, k: usize| if d == 0 { self.point(k, line) } else { self.point(line, k) };
let mut new_knots = Vec::new();
let mut rows: Vec<Vec<HPoint>> = Vec::with_capacity(lines);
for line in 0..lines {
let row: Vec<HPoint> = (0..len).map(|k| at(line, k)).collect();
let (nk, nr) = insert_knot(p, kn, &row, x)?;
new_knots = nk;
rows.push(nr);
}
let mut knots = self.knots.clone();
knots[d] = new_knots;
let new_len = len + 1;
let mut ctrl = Vec::with_capacity(lines * new_len);
if d == 0 {
for row in &rows {
ctrl.extend_from_slice(row);
}
} else {
for j in 0..new_len {
for row in &rows {
ctrl.push(row.get(j).copied().unwrap_or(HPoint::ZERO));
}
}
}
NurbsSurface::from_homogeneous(self.degree, knots, ctrl)
}
/// The same surface on the same domain with clamped knot vectors (`p+1` equal knots at
/// each end, so the boundary curves are given by the boundary rows of control points):
/// insert each domain end until it has multiplicity `p`, then drop the basis functions
/// that vanish on the domain (NURBS Book §5.2 knot insertion; a basis function's last span
/// does not depend on its first knot, so that knot can be moved to the domain end).
pub fn clamped(&self) -> SplineResult<NurbsSurface> {
let mut s = self.clone();
for dir in [Dir::S, Dir::T] {
let d = dir.pick(0, 1);
let p = s.degree[d];
for end in 0..2 {
let a = s.domain()[d][end];
while s.knots[d].iter().filter(|&&x| x == a).count() < p {
s = s.insert_knot(dir, a)?;
}
s = s.trim_end(d, end, a)?;
}
}
Ok(s)
}
/// Drop the basis functions that vanish on the domain past the knot `a` (multiplicity ≥ p)
/// at the start (`end == 0`) or the end of direction `d`.
fn trim_end(&self, d: usize, end: usize, a: f64) -> SplineResult<NurbsSurface> {
let p = self.degree[d];
let kn = &self.knots[d];
let n = self.count[d];
let (first, last) = match end {
0 => {
let last_a = kn.iter().rposition(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
// Functions i with i + p + 1 ≤ last_a vanish on [a, …).
(last_a.saturating_sub(p), n)
}
_ => {
let first_a = kn.iter().position(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
// Functions i with i ≥ first_a vanish on (…, a].
(0, first_a.min(n))
}
};
if last <= first + p {
return invalid("clamp: too few control points remain");
}
let mut knots = self.knots.clone();
let mut nk: Vec<f64> = kn.get(first..last + p + 1).map(<[f64]>::to_vec).unwrap_or_default();
match end {
0 => nk.first_mut().into_iter().for_each(|x| *x = a),
_ => nk.last_mut().into_iter().for_each(|x| *x = a),
}
knots[d] = nk;
let [nu, nv] = self.count;
let mut ctrl = Vec::new();
for j in 0..nv {
for i in 0..nu {
let k = if d == 0 { i } else { j };
if k >= first && k < last {
ctrl.push(self.point(i, j));
}
}
}
NurbsSurface::from_homogeneous(self.degree, knots, ctrl)
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// A small deterministic generator for test data.
pub(crate) struct Lcg(pub(crate) u64);
impl Lcg {
pub(crate) fn unit(&mut self) -> f64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(self.0 >> 11) as f64 / (1u64 << 53) as f64
}
}
const KNOTS: [f64; 12] = [0.0, 0.0, 0.0, 0.0, 0.3, 0.3, 0.5, 0.9, 1.0, 1.0, 1.0, 1.0];
fn samples() -> Vec<f64> {
(0..=200).map(|k| k as f64 / 200.0).collect()
}
#[test]
fn basis_partition_of_unity_non_negative_compact_support() {
let p = 3;
let n = KNOTS.len() - p - 1;
for u in samples() {
let side = if u >= 1.0 { Side::Left } else { Side::Right };
let mut sum = 0.0;
for i in 0..n {
let local = &KNOTS[i..i + p + 2];
let b = local_basis(local, u, side, 0)[0];
assert!(b >= -1e-15, "N{i}({u}) = {b}");
if u < local[0] || u > local[p + 1] {
assert_eq!(b, 0.0, "N{i} is not zero outside its support at {u}");
}
sum += b;
}
assert!((sum - 1.0).abs() < 1e-14, "Σ N({u}) = {sum}");
}
}
#[test]
fn basis_derivatives_match_finite_differences() {
let p = 3;
let h = 1e-6;
let mut worst = 0.0f64;
for i in 0..KNOTS.len() - p - 1 {
let local = &KNOTS[i..i + p + 2];
for u in samples() {
if KNOTS.iter().any(|k| (k - u).abs() < 2.0 * h) {
continue;
}
let d = local_basis(local, u, Side::Right, 2);
let f = |x: f64| local_basis(local, x, Side::Right, 0)[0];
let fd1 = (f(u + h) - f(u - h)) / (2.0 * h);
let df = |x: f64| local_basis(local, x, Side::Right, 1)[1];
let fd2 = (df(u + h) - df(u - h)) / (2.0 * h);
worst = worst.max((d[1] - fd1).abs()).max((d[2] - fd2).abs() / 100.0);
}
}
assert!(worst < 1e-5, "derivative error {worst}");
}
#[test]
fn single_function_knot_insertion_is_exact() {
let mut rng = Lcg(7);
for _ in 0..200 {
let mut kn: Vec<f64> = (0..5).map(|_| (rng.unit() * 8.0).round() / 8.0).collect();
kn.sort_by(f64::total_cmp);
if kn[0] == kn[4] {
continue;
}
let k = kn[0] + (kn[4] - kn[0]) * (rng.unit() * 8.0).round() / 8.0;
let (c1, c2) = split_basis(&kn, k).unwrap();
let mut merged = kn.clone();
let at = merged.iter().position(|&x| x > k).unwrap_or(5);
merged.insert(at, k);
for u in samples() {
let u = kn[0] - 0.1 + u * (kn[4] - kn[0] + 0.2);
let f = |k: &[f64]| local_basis(k, u, Side::Right, 0)[0];
let lhs = f(&kn);
let rhs = c1 * f(&merged[..5]) + c2 * f(&merged[1..]);
assert!((lhs - rhs).abs() < 1e-14, "{kn:?} insert {k} at {u}: {lhs} vs {rhs}");
}
}
}
#[test]
fn basis_bezier_coefficients_reproduce_the_function() {
let p = 3;
let spans: Vec<(f64, f64)> = KNOTS.windows(2).filter(|w| w[0] < w[1]).map(|w| (w[0], w[1])).collect();
for i in 0..KNOTS.len() - p - 1 {
let local = &KNOTS[i..i + p + 2];
for &(a, b) in &spans {
let c = basis_bezier(local, a, b).unwrap();
for k in 0..=20 {
let x = k as f64 / 20.0;
let u = a + (b - a) * x;
let side = if x >= 1.0 { Side::Left } else { Side::Right };
let want = local_basis(local, u, side, 0)[0];
let got: f64 = c.iter().enumerate().map(|(j, cj)| cj * bernstein(p, j, x)).sum();
assert!((want - got).abs() < 1e-14, "N{i} on [{a}, {b}] at {u}: {want} vs {got}");
}
}
}
assert!(basis_bezier(&KNOTS[..5], 0.0, 0.5).is_err(), "a knot inside the interval is refused");
assert!(basis_bezier(&KNOTS[..5], f64::NAN, 0.5).is_err());
}
fn curve(p: usize, knots: &[f64], ctrl: &[HPoint], u: f64) -> Vec3 {
let n = ctrl.len();
let side = if u >= knots[n] { Side::Left } else { Side::Right };
let mut acc = HPoint::ZERO;
for (i, c) in ctrl.iter().enumerate() {
acc += *c * local_basis(&knots[i..i + p + 2], u, side, 0)[0];
}
acc.project().unwrap()
}
#[test]
fn boehm_insertion_keeps_the_curve() {
let p = 3;
let mut rng = Lcg(3);
let ctrl: Vec<HPoint> = (0..8).map(|_| HPoint::weighted(Vec3::new(rng.unit(), rng.unit(), rng.unit()) * 10.0, 0.5 + rng.unit())).collect();
let (mut kn, mut pts) = (KNOTS.to_vec(), ctrl.clone());
for u in [0.0, 0.1, 0.3, 0.3, 0.77, 1.0, 1.0] {
(kn, pts) = insert_knot(p, &kn, &pts, u).unwrap();
}
let mut worst = 0.0f64;
for u in samples() {
worst = worst.max(curve(p, &KNOTS, &ctrl, u).dist(curve(p, &kn, &pts, u)));
}
assert!(worst < 1e-13, "deviation {worst}");
assert!(insert_knot(p, &KNOTS, &ctrl, 1.5).is_err());
assert!(insert_knot(p, &KNOTS, &ctrl, f64::NAN).is_err());
}
pub(crate) fn random_surface(seed: u64, ku: Vec<f64>, kv: Vec<f64>, degree: [usize; 2]) -> NurbsSurface {
let mut rng = Lcg(seed);
let n = (ku.len() - degree[0] - 1) * (kv.len() - degree[1] - 1);
let pts: Vec<Vec3> = (0..n).map(|_| Vec3::new(rng.unit(), rng.unit(), rng.unit()) * 10.0).collect();
let w: Vec<f64> = (0..n).map(|_| 0.5 + rng.unit()).collect();
NurbsSurface::new(degree, [ku, kv], &pts, &w).unwrap()
}
fn max_deviation(a: &NurbsSurface, b: &NurbsSurface) -> f64 {
let [du, dv] = a.domain();
let mut worst = 0.0f64;
for i in 0..=30 {
for j in 0..=30 {
let u = du[0] + (du[1] - du[0]) * i as f64 / 30.0;
let v = dv[0] + (dv[1] - dv[0]) * j as f64 / 30.0;
worst = worst.max(a.eval(u, v).unwrap().dist(b.eval(u, v).unwrap()));
}
}
worst
}
#[test]
fn surface_knot_insertion_and_clamping_keep_the_surface() {
let uniform: Vec<f64> = (0..10).map(|k| k as f64).collect();
let s = random_surface(11, uniform.clone(), (0..9).map(|k| k as f64 * 0.5).collect(), [3, 3]);
let r = s.insert_knot(Dir::S, 4.5).unwrap().insert_knot(Dir::T, 2.25).unwrap();
assert!(max_deviation(&s, &r) < 1e-12);
let c = s.clamped().unwrap();
assert_eq!(c.domain(), s.domain());
assert_eq!(&c.knots()[0][..4], &[3.0; 4]);
assert_eq!(&c.knots()[1][c.knots()[1].len() - 4..], &[2.5; 4]);
let dev = max_deviation(&s, &c);
assert!(dev < 1e-12, "clamping moved the surface by {dev}");
// A clamped surface interpolates its corner control points.
let corner = c.eval(3.0, 1.5).unwrap();
assert!(corner.dist(c.point(0, 0).project().unwrap()) < 1e-12);
}
#[test]
fn bezier_segments_lie_on_the_curve() {
let mut rng = Lcg(5);
let ctrl: Vec<HPoint> = (0..4).map(|_| HPoint::weighted(Vec3::new(rng.unit(), rng.unit(), 0.0), 0.5 + rng.unit())).collect();
let seg = bezier_segment(&ctrl, 0.2, 0.7).unwrap();
for k in 0..=10 {
let x = k as f64 / 10.0;
let want = bezier_point(&ctrl, 0.2 + 0.5 * x).project().unwrap();
assert!(bezier_point(&seg, x).project().unwrap().dist(want) < 1e-14);
}
assert!(bezier_segment(&ctrl, 0.7, 0.2).is_err());
}
#[test]
fn hostile_spline_input_is_refused() {
let pts = [Vec3::ZERO; 16];
let w = [1.0; 16];
let k = vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];
assert!(NurbsSurface::new([3, 3], [k.clone(), k.clone()], &pts, &w).is_ok());
assert!(NurbsSurface::new([3, 3], [vec![0.0, f64::NAN, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], k.clone()], &pts, &w).is_err());
assert!(NurbsSurface::new([3, 3], [k.clone(), k.clone()], &pts, &[0.0; 16]).is_err());
assert!(NurbsSurface::new([3, 3], [k.clone(), k.clone()], &pts[..15], &w[..15]).is_err());
assert!(NurbsSurface::new([0, 3], [k.clone(), k.clone()], &pts, &w).is_err());
assert!(NurbsSurface::new([9, 3], [k.clone(), k.clone()], &pts, &w).is_err());
assert!(NurbsSurface::new([3, 3], [vec![1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], k.clone()], &pts, &w).is_err());
assert!(check_knots(&vec![0.0; MAX_KNOTS + 1]).is_err());
let s = NurbsSurface::new([3, 3], [k.clone(), k], &pts, &w).unwrap();
assert!(s.eval(2.0, 0.5).is_err());
assert!(s.eval(f64::NAN, 0.5).is_err());
assert_eq!(local_basis(&[], 0.5, Side::Right, 1), vec![0.0, 0.0]);
}
}
+6 -2
View File
@@ -1,14 +1,18 @@
//! Geometry foundation for SolveCraft: 2D/3D vectors, sketch planes, axis-aligned boxes and
//! triangle meshes with their measures (volume, area, centroid). Units are millimetres.
//! Geometry foundation for SolveCraft: 2D/3D vectors, sketch planes, axis-aligned boxes,
//! triangle meshes with their measures (volume, area, centroid), and freeform surface maths
//! (B-spline basis and NURBS surfaces in [`bspline`], T-splines in [`tspline`]). Units are
//! millimetres.
//!
//! This crate has no knowledge of sketches, documents or the B-rep kernel; every other crate
//! builds on it.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod bspline;
mod mesh;
mod plane;
mod profile;
pub mod tspline;
mod vec;
pub use mesh::{Aabb3, InsideIndex, Mesh, MeshMeasure};
+64 -1
View File
@@ -527,11 +527,23 @@ impl Region2 {
ys.sort_by(f64::total_cmp);
ys.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
let x_at = |e: &(Vec2, Vec2), y: f64| e.0.x + (y - e.0.y) / (e.1.y - e.0.y) * (e.1.x - e.0.x);
// Sweep upward: an edge joins the slabs once its lower end is passed and leaves after
// its upper end, so each slab sorts only the edges crossing it (a finely sampled ring
// has hundreds of slabs and edges, but four edges in each slab).
edges.sort_by(|a, b| a.0.y.total_cmp(&b.0.y));
let mut next = 0;
let mut span: Vec<&(Vec2, Vec2)> = Vec::new();
let mut out = Vec::new();
for w in ys.windows(2) {
let (y0, y1) = (w[0], w[1]);
let ym = (y0 + y1) * 0.5;
let mut span: Vec<&(Vec2, Vec2)> = edges.iter().filter(|e| e.0.y <= ym && e.1.y >= ym).collect();
while let Some(e) = edges.get(next)
&& e.0.y <= ym
{
span.push(e);
next += 1;
}
span.retain(|e| e.1.y >= ym);
span.sort_by(|a, b| x_at(a, ym).total_cmp(&x_at(b, ym)));
for pair in span.chunks(2) {
let [l, r] = pair else { continue };
@@ -630,6 +642,57 @@ mod tests {
}
}
/// The sweep visits only the edges crossing each slab; it must give the triangles that
/// testing every edge against every slab gives, for a finely sampled ring too (#164).
#[test]
fn triangulation_sweep_matches_every_edge_against_every_slab() {
fn every_edge(r: &Region2, tol: f64) -> Vec<[Vec2; 3]> {
let mut edges = Vec::new();
for l in std::iter::once(&r.outer).chain(&r.holes) {
let pl = l.polyline(tol);
for i in 0..pl.len() {
let (a, b) = (pl[i], pl[(i + 1) % pl.len()]);
if (a.y - b.y).abs() > 1e-12 {
edges.push(if a.y < b.y { (a, b) } else { (b, a) });
}
}
}
let mut ys: Vec<f64> = edges.iter().flat_map(|(a, b): &(Vec2, Vec2)| [a.y, b.y]).collect();
ys.sort_by(f64::total_cmp);
ys.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
let x_at = |e: &(Vec2, Vec2), y: f64| e.0.x + (y - e.0.y) / (e.1.y - e.0.y) * (e.1.x - e.0.x);
let mut out = Vec::new();
for w in ys.windows(2) {
let ym = (w[0] + w[1]) * 0.5;
let mut span: Vec<_> = edges.iter().filter(|e| e.0.y <= ym && e.1.y >= ym).collect();
span.sort_by(|a, b| x_at(a, ym).total_cmp(&x_at(b, ym)));
for pair in span.chunks(2) {
let [l, r] = pair else { continue };
let (a, b) = (Vec2::new(x_at(l, w[0]), w[0]), Vec2::new(x_at(r, w[0]), w[0]));
let (c, d) = (Vec2::new(x_at(r, w[1]), w[1]), Vec2::new(x_at(l, w[1]), w[1]));
out.extend([[a, b, c], [a, c, d]]);
}
}
out
}
let comb = Loop2::polygon(&[
Vec2::new(0.0, 0.0),
Vec2::new(9.0, 0.0),
Vec2::new(9.0, 6.0),
Vec2::new(7.0, 2.0),
Vec2::new(5.0, 7.0),
Vec2::new(3.0, 2.5),
Vec2::new(1.0, 6.0),
]);
let ring = Region2 { outer: Loop2::circle(Vec2::ZERO, 45.0), holes: vec![Loop2::circle(Vec2::ZERO, 40.0).reversed()] };
let regions = [Region2 { outer: comb, holes: vec![Loop2::circle(Vec2::new(4.5, 1.2), 0.6).reversed()] }, ring];
for (r, tol) in regions.iter().zip([1e-3, 1e-4]) {
let tris = r.triangulate(tol);
assert!(tris.len() > 100);
assert_eq!(tris, every_edge(r, tol));
}
}
#[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)]);
+148
View File
@@ -0,0 +1,148 @@
//! Conversions: a tensor NURBS surface as a T-spline and back (a T-mesh without T-junctions is
//! a tensor B-spline; 2003 §3 and §5), and Bézier extraction — the surface as rational Bézier
//! patches over the rectangles where every blending function is one polynomial.
use super::mesh::{Grid, TMesh};
use super::{Blend, TSpline, frame};
use crate::bspline::{BezierPatch, HPoint, NurbsSurface, SplineError, SplineResult, basis_bezier, invalid};
/// Most unit-edge marks and coefficient products in one Bézier extraction.
const MAX_EXTRACT_WORK: usize = 1 << 26;
impl TSpline {
/// A tensor NURBS surface of odd bi-degree as a T-spline on the full grid: column `i` of the
/// mesh is knot `i + (p+1)/2` of the knot vector, so the blending function at a vertex is
/// the B-spline basis function of that control point (rays that leave the mesh repeat the
/// boundary line, 2003 §3). Knot vectors whose first and last `(p+1)/2 + 1` knots are not
/// equal are clamped first (same surface on the same domain).
pub fn from_nurbs(n: &NurbsSurface) -> SplineResult<TSpline> {
let degree = n.degree();
if degree.iter().any(|p| p % 2 == 0) {
return Err(SplineError::Unsupported("a NURBS surface of even degree as a T-spline".into()));
}
let ends_ok = |k: &[f64], p: usize| {
let h = p.div_ceil(2);
let same = |w: Option<&[f64]>| w.is_some_and(|w| w.windows(2).all(|x| matches!(x, [a, b] if a == b)));
same(k.get(..=h)) && same(k.len().checked_sub(h + 1).and_then(|s| k.get(s..)))
};
let n = if (0..2).all(|d| ends_ok(&n.knots()[d], degree[d])) { n.clone() } else { n.clamped()? };
let lines = |d: usize| -> Vec<f64> {
let h = degree[d].div_ceil(2);
let k = &n.knots()[d];
k.get(h..k.len().saturating_sub(h)).map(<[f64]>::to_vec).unwrap_or_default()
};
let mesh = TMesh::tensor(lines(0), lines(1))?;
TSpline::from_homogeneous(mesh, degree, n.homogeneous().to_vec())
}
/// The T-spline as a tensor NURBS surface, when its mesh has no T-junctions.
pub fn to_nurbs(&self) -> SplineResult<NurbsSurface> {
if !self.mesh.is_regular() {
return Err(SplineError::Unsupported("a T-mesh with T-junctions as one tensor surface".into()));
}
let pad = |k: &[f64], p: usize| -> Vec<f64> {
let h = p.div_ceil(2);
let (first, last) = (k.first().copied().unwrap_or(0.0), k.last().copied().unwrap_or(0.0));
std::iter::repeat_n(first, h).chain(k.iter().copied()).chain(std::iter::repeat_n(last, h)).collect()
};
let g = &self.mesh.grid;
NurbsSurface::from_homogeneous(self.degree, [pad(&g.s, self.degree[0]), pad(&g.t, self.degree[1])], self.ctrl.clone())
}
/// The surface as rational Bézier patches over rectangles of the domain, inside each of
/// which every blending function is one polynomial: the knot lines of all blending
/// functions (each function's s-knots across its t-support and t-knots across its
/// s-support) are marked on the index grid and the domain's cells are gathered into
/// rectangles that no marked line crosses. Each function's Bernstein coefficients on a
/// rectangle come from knot insertion ([`basis_bezier`]). Patches tile the domain and meet
/// at T-points where their corners land on a neighbour's side.
pub fn bezier_elements(&self) -> SplineResult<Vec<BezierPatch>> {
let g = &self.mesh.grid;
let marks = knot_lines(g, &self.blends)?;
let [cs, ct] = frame(self.degree);
let (ns, nt) = (g.ns(), g.nt());
let (ci_end, cj_end) = (ns - 1 - cs, nt - 1 - ct);
let mut taken = vec![false; ns * nt];
let is_taken = |taken: &[bool], i: usize, j: usize| taken.get(j * ns + i).copied().unwrap_or(true);
let mut rects = Vec::new();
for cj in ct..cj_end {
for ci in cs..ci_end {
if is_taken(&taken, ci, cj) {
continue;
}
let mut i1 = ci + 1;
while i1 < ci_end && !is_taken(&taken, i1, cj) && !marks.v(i1, cj) {
i1 += 1;
}
let mut j1 = cj + 1;
while j1 < cj_end && (ci..i1).all(|x| !is_taken(&taken, x, j1) && !marks.h(x, j1)) && (ci + 1..i1).all(|x| !marks.v(x, j1)) {
j1 += 1;
}
for y in cj..j1 {
for x in ci..i1 {
if let Some(c) = taken.get_mut(y * ns + x) {
*c = true;
}
}
}
rects.push((ci, i1, cj, j1));
}
}
let val = |k: &[f64], i: usize| k.get(i).copied().unwrap_or(0.0);
let [ps, pt] = self.degree;
let mut work = 0usize;
let mut out = Vec::new();
for (i0, i1, j0, j1) in rects {
let (s0, s1, t0, t1) = (val(&g.s, i0), val(&g.s, i1), val(&g.t, j0), val(&g.t, j1));
if !(s0 < s1 && t0 < t1) {
continue;
}
let mut ctrl = vec![HPoint::ZERO; (ps + 1) * (pt + 1)];
for (b, c) in self.blends.iter().zip(&self.ctrl) {
let [[bs0, bs1], [bt0, bt1]] = b.support();
if !(s1.min(bs1) > s0.max(bs0) && t1.min(bt1) > t0.max(bt0)) {
continue;
}
work += ctrl.len();
if work > MAX_EXTRACT_WORK {
return Err(SplineError::TooLarge("Bézier extraction needs too much work".into()));
}
let cs = basis_bezier(&b.sv, s0, s1)?;
let ct = basis_bezier(&b.tv, t0, t1)?;
for (j, bj) in ct.iter().enumerate() {
for (i, bi) in cs.iter().enumerate() {
if let Some(slot) = ctrl.get_mut(j * (ps + 1) + i) {
*slot += *c * (bi * bj);
}
}
}
}
if ctrl.iter().any(|h| !h.is_finite() || !(h.w > 0.0)) {
return invalid("a Bézier patch has a non-positive weight");
}
out.push(BezierPatch { s: [s0, s1], t: [t0, t1], degree: self.degree, ctrl });
}
Ok(out)
}
}
/// The unit edges of the index grid that carry a knot line of some blending function: each
/// function's s-knots across its t-support and its t-knots across its s-support.
fn knot_lines(g: &Grid, blends: &[Blend]) -> SplineResult<Grid> {
let mut m = Grid::empty(g.s.clone(), g.t.clone())?;
let mut work = 0usize;
for b in blends {
let (Some(&s0), Some(&s1), Some(&t0), Some(&t1)) = (b.s.first(), b.s.last(), b.t.first(), b.t.last()) else { continue };
work += (b.s.len() * (t1 - t0)) + (b.t.len() * (s1 - s0));
if work > MAX_EXTRACT_WORK {
return Err(SplineError::TooLarge("Bézier extraction needs too much work".into()));
}
for &x in &b.s {
(t0..t1).for_each(|y| m.set_v(x, y));
}
for &y in &b.t {
(s0..s1).for_each(|x| m.set_h(x, y));
}
}
Ok(m)
}
+442
View File
@@ -0,0 +1,442 @@
//! The T-mesh in index space (Sederberg, Zheng, Bakenov, Nasri 2003, §3; Scott, Li,
//! Sederberg, Hughes 2012, §2): a rectangle of index lines — columns `0..ns` carrying the
//! non-decreasing knot values `s[i]`, rows `0..nt` carrying `t[j]` — and the unit edges of
//! that grid that are present. Equal knot values on neighbouring lines give zero-length knot
//! intervals (repeated knots), which is how clamped boundaries and multiple knots are written.
//!
//! A valid T-mesh has its outer rectangle, rectangular faces (every grid point is empty, an
//! edge passing straight through, a T-junction, a crossing, or one of the four outer
//! corners), and at least one edge on every index line. A *vertex* is a point on both a column
//! edge and a row edge; every vertex carries a control point.
use crate::bspline::{Dir, SplineError, SplineResult, check_knots, invalid};
/// Most index lines in either direction.
pub const MAX_INDEX_LINES: usize = 1 << 12;
/// Most grid points (`ns · nt`).
pub const MAX_GRID_POINTS: usize = 1 << 20;
/// A run of edges on one index line, given to [`TMesh::from_segments`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Segment {
/// Edges on column `i` (constant s) from row `j0` to row `j1`.
Column { i: usize, j0: usize, j1: usize },
/// Edges on row `j` (constant t) from column `i0` to column `i1`.
Row { j: usize, i0: usize, i1: usize },
}
/// A rectangle in index space, `[i0, i1] × [j0, j1]`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct IndexRect {
pub i0: usize,
pub i1: usize,
pub j0: usize,
pub j1: usize,
}
/// The index lines and the unit edges present on them; no validity implied.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Grid {
pub(crate) s: Vec<f64>,
pub(crate) t: Vec<f64>,
/// Row edge `(i, j)–(i+1, j)` at `j·(ns−1) + i`.
h: Vec<bool>,
/// Column edge `(i, j)–(i, j+1)` at `i·(nt−1) + j`.
v: Vec<bool>,
}
impl Grid {
/// An empty grid (no edges) after checking sizes and knots.
pub(crate) fn empty(s: Vec<f64>, t: Vec<f64>) -> SplineResult<Grid> {
for k in [&s, &t] {
if k.len() < 2 {
return invalid("a T-mesh needs at least two index lines each way");
}
if k.len() > MAX_INDEX_LINES {
return Err(SplineError::TooLarge(format!("{} index lines (at most {MAX_INDEX_LINES})", k.len())));
}
check_knots(k)?;
}
if s.len() * t.len() > MAX_GRID_POINTS {
return Err(SplineError::TooLarge(format!("{} grid points (at most {MAX_GRID_POINTS})", s.len() * t.len())));
}
let (ns, nt) = (s.len(), t.len());
Ok(Grid { h: vec![false; (ns - 1) * nt], v: vec![false; ns * (nt - 1)], s, t })
}
pub(crate) fn ns(&self) -> usize {
self.s.len()
}
pub(crate) fn nt(&self) -> usize {
self.t.len()
}
/// Row edge from `(i, j)` to `(i+1, j)`.
pub(crate) fn h(&self, i: usize, j: usize) -> bool {
i + 1 < self.ns() && self.h.get(j * (self.ns() - 1) + i).copied().unwrap_or(false)
}
/// Column edge from `(i, j)` to `(i, j+1)`.
pub(crate) fn v(&self, i: usize, j: usize) -> bool {
j + 1 < self.nt() && self.v.get(i * (self.nt() - 1) + j).copied().unwrap_or(false)
}
pub(crate) fn set_h(&mut self, i: usize, j: usize) {
let ns = self.ns();
if i + 1 < ns
&& let Some(e) = self.h.get_mut(j * (ns - 1) + i)
{
*e = true;
}
}
pub(crate) fn set_v(&mut self, i: usize, j: usize) {
let nt = self.nt();
if j + 1 < nt
&& let Some(e) = self.v.get_mut(i * (nt - 1) + j)
{
*e = true;
}
}
/// The point `(i, j)` lies on a column edge (passing through or ending there).
pub(crate) fn on_column(&self, i: usize, j: usize) -> bool {
(j > 0 && self.v(i, j - 1)) || self.v(i, j)
}
/// The point `(i, j)` lies on a row edge.
pub(crate) fn on_row(&self, i: usize, j: usize) -> bool {
(i > 0 && self.h(i - 1, j)) || self.h(i, j)
}
/// The first `count` index lines a ray from `(i, j)` crosses (2003 §3: the knots of a
/// blending function are read off rays cast from its anchor). Along s it crosses columns
/// carrying an edge at row `j`; along t, rows carrying an edge at column `i`. Nearest
/// first; a ray that leaves the mesh repeats the boundary line (clamped ends).
pub(crate) fn ray(&self, i: usize, j: usize, dir: Dir, forward: bool, count: usize) -> Vec<usize> {
let (start, last) = dir.pick((i, self.ns() - 1), (j, self.nt() - 1));
let hit = |x: usize| match dir {
Dir::S => self.on_column(x, j),
Dir::T => self.on_row(i, x),
};
let mut out = Vec::with_capacity(count);
let mut x = start;
while out.len() < count {
let next = if forward { (x < last).then(|| x + 1) } else { x.checked_sub(1) };
match next {
Some(n) => {
x = n;
if hit(x) {
out.push(x);
}
}
None => out.push(x),
}
}
out
}
/// The local knot vector (as line indices) of a degree-`p` blending function anchored at
/// `(i, j)` in direction `dir`: `(p+1)/2` crossings each side of the anchor's own line
/// (odd degree; 2003 §3).
pub(crate) fn local_knots(&self, i: usize, j: usize, dir: Dir, p: usize) -> Vec<usize> {
let h = p.div_ceil(2);
let own = dir.pick(i, j);
let mut k: Vec<usize> = self.ray(i, j, dir, false, h);
k.reverse();
k.push(own);
k.extend(self.ray(i, j, dir, true, h));
k
}
/// The face containing the unit cell whose lower-left corner is `(ci, cj)`.
pub(crate) fn face_of_cell(&self, ci: usize, cj: usize) -> IndexRect {
let i0 = (0..=ci).rev().find(|&x| self.v(x, cj)).unwrap_or(0);
let i1 = (ci + 1..self.ns()).find(|&x| self.v(x, cj)).unwrap_or(self.ns() - 1);
let j0 = (0..=cj).rev().find(|&y| self.h(ci, y)).unwrap_or(0);
let j1 = (cj + 1..self.nt()).find(|&y| self.h(ci, y)).unwrap_or(self.nt() - 1);
IndexRect { i0, i1, j0, j1 }
}
/// Split a face with a full-height column edge at `x` so that the point `(x, y)` lies on
/// a column edge (the face above the point, or below it on the top row).
pub(crate) fn split_column_at(&mut self, x: usize, y: usize) {
let cj = if y + 1 < self.nt() { y } else { y.saturating_sub(1) };
let f = self.face_of_cell(x.min(self.ns() - 2), cj);
for j in f.j0..f.j1 {
self.set_v(x, j);
}
}
/// Split a face with a full-width row edge at `y` so that `(x, y)` lies on a row edge (the
/// face to the right of the point, or to its left on the last column).
pub(crate) fn split_row_at(&mut self, x: usize, y: usize) {
let ci = if x + 1 < self.ns() { x } else { x.saturating_sub(1) };
let f = self.face_of_cell(ci, y.min(self.nt() - 2));
for i in f.i0..f.i1 {
self.set_h(i, y);
}
}
/// Insert a new column at index `at` (`0 < at < ns`) with knot value `value`; row edges
/// crossing it are split in two, the new column has no edges.
pub(crate) fn insert_column(&mut self, at: usize, value: f64) -> SplineResult<()> {
let (ns, nt) = (self.ns(), self.nt());
if at == 0 || at >= ns || ns + 1 > MAX_INDEX_LINES || (ns + 1) * nt > MAX_GRID_POINTS {
return Err(SplineError::TooLarge("refinement needs too many index lines".into()));
}
let mut h = Vec::with_capacity(ns * nt);
for j in 0..nt {
for i in 0..ns {
// New row edge i runs (i, i+1) in the new numbering.
let old = if i + 1 < at {
self.h(i, j)
} else if i + 1 == at || i == at {
self.h(at - 1, j)
} else {
self.h(i - 1, j)
};
h.push(old);
}
}
let mut v = Vec::with_capacity((ns + 1) * (nt - 1));
for i in 0..=ns {
for j in 0..nt - 1 {
v.push(match i.cmp(&at) {
std::cmp::Ordering::Less => self.v(i, j),
std::cmp::Ordering::Equal => false,
std::cmp::Ordering::Greater => self.v(i - 1, j),
});
}
}
self.s.insert(at, value);
self.h = h;
self.v = v;
Ok(())
}
/// The grid transposed (s ↔ t), so row operations can reuse column code.
pub(crate) fn transposed(&self) -> Grid {
let (ns, nt) = (self.ns(), self.nt());
let mut g = Grid { s: self.t.clone(), t: self.s.clone(), h: vec![false; (nt - 1) * ns], v: vec![false; nt * (ns - 1)] };
for i in 0..ns {
for j in 0..nt {
if self.h(i, j) {
g.set_v(j, i);
}
if self.v(i, j) {
g.set_h(j, i);
}
}
}
g
}
}
/// A validated T-mesh.
#[derive(Clone, Debug, PartialEq)]
pub struct TMesh {
pub(crate) grid: Grid,
vertices: Vec<(usize, usize)>,
/// Vertex number of grid point `(i, j)` at `j·ns + i`, `u32::MAX` where there is none.
vid: Vec<u32>,
}
impl TMesh {
/// The full tensor-product mesh on the given knot lines.
pub fn tensor(s: Vec<f64>, t: Vec<f64>) -> SplineResult<TMesh> {
let (ns, nt) = (s.len(), t.len());
let segs: Vec<Segment> = (0..ns)
.map(|i| Segment::Column { i, j0: 0, j1: nt.saturating_sub(1) })
.chain((0..nt).map(|j| Segment::Row { j, i0: 0, i1: ns.saturating_sub(1) }))
.collect();
TMesh::from_segments(s, t, &segs)
}
/// A T-mesh from knot lines and runs of edges (the outer rectangle must be among them).
pub fn from_segments(s: Vec<f64>, t: Vec<f64>, segments: &[Segment]) -> SplineResult<TMesh> {
let mut g = Grid::empty(s, t)?;
if segments.len() > MAX_GRID_POINTS {
return Err(SplineError::TooLarge("too many segments".into()));
}
let (ns, nt) = (g.ns(), g.nt());
for seg in segments {
match *seg {
Segment::Column { i, j0, j1 } => {
if i >= ns || j0 >= j1 || j1 >= nt {
return invalid(format!("column segment {i}: rows {j0}..{j1} are not inside the mesh"));
}
(j0..j1).for_each(|j| g.set_v(i, j));
}
Segment::Row { j, i0, i1 } => {
if j >= nt || i0 >= i1 || i1 >= ns {
return invalid(format!("row segment {j}: columns {i0}..{i1} are not inside the mesh"));
}
(i0..i1).for_each(|i| g.set_h(i, j));
}
}
}
TMesh::from_grid(g)
}
/// Validate a grid as a T-mesh.
pub(crate) fn from_grid(g: Grid) -> SplineResult<TMesh> {
let (ns, nt) = (g.ns(), g.nt());
if (0..ns - 1).any(|i| !g.h(i, 0) || !g.h(i, nt - 1)) || (0..nt - 1).any(|j| !g.v(0, j) || !g.v(ns - 1, j)) {
return invalid("the T-mesh's outer rectangle is not closed");
}
if let Some(i) = (0..ns).find(|&i| !(0..nt - 1).any(|j| g.v(i, j))) {
return invalid(format!("column {i} carries no edge"));
}
if let Some(j) = (0..nt).find(|&j| !(0..ns - 1).any(|i| g.h(i, j))) {
return invalid(format!("row {j} carries no edge"));
}
let mut vertices = Vec::new();
let mut vid = vec![u32::MAX; ns * nt];
for j in 0..nt {
for i in 0..ns {
let l = i > 0 && g.h(i - 1, j);
let r = g.h(i, j);
let d = j > 0 && g.v(i, j - 1);
let u = g.v(i, j);
let n = [l, r, d, u].iter().filter(|&&e| e).count();
let corner = (i == 0 || i == ns - 1) && (j == 0 || j == nt - 1);
match n {
1 => return invalid(format!("an edge ends without meeting another at ({i}, {j})")),
2 if (l != r) && !corner => return invalid(format!("a face is not rectangular at ({i}, {j})")),
_ => {}
}
if (l || r) && (d || u) {
if let Some(slot) = vid.get_mut(j * ns + i) {
*slot = vertices.len() as u32;
}
vertices.push((i, j));
}
}
}
Ok(TMesh { grid: g, vertices, vid })
}
/// Knot values of the columns.
pub fn s_knots(&self) -> &[f64] {
&self.grid.s
}
/// Knot values of the rows.
pub fn t_knots(&self) -> &[f64] {
&self.grid.t
}
/// The vertices `(column, row)`, row by row (column fastest). Control points follow this
/// order.
pub fn vertices(&self) -> &[(usize, usize)] {
&self.vertices
}
/// Vertex number of grid point `(i, j)`.
pub fn vertex_at(&self, i: usize, j: usize) -> Option<usize> {
if i >= self.grid.ns() {
return None;
}
self.vid.get(j * self.grid.ns() + i).filter(|&&k| k != u32::MAX).map(|&k| k as usize)
}
/// The parameter point `(s, t)` of vertex `k`.
pub fn vertex_param(&self, k: usize) -> Option<(f64, f64)> {
let &(i, j) = self.vertices.get(k)?;
Some((*self.grid.s.get(i)?, *self.grid.t.get(j)?))
}
/// Is the edge from `(i, j)` to `(i+1, j)` present?
pub fn row_edge(&self, i: usize, j: usize) -> bool {
self.grid.h(i, j)
}
/// Is the edge from `(i, j)` to `(i, j+1)` present?
pub fn column_edge(&self, i: usize, j: usize) -> bool {
self.grid.v(i, j)
}
/// Every unit edge present: a tensor-product mesh without T-junctions.
pub fn is_regular(&self) -> bool {
(0..self.grid.nt()).all(|j| (0..self.grid.ns() - 1).all(|i| self.grid.h(i, j)))
&& (0..self.grid.ns()).all(|i| (0..self.grid.nt() - 1).all(|j| self.grid.v(i, j)))
}
/// The faces, by their lower-left corner.
pub fn faces(&self) -> Vec<IndexRect> {
let g = &self.grid;
let mut out = Vec::new();
for cj in 0..g.nt() - 1 {
for ci in 0..g.ns() - 1 {
if g.v(ci, cj) && g.h(ci, cj) {
out.push(g.face_of_cell(ci, cj));
}
}
}
out
}
/// The local knot vector (line indices) of a degree-`p` blending function anchored at
/// vertex `(i, j)` in direction `dir` (2003 §3).
pub fn local_knots(&self, i: usize, j: usize, dir: Dir, p: usize) -> Vec<usize> {
self.grid.local_knots(i, j, dir, p)
}
/// The T-junction extensions for bi-degree `degree` (Scott, Li, Sederberg, Hughes 2012,
/// §2, after Li, Zheng, Sederberg, Hughes, Scott 2012): from each interior T-junction a face
/// extension runs in the direction of the missing edge across `⌈p/2⌉` perpendicular lines
/// carrying an edge there, and an edge extension the other way across `⌊p/2⌋` of them.
/// `p` is the degree along the extension.
pub fn extensions(&self, degree: [usize; 2]) -> Vec<Extension> {
let g = &self.grid;
let (ns, nt) = (g.ns(), g.nt());
let mut out = Vec::new();
for &(i, j) in &self.vertices {
if i == 0 || j == 0 || i == ns - 1 || j == nt - 1 {
continue;
}
let (l, r, d, u) = (g.h(i - 1, j), g.h(i, j), g.v(i, j - 1), g.v(i, j));
if [l, r, d, u].iter().filter(|&&e| e).count() != 3 {
continue;
}
let (dir, missing_forward) = if !l || !r { (Dir::S, !r) } else { (Dir::T, !u) };
let p = dir.pick(degree[0], degree[1]);
let reach = |forward: bool, count: usize| -> usize {
if count == 0 {
return dir.pick(i, j);
}
g.ray(i, j, dir, forward, count).last().copied().unwrap_or(0)
};
let face = reach(missing_forward, p.div_ceil(2));
let edge = reach(!missing_forward, p / 2);
let range = if face < edge { [face, edge] } else { [edge, face] };
let at = dir.pick(j, i);
out.push(Extension { dir, at, range, from: (i, j) });
}
out
}
/// Analysis-suitable for bi-degree `degree`: no extension along s meets an extension along
/// t (Scott et al. 2012, §2). Analysis-suitable T-splines have linearly independent
/// blending functions that form a partition of unity.
pub fn is_analysis_suitable(&self, degree: [usize; 2]) -> bool {
self.conflict(degree).is_none()
}
/// The first extension along s that meets an extension along t.
pub(crate) fn conflict(&self, degree: [usize; 2]) -> Option<Extension> {
let ns = self.grid.ns();
let ext = self.extensions(degree);
let mut covered: Vec<Option<usize>> = vec![None; ns * self.grid.nt()];
for (k, e) in ext.iter().enumerate().filter(|(_, e)| e.dir == Dir::S) {
for x in e.range[0]..=e.range[1] {
if let Some(c) = covered.get_mut(e.at * ns + x) {
*c = Some(k);
}
}
}
ext.iter()
.filter(|e| e.dir == Dir::T)
.find_map(|e| (e.range[0]..=e.range[1]).find_map(|y| covered.get(y * ns + e.at).copied().flatten()))
.and_then(|k| ext.get(k).copied())
}
}
/// A T-junction extension: a closed segment along `dir` on index line `at` (the row for
/// [`Dir::S`], the column for [`Dir::T`]) spanning the lines `range`, from the T-junction
/// `from`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Extension {
pub dir: Dir,
pub at: usize,
pub range: [usize; 2],
pub from: (usize, usize),
}
+219
View File
@@ -0,0 +1,219 @@
//! T-spline surfaces: control points on a T-mesh, which allows T-junctions, so a surface can
//! be refined locally and patches with different knot vectors join without gaps.
//!
//! References: Sederberg, Zheng, Bakenov, Nasri 2003, "T-splines and T-NURCCs" (ACM ToG
//! 22(3)) — §3 the T-spline equation and the local knot vectors read off the T-mesh by rays;
//! Sederberg, Cardon, Finnigan, North, Zheng, Lyche 2004, "T-spline simplification and local
//! refinement" (ACM ToG 23(3)) — §3 local refinement by knot insertion into blending
//! functions; Scott, Li, Sederberg, Hughes 2012, "Local refinement of analysis-suitable
//! T-splines" (CMAME 213–216) — §2 index-space T-meshes and T-junction extensions; Piegl &
//! Tiller, *The NURBS Book* — the univariate basis.
//!
//! Scope: odd degree in each direction (control points at vertices, as in the 2003 paper;
//! even degrees put anchors on faces and are refused), rectangular index domains.
//!
//! The surface is `S(s, t) = Σ wₖ Pₖ Bₖ(s, t) / Σ wₖ Bₖ(s, t)` with `Bₖ(s, t) = N[sₖ](s) ·
//! N[tₖ](t)`, the univariate B-splines of the vertex's local knot vectors (2003, eq. 2–3).
//! Near the boundary the outermost `(p−1)/2` index lines each way form a frame that must be
//! complete lines; the domain is the rectangle inside it.
mod extract;
mod mesh;
mod refine;
pub use crate::bspline::Dir;
pub use mesh::{Extension, IndexRect, MAX_GRID_POINTS, MAX_INDEX_LINES, Segment, TMesh};
pub use refine::Cut;
use crate::Vec3;
use crate::bspline::{HPoint, MAX_SPLINE_DEGREE, Side, SplineError, SplineResult, invalid, local_basis, weighted_points};
/// A blending function: its anchor's local knot vectors as line indices and as values, and
/// its support.
#[derive(Clone, Debug, PartialEq)]
struct Blend {
s: Vec<usize>,
t: Vec<usize>,
sv: Vec<f64>,
tv: Vec<f64>,
}
impl Blend {
fn support(&self) -> [[f64; 2]; 2] {
let ends = |v: &[f64]| [v.first().copied().unwrap_or(0.0), v.last().copied().unwrap_or(0.0)];
[ends(&self.sv), ends(&self.tv)]
}
}
/// The number of frame lines outside the domain on each side, `(p−1)/2` per direction.
fn frame(degree: [usize; 2]) -> [usize; 2] {
degree.map(|p| p.saturating_sub(1) / 2)
}
/// A T-spline surface of odd bi-degree.
#[derive(Clone, Debug, PartialEq)]
pub struct TSpline {
mesh: TMesh,
degree: [usize; 2],
ctrl: Vec<HPoint>,
blends: Vec<Blend>,
}
impl TSpline {
/// A T-spline from a mesh, a bi-degree and one point and positive weight per vertex (in
/// [`TMesh::vertices`] order).
pub fn new(mesh: TMesh, degree: [usize; 2], points: &[Vec3], weights: &[f64]) -> SplineResult<TSpline> {
TSpline::from_homogeneous(mesh, degree, weighted_points(points, weights)?)
}
/// A T-spline from homogeneous control points (`(x·w, y·w, z·w, w)`, `w > 0`).
pub fn from_homogeneous(mesh: TMesh, degree: [usize; 2], ctrl: Vec<HPoint>) -> SplineResult<TSpline> {
for p in degree {
if p == 0 || p > MAX_SPLINE_DEGREE {
return Err(SplineError::Unsupported(format!("degree {p} (1 to {MAX_SPLINE_DEGREE})")));
}
if p % 2 == 0 {
return Err(SplineError::Unsupported(format!("even degree {p} (T-splines here have odd degrees)")));
}
}
let g = &mesh.grid;
let (ns, nt) = (g.ns(), g.nt());
let [cs, ct] = frame(degree);
if ns < 2 * cs + 2 || nt < 2 * ct + 2 {
return invalid("too few index lines for the degree");
}
let full_col = |i: usize| (0..nt - 1).all(|j| g.v(i, j));
let full_row = |j: usize| (0..ns - 1).all(|i| g.h(i, j));
if (0..=cs).any(|k| !full_col(k) || !full_col(ns - 1 - k)) || (0..=ct).any(|k| !full_row(k) || !full_row(nt - 1 - k)) {
return invalid("the outermost (p−1)/2 index lines must be complete");
}
if !(g.s.get(cs) < g.s.get(ns - 1 - cs)) || !(g.t.get(ct) < g.t.get(nt - 1 - ct)) {
return invalid("the T-spline's domain is empty");
}
if ctrl.len() != mesh.vertices().len() {
return invalid(format!("{} control points for {} vertices", ctrl.len(), mesh.vertices().len()));
}
if ctrl.iter().any(|h| !h.is_finite() || !(h.w > 0.0) || h.project().is_none()) {
return invalid("control points must be finite with positive weights");
}
let mut blends = Vec::with_capacity(ctrl.len());
for &(i, j) in mesh.vertices() {
let s = g.local_knots(i, j, Dir::S, degree[0]);
let t = g.local_knots(i, j, Dir::T, degree[1]);
let sv: Vec<f64> = s.iter().map(|&x| g.s.get(x).copied().unwrap_or(0.0)).collect();
let tv: Vec<f64> = t.iter().map(|&y| g.t.get(y).copied().unwrap_or(0.0)).collect();
let b = Blend { s, t, sv, tv };
let [[s0, s1], [t0, t1]] = b.support();
if !(s0 < s1 && t0 < t1) {
return invalid(format!("repeated knots leave the blending function at ({i}, {j}) empty"));
}
blends.push(b);
}
Ok(TSpline { mesh, degree, ctrl, blends })
}
pub fn mesh(&self) -> &TMesh {
&self.mesh
}
pub fn degree(&self) -> [usize; 2] {
self.degree
}
/// Homogeneous control points, one per vertex.
pub fn homogeneous(&self) -> &[HPoint] {
&self.ctrl
}
/// Cartesian control points, one per vertex.
pub fn points(&self) -> Vec<Vec3> {
self.ctrl.iter().map(|h| h.project().unwrap_or(Vec3::ZERO)).collect()
}
/// Weights, one per vertex.
pub fn weights(&self) -> Vec<f64> {
self.ctrl.iter().map(|h| h.w).collect()
}
/// The local knot vectors (values) of vertex `k`'s blending function.
pub fn local_knots(&self, k: usize) -> Option<(&[f64], &[f64])> {
self.blends.get(k).map(|b| (b.sv.as_slice(), b.tv.as_slice()))
}
/// The parameter domain `[[s₀, s₁], [t₀, t₁]]`.
pub fn domain(&self) -> [[f64; 2]; 2] {
let g = &self.mesh.grid;
let [cs, ct] = frame(self.degree);
let at = |k: &[f64], i: usize| k.get(i).copied().unwrap_or(0.0);
[[at(&g.s, cs), at(&g.s, g.ns() - 1 - cs)], [at(&g.t, ct), at(&g.t, g.nt() - 1 - ct)]]
}
/// Analysis-suitable (no s-extension meets a t-extension; Scott et al. 2012, §2).
pub fn is_analysis_suitable(&self) -> bool {
self.mesh.is_analysis_suitable(self.degree)
}
/// Which side of the knots to evaluate on, after checking `(s, t)` lies in the domain.
fn sides(&self, s: f64, t: f64) -> SplineResult<[Side; 2]> {
let [ds, dt] = self.domain();
if !(s >= ds[0] && s <= ds[1] && t >= dt[0] && t <= dt[1]) {
return invalid(format!("({s}, {t}) is outside the T-spline's domain"));
}
let side = |x: f64, hi: f64| if x >= hi { Side::Left } else { Side::Right };
Ok([side(s, ds[1]), side(t, dt[1])])
}
/// Blending function `k` at `(s, t)`, with its first derivatives `[B, ∂B/∂s, ∂B/∂t]`.
fn blend_at(&self, b: &Blend, s: f64, t: f64, sides: [Side; 2], n: usize) -> [f64; 3] {
let [[s0, s1], [t0, t1]] = b.support();
if s < s0 || s > s1 || t < t0 || t > t1 {
return [0.0; 3];
}
let ns = local_basis(&b.sv, s, sides[0], n);
let nt = local_basis(&b.tv, t, sides[1], n);
let (n0, n1) = (ns.first().copied().unwrap_or(0.0), ns.get(1).copied().unwrap_or(0.0));
let (m0, m1) = (nt.first().copied().unwrap_or(0.0), nt.get(1).copied().unwrap_or(0.0));
[n0 * m0, n1 * m0, n0 * m1]
}
/// The value of vertex `k`'s blending function `Bₖ(s, t)` (no weight).
pub fn blending(&self, k: usize, s: f64, t: f64) -> SplineResult<f64> {
let sides = self.sides(s, t)?;
let b = self.blends.get(k).ok_or_else(|| SplineError::Invalid(format!("no vertex {k}")))?;
Ok(self.blend_at(b, s, t, sides, 0)[0])
}
/// Homogeneous sums `[A, ∂A/∂s, ∂A/∂t]` at `(s, t)`.
fn sums(&self, s: f64, t: f64, n: usize) -> SplineResult<[HPoint; 3]> {
let sides = self.sides(s, t)?;
let mut acc = [HPoint::ZERO; 3];
for (b, c) in self.blends.iter().zip(&self.ctrl) {
let v = self.blend_at(b, s, t, sides, n);
for (a, x) in acc.iter_mut().zip(v) {
if x != 0.0 {
*a += *c * x;
}
}
}
Ok(acc)
}
/// The point at `(s, t)`.
pub fn eval(&self, s: f64, t: f64) -> SplineResult<Vec3> {
let [a, _, _] = self.sums(s, t, 0)?;
a.project().ok_or_else(|| SplineError::Invalid(format!("the blending functions vanish at ({s}, {t})")))
}
/// The point and the first partial derivatives `[S, ∂S/∂s, ∂S/∂t]` at `(s, t)` (quotient
/// rule on the homogeneous sums).
pub fn derivs(&self, s: f64, t: f64) -> SplineResult<[Vec3; 3]> {
let [a, a_s, a_t] = self.sums(s, t, 1)?;
let p = a.project().ok_or_else(|| SplineError::Invalid(format!("the blending functions vanish at ({s}, {t})")))?;
let d = |h: HPoint| (h.xyz() - p * h.w) / a.w;
Ok([p, d(a_s), d(a_t)])
}
/// The unit normal `∂S/∂s × ∂S/∂t` at `(s, t)`, if the surface is regular there.
pub fn normal(&self, s: f64, t: f64) -> SplineResult<Option<Vec3>> {
let [_, ds, dt] = self.derivs(s, t)?;
Ok(ds.cross(dt).normalized())
}
}
#[cfg(test)]
mod tests;
+317
View File
@@ -0,0 +1,317 @@
//! Local refinement that keeps the surface exactly (Sederberg, Cardon, Finnigan, North,
//! Zheng, Lyche 2004, §3).
//!
//! The requested knot lines are cut into the T-mesh first. Then every blending function of
//! the original surface is carried as a record — its local knot vectors (as index lines) and
//! its homogeneous coefficient — and the records are made to agree with the refined mesh:
//!
//! * **Violation 1** — the mesh's ray from a record's anchor crosses a line inside the
//! record's support that the record lacks: insert that knot into the record, which splits
//! it into two records (knot insertion into one blending function, 2004 §3).
//! * **Violation 2** — the record has a knot where the ray crosses nothing: split the face
//! there so the mesh dictates that knot.
//! * **Violation 3** — a record's anchor is not a vertex: split a face so it becomes one.
//!
//! Records with the same knot vectors merge. When nothing is violated, each record is exactly
//! the blending function of the vertex at its anchor, so summing the coefficients per vertex
//! gives control points of the refined T-spline, and the surface is unchanged. All knots are
//! handled as line indices, so every comparison is exact; no new index line appears after
//! the cuts, so the work is bounded by the grid.
use std::collections::BTreeMap;
use super::TSpline;
use super::mesh::{Grid, TMesh};
use crate::bspline::{Dir, HPoint, SplineError, SplineResult, invalid, split_basis, split_knots};
/// Most cuts in one refinement.
const MAX_CUTS: usize = 1 << 12;
/// Most record checks and mesh changes in one refinement.
const MAX_REFINE_STEPS: usize = 1 << 22;
/// A knot line to cut into a T-spline.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Cut {
/// The line `s = s` from `t = t0` to `t = t1`: every face it crosses there is split.
S { s: f64, t0: f64, t1: f64 },
/// The line `t = t` from `s = s0` to `s = s1`.
T { t: f64, s0: f64, s1: f64 },
}
impl Cut {
/// The direction the cut line is constant in: [`Dir::S`] for a line `s = …`.
fn dir(&self) -> Dir {
match self {
Cut::S { .. } => Dir::S,
Cut::T { .. } => Dir::T,
}
}
}
/// A record's knot vectors (s, t) as line indices.
type Key = (Vec<usize>, Vec<usize>);
enum Fix {
/// Violation 1: insert line `k` into the record along the direction.
Split(Dir, usize),
/// Violations 2 and 3: the point (column, row) must lie on a column edge.
Column(usize, usize),
/// … on a row edge.
Row(usize, usize),
}
impl TSpline {
/// The same surface on a refined T-mesh with the given knot lines cut in (2004 §3; see the
/// module notes). The cut lines must lie strictly inside the domain; a cut at the value
/// of an existing knot line extends that line. Further faces may be split to keep the
/// surface exact and, when this T-spline is analysis-suitable, to keep the result
/// analysis-suitable (T-junctions are carried across faces until no s-extension meets a
/// t-extension; Scott et al. 2012).
pub fn refine(&self, cuts: &[Cut]) -> SplineResult<TSpline> {
if cuts.len() > MAX_CUTS {
return Err(SplineError::TooLarge(format!("{} cuts (at most {MAX_CUTS})", cuts.len())));
}
let mut g = self.mesh.grid.clone();
let mut smap: Vec<usize> = (0..g.ns()).collect();
let mut tmap: Vec<usize> = (0..g.nt()).collect();
let [ds, dt] = self.domain();
// Row cuts run as column cuts on the transposed grid; consecutive cuts along one
// direction share a transposition.
for run in cuts.chunk_by(|a, b| a.dir() == b.dir()) {
let along_t = run.first().is_some_and(|c| c.dir() == Dir::T);
if along_t {
g = g.transposed();
}
for cut in run {
match *cut {
Cut::S { s, t0, t1 } => cut_column(&mut g, &mut smap, s, [t0, t1], ds)?,
Cut::T { t, s0, s1 } => cut_column(&mut g, &mut tmap, t, [s0, s1], dt)?,
}
}
if along_t {
g = g.transposed();
}
}
let remap = |idx: &[usize], map: &[usize]| -> Vec<usize> { idx.iter().map(|&x| map.get(x).copied().unwrap_or(x)).collect() };
let mut rec: BTreeMap<Key, HPoint> = BTreeMap::new();
for (b, c) in self.blends.iter().zip(&self.ctrl) {
*rec.entry((remap(&b.s, &smap), remap(&b.t, &tmap))).or_insert(HPoint::ZERO) += *c;
}
// Keep analysis-suitability (Scott et al. 2012): while an s-extension meets a
// t-extension, carry the s-extension's T-junction across its face, then make the
// records agree with the mesh again. Every round adds edges, so this ends.
let keep_suitable = self.is_analysis_suitable();
let mesh = loop {
close(&mut g, &mut rec, self.degree)?;
let mesh = TMesh::from_grid(g.clone())?;
match mesh.conflict(self.degree).filter(|_| keep_suitable) {
Some(e) => extend_t_junction(&mut g, e.from),
None => break mesh,
}
};
let mut ctrl = vec![HPoint::ZERO; mesh.vertices().len()];
let mut seen = vec![false; ctrl.len()];
for ((s, t), c) in &rec {
let (ci, cj) = (centre(s), centre(t));
let k = mesh.vertex_at(ci, cj).ok_or_else(|| SplineError::Invalid("refinement left a blending function without a vertex".into()))?;
if let (Some(slot), Some(f)) = (ctrl.get_mut(k), seen.get_mut(k)) {
*slot += *c;
*f = true;
}
}
if let Some(k) = seen.iter().position(|&f| !f) {
let (i, j) = mesh.vertices().get(k).copied().unwrap_or_default();
return Err(SplineError::Unsupported(format!(
"this refinement leaves the control point at ({i}, {j}) without a blending function (the refined T-mesh is not analysis-suitable)"
)));
}
let out = TSpline::from_homogeneous(mesh, self.degree, ctrl)?;
// The records are the refined blending functions, one per vertex.
for (s, t) in rec.keys() {
let k = out.mesh.vertex_at(centre(s), centre(t));
if k.and_then(|k| out.blends.get(k)).is_none_or(|b| &b.s != s || &b.t != t) {
return Err(SplineError::Invalid("refinement did not converge to the refined mesh's blending functions".into()));
}
}
Ok(out)
}
}
/// Carry a T-junction's missing edge across the face beyond it.
fn extend_t_junction(g: &mut Grid, (i, j): (usize, usize)) {
let (l, r, d, u) = (i > 0 && g.h(i - 1, j), g.h(i, j), j > 0 && g.v(i, j - 1), g.v(i, j));
if !r {
g.split_row_at(i, j);
} else if !l {
g.split_row_at(i.saturating_sub(1), j);
} else if !u {
g.split_column_at(i, j);
} else if !d {
g.split_column_at(i, j.saturating_sub(1));
}
}
/// The anchor line of a local knot vector.
fn centre(k: &[usize]) -> usize {
k.get(k.len() / 2).copied().unwrap_or(0)
}
/// Cut the column line `value` into every face it crosses between `range[0]` and `range[1]`,
/// adding the column if needed.
fn cut_column(g: &mut Grid, map: &mut [usize], value: f64, range: [f64; 2], dom: [f64; 2]) -> SplineResult<()> {
let [a, b] = range;
if !(value.is_finite() && a.is_finite() && b.is_finite()) {
return invalid("a cut needs finite numbers");
}
if !(a < b) {
return invalid("a cut needs a non-empty range");
}
if !(value > dom[0] && value < dom[1]) {
return invalid(format!("the cut at {value} is not inside the domain"));
}
let same: Vec<usize> = (0..g.ns()).filter(|&x| g.s.get(x) == Some(&value)).collect();
let (x, new) = match same.as_slice() {
[] => {
let at = g.s.iter().position(|&k| k > value).unwrap_or(g.ns() - 1);
g.insert_column(at, value)?;
map.iter_mut().filter(|m| **m >= at).for_each(|m| *m += 1);
(at, true)
}
[x] => (*x, false),
_ => return Err(SplineError::Unsupported(format!("cutting along the repeated knot {value}"))),
};
let mut added = false;
for y in 0..g.nt() - 1 {
if g.v(x, y) {
continue;
}
let f = g.face_of_cell(x, y);
let (fa, fb) = (g.t.get(f.j0).copied().unwrap_or(0.0), g.t.get(f.j1).copied().unwrap_or(0.0));
// Faces overlapping the range, and faces of zero height inside it (so a cut from a
// clamped domain end runs on through the zero-height frame to the boundary).
if fb.min(b) - fa.max(a) > 0.0 || (fa == fb && fa >= a && fb <= b) {
(f.j0..f.j1).for_each(|j| g.set_v(x, j));
added = true;
}
}
if new && !added {
return invalid(format!("the cut at {value} crosses no face"));
}
Ok(())
}
/// Resolve the violations until every record is a blending function of the mesh.
fn close(g: &mut Grid, rec: &mut BTreeMap<Key, HPoint>, degree: [usize; 2]) -> SplineResult<()> {
let mut steps = 0usize;
let mut tick = |n: usize| -> SplineResult<()> {
steps += n;
if steps > MAX_REFINE_STEPS { Err(SplineError::TooLarge("the refinement needs too much work".into())) } else { Ok(()) }
};
loop {
// Violation 1 first (knot insertion into records), until none is left.
loop {
let keys: Vec<Key> = rec.keys().cloned().collect();
tick(keys.len())?;
let mut changed = false;
for key in keys {
if let Some(Fix::Split(dir, k)) = check(g, &key, degree)
&& let Some(c) = rec.remove(&key)
{
for (piece, f) in split(g, &key, dir, k)? {
if f != 0.0 {
*rec.entry(piece).or_insert(HPoint::ZERO) += c * f;
}
}
changed = true;
}
}
if !changed {
break;
}
}
// Then one mesh change for the first record that needs one.
tick(rec.len())?;
let fix = rec.keys().find_map(|k| match check(g, k, degree) {
Some(Fix::Split(..)) | None => None,
other => other,
});
match fix {
Some(Fix::Column(x, y)) => g.split_column_at(x, y),
Some(Fix::Row(x, y)) => g.split_row_at(x, y),
_ => return Ok(()),
}
}
}
/// The first violation of a record against the mesh, if any.
fn check(g: &Grid, key: &Key, degree: [usize; 2]) -> Option<Fix> {
let (s, t) = key;
let (ci, cj) = (centre(s), centre(t));
let ms = g.local_knots(ci, cj, Dir::S, degree[0]);
match compare(s, &ms) {
Some(Ok(k)) => return Some(Fix::Split(Dir::S, k)),
Some(Err(x)) => return Some(Fix::Column(x, cj)),
None => {}
}
let mt = g.local_knots(ci, cj, Dir::T, degree[1]);
match compare(t, &mt) {
Some(Ok(k)) => return Some(Fix::Split(Dir::T, k)),
Some(Err(y)) => return Some(Fix::Row(ci, y)),
None => {}
}
if !g.on_column(ci, cj) {
return Some(Fix::Column(ci, cj));
}
if !g.on_row(ci, cj) {
return Some(Fix::Row(ci, cj));
}
None
}
/// Compare a record's knots with the mesh's, outward from the anchor: `Ok(k)` the mesh
/// crosses line `k` first (insert it into the record), `Err(x)` the record has line `x` where
/// the mesh crosses nothing (the mesh must gain it).
fn compare(rec: &[usize], mesh: &[usize]) -> Option<Result<usize, usize>> {
let mid = rec.len() / 2;
for k in mid + 1..rec.len() {
let (r, m) = (rec.get(k)?, mesh.get(k)?);
if m < r {
return Some(Ok(*m));
}
if m > r {
return Some(Err(*r));
}
}
for k in (0..mid).rev() {
let (r, m) = (rec.get(k)?, mesh.get(k)?);
if m > r {
return Some(Ok(*m));
}
if m < r {
return Some(Err(*r));
}
}
None
}
/// Insert line `k` into a record along `dir`: the two pieces and their factors (2004 §3).
/// Pieces whose knots all have one value are the zero function and are dropped.
fn split(g: &Grid, key: &Key, dir: Dir, k: usize) -> SplineResult<Vec<(Key, f64)>> {
let (idx, vals) = dir.pick((&key.0, &g.s), (&key.1, &g.t));
let value = |x: usize| vals.get(x).copied().unwrap_or(0.0);
let values: Vec<f64> = idx.iter().map(|&x| value(x)).collect();
let (c1, c2) = split_basis(&values, value(k)).ok_or_else(|| SplineError::Invalid("refinement inserted a knot outside a support".into()))?;
let mut out = Vec::with_capacity(2);
for (piece, c) in split_knots(idx, k).into_iter().zip([c1, c2]) {
let (Some(&lo), Some(&hi)) = (piece.first(), piece.last()) else { continue };
if value(lo) == value(hi) {
continue;
}
let key = match dir {
Dir::S => (piece, key.1.clone()),
Dir::T => (key.0.clone(), piece),
};
out.push((key, c));
}
Ok(out)
}
+350
View File
@@ -0,0 +1,350 @@
use super::*;
use crate::bspline::tests::{Lcg, random_surface};
use crate::bspline::{BezierPatch, NurbsSurface, SplineError};
fn uniform(n: usize) -> Vec<f64> {
(0..n).map(|k| k as f64).collect()
}
fn col(i: usize, j0: usize, j1: usize) -> Segment {
Segment::Column { i, j0, j1 }
}
fn row(j: usize, i0: usize, i1: usize) -> Segment {
Segment::Row { j, i0, i1 }
}
/// 7 × 7 lines, row 3 only from column 0 to column 3: one T-junction at (3, 3) whose
/// extension runs along s.
fn t_mesh() -> TMesh {
let mut segs: Vec<Segment> = (0..7).map(|i| col(i, 0, 6)).collect();
segs.extend([0, 1, 2, 4, 5, 6].map(|j| row(j, 0, 6)));
segs.push(row(3, 0, 3));
TMesh::from_segments(uniform(7), uniform(7), &segs).unwrap()
}
fn random_points(seed: u64, n: usize) -> (Vec<Vec3>, Vec<f64>) {
let mut rng = Lcg(seed);
let pts = (0..n).map(|_| Vec3::new(rng.unit(), rng.unit(), rng.unit()) * 10.0).collect();
let w = (0..n).map(|_| 0.5 + rng.unit()).collect();
(pts, w)
}
fn t_spline(seed: u64) -> TSpline {
let mesh = t_mesh();
let (pts, w) = random_points(seed, mesh.vertices().len());
TSpline::new(mesh, [3, 3], &pts, &w).unwrap()
}
/// A clamped bicubic NURBS with uneven interior knots, as a T-spline.
fn tensor_spline(seed: u64) -> (NurbsSurface, TSpline) {
let ku = vec![0.0, 0.0, 0.0, 0.0, 0.2, 0.45, 0.5, 0.8, 1.0, 1.0, 1.0, 1.0];
let kv = vec![0.0, 0.0, 0.0, 0.0, 0.3, 0.6, 1.0, 1.0, 1.0, 1.0];
let n = random_surface(seed, ku, kv, [3, 3]);
let ts = TSpline::from_nurbs(&n).unwrap();
(n, ts)
}
fn grid_params(dom: [[f64; 2]; 2], k: usize) -> Vec<(f64, f64)> {
let mut out = Vec::new();
for i in 0..=k {
for j in 0..=k {
let s = dom[0][0] + (dom[0][1] - dom[0][0]) * i as f64 / k as f64;
let t = dom[1][0] + (dom[1][1] - dom[1][0]) * j as f64 / k as f64;
out.push((s, t));
}
}
out
}
fn max_deviation(a: &TSpline, b: &TSpline) -> f64 {
assert_eq!(a.domain(), b.domain());
grid_params(a.domain(), 60).into_iter().map(|(s, t)| a.eval(s, t).unwrap().dist(b.eval(s, t).unwrap())).fold(0.0, f64::max)
}
#[test]
fn tmesh_validation_refuses_malformed_meshes() {
let frame = |extra: &[Segment]| -> Vec<Segment> {
let mut s = vec![col(0, 0, 4), col(4, 0, 4), row(0, 0, 4), row(4, 0, 4)];
s.extend_from_slice(extra);
s
};
let full: Vec<Segment> = (1..4).flat_map(|k| [col(k, 0, 4), row(k, 0, 4)]).collect();
assert!(TMesh::from_segments(uniform(5), uniform(5), &frame(&full)).is_ok());
// A dangling edge, an L-shaped corner, an open boundary, an empty line.
let mut dangling = full.clone();
dangling.push(col(2, 0, 4));
dangling.retain(|s| *s != row(2, 0, 4));
dangling.push(row(2, 0, 1));
dangling.push(row(2, 3, 4));
assert!(TMesh::from_segments(uniform(5), uniform(5), &frame(&dangling)).is_ok(), "T-junctions on both sides are fine");
let dangling_edge = frame(&[col(1, 0, 4), col(3, 0, 4), row(1, 0, 4), row(2, 0, 4), row(3, 0, 1), col(2, 0, 3)]);
assert!(TMesh::from_segments(uniform(5), uniform(5), &dangling_edge).is_err(), "edge ending inside a face");
let mut l_shape = frame(&[col(1, 0, 4), col(2, 0, 4), col(3, 0, 4), row(1, 0, 4), row(3, 0, 4)]);
l_shape.push(row(2, 0, 2));
l_shape.retain(|s| *s != col(2, 0, 4));
l_shape.extend([col(2, 0, 2), col(2, 3, 4)]);
assert!(TMesh::from_segments(uniform(5), uniform(5), &l_shape).is_err(), "L-shaped face corner");
assert!(TMesh::from_segments(uniform(5), uniform(5), &full).is_err(), "no outer rectangle");
let mut empty = frame(&full);
empty.retain(|s| *s != col(2, 0, 4));
assert!(TMesh::from_segments(uniform(5), uniform(5), &empty).is_err(), "a column without edges");
assert!(TMesh::from_segments(vec![0.0, 1.0, f64::NAN, 3.0, 4.0], uniform(5), &frame(&full)).is_err());
assert!(TMesh::from_segments(vec![0.0, 2.0, 1.0, 3.0, 4.0], uniform(5), &frame(&full)).is_err());
assert!(TMesh::from_segments(uniform(5), uniform(5), &[col(9, 0, 4)]).is_err());
assert!(TMesh::from_segments(uniform(5), uniform(5), &[col(1, 3, 3)]).is_err());
assert!(matches!(TMesh::tensor(uniform(MAX_INDEX_LINES + 1), uniform(2)), Err(SplineError::TooLarge(_))));
assert!(matches!(TMesh::tensor(uniform(2048), uniform(2048)), Err(SplineError::TooLarge(_))));
assert!(TMesh::tensor(uniform(1), uniform(3)).is_err());
}
#[test]
fn local_knot_vectors_are_read_off_rays() {
let ts = t_spline(1);
let m = ts.mesh();
assert!(m.vertex_at(4, 3).is_none(), "(4, 3) lies on a column edge only");
assert!(m.vertex_at(3, 3).is_some(), "the T-junction is a vertex");
let knots = |i, j| {
let k = m.vertex_at(i, j).unwrap();
let (s, t) = ts.local_knots(k).unwrap();
(s.to_vec(), t.to_vec())
};
assert_eq!(knots(2, 3), (vec![0.0, 1.0, 2.0, 3.0, 4.0], vec![1.0, 2.0, 3.0, 4.0, 5.0]));
assert_eq!(knots(3, 4).1, vec![2.0, 3.0, 4.0, 5.0, 6.0]);
// The ray down column 4 passes (4, 3), which carries no row edge.
assert_eq!(knots(4, 4).1, vec![1.0, 2.0, 4.0, 5.0, 6.0]);
assert_eq!(knots(4, 2).1, vec![0.0, 1.0, 2.0, 4.0, 5.0]);
// Rays leaving the mesh repeat the boundary line.
assert_eq!(knots(0, 0), (vec![0.0, 0.0, 0.0, 1.0, 2.0], vec![0.0, 0.0, 0.0, 1.0, 2.0]));
assert_eq!(knots(6, 5).1, vec![2.0, 4.0, 5.0, 6.0, 6.0]);
}
#[test]
fn regular_tspline_is_the_tensor_nurbs_surface() {
for (seed, degree, ku, kv) in [
(2, [3, 3], vec![0.0, 0.0, 0.0, 0.0, 0.2, 0.45, 0.5, 0.8, 1.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 0.0, 0.0, 0.3, 0.6, 1.0, 1.0, 1.0, 1.0]),
(3, [3, 1], vec![0.0, 0.0, 0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 0.4, 1.0, 1.0]),
(4, [5, 3], vec![0.0; 6].into_iter().chain([0.3, 0.7]).chain([1.0; 6]).collect(), vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0]),
] {
let n = random_surface(seed, ku, kv, degree);
let ts = TSpline::from_nurbs(&n).unwrap();
assert!(ts.mesh().is_regular() && ts.is_analysis_suitable());
assert_eq!(ts.domain(), n.domain());
let worst = grid_params(n.domain(), 40).into_iter().map(|(s, t)| ts.eval(s, t).unwrap().dist(n.eval(s, t).unwrap())).fold(0.0, f64::max);
assert!(worst < 1e-12, "degree {degree:?}: T-spline differs from NURBS by {worst}");
assert_eq!(ts.to_nurbs().unwrap(), n, "NURBS → T-spline → NURBS is exact");
}
// An unclamped NURBS is clamped on the way in (same surface).
let n = random_surface(5, uniform(10), uniform(9), [3, 3]);
let ts = TSpline::from_nurbs(&n).unwrap();
let worst = grid_params(n.domain(), 30).into_iter().map(|(s, t)| ts.eval(s, t).unwrap().dist(n.eval(s, t).unwrap())).fold(0.0, f64::max);
assert!(worst < 1e-12, "unclamped: {worst}");
assert!(TSpline::from_nurbs(&random_surface(6, uniform(9), uniform(9), [2, 3])).is_err(), "even degree");
}
#[test]
fn blending_functions_sum_to_one_with_t_junctions() {
let ts = t_spline(1);
assert!(ts.is_analysis_suitable());
let n = ts.mesh().vertices().len();
let mut worst = 0.0f64;
for (s, t) in grid_params(ts.domain(), 64) {
let sum: f64 = (0..n).map(|k| ts.blending(k, s, t).unwrap()).sum();
worst = worst.max((sum - 1.0).abs());
}
assert!(worst < 1e-14, "partition of unity off by {worst}");
}
#[test]
fn derivatives_match_finite_differences() {
let ts = t_spline(9);
let h = 1e-6;
let mut worst = 0.0f64;
for (s, t) in grid_params([[1.05, 4.95], [1.05, 4.95]], 17) {
let [_, ds, dt] = ts.derivs(s, t).unwrap();
let fs = (ts.eval(s + h, t).unwrap() - ts.eval(s - h, t).unwrap()) / (2.0 * h);
let ft = (ts.eval(s, t + h).unwrap() - ts.eval(s, t - h).unwrap()) / (2.0 * h);
worst = worst.max((ds - fs).len()).max((dt - ft).len());
}
assert!(worst < 1e-6, "derivative error {worst}");
assert!(ts.normal(3.0, 3.0).unwrap().is_some());
}
#[test]
fn analysis_suitability_follows_t_junction_extensions() {
assert!(tensor_spline(1).1.is_analysis_suitable());
let ext = t_mesh().extensions([3, 3]);
assert_eq!(ext, vec![Extension { dir: Dir::S, at: 3, range: [2, 5], from: (3, 3) }]);
assert!(t_mesh().is_analysis_suitable([3, 3]));
// Row 4 stops at column 4 (extension along s over columns 3..7 on row 4: column 5 has no
// edge there) and column 5 stops at row 3 (extension along t over rows 2..6 on column 5):
// they cross at (5, 4).
let mut segs: Vec<Segment> = [0, 1, 2, 3, 4, 6, 7, 8].iter().map(|&i| col(i, 0, 8)).collect();
segs.push(col(5, 0, 3));
segs.extend([0, 1, 2, 3, 5, 6, 7, 8].map(|j| row(j, 0, 8)));
segs.push(row(4, 0, 4));
let m = TMesh::from_segments(uniform(9), uniform(9), &segs).unwrap();
let ext = m.extensions([3, 3]);
assert!(ext.contains(&Extension { dir: Dir::S, at: 4, range: [3, 7], from: (4, 4) }));
assert!(ext.contains(&Extension { dir: Dir::T, at: 5, range: [2, 6], from: (5, 3) }));
assert!(!m.is_analysis_suitable([3, 3]));
// Refining it does not repair it (only suitable T-splines are kept suitable), but stays exact.
let (pts, w) = random_points(4, m.vertices().len());
let ts = TSpline::new(m, [3, 3], &pts, &w).unwrap();
let r = ts.refine(&[Cut::S { s: 2.5, t0: 2.0, t1: 3.0 }]).unwrap();
assert!(max_deviation(&ts, &r) < 1e-12);
}
#[test]
fn a_cut_across_the_domain_is_boehm_knot_insertion() {
let (n, ts) = tensor_spline(4);
let r = ts.refine(&[Cut::S { s: 0.33, t0: 0.0, t1: 1.0 }, Cut::T { t: 0.75, s0: 0.0, s1: 1.0 }]).unwrap();
assert!(r.mesh().is_regular());
let want = n.insert_knot(Dir::S, 0.33).unwrap().insert_knot(Dir::T, 0.75).unwrap();
let got = r.to_nurbs().unwrap();
assert_eq!(got.knots(), want.knots());
let worst = got.homogeneous().iter().zip(want.homogeneous()).map(|(a, b)| (*a - *b).xyz().len() + (a.w - b.w).abs()).fold(0.0, f64::max);
assert!(worst < 1e-12, "control points differ by {worst}");
}
#[test]
fn local_refinement_keeps_the_surface() {
let (_, ts) = tensor_spline(7);
let before = ts.mesh().vertices().len();
let r = ts.refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }]).unwrap();
let dev = max_deviation(&ts, &r);
eprintln!("local cut: {} → {} vertices, AS {}, max deviation {dev:e}", before, r.mesh().vertices().len(), r.is_analysis_suitable());
assert!(dev < 1e-12, "deviation {dev}");
assert!(r.mesh().vertices().len() <= before + 4, "a one-face cut stays local");
assert!(r.is_analysis_suitable());
// Refining again near the new T-junctions, and across them.
let r2 = r.refine(&[Cut::T { t: 0.45, s0: 0.2, s1: 0.45 }, Cut::S { s: 0.39, t0: 0.3, t1: 0.45 }]).unwrap();
let dev2 = max_deviation(&ts, &r2);
eprintln!("second refinement: {} vertices, max deviation {dev2:e}", r2.mesh().vertices().len());
assert!(dev2 < 1e-12, "deviation {dev2}");
assert!(r2.is_analysis_suitable(), "refinement keeps analysis-suitability");
let n2 = r2.mesh().vertices().len();
let pu = grid_params(r2.domain(), 50)
.into_iter()
.map(|(s, t)| ((0..n2).map(|k| r2.blending(k, s, t).unwrap()).sum::<f64>() - 1.0).abs())
.fold(0.0, f64::max);
assert!(pu < 1e-14, "partition of unity after refinement off by {pu}");
// A rational T-spline with T-junctions.
let t = t_spline(3);
let r3 = t.refine(&[Cut::S { s: 2.5, t0: 2.0, t1: 4.0 }, Cut::T { t: 3.5, s0: 3.0, s1: 5.0 }]).unwrap();
let dev3 = max_deviation(&t, &r3);
eprintln!("rational T-spline: {} → {} vertices, max deviation {dev3:e}", t.mesh().vertices().len(), r3.mesh().vertices().len());
assert!(dev3 < 1e-12, "deviation {dev3}");
}
#[test]
fn random_local_refinements_keep_the_surface() {
let mut rng = Lcg(42);
let mut worst = 0.0f64;
let mut vertices = 0;
for round in 0..40 {
let (_, ts) = tensor_spline(round);
let mut cur = ts.clone();
for _ in 0..1 + (rng.unit() * 3.0) as usize {
let v = 0.05 + 0.9 * (rng.unit() * 40.0).round() / 40.0;
let a = (rng.unit() * 8.0).round() / 10.0;
let b = (a + 0.1 + (rng.unit() * 5.0).round() / 10.0).min(1.0);
let cut = if rng.unit() < 0.5 { Cut::S { s: v, t0: a, t1: b } } else { Cut::T { t: v, s0: a, s1: b } };
cur = cur.refine(&[cut]).unwrap_or_else(|e| panic!("round {round}: {cut:?}: {e}"));
}
assert!(cur.is_analysis_suitable(), "round {round}: refinement keeps analysis-suitability");
vertices += cur.mesh().vertices().len() - ts.mesh().vertices().len();
worst = worst.max(max_deviation(&ts, &cur));
}
eprintln!("random refinements: max deviation {worst:e}, {vertices} vertices added over 40 rounds");
assert!(worst < 1e-12, "deviation {worst}");
}
fn check_patches(ts: &TSpline, patches: &[BezierPatch]) -> f64 {
let [ds, dt] = ts.domain();
let area: f64 = patches.iter().map(|p| (p.s[1] - p.s[0]) * (p.t[1] - p.t[0])).sum();
assert!((area - (ds[1] - ds[0]) * (dt[1] - dt[0])).abs() < 1e-12, "the patches tile the domain");
let mut worst = 0.0f64;
for p in patches {
for (s, t) in grid_params([p.s, p.t], 6) {
worst = worst.max(p.eval(s, t).unwrap().dist(ts.eval(s, t).unwrap()));
}
}
worst
}
#[test]
fn bezier_extraction_reproduces_the_surface() {
let ts = t_spline(5);
let patches = ts.bezier_elements().unwrap();
let dev = check_patches(&ts, &patches);
eprintln!("Bézier extraction, T-junction mesh: {} patches, max deviation {dev:e}", patches.len());
assert!(dev < 1e-12);
let (n, tensor) = tensor_spline(8);
let patches = tensor.bezier_elements().unwrap();
assert_eq!(patches.len(), 5 * 3, "one patch per pair of knot spans");
assert!(check_patches(&tensor, &patches) < 1e-12);
let _ = n;
let refined = tensor.refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }, Cut::T { t: 0.45, s0: 0.2, s1: 0.45 }]).unwrap();
let patches = refined.bezier_elements().unwrap();
let dev = check_patches(&refined, &patches);
eprintln!("Bézier extraction, refined: {} patches, max deviation {dev:e}", patches.len());
assert!(dev < 1e-12);
}
#[test]
fn hostile_tspline_input_is_refused() {
let mesh = t_mesh();
let n = mesh.vertices().len();
let (pts, w) = random_points(1, n);
assert!(TSpline::new(mesh.clone(), [3, 3], &pts, &w).is_ok());
let mut bad = pts.clone();
bad[3] = Vec3::new(f64::NAN, 0.0, 0.0);
assert!(TSpline::new(mesh.clone(), [3, 3], &bad, &w).is_err());
let mut bw = w.clone();
bw[0] = 0.0;
assert!(TSpline::new(mesh.clone(), [3, 3], &pts, &bw).is_err());
bw[0] = f64::INFINITY;
assert!(TSpline::new(mesh.clone(), [3, 3], &pts, &bw).is_err());
assert!(TSpline::new(mesh.clone(), [3, 3], &pts[1..], &w[1..]).is_err());
assert!(matches!(TSpline::new(mesh.clone(), [2, 3], &pts, &w), Err(SplineError::Unsupported(_))));
assert!(TSpline::new(mesh.clone(), [0, 3], &pts, &w).is_err());
assert!(TSpline::new(mesh.clone(), [9, 3], &pts, &w).is_err());
// Degree 5 needs the three outermost lines each way complete; row 3 is not, so a mesh
// whose row 2 stops short is refused.
let mut segs: Vec<Segment> = (0..7).map(|i| col(i, 0, 6)).collect();
segs.extend([0, 1, 3, 4, 5, 6].map(|j| row(j, 0, 6)));
segs.push(row(2, 0, 3));
let short_frame = TMesh::from_segments(uniform(7), uniform(7), &segs).unwrap();
let (p6, w6) = random_points(3, short_frame.vertices().len());
assert!(TSpline::new(short_frame.clone(), [3, 3], &p6, &w6).is_ok());
assert!(TSpline::new(short_frame, [5, 5], &p6, &w6).is_err());
// A row of knots repeated so often that a blending function is empty.
let m = TMesh::tensor(vec![0.0, 0.0, 0.0, 1.0, 2.0], uniform(5)).unwrap();
let (p, w5) = random_points(2, 25);
assert!(TSpline::new(m, [3, 3], &p, &w5).is_err());
// An empty domain.
let m = TMesh::tensor(vec![0.0, 1.0, 1.0, 2.0], uniform(4)).unwrap();
assert!(TSpline::new(m, [3, 3], &p[..16], &w5[..16]).is_err());
let ts = TSpline::new(mesh, [3, 3], &pts, &w).unwrap();
for (s, t) in [(f64::NAN, 2.0), (2.0, f64::INFINITY), (0.5, 2.0), (2.0, 5.5)] {
assert!(ts.eval(s, t).is_err() && ts.derivs(s, t).is_err() && ts.blending(0, s, t).is_err());
}
assert!(ts.blending(n + 5, 2.0, 2.0).is_err());
for cut in [
Cut::S { s: f64::NAN, t0: 1.0, t1: 2.0 },
Cut::S { s: 2.5, t0: 2.0, t1: 1.0 },
Cut::S { s: 2.5, t0: 2.0, t1: f64::INFINITY },
Cut::S { s: 0.5, t0: 1.0, t1: 2.0 },
Cut::T { t: 5.0, s0: 1.0, s1: 2.0 },
Cut::T { t: 9.0, s0: 1.0, s1: 2.0 },
Cut::S { s: 2.5, t0: 8.0, t1: 9.0 },
] {
assert!(ts.refine(&[cut]).is_err(), "{cut:?}");
}
assert!(matches!(ts.refine(&vec![Cut::S { s: 2.5, t0: 1.0, t1: 2.0 }; 5000]), Err(SplineError::TooLarge(_))));
// Refining the same line twice is a no-op the second time.
let a = ts.refine(&[Cut::S { s: 2.5, t0: 1.0, t1: 5.0 }]).unwrap();
let b = a.refine(&[Cut::S { s: 2.5, t0: 1.0, t1: 5.0 }]).unwrap();
assert_eq!(a, b);
}
+186 -88
View File
@@ -209,15 +209,27 @@ fn blend_geometry(
) -> 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. A side edge that the fillet's setback uses up
// exactly is consumed: the rail ends at its far vertex (`ca`, `cb`).
// The arc centre is r from both faces (inside the material for a convex edge, outside for a
// concave one), in the cross-section through `p`.
let centre = |p: Vec3| p + t1 * s + n1 * (r * side);
// Each end of the blend lies in one more face (the end face, square to the edge or at an
// angle) or, for a fillet, in two that meet at the edge's end (Vida, Martin and Várady 1994:
// trimlines joined across existing faces): the blend then crosses the edge between them,
// which is cut back to the crossing. The rails run parallel to the edge, set back by s in
// their side faces, and end in the end plane they reach (`ga` from f1, `gb` from f2). A side
// edge that the setback uses up exactly is consumed: the rail ends at its far vertex (`ca`,
// `cb`; one end face only).
struct End {
v: mt::Vertex,
g: usize,
g: Vec<usize>,
e1: mt::Edge,
e2: mt::Edge,
/// The edge between two end faces, and where the blend crosses it.
e3: Option<(mt::Edge, Vec3)>,
a: Vec3,
b: Vec3,
/// The end's section of the blend, from A to B (across both faces when two).
planes: Vec<(Vec3, Vec3)>,
ca: Option<mt::Vertex>,
cb: Option<mt::Vertex>,
}
@@ -225,70 +237,98 @@ fn blend_geometry(
for (v, p) in [(&v0, p0), (&v1, p1)] {
let at_v: Vec<usize> = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.vertex_iter().any(|x| &x == v))).collect();
let others: Vec<usize> = at_v.iter().copied().filter(|i| *i != i1 && *i != i2).collect();
let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) };
let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?;
let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
if shape == Shape::Round && ng.dot(d).abs() < 1.0 - 1e-9 {
return Err(unsupported("the faces at the edge ends must be perpendicular to the edge"));
}
if ng.dot(d).abs() < 1e-9 {
return Err(unsupported("an end face parallel to the edge"));
}
// Each rail is parallel to the original edge and set back by s in its side
// face. Slide its endpoint along d until it lies on the actual end plane.
let endpoint = |t: Vec3| {
let q = p + t * s;
if shape == Shape::Flat { q - d * (ng.dot(t) * s / ng.dot(d)) } else { q }
// The edge at v that face `fi` shares with face `g`, other than the blended edge.
let shared = |fi: usize, g: usize| -> Option<mt::Edge> {
let (f, gf) = (faces.get(fi)?, faces.get(g)?);
f.edge_iter().find(|e| e.id() != edge.id() && (e.front() == v || e.back() == v) && gf.edge_iter().any(|x| x.id() == e.id()))
};
let (a, b) = (endpoint(t1), endpoint(t2));
// Edges at v shared by g with f1 and with f2.
let shared = |fi: usize| -> Result<mt::Edge> {
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
f.edge_iter()
.find(|e| e.id() != edge.id() && (e.front() == v || e.back() == v) && gf.edge_iter().any(|x| x.id() == e.id()))
.ok_or_else(|| unsupported("unexpected corner topology"))
let (ga, gb) = match others[..] {
[g] => (g, g),
[x, y] if shape == Shape::Round && shared(i1, x).is_some() && shared(i2, y).is_some() => (x, y),
[x, y] if shape == Shape::Round && shared(i1, y).is_some() && shared(i2, x).is_some() => (y, x),
_ => return Err(unsupported("each end of the edge must touch exactly one more face")),
};
let (e1, e2) = (shared(i1)?, shared(i2)?);
let normal = |g: usize| -> Result<Vec3> {
let n = faces.get(g).and_then(|f| plane_normal(f)).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
if n.dot(d).abs() < 1e-9 {
return Err(unsupported("an end face parallel to the edge"));
}
Ok(n)
};
let (na, nb) = (normal(ga)?, normal(gb)?);
// Slide a point of the cross-section through `p` along d onto the end plane through p.
let slide = |x: Vec3, n: Vec3| x - d * (n.dot(x - p) / n.dot(d));
let (a, b) = (slide(p + t1 * s, na), slide(p + t2 * s, nb));
let (e1, e2) = (
shared(i1, ga).ok_or_else(|| unsupported("unexpected corner topology"))?,
shared(i2, gb).ok_or_else(|| unsupported("unexpected corner topology"))?,
);
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() {
for (k, (e, q, fi)) in [(&e1, a, i1), (&e2, b, i2)].into_iter().enumerate() {
if !matches!(e.curve(), mt::Curve::Line(_)) {
return Err(unsupported("the edges at the corners must be straight"));
}
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,
// 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 {
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
if along <= s + tol
&& let Some(slot) = consumed.get_mut(k)
{
let delta = vtx(&far) - p;
let len = delta.len();
let dir = delta.normalized().ok_or_else(|| unsupported("zero-length corner edge"))?;
let along = (q - p).dot(dir);
if (q - p - dir * along).len() > tol * 100.0 + 1e-7 {
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
// A fillet's setback that uses up the side edge exactly consumes it.
if shape == Shape::Round && ga == gb && (along - len).abs() <= tol {
if let Some(slot) = consumed.get_mut(k) {
*slot = Some(far);
}
continue;
}
let len = delta.len();
let dir = delta.normalized().ok_or_else(|| unsupported("zero-length corner edge"))?;
let along = (q - p).dot(dir);
// An inner rim extends the neighbouring rim edge away from its other
// vertex. This is valid only within the trimmed side face.
if (q - p - dir * along).len() > tol * 100.0 + 1e-7
|| along >= len - tol
|| (along < 0.0 && !faces.get(fi).is_some_and(|f| extension_fits(f, p, q, tol)))
{
// A rail ending before the old corner extends the neighbouring rim edge away from its
// other vertex: valid only within the trimmed side face.
if along >= len - tol || (along < 0.0 && !faces.get(fi).is_some_and(|f| extension_fits(f, p, q, tol))) {
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
}
// Across two end faces: where the blend's cylinder crosses the edge between them, on the
// blend's side of the axis (between its two rails).
let e3 = if ga == gb {
None
} else {
let (Some(fa), Some(fb)) = (faces.get(ga), faces.get(gb)) else { return Err(KernelError::Failed("face".into())) };
let e3 = fa
.edge_iter()
.find(|e| (e.front() == v || e.back() == v) && fb.edge_iter().any(|x| x.id() == e.id()))
.ok_or_else(|| unsupported("unexpected corner topology"))?;
if !matches!(e3.curve(), mt::Curve::Line(_)) {
return Err(unsupported("the edges at the corners must be straight"));
}
let far = if e3.front() == v { vtx(e3.back()) } else { vtx(e3.front()) };
let (len3, u) = (far.dist(p), (far - p).normalized().ok_or_else(|| unsupported("zero-length corner edge"))?);
let c0 = centre(p);
let perp = |x: Vec3| x - d * x.dot(d);
let (w, up) = (perp(p - c0), perp(u));
// |w + λ up|² = r²
let (qa, qb, qc) = (up.len2(), 2.0 * w.dot(up), w.len2() - r * r);
let disc = qb * qb - 4.0 * qa * qc;
if qa < 1e-18 || disc < 0.0 {
return Err(unsupported("the blend does not cross the edge between the end faces"));
}
let (ra, rb) = (perp(p + t1 * s - c0), perp(p + t2 * s - c0));
let angle = |x: Vec3, y: Vec3| (x.dot(y) / (x.len() * y.len()).max(1e-300)).clamp(-1.0, 1.0).acos();
let between = |x: Vec3| (angle(ra, x) + angle(x, rb) - angle(ra, rb)).abs() < 1e-6;
let root = [(-qb - disc.sqrt()) / (2.0 * qa), (-qb + disc.sqrt()) / (2.0 * qa)]
.into_iter()
.filter(|l| *l > tol && *l < len3 - tol)
.find(|l| between(w + up * *l))
.ok_or_else(|| unsupported("the blend does not cross the edge between the end faces"))?;
Some((e3.clone(), p + u * root))
};
let planes = if ga == gb { vec![(p, na)] } else { vec![(p, na), (p, nb)] };
let [ca, cb] = consumed;
ends.push(End { v: v.clone(), g, e1, e2, a, b, ca, cb });
ends.push(End { v: v.clone(), g: if ga == gb { vec![ga] } else { vec![ga, gb] }, e1, e2, e3, a, b, planes, ca, cb });
}
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
if end0.g == end1.g {
if end0.g.iter().any(|g| end1.g.contains(g)) {
return Err(unsupported("both ends touch the same face"));
}
if (end1.a - end0.a).dot(d) <= tol || (end1.b - end0.b).dot(d) <= tol {
@@ -316,42 +356,70 @@ fn blend_geometry(
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.
let centre = |p: Vec3| p + t1 * s + n1 * (r * side);
let arc_mid = |p: Vec3| {
let c = centre(p);
c + (p - c).normalized().unwrap_or(-n1) * r
};
let corner = |a: &mt::Vertex, b: &mt::Vertex, p: Vec3| -> mt::Edge {
match shape {
Shape::Round => builder::circle_arc(a, b, p3(arc_mid(p))),
Shape::Flat => builder::line(a, b),
// Corner curves on the end faces (A → B): the blend's cross-section through the end vertex,
// slid along the edge onto each end plane (an ellipse arc; the circle itself on a square
// end face).
let section = |p: Vec3, va: &mt::Vertex, vb: &mt::Vertex, from: Vec3, to: Vec3, plane: (Vec3, Vec3)| -> Result<mt::Edge> {
if shape == Shape::Flat {
return Ok(builder::line(va, vb));
}
let c = centre(p);
let mid = c + ((from - c) + (to - c)).normalized().unwrap_or(-n1) * r;
let (o, n) = plane;
if n.dot(d).abs() >= 1.0 - 1e-12 {
return Ok(builder::circle_arc(va, vb, p3(mid)));
}
let arc = builder::circle_arc(&builder::vertex(p3(from)), &builder::vertex(p3(to)), p3(mid));
let den = d.dot(n);
// x ↦ x − d ((x − o)·n) / (d·n), column-major.
let col = |j: usize| {
let ej = [Vec3::X, Vec3::Y, Vec3::Z][j];
ej - d * (ej.dot(n) / den)
};
let (c0, c1, c2) = (col(0), col(1), col(2));
let tr = d * (o.dot(n) / den);
let mat = mt::Matrix4::new(c0.x, c0.y, c0.z, 0.0, c1.x, c1.y, c1.z, 0.0, c2.x, c2.y, c2.z, 0.0, tr.x, tr.y, tr.z, 1.0);
let mut curve = arc.curve();
mt::Transformed::transform_by(&mut curve, mat);
Ok(mt::Edge::new(va, vb, curve))
};
let c0 = corner(&a0, &b0, p0);
let c1 = corner(&a1, &b1, p1);
let la = rail_a.clone().unwrap_or_else(|| builder::line(&a0, &a1));
let lb = rail_b.clone().unwrap_or_else(|| builder::line(&b0, &b1));
let mut corners: Vec<(mt::Vertex, mt::Vertex, Vec<mt::Edge>, Option<mt::Vertex>)> = Vec::new();
for (end, p) in [(end0, p0), (end1, p1)] {
let (va, vb) = (vertex(&end.ca, end.a), vertex(&end.cb, end.b));
let (sa, sb) = (p + t1 * s, p + t2 * s);
let (chain, vq) = match (&end.e3, &end.planes[..]) {
(None, [plane]) => (vec![section(p, &va, &vb, sa, sb, *plane)?], None),
(Some((_, q)), [pa, pb]) => {
let vq = builder::vertex(p3(*q));
let sq = *q - d * (*q - p).dot(d);
(vec![section(p, &va, &vq, sa, sq, *pa)?, section(p, &vq, &vb, sq, sb, *pb)?], Some(vq))
}
_ => return Err(KernelError::Failed("blend end".into())),
};
corners.push((va, vb, chain, vq));
}
let [(a0, b0, c0, q0), (a1, b1, c1, q1)] = &corners[..] else { return Err(KernelError::Failed("ends".into())) };
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 (consumed ones are dropped); the
// blended edge moves to A or B.
// Substitutions: side edges at the ends now stop at A/B (consumed ones are dropped), an edge
// between two end faces stops where the blend crosses it; 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)] {
let shorten = |e: &mt::Edge, v: &mt::Vertex, nv: &mt::Vertex| {
let (f, bk) = (e.absolute_front(), e.absolute_back());
if f == v { builder::line(nv, bk) } else { builder::line(f, nv) }
};
for (end, a, b, q) in [(end0, a0, b0, q0), (end1, a1, b1, q1)] {
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);
subst.insert(e.id(), shorten(e, &end.v, nv));
}
if let (Some((e3, _)), Some(q)) = (&end.e3, q) {
subst.insert(e3.id(), shorten(e3, &end.v, q));
}
}
// The side edges at the ends are pieces of themselves; the rails and corner curves are new.
@@ -370,10 +438,12 @@ fn blend_geometry(
let mut m = subst.clone();
m.insert(edge.id(), lb.clone());
rebuild_face(f, &m, &[])?
} else if i == end0.g {
rebuild_face_dropping(f, &subst, &dropped, std::slice::from_ref(&c0))?
} else if i == end1.g {
rebuild_face_dropping(f, &subst, &dropped, std::slice::from_ref(&c1))?
} else if end0.g.contains(&i) {
rebuild_face_dropping(f, &subst, &dropped, c0)?
} else if end1.g.contains(&i) {
rebuild_face_dropping(f, &subst, &dropped, c1)?
} else if f.edge_iter().any(|e| subst.contains_key(&e.id())) {
rebuild_face(f, &subst, &[])?
} else {
(*f).clone()
};
@@ -382,19 +452,39 @@ fn blend_geometry(
}
// The blend face uses each new edge opposite to its neighbour.
let f1_forward = faces.get(i1).is_some_and(|f| f.boundaries().iter().any(|w| w.edge_iter().any(|e| e.id() == edge.id() && e.front() == &v0)));
let wire: mt::Wire = if f1_forward {
vec![c0.clone(), lb.clone(), c1.inverse(), la.inverse()].into()
let reversed = |chain: &[mt::Edge]| chain.iter().rev().map(|e| e.inverse()).collect::<Vec<_>>();
let mut edges_out: Vec<mt::Edge> = Vec::new();
if f1_forward {
edges_out.extend(c0.iter().cloned());
edges_out.push(lb.clone());
edges_out.extend(reversed(c1));
edges_out.push(la.inverse());
} else {
vec![la.clone(), c1.clone(), lb.inverse(), c0.inverse()].into()
};
edges_out.push(la.clone());
edges_out.extend(c1.iter().cloned());
edges_out.push(lb.inverse());
edges_out.extend(reversed(c0));
}
let wire: mt::Wire = edges_out.into();
let blend_face = match shape {
Shape::Flat => builder::try_attach_plane(&[wire]).map_err(|e| KernelError::Failed(format!("chamfer face: {e}")))?,
Shape::Round => {
let swept: mt::Face = builder::tsweep(&c0, v3(p1 - p0));
// The cylinder, from a cross-section before the first end to one past the second, so
// it holds the end sections wherever the end planes slide them.
let reach = ends
.iter()
.flat_map(|e| e.planes.iter().map(|(_, n)| (s + r) * (1.0 - n.dot(d).powi(2)).max(0.0).sqrt() / n.dot(d).abs()))
.fold(0.0, f64::max)
+ r;
let start = p0 - d * reach;
let (sa, sb) = (start + t1 * s, start + t2 * s);
let c = centre(start);
let mid = c + ((sa - c) + (sb - c)).normalized().unwrap_or(-n1) * r;
let base = builder::circle_arc(&builder::vertex(p3(sa)), &builder::vertex(p3(sb)), p3(mid));
let swept: mt::Face = builder::tsweep(&base, v3(p1 - p0 + d * (2.0 * reach)));
let mut surf = swept.oriented_surface();
// Outward normal at the arc middle: away from the centre on a convex edge (the
// centre is in the material), toward it on a concave one.
let (c, mid) = (centre(p0), arc_mid(p0));
let outward = (mid - c) * (-side);
use mt::{ParametricSurface3D, SearchParameter};
if let Some((u, v)) = surf.search_parameter(p3(mid), None, 100) {
@@ -1113,8 +1203,16 @@ mod tests {
assert!(result.solid.vertex_iter().any(|v| vtx(&v).dist(point) < 1e-8));
}
assert!(blend(&body, &pick, 25.0, Shape::Flat, "chamfer").is_err());
// Oblique fillet caps need elliptical trims, which this local path does not build.
assert!(blend(&body, &pick, 2.0, Shape::Round, "fillet").is_err());
// A fillet ends in each sloped end plane along an ellipse: the removed section's
// length is taken at its centroid, c from the edge.
let rounded = blend(&body, &pick, 2.0, Shape::Round, "fillet").unwrap();
let (area, c) =
((1.0 - std::f64::consts::PI / 4.0) * 4.0, 2.0 * (10.0 - 3.0 * std::f64::consts::PI) / (3.0 * (4.0 - std::f64::consts::PI)));
let removed = area * (40.0 - 2.0 * slope * c);
let actual = crate::measure(&body).unwrap().volume - crate::measure(&rounded).unwrap().volume;
// `measure` reads a round about 6e-4 heavy, square ends too.
assert!((actual - removed).abs() < 1e-3 * removed, "{actual} vs {removed}");
assert!(rounded.validity().is_empty());
}
}
}
+2
View File
@@ -64,6 +64,7 @@ mod sweep_path;
#[cfg(test)]
mod thread_tests;
mod topo;
mod tspline;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet};
pub use blend_corner::fillet_radii;
@@ -96,6 +97,7 @@ pub use surfaces::{copy_faces, extend, patch_edges, patch_region, stitch, thicke
pub use sweep_path::sweep_path;
pub use topo::{CylinderFace, cylinder_face_at};
pub use topo::{TopoCounts, merged_topology, seam_flags};
pub use tspline::tspline_sheet;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum KernelError {
+17 -1
View File
@@ -97,6 +97,22 @@ fn seg_of(e: &mt::Edge, tol: f64) -> Option<Seg> {
Some(Seg::Arc { a, c: centre, axis, angle, mid: m })
}
/// Whether an edge is a straight segment: a line, or a curve whose points all lie on the chord
/// (the contact lines of a rolling-ball blend between parallel walls are such curves).
fn is_straight(e: &mt::Edge, tol: f64) -> bool {
use mt::{BoundedCurve, ParametricCurve};
if matches!(e.curve(), mt::Curve::Line(_)) {
return true;
}
let (a, b) = (from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point()));
if a.dist(b) < tol {
return false;
}
let c = e.curve();
let (t0, t1) = c.range_tuple();
(0..=16).all(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 16.0)).dist_to_segment(a, b) < tol)
}
/// `p` turned about the line (c, axis) by `ang`.
fn turn(p: Vec3, c: Vec3, axis: Vec3, ang: f64) -> Vec3 {
let k = axis.normalized().unwrap_or(Vec3::Z);
@@ -518,7 +534,7 @@ fn build(
}
let (fa, fb) = (e.absolute_front().clone(), e.absolute_back().clone());
let other = if &fa == v { &fb } else { &fa };
if !matches!(e.curve(), mt::Curve::Line(_)) {
if !is_straight(&e, (size * 1e-5).max(1e-6)) {
return Err(unsupported("the wall seams must be straight"));
}
let face_side = on_face(&e);
+6 -1
View File
@@ -357,7 +357,12 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
};
match &r {
Some(body) => {
let v = volume(body);
// Measured as the operands were: a coarser mesh of a plate with many holes
// overstates its volume by more than the slack.
let v = match &meshes {
(Some(_), Some(_)) => body.tessellate_with(tol_m, true).map(|m| m.measure().volume).unwrap_or(f64::NAN),
_ => volume(body),
};
let bounds = match op {
BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack,
BoolOp::Cut => v >= va - vb - slack && v <= va + slack,
+89 -1
View File
@@ -737,9 +737,47 @@ fn crossings(s: &Seg2, t: &Seg2, tol: f64) -> Vec<(f64, f64)> {
/// Ray parity against analytic lines/arcs. Fragment classification must not depend on a
/// display chord tolerance: a tiny tool can sit between a circular boundary and its chords.
/// Loops are closed only within the chaining tolerance (an arrangement's own output joins its
/// pieces within `10 · tol`), so a gap where one line ends and the next begins is closed by a
/// straight connector: a ray through the gap would otherwise slip out of the loop without a
/// crossing and flip the parity of points level with the corner. Arcs decide their own ends
/// within an angular tolerance (below), so joints with an arc keep that rule.
fn inside_loop(lp: &Loop2, p: Vec2) -> bool {
// A ray through a vertex or grazing an arc's top or bottom is decided by the half-open
// rule, which needs both curves meeting there to agree on that point exactly. Arcs read
// back from a solid (a patterned hole's two halves) can disagree by a few ulps, so one half
// counts the touch and the other misses it. Move the ray off such points: a shift far below
// any modelling tolerance cannot change the answer for a point that is not on the loop.
let scale = 1.0 + p.x.abs().max(p.y.abs());
// A point on the loop keeps the half-open answer.
if lp.segs.iter().any(|s| s.point_at(s.closest_param(p)).dist(p) < scale * 1e-9) {
return inside_loop_ray(lp, p);
}
let grazes = |q: Vec2| {
let near = |y: f64| (y - q.y).abs() < scale * 1e-9;
lp.segs.iter().any(|s| {
near(s.start().y)
|| match *s {
Seg2::Arc { center, radius, .. } => near(center.y + radius) || near(center.y - radius),
_ => false,
}
})
};
let ray = [0.0, 1.0, -1.0, 2.0, -2.0, 3.0, -3.0].into_iter().map(|k| Vec2::new(p.x, p.y + k * scale * 1e-7)).find(|q| !grazes(*q)).unwrap_or(p);
inside_loop_ray(lp, ray)
}
/// [`inside_loop`] with the ray along +x from `p` taken as given.
fn inside_loop_ray(lp: &Loop2, p: Vec2) -> bool {
let mut odd = false;
for seg in &lp.segs {
let nexts = lp.segs.iter().skip(1).chain(lp.segs.first());
for (seg, next) in lp.segs.iter().zip(nexts) {
if let (Seg2::Line { b: a, .. }, Seg2::Line { a: b, .. }) = (*seg, *next)
&& (a.y > p.y) != (b.y > p.y)
&& a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) > p.x
{
odd = !odd;
}
match *seg {
Seg2::Line { a, b } => {
if (a.y > p.y) != (b.y > p.y) {
@@ -2147,6 +2185,56 @@ mod planar_tests {
assert!(!inside_loop(&lp, Vec2::new(-5.0, 0.0)));
assert!(!inside_loop(&lp, Vec2::new(-5.0, -10.0)));
}
// #307: a circle read back as two halves whose centres disagree by a few ulps, split
// at its bottom. A ray grazing that bottom must not count one half's touch only.
let halves = Loop2 {
segs: vec![
Seg2::Arc { center: Vec2::new(0.0, 25.0), radius: 5.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
Seg2::Arc {
center: Vec2::new(2.9e-15, 25.000000000000004),
radius: 4.99999999999999,
start: std::f64::consts::FRAC_PI_2,
sweep: std::f64::consts::PI,
},
],
};
for lp in [halves.clone(), halves.reversed()] {
for x in [-30.0, -5.0, -0.1] {
assert!(!inside_loop(&lp, Vec2::new(x, 20.0)), "{x}");
assert!(!inside_loop(&lp, Vec2::new(x, 30.0)), "{x}");
}
assert!(inside_loop(&lp, Vec2::new(0.0, 25.0)));
}
}
/// Loops are closed only within the chaining tolerance. A ray level with one end of such a
/// gap slipped through it without a crossing, so a point beside the loop counted as inside:
/// a sheet shrunk by a parameter change (sketch solver residuals of 1e-13 mm) lost a bend
/// zone that way and its unfold failed.
#[test]
fn ray_parity_closes_the_gaps_chaining_leaves() {
let gap = 1e-13;
let square = Loop2 {
segs: vec![
Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) },
Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 10.0) },
Seg2::Line { a: Vec2::new(10.0, 10.0), b: Vec2::new(0.0, 10.0) },
Seg2::Line { a: Vec2::new(0.0, 10.0), b: Vec2::new(0.0, 5.0 + gap) },
Seg2::Line { a: Vec2::new(0.0, 5.0), b: Vec2::new(0.0, 0.0) },
],
};
for lp in [square.clone(), square.reversed()] {
assert!(!inside_loop(&lp, Vec2::new(-5.0, 5.0)));
assert!(inside_loop(&lp, Vec2::new(5.0, 5.0)));
assert!(!inside_loop(&lp, Vec2::new(15.0, 5.0)));
}
// A neighbour with an edge level with the gap joins the square instead of being
// dropped as if it lay inside it (which left the outline open: no result at all).
let a = Region2 { outer: square, holes: vec![] };
let b = rectangle(Vec2::new(-4.0, 2.0), Vec2::new(0.0, 5.0));
let union = planar_regions_boolean(&[a], &[b], BoolOp::Union, 1e-7).unwrap();
assert_eq!(union.len(), 1);
assert!((union[0].area().abs() - 112.0).abs() < 1e-9, "{}", union[0].area());
}
#[test]
fn tiny_tool_between_circle_and_display_chord_is_not_lost() {
+1 -1
View File
@@ -22,7 +22,7 @@ impl Body {
}
/// A body of faces as they are (no orientation fix: a surface has no inside).
fn surface_body(faces: Vec<mt::Face>) -> Body {
pub(crate) fn surface_body(faces: Vec<mt::Face>) -> Body {
surface_of(mt::Shell::from(faces))
}
+84 -5
View File
@@ -176,9 +176,87 @@ fn fillets_on_several_edges_and_angles() {
let cut = 24000.0 - measure(&two).unwrap().volume;
let a = 4.0 * (1.0 - PI / 4.0);
assert!(cut > a * (20.0 + 30.0 - 2.0) && cut < a * 50.0, "{cut}");
// Concave edge: not supported.
// Concave edge where a post stands on the plate: the round adds material, its end caps
// extending the post's end faces down onto the plate.
let u = boolean(&b, &box_solid(Vec3::new(10.0, 10.0, 15.0), Vec3::new(20.0, 20.0, 30.0)).unwrap(), BoolOp::Union).unwrap().unwrap();
assert!(fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).is_err());
let f = fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).unwrap();
let added = measure(&f).unwrap().volume - measure(&u).unwrap().volume;
assert!(rel(added, (1.0 - PI / 4.0) * 10.0) < 1e-3, "{added}");
assert!(f.validity().is_empty());
assert_eq!(f.lumps().unwrap().len(), 1);
}
#[test]
fn fillet_ends_in_an_end_face_at_an_angle() {
// #215: the round ends in the end face's plane along an ellipse (its section) when that face
// is not square to the edge. A prism on a trapezoid (0,0) (40,0) (30,20) (0,20), 10 high: the
// top edge along y = 0 ends at x = 0 (square) and in the face x = 40 - y/2 (at an angle).
let r: f64 = 2.0;
let (area, c) = ((1.0 - PI / 4.0) * r * r, r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
let trapezoid = |far: f64| {
let pts = [Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(far, 20.0), Vec2::new(0.0, 20.0)];
extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap()
};
// The removed section's length at depth y into the top face is 40 + (far - 40) y / 20; its
// mean over the section is taken at the section's centroid (c from the edge).
for far in [30.0, 50.0] {
let b = trapezoid(far);
let v0 = measure(&b).unwrap().volume;
let f = fillet(&b, &[Vec3::new(20.0, 0.0, 10.0)], r).unwrap();
let want = v0 - area * (40.0 + (far - 40.0) * c / 20.0);
let m = measure(&f).unwrap();
assert!(rel(m.volume, want) < 2e-4, "far {far}: {} vs {want}", m.volume);
assert_eq!(f.lumps().unwrap().len(), 1);
assert!(f.validity().is_empty(), "far {far}: {:?}", f.validity());
}
}
#[test]
fn fillet_ending_across_two_faces() {
// #215: a plate's outline chamfered, then the mitre where two chamfers meet at a corner
// rounded. The mitre runs from the top face (ending in it at an angle) down to the corner
// where two side faces meet; the round ends across both, cutting back the vertical edge.
let (r, k) = (2.0, 5.0);
let b = box_solid(Vec3::ZERO, Vec3::new(100.0, 50.0, 20.0)).unwrap();
let outline = [Vec3::new(50.0, 0.0, 20.0), Vec3::new(100.0, 25.0, 20.0), Vec3::new(50.0, 50.0, 20.0), Vec3::new(0.0, 25.0, 20.0)];
let c = chamfer(&b, &outline, k).unwrap();
let f = fillet(&c, &[Vec3::new(k / 2.0, k / 2.0, 20.0 - k / 2.0)], r).unwrap();
assert!(f.validity().is_empty(), "{:?}", f.validity());
assert_eq!(f.lumps().unwrap().len(), 1);
// The removed material, by definition: inside the plate, between the two chamfers and the
// round, i.e. outside the round's cylinder and on the corner's side of both lines where it
// touches the chamfers (each at the setback s = r cot(φ/2) from the mitre, φ = 120° between
// the chamfers). Sampled at pseudo-random points (a regular grid aliases with the region's
// thin slivers along the chamfers): about 1.89 mm³, standard error about 0.02.
let d = Vec3::new(1.0, 1.0, 1.0).normalized().unwrap();
let (n1, n2) = (Vec3::new(0.0, -1.0, 1.0).normalized().unwrap(), Vec3::new(-1.0, 0.0, 1.0).normalized().unwrap());
// In each chamfer, square to the mitre and away from the other chamfer.
let into = |n: Vec3, other: Vec3| {
let t = n.cross(d).normalized().unwrap();
if t.dot(other) < 0.0 { t } else { -t }
};
let (t1, t2) = (into(n1, n2), into(n2, n1));
let phi = (-n1.dot(n2)).acos();
let s = r / (phi / 2.0).tan();
let corner = Vec3::new(0.0, 0.0, 20.0 - k);
let axis_at = corner + t1 * s - n1 * r;
let removed_at = |q: Vec3| {
let u = q - axis_at;
q.z - q.x <= 15.0 && q.z - q.y <= 15.0 && (q - corner).dot(t1) < s && (q - corner).dot(t2) < s && (u - d * u.dot(d)).len() > r
};
let (lo, span) = (Vec3::new(0.0, 0.0, 20.0 - k - 2.0 * r), k + 2.0 * r);
let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
let mut unit = || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
(state >> 11) as f64 / (1u64 << 53) as f64
};
let samples = 4_000_000;
let hits = (0..samples).filter(|_| removed_at(lo + Vec3::new(unit(), unit(), unit()) * span)).count();
let removed = span.powi(3) * hits as f64 / samples as f64;
let lost = measure(&c).unwrap().volume - measure(&f).unwrap().volume;
assert!((lost - removed).abs() < 0.07, "{lost} vs {removed}");
}
#[test]
@@ -1721,8 +1799,9 @@ fn loft_section_order_gives_the_same_body() {
#[test]
fn tangent_holes_pinch_alike() {
// Holes at ±15 and ±25 touch along a line (x = ±20); ±30 overlaps ±25. The ±25 wall is cut
// in two at the pinch, the same way on both sides (as Fusion counts it).
// Holes at ±15 and ±25 touch along a line (x = ±20); ±30 overlaps ±25. A tangent line
// contact does not split a face (#307, as the reference kernel counts it): the ±25 wall is one
// face on both sides, so 6 planes and 6 hole walls.
let mut b = box_solid(Vec3::new(-50.0, -20.0, 0.0), Vec3::new(50.0, 20.0, 5.0)).unwrap();
for x in [15.0, 25.0, 30.0, -15.0, -25.0, -30.0] {
let plane = Plane::XY.offset(-1.0);
@@ -1731,7 +1810,7 @@ fn tangent_holes_pinch_alike() {
b = boolean(&b, &t, BoolOp::Cut).unwrap().unwrap();
}
let m = measure(&b).unwrap();
assert_eq!(m.merged.faces, 14, "{:?}", m.merged);
assert_eq!(m.merged.faces, 12, "{:?}", m.merged);
}
/// A d10 tube bent at R5: the inside of the bend is a horn torus pinched to a point. One torus
+43 -5
View File
@@ -239,7 +239,7 @@ pub fn seam_flags(b: &Body, mesh: &Mesh) -> Vec<bool> {
let solid = &*b.solid;
let nf = solid.face_iter().count();
let tol = (b.size() * 1e-4).max(1e-6);
let surfs = classify_faces(mesh, nf, tol);
let surfs = classify_faces(b, mesh, nf, tol);
let verts = face_vertices(mesh, nf);
// Faces cut from one surface (an imported face split where it wraps around a closed
// surface) carry the very same surface: the edge between them is a seam.
@@ -312,7 +312,7 @@ fn fit_sphere(pts: &[Vec3]) -> Option<(Vec3, f64)> {
(r2 > 0.0 && r2.is_finite()).then(|| (c + m, r2.sqrt()))
}
fn classify_faces(mesh: &Mesh, nf: usize, tol: f64) -> Vec<Surf> {
fn classify_faces(b: &Body, mesh: &Mesh, nf: usize, tol: f64) -> Vec<Surf> {
let mut pts: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
let mut nrm: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
@@ -351,7 +351,27 @@ fn classify_faces(mesh: &Mesh, nf: usize, tol: f64) -> Vec<Surf> {
nv.retain(|_| it.next().copied().unwrap_or(true));
}
}
(0..nf).map(|i| classify(pts.get(i).map(Vec::as_slice).unwrap_or(&[]), nrm.get(i).map(Vec::as_slice).unwrap_or(&[]), tol * 10.0, i)).collect()
let fitted = (0..nf).map(|i| classify(pts.get(i).map(Vec::as_slice).unwrap_or(&[]), nrm.get(i).map(Vec::as_slice).unwrap_or(&[]), tol * 10.0, i));
// A sliver (a strip one triangle wide) has too few distinct samples to fit: its points
// can fit a sphere or nothing. Where the fit finds no plane or cylinder, take the face's
// exact surface when that is a plane, cylinder or sphere.
let exact_tol = (b.size() * 1e-7).max(1e-9) * 100.0;
let faces: Vec<_> = b.solid.face_iter().collect();
fitted
.enumerate()
.map(|(i, fit)| match (fit, faces.get(i)) {
(Surf::Sphere { .. } | Surf::Other(_), Some(face)) => match crate::offset::surf_of(face, exact_tol) {
Some(crate::offset::Surf::Plane { n, d }) => Surf::Plane { n, d },
Some(crate::offset::Surf::Cylinder { o, a, r, .. }) => {
let axis = if a.x + a.y * 1e-3 + a.z * 1e-6 < 0.0 { -a } else { a };
Surf::Cylinder { axis, p: o, r }
}
Some(crate::offset::Surf::Sphere { c, r, .. }) => Surf::Sphere { c, r },
_ => fit,
},
_ => fit,
})
.collect()
}
/// Merged face/edge/vertex counts (see module docs). `mesh` must be `b.tessellate(..)`.
@@ -373,7 +393,7 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
let solid = &*b.solid;
let nf = solid.face_iter().count();
let tol = (b.size() * 1e-4).max(1e-6);
let surfs = classify_faces(mesh, nf, tol);
let surfs = classify_faces(b, mesh, nf, tol);
let verts = face_vertices(mesh, nf);
// Zero-area faces (a revolved profile edge lying on the axis) don't count.
let mut area = vec![0.0f64; nf];
@@ -472,6 +492,24 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
}
}
}
// Two tangent holes touch along a line that both carry: there four faces meet, two on
// each hole's surface. Each surface's two pieces are one face, as for any other edge.
for (ei, faces) in &edge_faces {
if faces.len() <= 2 || faces.len() > 8 || (keep_split && poly(*ei).is_some_and(|pl| crate::heal::on_split_line(pl, b_keep))) {
continue;
}
for (i, &a) in faces.iter().enumerate() {
for &b in faces.iter().skip(i + 1) {
let analytic = !matches!(surfs.get(a), Some(Surf::Other(_)) | None);
if analytic && same_surface(&surfs, &verts, poly(*ei), a, b, tol * 10.0) {
let (ra, rb) = (find(&mut fp, a), find(&mut fp, b));
if let Some(s) = fp.get_mut(ra) {
*s = rb;
}
}
}
}
}
let mut groups: BTreeMap<usize, usize> = BTreeMap::new();
let mut face_types: BTreeMap<String, usize> = BTreeMap::new();
for f in 0..nf {
@@ -604,7 +642,7 @@ pub fn cylinder_face_at(b: &Body, p: Vec3) -> Option<CylinderFace> {
}
let nf = b.face_count();
let tol = (size * 1e-4).max(1e-6);
let surfs = classify_faces(&mesh, nf, tol);
let surfs = classify_faces(b, &mesh, nf, tol);
let Surf::Cylinder { axis, p: ap, r } = *surfs.get(face as usize)? else { return None };
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
+267
View File
@@ -0,0 +1,267 @@
//! T-spline sheets: a T-spline surface (`solvecraft_geom::tspline`) as a surface body of NURBS
//! faces. A T-mesh without T-junctions becomes one clamped tensor face; otherwise each Bézier
//! element is a face, and the faces share their edges and vertices — a side that meets two
//! neighbours is split at the T-point between them — so the sheet is one sewn open shell.
use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use solvecraft_geom::bspline::{BezierPatch, HPoint, NurbsSurface, SplineError, bezier_segment};
use solvecraft_geom::tspline::TSpline;
use truck_modeling as mt;
use crate::body::Body;
use crate::{KernelError, Result, guard};
/// A T-spline as a surface body (an open shell of NURBS faces whose normals follow
/// `∂S/∂s × ∂S/∂t`).
pub fn tspline_sheet(ts: &TSpline) -> Result<Body> {
let faces = if ts.mesh().is_regular() {
let n = ts.to_nurbs().and_then(|n| n.clamped()).map_err(spline_err)?;
guard("t-spline sheet", || tensor_face(&n).map(|f| vec![f]))?
} else {
let patches = ts.bezier_elements().map_err(spline_err)?;
guard("t-spline sheet", || patch_faces(&patches))?
};
Ok(crate::surfaces::surface_body(faces))
}
fn spline_err(e: SplineError) -> KernelError {
KernelError::Invalid(format!("t-spline: {e}"))
}
fn fail(m: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("t-spline sheet: {m}"))
}
fn h4(h: HPoint) -> mt::Vector4 {
mt::Vector4::new(h.x, h.y, h.z, h.w)
}
fn vertex(h: HPoint) -> Result<mt::Vertex> {
let p = h.project().ok_or_else(|| fail("a corner has no position"))?;
Ok(mt::builder::vertex(crate::body::p3(p)))
}
fn nurbs_curve(knots: mt::KnotVec, ctrl: &[HPoint]) -> Result<mt::Curve> {
let c = mt::BSplineCurve::try_new(knots, ctrl.iter().copied().map(h4).collect()).map_err(fail)?;
Ok(mt::Curve::NurbsCurve(mt::NurbsCurve::new(c)))
}
fn surface(knots: (mt::KnotVec, mt::KnotVec), rows: Vec<Vec<mt::Vector4>>) -> Result<mt::Surface> {
let s = mt::BSplineSurface::try_new(knots, rows).map_err(fail)?;
Ok(mt::Surface::NurbsSurface(mt::NurbsSurface::new(s)))
}
/// One face from a clamped tensor surface, bounded by its four boundary curves.
fn tensor_face(n: &NurbsSurface) -> Result<mt::Face> {
let [nu, nv] = n.count();
let [ku, kv] = n.knots();
let (ku, kv) = (mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone()));
let corners = [n.point(0, 0), n.point(nu - 1, 0), n.point(nu - 1, nv - 1), n.point(0, nv - 1)];
let v: Vec<mt::Vertex> = corners.into_iter().map(vertex).collect::<Result<_>>()?;
let row = |j: usize| -> Vec<HPoint> { (0..nu).map(|i| n.point(i, j)).collect() };
let column = |i: usize| -> Vec<HPoint> { (0..nv).map(|j| n.point(i, j)).collect() };
let (Some(v0), Some(v1), Some(v2), Some(v3)) = (v.first(), v.get(1), v.get(2), v.get(3)) else { return Err(fail("corners")) };
let edge = |a: &mt::Vertex, b: &mt::Vertex, c: mt::Curve| mt::Edge::try_new(a, b, c).map_err(fail);
let bottom = edge(v0, v1, nurbs_curve(ku.clone(), &row(0))?)?;
let right = edge(v1, v2, nurbs_curve(kv.clone(), &column(nu - 1))?)?;
let top = edge(v3, v2, nurbs_curve(ku.clone(), &row(nv - 1))?)?;
let left = edge(v0, v3, nurbs_curve(kv.clone(), &column(0))?)?;
let wire: mt::Wire = vec![bottom, right, top.inverse(), left.inverse()].into();
let rows: Vec<Vec<mt::Vector4>> = (0..nu).map(|i| (0..nv).map(|j| h4(n.point(i, j))).collect()).collect();
mt::Face::try_new(vec![wire], surface((ku, kv), rows)?).map_err(fail)
}
/// A parameter point, exact (knot values are shared, so equal points have equal bits).
type Key = (u64, u64);
fn key(s: f64, t: f64) -> Key {
((s + 0.0).to_bits(), (t + 0.0).to_bits())
}
/// One face per Bézier patch, sharing vertices and edges; sides are split where a
/// neighbour's corner lands on them.
fn patch_faces(patches: &[BezierPatch]) -> Result<Vec<mt::Face>> {
// Corner parameters on each line of constant t (their s values) and of constant s.
let mut on_t: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
let mut on_s: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
for p in patches {
for s in p.s {
for t in p.t {
let (ks, kt) = key(s, t);
on_t.entry(kt).or_default().insert(ks);
on_s.entry(ks).or_default().insert(kt);
}
}
}
let mut verts: HashMap<Key, mt::Vertex> = HashMap::new();
let mut edges: HashMap<(Key, Key), mt::Edge> = HashMap::new();
let mut faces = Vec::with_capacity(patches.len());
for p in patches {
let mut wire: Vec<mt::Edge> = Vec::new();
for side in 0..4 {
// Side 0: t = t₀, 1: s = s₁, 2: t = t₁, 3: s = s₀; each runs with its parameter
// rising, sides 2 and 3 are walked backwards.
let (along_s, fixed, [lo, hi]) = match side {
0 => (true, p.t[0], p.s),
1 => (false, p.s[1], p.t),
2 => (true, p.t[1], p.s),
_ => (false, p.s[0], p.t),
};
let line = if along_s { on_t.get(&key(0.0, fixed).1) } else { on_s.get(&key(fixed, 0.0).0) };
let mut breaks: Vec<f64> = line.into_iter().flatten().map(|&b| f64::from_bits(b)).filter(|&x| x > lo && x < hi).collect();
breaks.insert(0, lo);
breaks.push(hi);
let ctrl = p.side(side);
let at = |x: f64| if along_s { key(x, fixed) } else { key(fixed, x) };
let mut pieces = Vec::with_capacity(breaks.len());
for w in breaks.windows(2) {
let &[x0, x1] = w else { continue };
let (ka, kb) = (at(x0), at(x1));
let e = if let Some(e) = edges.get(&(ka, kb)) {
e.clone()
} else if let Some(e) = edges.get(&(kb, ka)) {
e.inverse()
} else {
let seg = bezier_segment(&ctrl, (x0 - lo) / (hi - lo), (x1 - lo) / (hi - lo)).map_err(spline_err)?;
let (Some(&first), Some(&last)) = (seg.first(), seg.last()) else { return Err(fail("an empty side")) };
let mut corner = |k: Key, h: HPoint| -> Result<mt::Vertex> {
Ok(match verts.entry(k) {
Entry::Occupied(e) => e.get().clone(),
Entry::Vacant(e) => e.insert(vertex(h)?).clone(),
})
};
let (va, vb) = (corner(ka, first)?, corner(kb, last)?);
let curve = nurbs_curve(mt::KnotVec::bezier_knot(seg.len() - 1), &seg)?;
let e = mt::Edge::try_new(&va, &vb, curve).map_err(fail)?;
edges.insert((ka, kb), e.clone());
e
};
pieces.push(e);
}
if side >= 2 {
pieces.reverse();
pieces.iter_mut().for_each(|e| *e = e.inverse());
}
wire.extend(pieces);
}
let [ps, pt] = p.degree;
let rows: Vec<Vec<mt::Vector4>> = (0..=ps).map(|i| (0..=pt).map(|j| h4(p.point(i, j))).collect()).collect();
let surf = surface((mt::KnotVec::bezier_knot(ps), mt::KnotVec::bezier_knot(pt)), rows)?;
faces.push(mt::Face::try_new(vec![wire.into()], surf).map_err(fail)?);
}
Ok(faces)
}
#[cfg(test)]
mod tests {
use solvecraft_geom::Vec3;
use solvecraft_geom::bspline::NurbsSurface;
use solvecraft_geom::tspline::{Cut, TSpline};
use truck_modeling as mt;
use super::tspline_sheet;
use crate::fuzz::Rng;
/// A rational bicubic with uneven knots over a 10 × 8 mm area, bulging up to ~3 mm.
fn surface(seed: u64) -> TSpline {
let mut rng = Rng::new(seed);
let ku = vec![0.0, 0.0, 0.0, 0.0, 0.2, 0.45, 0.5, 0.8, 1.0, 1.0, 1.0, 1.0];
let kv = vec![0.0, 0.0, 0.0, 0.0, 0.3, 0.6, 1.0, 1.0, 1.0, 1.0];
let (nu, nv) = (ku.len() - 4, kv.len() - 4);
let mut pts = Vec::new();
for j in 0..nv {
for i in 0..nu {
pts.push(Vec3::new(10.0 * i as f64 / (nu - 1) as f64, 8.0 * j as f64 / (nv - 1) as f64, 3.0 * rng.unit()));
}
}
let w: Vec<f64> = (0..pts.len()).map(|_| 0.7 + 0.6 * rng.unit()).collect();
TSpline::from_nurbs(&NurbsSurface::new([3, 3], [ku, kv], &pts, &w).unwrap()).unwrap()
}
/// Distance from `p` to the T-spline: nearest of a sample grid, then Gauss–Newton.
fn distance(ts: &TSpline, p: Vec3) -> f64 {
let [ds, dt] = ts.domain();
let n = 24;
let mut best = (f64::MAX, 0.0, 0.0);
for i in 0..=n {
for j in 0..=n {
let (s, t) = (ds[0] + (ds[1] - ds[0]) * i as f64 / n as f64, dt[0] + (dt[1] - dt[0]) * j as f64 / n as f64);
let d = ts.eval(s, t).unwrap().dist(p);
if d < best.0 {
best = (d, s, t);
}
}
}
let (_, mut s, mut t) = best;
for _ in 0..60 {
let [q, a, b] = ts.derivs(s, t).unwrap();
let r = q - p;
let (aa, ab, bb) = (a.dot(a), a.dot(b), b.dot(b));
let det = aa * bb - ab * ab;
if det.abs() < 1e-30 {
break;
}
let (ga, gb) = (a.dot(r), b.dot(r));
s = (s - (bb * ga - ab * gb) / det).clamp(ds[0], ds[1]);
t = (t - (aa * gb - ab * ga) / det).clamp(dt[0], dt[1]);
}
ts.eval(s, t).unwrap().dist(p)
}
fn check_sheet(ts: &TSpline, faces: usize) {
let body = tspline_sheet(ts).unwrap();
assert!(body.is_surface());
assert_eq!(body.shell_count(), 1);
assert_eq!(body.face_count(), faces);
let shell = &body.solid.boundaries()[0];
assert_eq!(shell.shell_condition(), mt::ShellCondition::Oriented, "neighbours share edges, run opposite ways");
let euler = body.vertex_count() as i64 - body.edge_count() as i64 + body.face_count() as i64;
assert_eq!(euler, 1, "one sewn sheet (a disc)");
let mesh = body.tessellate(0.01).unwrap();
assert!(!mesh.triangles.is_empty());
// Mesh points lie on the surface (not merely within the tessellation tolerance).
let step = (mesh.positions.len() / 300).max(1);
let worst = mesh.positions.iter().step_by(step).map(|p| distance(ts, *p)).fold(0.0, f64::max);
eprintln!("t-spline sheet: {faces} faces, {} mesh points, farthest checked {worst:e} mm from the surface", mesh.positions.len());
assert!(worst < 1e-6, "a mesh point is {worst} mm off the T-spline");
}
#[test]
fn regular_tspline_becomes_one_nurbs_face() {
check_sheet(&surface(1), 1);
}
#[test]
fn tspline_with_t_junctions_becomes_sewn_bezier_faces() {
let ts = surface(2).refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }, Cut::T { t: 0.45, s0: 0.2, s1: 0.45 }]).unwrap();
assert!(!ts.mesh().is_regular());
let n = ts.bezier_elements().unwrap().len();
check_sheet(&ts, n);
}
#[test]
fn hostile_tsplines_never_panic() {
let base = surface(3);
let n = base.mesh().vertices().len();
let mesh = base.mesh().clone();
// Every control point at one place, enormous coordinates, a needle-thin patch.
let cases: Vec<Vec<Vec3>> = vec![
vec![Vec3::new(1.0, 2.0, 3.0); n],
(0..n).map(|k| Vec3::new(1e300, -1e300, k as f64 * 1e299)).collect(),
(0..n).map(|k| Vec3::new(k as f64 * 1e-12, 0.0, 0.0)).collect(),
];
for pts in cases {
let Ok(ts) = TSpline::new(mesh.clone(), [3, 3], &pts, &vec![1.0; n]) else { continue };
if let Ok(body) = tspline_sheet(&ts) {
let _ = body.tessellate(0.01);
}
if let Ok(r) = ts.refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }])
&& let Ok(body) = tspline_sheet(&r)
{
let _ = body.tessellate(0.01);
}
}
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Solve times of an n×n grid (origin corner fixed, every row and column edge horizontal or
//! vertical with a distance), started at the solution and ±1 mm off it, and of one held drag
//! frame on a strip of rectangles. Run with
//! `cargo run --release -p solvecraft-sketch --example solve_bench`; results go in docs/perf.md.
use std::time::Instant;
use solvecraft_geom::Vec2;
use solvecraft_sketch::{Analysis, Budget, ConstraintKind, Hold, Sketch, SolveReport, solve, solve_with};
/// A small deterministic generator for the start offsets.
struct Lcg(u64);
impl Lcg {
/// In [-1, 1).
fn next(&mut self) -> f64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((self.0 >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
}
}
fn grid(n: usize, jitter: f64) -> Result<Sketch, Box<dyn std::error::Error>> {
let mut sk = Sketch::new();
let mut rnd = Lcg(12345);
let mut id = vec![vec![0; n]; n];
for (r, row) in id.iter_mut().enumerate() {
for (c, slot) in row.iter_mut().enumerate() {
let j = if r == 0 && c == 0 { Vec2::ZERO } else { Vec2::new(rnd.next(), rnd.next()) * jitter };
*slot = sk.add_point(Vec2::new(c as f64 * 10.0, r as f64 * 10.0) + j, None)?;
}
}
sk.add_constraint(ConstraintKind::Fix { p: id[0][0] }, None)?;
for r in 0..n {
for c in 0..n {
for (dr, dc, horizontal) in [(0, 1, true), (1, 0, false)] {
let (Some(&p), Some(&q)) = (id.get(r).and_then(|x| x.get(c)), id.get(r + dr).and_then(|x| x.get(c + dc))) else { continue };
let l = sk.add_line_pts(p, q, None)?;
sk.add_constraint(if horizontal { ConstraintKind::Horizontal { l } } else { ConstraintKind::Vertical { l } }, None)?;
sk.add_constraint(ConstraintKind::Distance { p, q, value: 10.0 }, None)?;
}
}
}
Ok(sk)
}
/// `n` rectangles side by side sharing their vertical edges: horizontal and vertical sides, every
/// width dimensioned, the first height too. Returns the sketch and its first corner.
fn strip(n: usize) -> Result<(Sketch, usize), Box<dyn std::error::Error>> {
let mut sk = Sketch::new();
let bottom: Vec<usize> = (0..=n).map(|i| sk.add_point(Vec2::new(10.0 + i as f64 * 20.0, 10.0), None)).collect::<Result<_, _>>()?;
let top: Vec<usize> = (0..=n).map(|i| sk.add_point(Vec2::new(10.0 + i as f64 * 20.0, 25.0), None)).collect::<Result<_, _>>()?;
for i in 0..=n {
let l = sk.add_line_pts(bottom[i], top[i], None)?;
sk.add_constraint(ConstraintKind::Vertical { l }, None)?;
if i == 0 {
sk.add_constraint(ConstraintKind::Length { l, value: 15.0 }, None)?;
}
}
for i in 0..n {
let b = sk.add_line_pts(bottom[i], bottom[i + 1], None)?;
let t = sk.add_line_pts(top[i], top[i + 1], None)?;
sk.add_constraint(ConstraintKind::Horizontal { l: b }, None)?;
sk.add_constraint(ConstraintKind::Horizontal { l: t }, None)?;
sk.add_constraint(ConstraintKind::Length { l: b, value: 20.0 }, None)?;
}
Ok((sk, bottom[0]))
}
/// Best of `reps` solves of a copy of `sk` (ms) and the last report.
fn time(sk: &Sketch, reps: usize, f: impl Fn(&mut Sketch) -> SolveReport) -> (f64, SolveReport) {
let mut best = f64::MAX;
let mut rep = None;
for _ in 0..reps {
let mut k = sk.clone();
let t = Instant::now();
let r = f(&mut k);
best = best.min(t.elapsed().as_secs_f64() * 1e3);
rep = Some(r);
}
(best, rep.unwrap_or_else(|| solve(&mut sk.clone())))
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("| grid | points | start | ms | status | dof | iterations |");
println!("|---|---|---|---|---|---|---|");
for n in [10usize, 15, 20] {
for jitter in [0.0, 1.0] {
let sk = grid(n, jitter)?;
let (ms, rep) = time(&sk, 5, solve);
let start = if jitter == 0.0 { "at solution" } else { "±1 mm" };
println!("| {n}×{n} | {} | {start} | {ms:.2} | {:?} | {} | {} |", n * n, rep.status, rep.dof, rep.iterations);
}
}
println!();
println!("| strip | points | held drag frame ms | full solve ms |");
println!("|---|---|---|---|");
for n in [30usize, 100] {
let (mut sk, corner) = strip(n)?;
if let Some(p) = sk.points.get_mut(corner) {
p.pos -= Vec2::new(0.5, 0.0);
}
let hold = Hold { points: vec![corner], radii: vec![] };
let (frame, rep) = time(&sk, 5, |k| solve_with(k, &hold, None, Analysis::StatusOnly, Some(&mut Budget::frame())));
let (full, _) = time(&sk, 5, solve);
println!("| {n} rectangles | {} | {frame:.2} ({:?}) | {full:.2} |", sk.points.len(), rep.status);
}
Ok(())
}
+352
View File
@@ -0,0 +1,352 @@
//! Constructive seed for the solver.
//!
//! Before Levenberg–Marquardt runs, every point the constraints pin down relative to the given
//! points is placed exactly (Owen 1991, §3 and §5; Fudos and Hoffmann 1997), in two
//! interleaved steps repeated until neither places anything:
//!
//! - **propagation**: a point with two loci anchored on already placed points goes to their
//! intersection;
//! - **decomposition** ([`crate::decompose`]): points no point-by-point order reaches but that
//! are rigid relative to the placed ones (Owen's articulation-pair reduction) are placed by
//! cluster merging, with the placed points as the ground.
//!
//! Then a rigid cluster that is free or pinned at a single placed point (nothing decides its
//! pose, and LM alone may land it mirrored) is placed by its reference pose
//! ([`Decomposition::floats`]). That is a guess at free motion ([`Placement::Float`]): the
//! solver starts from it, nothing is built on it.
//!
//! Of two mirror-image intersections, the one keeping the orientation the points had before
//! the solve is kept, so a dimension edit keeps each triangle's orientation, each arc on its
//! side of its tangent lines and each line's direction instead of letting the iteration fall
//! into a mirrored solution.
//!
//! The seed gives the iteration its start. Constructed values the constraints pin down one
//! after another, and whose constraints hold there, stay constants while LM solves the rest
//! (`solver::pinned`); every constraint is still verified, and the rank analysis of the solved
//! Jacobian still decides the degrees of freedom. A point with only one locus on placed points keeps its own position (projecting
//! it onto that locus would freeze a point that still has a degree of freedom). What neither
//! step places (triconnected remainders, under-constrained freedom, constraints read by
//! neither) is left to LM.
//!
//! What the constraints say about a point, and where two of them put it, is read by
//! [`crate::loci`] (the same reading drives the decomposition): coincident points; circles
//! (distances, line lengths, arc radii, points on circles of known radius, tangent circles);
//! rows and columns (horizontal and vertical lines and points, and offsets from them on the
//! side the point was on); lines through placed points along a known direction (a point on a
//! line, a line at a known angle, parallel or perpendicular to a placed line); lines at a
//! distance from a placed line (a point at a distance, a tangent circle's centre), on the side
//! the point was on; the radius to a touch point, normal to the line; midpoints and mirrored
//! points. A circle's radius set by its dimensions is seeded too.
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use solvecraft_geom::Vec2;
use crate::decompose::{Budget, Decomposition, enqueue};
use crate::loci::{MAX_LOCI, Reading, Rel, View, place, radii, read};
use crate::model::{Sketch, in_range};
/// Rounds of decomposition then propagation per seed. Each round after the first must follow a
/// propagation that placed something, so on real sketches the loop ends after a few; this
/// bounds a hostile chain that alternates.
const MAX_ROUNDS: usize = 16;
/// Start positions for the solver: `pos[i]` is the position of point `i` when it is given or
/// constructed, `placed_by[i]` how it was placed; `radius[k]` the radius of circle or arc `k`
/// when its dimensions set it.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Seed {
pub pos: Vec<Option<Vec2>>,
pub placed_by: Vec<Option<Placement>>,
pub radius: Vec<Option<f64>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Placement {
/// Fixed, fixed by a constraint, a point of a fixed curve, or held for this solve.
Given,
/// At the intersection of two loci anchored on the placed points `a` ≤ `b` (the same point
/// when both loci come from it, or for a coincident point). `transversal` when the loci
/// cross (the point is pinned down well); false when they touch.
TwoLoci { a: usize, b: usize, transversal: bool },
/// By cluster decomposition: rigid relative to the placed points, built from crossing
/// intersections only.
Cluster,
/// A point of a rigid cluster whose pose nothing decides (free, or pinned at a single
/// placed point), placed by the cluster's reference pose: a guess at free motion, so the
/// solver starts from it but nothing is built on it.
Float,
}
impl Placement {
/// The solver starts from it. A touching placement is recorded but the solver starts the
/// point where it is.
pub(crate) fn anchors(self) -> bool {
!matches!(self, Placement::TwoLoci { transversal: false, .. })
}
/// Later points may be built on it, and the solver may keep it (`solver::pinned`): it is
/// pinned down well and not a guess.
pub(crate) fn builds(self) -> bool {
self.anchors() && self != Placement::Float
}
}
/// Place the points the constraints pin down; `given[i]` marks the points that are constants
/// for this solve. Branches are chosen to match the orientation of `reference` (positions
/// before an interactive move) where it covers a point, of the sketch's current positions
/// otherwise; a reference longer than the point list is ignored. `on_curve` holds the sketch's
/// point-on-curve pairs ([`crate::solver::points_on_curves`]); the decomposition works within
/// what is left of `budget`, and charges it.
pub(crate) fn seed(sk: &Sketch, given: &[bool], reference: Option<&[Vec2]>, on_curve: &[(usize, usize)], budget: &mut Budget) -> Seed {
let n = sk.points.len();
let reference = reference.filter(|r| r.len() <= n);
let before = |i: usize| match reference.and_then(|r| r.get(i)) {
Some(p) => Some(*p),
None => sk.points.get(i).map(|p| p.pos),
};
let is_given = |i: usize| given.get(i).copied().unwrap_or(false);
let pos: Vec<Option<Vec2>> = sk.points.iter().enumerate().map(|(i, p)| (is_given(i) && in_range(p.pos)).then_some(p.pos)).collect();
let placed_by = pos.iter().map(|p| p.map(|_| Placement::Given)).collect();
let mut out = Seed { pos, placed_by, radius: radii(sk) };
let reading = read(sk, &before, on_curve, &out.radius);
let mut prop = Propagation {
reading: &reading,
free: (0..n).map(|i| !is_given(i)).collect(),
anchors: out.pos.clone(),
placed_near: vec![0; n],
queued: vec![false; n],
queue: VecDeque::new(),
};
for i in 0..n {
if prop.anchors.get(i).is_some_and(Option::is_some) {
prop.anchored(i);
}
}
// Every free point is examined once, and again when a neighbour placed later can complete
// a pair of loci (see `Propagation::anchored`); there is nothing to examine when no free
// point is related to a given one.
if prop.next_to_free() {
for i in (0..n).filter(|i| !is_given(*i)) {
enqueue(&mut prop.queued, &mut prop.queue, i);
}
}
let mut steps = n.saturating_add(reading.tie_count()).saturating_mul(2 * (MAX_LOCI + 1));
prop.run(&mut out, &mut steps);
// Where propagation stalls, the decomposition places what is rigid relative to the
// anchors; propagation goes on from its placements, and the graph takes in what
// propagation placed.
if !switch::decompose_on() {
return out;
}
// A drag step solves against its reference and moves only held points; any other solve
// applies edits.
let edits = reference.is_none();
let mut clusters = Decomposition::new(&reading, &prop.anchors, edits, budget.left());
if !clusters.built() {
return out;
}
for _ in 0..MAX_ROUNDS {
let candidates: Vec<(usize, Vec2)> = (0..n).filter(|i| prop.is_free(*i)).filter_map(|i| Some((i, clusters.pos(i)?))).collect();
let accepted = prop.consistent(&candidates);
if accepted.is_empty() {
break;
}
for (i, at) in accepted {
prop.place(&mut out, i, at, Placement::Cluster);
}
let anchored = prop.run(&mut out, &mut steps);
if anchored.is_empty() || clusters.exhausted() {
break;
}
clusters.know(&anchored);
}
// What the constraints decide is placed. A rigid cluster they leave free to move goes where
// its reference pose puts it, if that holds every relation to the anchors and no point of
// it is placed already: a guess, so it anchors nothing and the next is checked against the
// anchors alone.
for float in clusters.floats(&|i| prop.anchors.get(i).copied().flatten()) {
let open = float.iter().all(|(i, _)| out.pos.get(*i).is_some_and(Option::is_none));
if open && prop.consistent(&float).len() == float.len() {
for (i, at) in float {
prop.place(&mut out, i, at, Placement::Float);
}
}
}
let (work, cut) = clusters.work();
budget.charge(work, cut);
out
}
/// Point-by-point placement state, kept across the decomposition rounds.
struct Propagation<'a> {
reading: &'a Reading,
/// Not given: may be placed.
free: Vec<bool>,
/// Points later points may be built on (see [`Placement::anchors`]).
anchors: Vec<Option<Vec2>>,
/// Anchoring neighbours (by a relation between two points) per point.
placed_near: Vec<usize>,
queued: Vec<bool>,
queue: VecDeque<usize>,
}
impl Propagation<'_> {
fn is_free(&self, i: usize) -> bool {
self.free.get(i).copied().unwrap_or(false) && self.anchors.get(i).is_some_and(Option::is_none)
}
/// Some free point is related to an anchor.
fn next_to_free(&self) -> bool {
let anchored = |o: usize| self.anchors.get(o).is_some_and(Option::is_some);
(0..self.free.len()).any(|i| self.is_free(i) && self.reading.neighbours(i).any(anchored))
}
/// The candidate positions of free points, in range, that hold every relation to the
/// anchors and to the other candidates, as a propagated point must. One that misses is not
/// placed, nor is any candidate related to it (the decomposition may have built it on the
/// miss).
fn consistent(&self, candidates: &[(usize, Vec2)]) -> Vec<(usize, Vec2)> {
let by_point: BTreeMap<usize, Vec2> = candidates.iter().copied().filter(|(i, at)| self.is_free(*i) && in_range(*at)).collect();
let at = |i: usize| self.anchors.get(i).copied().flatten().or(by_point.get(&i).copied());
let view = View { at: &at, oriented: true };
let mut dropped: BTreeSet<usize> = by_point.iter().filter(|(i, x)| !self.reading.holds(**i, **x, &view)).map(|(i, _)| *i).collect();
let mut cascade: Vec<usize> = dropped.iter().copied().collect();
while let Some(i) = cascade.pop() {
for o in self.reading.neighbours(i) {
if by_point.contains_key(&o) && dropped.insert(o) {
cascade.push(o);
}
}
}
by_point.into_iter().filter(|(i, _)| !dropped.contains(i)).collect()
}
/// Record `p` at `at`; an anchoring placement also anchors it and requeues the neighbours it
/// may complete (true then). A given point is never placed.
fn place(&mut self, out: &mut Seed, p: usize, at: Vec2, how: Placement) -> bool {
if !self.is_free(p) {
return false;
}
if let (Some(slot), Some(by)) = (out.pos.get_mut(p), out.placed_by.get_mut(p)) {
*slot = Some(at);
*by = Some(how);
}
if how.builds()
&& let Some(slot) = self.anchors.get_mut(p)
{
*slot = Some(at);
self.anchored(p);
return true;
}
false
}
/// Count the new anchor `p` at each neighbour, and requeue a free neighbour it can complete a
/// pair of loci for (up to the MAX_LOCI-th anchor): a coincident neighbour, the second
/// anchor, the first of a point with ties (they may give its other locus), and the points
/// of the ties `p` may complete.
fn anchored(&mut self, p: usize) {
let Propagation { reading, free, anchors, placed_near, queued, queue } = self;
let open = |o: usize| free.get(o).copied().unwrap_or(false) && anchors.get(o).is_some_and(Option::is_none);
for e in reading.adj(p) {
let o = e.other(p);
let Some(count) = placed_near.get_mut(o) else { continue };
*count += 1;
if *count <= MAX_LOCI && (matches!(e.rel, Rel::Same) || *count >= 2 || reading.tie_count_of(o) > 0) && open(o) {
enqueue(queued, queue, o);
}
}
for o in reading.woken(p) {
if open(o) {
enqueue(queued, queue, o);
}
}
}
/// Place queued points until the queue or `steps` runs out; the points that became anchors.
fn run(&mut self, out: &mut Seed, steps: &mut usize) -> Vec<(usize, Vec2)> {
let mut anchored = Vec::new();
while *steps > 0
&& let Some(p) = self.queue.pop_front()
{
*steps -= 1;
if let Some(q) = self.queued.get_mut(p) {
*q = false;
}
if !self.is_free(p) {
continue;
}
let anchors = &self.anchors;
let at = |i: usize| anchors.get(i).copied().flatten();
let items = self.reading.items(p, &View { at: &at, oriented: true });
let Some(fix) = place(&items, self.reading.before(p)) else { continue };
let how = Placement::TwoLoci { a: fix.a, b: fix.b, transversal: fix.transversal };
if self.place(out, p, fix.at, how) {
anchored.push((p, fix.at));
}
}
anchored
}
}
#[cfg(test)]
pub(crate) mod switch {
//! Test-only switches: the seed, or only its decomposition step, can be turned off, and
//! every solve in this crate's tests is repeated without the seed to check that the
//! outcome is the same.
use std::cell::Cell;
use std::thread::LocalKey;
thread_local! {
static SEED: Cell<bool> = const { Cell::new(true) };
static DECOMPOSE: Cell<bool> = const { Cell::new(true) };
static DIFFERENTIAL: Cell<bool> = const { Cell::new(true) };
}
pub(crate) fn seed_on() -> bool {
SEED.with(Cell::get)
}
pub(crate) fn decompose_on() -> bool {
DECOMPOSE.with(Cell::get)
}
pub(crate) fn differential_on() -> bool {
DIFFERENTIAL.with(Cell::get)
}
/// Restores a switch when dropped, also when the test panics.
struct Restore(&'static LocalKey<Cell<bool>>, bool);
impl Drop for Restore {
fn drop(&mut self) {
self.0.with(|s| s.set(self.1));
}
}
fn off<T>(key: &'static LocalKey<Cell<bool>>, f: impl FnOnce() -> T) -> T {
let _restore = Restore(key, key.with(|s| s.replace(false)));
f()
}
/// Run `f` with the seed off.
pub(crate) fn without_seed<T>(f: impl FnOnce() -> T) -> T {
off(&SEED, f)
}
/// Run `f` with the seed's propagation only (no decomposition).
pub(crate) fn without_decomposition<T>(f: impl FnOnce() -> T) -> T {
off(&DECOMPOSE, f)
}
/// Run `f` without the seed-on/seed-off comparison (for tests that measure the difference).
pub(crate) fn without_differential<T>(f: impl FnOnce() -> T) -> T {
off(&DIFFERENTIAL, f)
}
}
#[cfg(not(test))]
pub(crate) mod switch {
pub(crate) const fn seed_on() -> bool {
true
}
pub(crate) const fn decompose_on() -> bool {
true
}
}
+813
View File
@@ -0,0 +1,813 @@
//! Harness for the constructive seed on lines, arcs and tangency: a filleted rectangle, a slot,
//! a tangent line–arc–line chain (also at axis-aligned line angles), a bolt circle, a mirrored
//! point, collinear lines, points at a distance from a line, equal lines and points on an arc
//! of unknown radius, under scaled dimension edits. Every arc must keep its side of its lines
//! and its sweep, every line its direction, every point its side of its lines.
//! Deterministic (fixed LCG seeds), no timing.
use solvecraft_geom::Vec2;
use crate::construct::{Placement, seed};
use crate::decompose::Budget;
use crate::solver::points_on_curves;
use crate::tests::{Lcg, given, seed_of, seeded, v};
use crate::*;
/// What an edit must keep.
#[derive(Clone, Copy, Debug)]
enum Check {
/// `p` stays on its side of the line through `a` and `b`.
Side { p: usize, a: usize, b: usize },
/// The line from `a` to `b` points within a quarter turn of `d` (where the edit turns it).
Toward { a: usize, b: usize, d: Vec2 },
/// `p` stays ahead of or behind `a` along the line from `a` to `b`.
Along { p: usize, a: usize, b: usize },
/// The counter-clockwise sweep of the arc `curve` stays in `lo..hi` (radians).
Sweep { curve: usize, lo: f64, hi: f64 },
/// `p` ends at `at`.
At { p: usize, at: Vec2 },
}
/// A family member: the solved sketch, the dimension edits (constraint index, new value) and
/// what they must keep.
struct Case {
sk: Sketch,
edits: Vec<(usize, f64)>,
checks: Vec<Check>,
}
fn sweep(sk: &Sketch, curve: usize) -> f64 {
let CurveKind::Arc { c, a, b } = sk.curves[curve].kind else { return f64::NAN };
let (c, a, b) = (sk.points[c].pos, sk.points[a].pos, sk.points[b].pos);
((b - c).angle() - (a - c).angle()).rem_euclid(std::f64::consts::TAU)
}
/// Does `sk` keep every check (sides as in `before`)?
fn holds(sk: &Sketch, checks: &[Check], before: &Sketch) -> bool {
let p = |i: usize| sk.points[i].pos;
checks.iter().all(|c| match *c {
Check::Side { p: x, a, b } => {
let side = |s: &Sketch| (s.points[b].pos - s.points[a].pos).cross(s.points[x].pos - s.points[a].pos).signum();
side(sk) == side(before)
}
Check::Toward { a, b, d } => (p(b) - p(a)).dot(d) > 0.0,
Check::Along { p: x, a, b } => {
let along = |s: &Sketch| (s.points[b].pos - s.points[a].pos).dot(s.points[x].pos - s.points[a].pos).signum();
along(sk) == along(before)
}
Check::Sweep { curve, lo, hi } => (lo..hi).contains(&sweep(sk, curve)),
Check::At { p: x, at } => p(x).dist(at) < 1e-6,
})
}
/// The member's sketch with its edits applied.
fn edited(case: &Case) -> Sketch {
let mut sk = case.sk.clone();
for (i, value) in &case.edits {
sk.constraints[*i].kind.set_value(*value);
}
sk
}
/// Apply the edits and solve; (failed, a check broke).
fn run(case: &Case, on: bool) -> (bool, bool) {
let mut sk = edited(case);
let rep = seeded(on, || solve(&mut sk));
if !rep.ok() {
return (true, false);
}
(false, !holds(&sk, &case.checks, &case.sk))
}
fn push(sk: &mut Sketch, kind: ConstraintKind) -> usize {
sk.add_constraint(kind, None).unwrap();
sk.constraints.len() - 1
}
/// The four arcs' ends and centres of a rounded rectangle, CCW: bottom, right, top and left
/// lines between quarter arcs at the corners, every line tangent to both its arcs. The bottom
/// line's start is fixed; lengths of the bottom and left lines and the four radii are
/// dimensions, scaled by `lo..hi`. Only edits where the top and right lines keep a length are
/// kept.
fn filleted_rectangle(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let o = v(rnd.range(-50.0, 50.0), rnd.range(-50.0, 50.0));
let (w, h) = (rnd.range(60.0, 100.0), rnd.range(40.0, 70.0));
let r: [f64; 4] = std::array::from_fn(|_| rnd.range(4.0, 12.0)); // BR, TR, TL, BL
let mut sk = Sketch::new();
let at = |x: f64, y: f64| o + v(x, y);
let bottom = sk.add_line(at(r[3], 0.0), at(w - r[0], 0.0), None, None, None).unwrap();
let right = sk.add_line(at(w, r[0]), at(w, h - r[1]), None, None, None).unwrap();
let top = sk.add_line(at(w - r[1], h), at(r[2], h), None, None, None).unwrap();
let left = sk.add_line(at(0.0, h - r[2]), at(0.0, r[3]), None, None, None).unwrap();
let ends = |sk: &Sketch, l: usize| sk.curves[l].kind.ends().unwrap();
let ((b0, b1), (r0, r1), (t0, t1), (l0, l1)) = (ends(&sk, bottom), ends(&sk, right), ends(&sk, top), ends(&sk, left));
let corners = [
(at(w - r[0], r[0]), b1, r0, bottom, right),
(at(w - r[1], h - r[1]), r1, t0, right, top),
(at(r[2], h - r[2]), t1, l0, top, left),
(at(r[3], r[3]), l1, b0, left, bottom),
];
let mut checks = Vec::new();
let mut radius = Vec::new();
for (k, (c, a, b, la, lb)) in corners.into_iter().enumerate() {
let (pa, pb) = (sk.points[a].pos, sk.points[b].pos);
let arc = sk.add_arc(c, pa, pb, [None, Some(a), Some(b)], None).unwrap();
let CurveKind::Arc { c: centre, .. } = sk.curves[arc].kind else { panic!() };
push(&mut sk, ConstraintKind::Tangent { a: la, b: arc });
push(&mut sk, ConstraintKind::Tangent { a: lb, b: arc });
radius.push((push(&mut sk, ConstraintKind::Radius { c: arc, value: r[k] }), r[k]));
for l in [la, lb] {
let (a, b) = ends(&sk, l);
checks.push(Check::Side { p: centre, a, b });
}
checks.push(Check::Sweep { curve: arc, lo: 0.0, hi: std::f64::consts::PI });
}
for l in [bottom, right, top, left] {
let (a, b) = ends(&sk, l);
checks.push(Check::Toward { a, b, d: sk.points[b].pos - sk.points[a].pos });
}
push(&mut sk, ConstraintKind::Fix { p: b0 });
push(&mut sk, ConstraintKind::Horizontal { l: bottom });
push(&mut sk, ConstraintKind::Horizontal { l: top });
push(&mut sk, ConstraintKind::Vertical { l: left });
push(&mut sk, ConstraintKind::Vertical { l: right });
let (lb, ll) = (w - r[3] - r[0], h - r[2] - r[3]);
let len_b = push(&mut sk, ConstraintKind::Length { l: bottom, value: lb });
let len_l = push(&mut sk, ConstraintKind::Length { l: left, value: ll });
let mut s = || rnd.range(lo, hi);
let r2: Vec<f64> = radius.iter().map(|(_, r)| r * s()).collect();
let (lb2, ll2) = (lb * s(), ll * s());
// Width and height after the edit; the top and right lines must keep a length.
let (w2, h2) = (lb2 + r2[3] + r2[0], ll2 + r2[2] + r2[3]);
if w2 - r2[1] - r2[2] < 2.0 || h2 - r2[0] - r2[1] < 2.0 {
return None;
}
let mut edits: Vec<(usize, f64)> = radius.iter().zip(&r2).map(|((i, _), r)| (*i, *r)).collect();
edits.extend([(len_b, lb2), (len_l, ll2)]);
Some(Case { sk, edits, checks })
}
/// A slot at an angle: two semicircular caps of equal radius joined by two lines tangent to
/// both, the bottom line at a line angle and the top one parallel to it, cap A's centre fixed.
/// The angle turns by up to ±145°, the radius and the cap distance (centre distance, or the
/// overall length) scale by `lo..hi`.
fn slot(rnd: &mut Lcg, lo: f64, hi: f64, overall: bool) -> Option<Case> {
let th = rnd.range(-3.1, 3.1);
let (len, r) = (rnd.range(30.0, 60.0), rnd.range(5.0, 13.0));
let (u, n) = (Vec2::from_angle(th), Vec2::from_angle(th).perp());
let mut sk = Sketch::new();
let ca = v(rnd.range(-40.0, 40.0), rnd.range(-40.0, 40.0));
let cb = ca + u * len;
let bottom = sk.add_line(ca - n * r, cb - n * r, None, None, None).unwrap();
let top = sk.add_line(cb + n * r, ca + n * r, None, None, None).unwrap();
let (ba, bb) = sk.curves[bottom].kind.ends().unwrap();
let (tb, ta) = sk.curves[top].kind.ends().unwrap();
let arc_b = sk.add_arc(cb, cb - n * r, cb + n * r, [None, Some(bb), Some(tb)], None).unwrap();
let arc_a = sk.add_arc(ca, ca + n * r, ca - n * r, [None, Some(ta), Some(ba)], None).unwrap();
let centre = |sk: &Sketch, a: usize| match sk.curves[a].kind {
CurveKind::Arc { c, .. } => c,
_ => panic!(),
};
let (pa, pb) = (centre(&sk, arc_a), centre(&sk, arc_b));
push(&mut sk, ConstraintKind::Fix { p: pa });
let angle = push(&mut sk, ConstraintKind::LineAngle { l: bottom, value: th });
push(&mut sk, ConstraintKind::Parallel { a: top, b: bottom });
for (l, a) in [(bottom, arc_a), (bottom, arc_b), (top, arc_a), (top, arc_b)] {
push(&mut sk, ConstraintKind::Tangent { a: l, b: a });
}
let radius = push(&mut sk, ConstraintKind::Radius { c: arc_a, value: r });
push(&mut sk, ConstraintKind::Equal { a: arc_a, b: arc_b });
let length = if overall {
push(&mut sk, ConstraintKind::SlotOverallLength { a: arc_a, b: arc_b, value: len + 2.0 * r })
} else {
push(&mut sk, ConstraintKind::Distance { p: pa, q: pb, value: len })
};
let (r2, len2) = (r * rnd.range(lo, hi), len * rnd.range(lo, hi));
let th2 = th + rnd.range(-2.5, 2.5);
let half = std::f64::consts::FRAC_PI_2;
let checks = vec![
Check::Side { p: pa, a: ba, b: bb },
Check::Side { p: pb, a: ba, b: bb },
Check::Side { p: pa, a: tb, b: ta },
Check::Side { p: pb, a: tb, b: ta },
Check::Sweep { curve: arc_a, lo: half, hi: 3.0 * half },
Check::Sweep { curve: arc_b, lo: half, hi: 3.0 * half },
Check::Toward { a: pa, b: pb, d: Vec2::from_angle(th2) },
];
let edits = vec![(angle, th2), (radius, r2), (length, if overall { len2 + 2.0 * r2 } else { len2 })];
Some(Case { sk, edits, checks })
}
/// Line – arc – line, tangent at shared ends: L1 from a fixed point at a line angle with a
/// length, the arc turning left or right by β with a radius, L2 at β from L1 with a length.
/// Lengths and radius scale by `lo..hi`, the line angle turns by up to ±115°, β scales within
/// 15°–165°.
fn tangent_chain(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let alpha = rnd.range(-3.1, 3.1);
let alpha2 = alpha + rnd.range(-2.0, 2.0);
chain(rnd, lo, hi, alpha, alpha2)
}
/// [`tangent_chain`] with L1 along an axis, turned to another axis (a quarter or half turn
/// either way).
fn axis_chain(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let quarter = std::f64::consts::FRAC_PI_2;
let alpha = quarter * (rnd.next() * 4.0).floor() - std::f64::consts::PI;
let turn = [-2.0, -1.0, 1.0, 2.0][(rnd.next() * 4.0) as usize % 4];
chain(rnd, lo, hi, alpha, alpha + quarter * turn)
}
/// The chain of [`tangent_chain`] with L1 at `alpha`, edited to `alpha2`.
fn chain(rnd: &mut Lcg, lo: f64, hi: f64, alpha: f64, alpha2: f64) -> Option<Case> {
let (a, b, r) = (rnd.range(20.0, 40.0), rnd.range(20.0, 40.0), rnd.range(5.0, 15.0));
let beta = rnd.range(30f64.to_radians(), 150f64.to_radians());
let turn = rnd.sign();
let p0 = v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0));
let u1 = Vec2::from_angle(alpha);
let u2 = Vec2::from_angle(alpha + turn * beta);
let e1 = p0 + u1 * a;
let c = e1 + u1.perp() * (turn * r);
let e2 = c - u2.perp() * (turn * r);
let p3 = e2 + u2 * b;
let mut sk = Sketch::new();
let l1 = sk.add_line(p0, e1, None, None, None).unwrap();
let (i0, i1) = sk.curves[l1].kind.ends().unwrap();
let l2 = sk.add_line(e2, p3, None, None, None).unwrap();
let (i2, i3) = sk.curves[l2].kind.ends().unwrap();
let arc = if turn > 0.0 {
sk.add_arc(c, e1, e2, [None, Some(i1), Some(i2)], None).unwrap()
} else {
sk.add_arc(c, e2, e1, [None, Some(i2), Some(i1)], None).unwrap()
};
let CurveKind::Arc { c: ic, .. } = sk.curves[arc].kind else { panic!() };
push(&mut sk, ConstraintKind::Fix { p: i0 });
let angle = push(&mut sk, ConstraintKind::LineAngle { l: l1, value: alpha });
let len1 = push(&mut sk, ConstraintKind::Length { l: l1, value: a });
let bend = push(&mut sk, ConstraintKind::Angle { a: l1, b: l2, value: turn * beta, flip: false });
let len2 = push(&mut sk, ConstraintKind::Length { l: l2, value: b });
let radius = push(&mut sk, ConstraintKind::Radius { c: arc, value: r });
push(&mut sk, ConstraintKind::Tangent { a: l1, b: arc });
push(&mut sk, ConstraintKind::Tangent { a: l2, b: arc });
let mut s = || rnd.range(lo, hi);
let beta2 = (beta * s()).clamp(15f64.to_radians(), 165f64.to_radians());
let edits = vec![(len1, a * s()), (len2, b * s()), (radius, r * s()), (bend, turn * beta2), (angle, alpha2)];
let checks = vec![
Check::Side { p: ic, a: i0, b: i1 },
Check::Side { p: ic, a: i2, b: i3 },
Check::Sweep { curve: arc, lo: 0.0, hi: std::f64::consts::PI },
Check::Toward { a: i0, b: i1, d: Vec2::from_angle(alpha2) },
Check::Toward { a: i2, b: i3, d: Vec2::from_angle(alpha2 + turn * beta2) },
];
Some(Case { sk, edits, checks })
}
/// Bolt circle: `k` points on a circle (diameter dimension, centre fixed), each on a line from
/// the centre; the first line at a line angle, each next at 360°/k from the one before. The
/// diameter scales by `lo..hi`, the first angle turns by up to ±170°; every point must end where
/// the dimensions put it.
fn bolt_circle(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let k = 3 + (rnd.next() * 6.0) as usize;
let r = rnd.range(15.0, 40.0);
let phi = rnd.range(-3.1, 3.1);
let step = std::f64::consts::TAU / k as f64;
let mut sk = Sketch::new();
let centre = sk.add_point(v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)), None).unwrap();
let c = sk.points[centre].pos;
let circle = sk.add_circle(c, r, Some(centre), None).unwrap();
push(&mut sk, ConstraintKind::Fix { p: centre });
let diameter = push(&mut sk, ConstraintKind::Diameter { c: circle, value: 2.0 * r });
let mut lines = Vec::new();
let mut holes = Vec::new();
for i in 0..k {
let h = sk.add_point(c + Vec2::from_angle(phi + i as f64 * step) * r, None).unwrap();
lines.push(sk.add_line_pts(centre, h, None).unwrap());
holes.push(h);
push(&mut sk, ConstraintKind::PointOnCurve { p: h, c: circle });
}
let angle = push(&mut sk, ConstraintKind::LineAngle { l: lines[0], value: phi });
for w in lines.windows(2) {
push(&mut sk, ConstraintKind::Angle { a: w[0], b: w[1], value: step, flip: false });
}
let (r2, phi2) = (r * rnd.range(lo, hi), phi + rnd.range(-3.0, 3.0));
let checks = holes.iter().enumerate().map(|(i, h)| Check::At { p: *h, at: c + Vec2::from_angle(phi2 + i as f64 * step) * r2 }).collect();
Some(Case { sk, edits: vec![(diameter, 2.0 * r2), (angle, phi2)], checks })
}
/// A point mirrored across a line: the line from a fixed point at a line angle with a length,
/// `p` at two distances from its ends (one side), `q` its mirror image. The angle turns by up
/// to ±115°, the dimensions scale by `lo..hi` (only edits where the triangle still exists).
fn mirrored(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let theta = rnd.range(-3.1, 3.1);
let len = rnd.range(30.0, 60.0);
let (u, a) = (Vec2::from_angle(theta), v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)));
let (t, h) = (rnd.range(0.2, 0.8) * len, rnd.range(5.0, 20.0) * rnd.sign());
let (p, q) = (a + u * t + u.perp() * h, a + u * t - u.perp() * h);
let mut sk = Sketch::new();
let axis = sk.add_line(a, a + u * len, None, None, None).unwrap();
let (ia, ib) = sk.curves[axis].kind.ends().unwrap();
let (ip, iq) = (sk.add_point(p, None).unwrap(), sk.add_point(q, None).unwrap());
push(&mut sk, ConstraintKind::Fix { p: ia });
let angle = push(&mut sk, ConstraintKind::LineAngle { l: axis, value: theta });
let length = push(&mut sk, ConstraintKind::Length { l: axis, value: len });
let (d1, d2) = (p.dist(a), p.dist(a + u * len));
let k1 = push(&mut sk, ConstraintKind::Distance { p: ia, q: ip, value: d1 });
let k2 = push(&mut sk, ConstraintKind::Distance { p: ib, q: ip, value: d2 });
push(&mut sk, ConstraintKind::Symmetric { p: ip, q: iq, l: axis });
let theta2 = theta + rnd.range(-2.0, 2.0);
let mut s = || rnd.range(lo, hi);
let (len2, e1, e2) = (len * s(), d1 * s(), d2 * s());
if e1 + e2 < len2 * 1.02 || (e1 - e2).abs() > len2 * 0.98 {
return None;
}
let checks =
vec![Check::Side { p: ip, a: ia, b: ib }, Check::Side { p: iq, a: ia, b: ib }, Check::Toward { a: ia, b: ib, d: Vec2::from_angle(theta2) }];
Some(Case { sk, edits: vec![(angle, theta2), (length, len2), (k1, e1), (k2, e2)], checks })
}
/// Two collinear lines: L1 from a fixed point at a line angle with a length, L2 collinear with
/// it, its start a distance past L1's end, with a length. The angle turns by up to ±115°, the
/// dimensions scale by `lo..hi`; both lines and the gap keep their direction.
fn collinear(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let theta = rnd.range(-3.1, 3.1);
let (a, g, b) = (rnd.range(20.0, 40.0), rnd.range(5.0, 30.0), rnd.range(20.0, 40.0));
let (u, o) = (Vec2::from_angle(theta), v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)));
let mut sk = Sketch::new();
let l1 = sk.add_line(o, o + u * a, None, None, None).unwrap();
let l2 = sk.add_line(o + u * (a + g), o + u * (a + g + b), None, None, None).unwrap();
let ((i0, i1), (i2, i3)) = (sk.curves[l1].kind.ends().unwrap(), sk.curves[l2].kind.ends().unwrap());
push(&mut sk, ConstraintKind::Fix { p: i0 });
let angle = push(&mut sk, ConstraintKind::LineAngle { l: l1, value: theta });
let len1 = push(&mut sk, ConstraintKind::Length { l: l1, value: a });
push(&mut sk, ConstraintKind::Collinear { a: l1, b: l2 });
let gap = push(&mut sk, ConstraintKind::Distance { p: i1, q: i2, value: g });
let len2 = push(&mut sk, ConstraintKind::Length { l: l2, value: b });
let theta2 = theta + rnd.range(-2.0, 2.0);
let u2 = Vec2::from_angle(theta2);
let mut s = || rnd.range(lo, hi);
let edits = vec![(angle, theta2), (len1, a * s()), (gap, g * s()), (len2, b * s())];
let checks = vec![Check::Toward { a: i0, b: i1, d: u2 }, Check::Toward { a: i1, b: i2, d: u2 }, Check::Toward { a: i2, b: i3, d: u2 }];
Some(Case { sk, edits, checks })
}
/// Points at a distance from a line: the line from a fixed point at a line angle with a
/// length; `p` at a distance from it (left) and from its start, `r` at half a linear diameter
/// from it (right) and at a distance from its end. The angle turns by up to ±115°, the
/// dimensions scale by `lo..hi` (only edits where both points still exist).
fn line_distance(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let theta = rnd.range(-3.1, 3.1);
let len = rnd.range(30.0, 60.0);
let (u, o) = (Vec2::from_angle(theta), v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)));
let n = u.perp();
let (h1, h2) = (rnd.range(5.0, 20.0), rnd.range(5.0, 20.0));
let p = o + u * rnd.range(5.0, 25.0) + n * h1;
let r = o + u * (len - rnd.range(5.0, 25.0)) - n * h2;
let mut sk = Sketch::new();
let l = sk.add_line(o, o + u * len, None, None, None).unwrap();
let (ia, ib) = sk.curves[l].kind.ends().unwrap();
let (ip, ir) = (sk.add_point(p, None).unwrap(), sk.add_point(r, None).unwrap());
push(&mut sk, ConstraintKind::Fix { p: ia });
let angle = push(&mut sk, ConstraintKind::LineAngle { l, value: theta });
let length = push(&mut sk, ConstraintKind::Length { l, value: len });
let kh1 = push(&mut sk, ConstraintKind::PointLineDistance { p: ip, l, value: h1 });
let (d1, d2) = (p.dist(o), r.dist(o + u * len));
let kd1 = push(&mut sk, ConstraintKind::Distance { p: ia, q: ip, value: d1 });
let kh2 = push(&mut sk, ConstraintKind::LinearDiameter { p: ir, l, value: 2.0 * h2 });
let kd2 = push(&mut sk, ConstraintKind::Distance { p: ib, q: ir, value: d2 });
let theta2 = theta + rnd.range(-2.0, 2.0);
let mut s = || rnd.range(lo, hi);
let (len2, g1, e1, g2, e2) = (len * s(), h1 * s(), d1 * s(), h2 * s(), d2 * s());
if e1 < g1 * 1.1 || e2 < g2 * 1.1 {
return None;
}
let edits = vec![(angle, theta2), (length, len2), (kh1, g1), (kd1, e1), (kh2, 2.0 * g2), (kd2, e2)];
let checks = vec![
Check::Side { p: ip, a: ia, b: ib },
Check::Side { p: ir, a: ia, b: ib },
Check::Along { p: ip, a: ia, b: ib },
Check::Along { p: ir, a: ib, b: ia },
Check::Toward { a: ia, b: ib, d: Vec2::from_angle(theta2) },
];
Some(Case { sk, edits, checks })
}
/// Equal lines: L1 from a fixed point at a line angle, its end at a distance (no length
/// dimension), L2 from L1's end at an angle β from it and equal to it. The angle turns by up
/// to ±115°, the distance scales by `lo..hi`, β within 15°–165° either way.
fn equal_lines(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let theta = rnd.range(-3.1, 3.1);
let a = rnd.range(20.0, 40.0);
let beta = rnd.range(30f64.to_radians(), 150f64.to_radians()) * rnd.sign();
let o = v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0));
let b = o + Vec2::from_angle(theta) * a;
let mut sk = Sketch::new();
let l1 = sk.add_line(o, b, None, None, None).unwrap();
let (i0, i1) = sk.curves[l1].kind.ends().unwrap();
let l2 = sk.add_line(b, b + Vec2::from_angle(theta + beta) * a, Some(i1), None, None).unwrap();
let (_, i2) = sk.curves[l2].kind.ends().unwrap();
push(&mut sk, ConstraintKind::Fix { p: i0 });
let angle = push(&mut sk, ConstraintKind::LineAngle { l: l1, value: theta });
let dist = push(&mut sk, ConstraintKind::Distance { p: i0, q: i1, value: a });
let bend = push(&mut sk, ConstraintKind::Angle { a: l1, b: l2, value: beta, flip: false });
push(&mut sk, ConstraintKind::Equal { a: l1, b: l2 });
let theta2 = theta + rnd.range(-2.0, 2.0);
let beta2 = beta.signum() * (beta.abs() * rnd.range(lo, hi)).clamp(15f64.to_radians(), 165f64.to_radians());
let edits = vec![(angle, theta2), (dist, a * rnd.range(lo, hi)), (bend, beta2)];
let checks = vec![
Check::Toward { a: i0, b: i1, d: Vec2::from_angle(theta2) },
Check::Toward { a: i1, b: i2, d: Vec2::from_angle(theta2 + beta2) },
Check::Side { p: i2, a: i0, b: i1 },
];
Some(Case { sk, edits, checks })
}
/// Points on an arc of unknown radius: the arc's centre fixed, its start on a line from the
/// centre at a line angle and a distance (no radius dimension), its end and a point on it on
/// lines from the centre at angles σ and τ from the first. The angle turns by up to ±115°, the
/// distance scales by `lo..hi`, σ within 20°–170° (τ keeps its share of σ).
fn on_arc(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let phi = rnd.range(-3.1, 3.1);
let r = rnd.range(15.0, 40.0);
let sigma = rnd.range(40f64.to_radians(), 150f64.to_radians());
let share = rnd.range(0.2, 0.8);
let o = v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0));
let at = |a: f64| o + Vec2::from_angle(a) * r;
let mut sk = Sketch::new();
let arc = sk.add_arc(o, at(phi), at(phi + sigma), [None, None, None], None).unwrap();
let CurveKind::Arc { c, a: s0, b: e0 } = sk.curves[arc].kind else { panic!() };
let p = sk.add_point(at(phi + share * sigma), None).unwrap();
let (ls, le, lp) = (sk.add_line_pts(c, s0, None).unwrap(), sk.add_line_pts(c, e0, None).unwrap(), sk.add_line_pts(c, p, None).unwrap());
push(&mut sk, ConstraintKind::Fix { p: c });
let angle = push(&mut sk, ConstraintKind::LineAngle { l: ls, value: phi });
let dist = push(&mut sk, ConstraintKind::Distance { p: c, q: s0, value: r });
let ke = push(&mut sk, ConstraintKind::Angle { a: ls, b: le, value: sigma, flip: false });
let kp = push(&mut sk, ConstraintKind::Angle { a: ls, b: lp, value: share * sigma, flip: false });
push(&mut sk, ConstraintKind::PointOnCurve { p, c: arc });
let phi2 = phi + rnd.range(-2.0, 2.0);
let sigma2 = (sigma * rnd.range(lo, hi)).clamp(20f64.to_radians(), 170f64.to_radians());
let edits = vec![(angle, phi2), (dist, r * rnd.range(lo, hi)), (ke, sigma2), (kp, share * sigma2)];
let checks = vec![
Check::Sweep { curve: arc, lo: 0.0, hi: std::f64::consts::PI },
Check::Toward { a: c, b: e0, d: Vec2::from_angle(phi2 + sigma2) },
Check::Toward { a: c, b: p, d: Vec2::from_angle(phi2 + share * sigma2) },
];
Some(Case { sk, edits, checks })
}
type Family = fn(&mut Lcg, f64, f64) -> Option<Case>;
fn families() -> Vec<(&'static str, Family)> {
vec![
("filleted rectangle", filleted_rectangle),
("slot (centre distance)", |r, lo, hi| slot(r, lo, hi, false)),
("slot (overall length)", |r, lo, hi| slot(r, lo, hi, true)),
("tangent chain", tangent_chain),
("axis-aligned chain", axis_chain),
("bolt circle", bolt_circle),
("mirrored point", mirrored),
("collinear lines", collinear),
("distance from a line", line_distance),
("equal lines", equal_lines),
("points on an arc", on_arc),
]
}
const EDITS: usize = 200;
/// `n` feasible members of `family` at scales `lo..hi`; fails the test when the family yields
/// fewer than one in fifty.
fn sample(rnd: &mut Lcg, family: Family, lo: f64, hi: f64, n: usize) -> Vec<Case> {
let mut out = Vec::new();
for _ in 0..n.saturating_mul(50) {
if out.len() == n {
break;
}
out.extend(family(rnd, lo, hi));
}
assert_eq!(out.len(), n, "the family yields too few feasible members at {lo}–{hi}×");
out
}
/// (failed, broke a check) over `EDITS` kept edits of `family` at scales `lo..hi`.
fn tally(family: Family, lo: f64, hi: f64, on: bool) -> (usize, usize) {
let mut rnd = Lcg(7);
let (mut failed, mut broke) = (0, 0);
for case in sample(&mut rnd, family, lo, hi, EDITS) {
let (f, b) = run(&case, on);
failed += f as usize;
broke += b as usize;
}
(failed, broke)
}
fn table(on: bool) -> Vec<(&'static str, f64, f64, usize, usize)> {
let mut out = Vec::new();
for (name, family) in families() {
for (lo, hi) in [(0.8, 1.25), (0.5, 2.0), (0.3, 3.0)] {
let (failed, broke) = tally(family, lo, hi, on);
eprintln!("seed {} {name:<24} {lo}–{hi}×: {failed} failed, {broke}/{EDITS} lost a side", if on { "on " } else { "off" });
out.push((name, lo, hi, failed, broke));
}
}
out
}
#[test]
fn rounds_and_tangents_keep_their_sides_on_dimension_edits() {
let rows = table(true);
assert!(rows.iter().all(|r| r.3 == 0 && r.4 == 0), "{rows:?}");
}
/// The measurement the seed is judged against: without it, LM folds arcs and lines over.
#[test]
#[ignore = "measurement: run with --ignored --nocapture"]
fn rounds_and_tangents_without_the_seed() {
table(false);
}
/// The solved members of every family are left exactly where they are.
#[test]
fn solved_members_stay_put() {
let mut rnd = Lcg(3);
for (name, family) in families() {
for case in sample(&mut rnd, family, 1.0, 1.0, 10) {
let mut sk = case.sk.clone();
let rep = solve(&mut sk);
assert!(rep.ok() && rep.dof == 0, "{name}: {rep:?}");
for (p, q) in sk.points.iter().zip(&case.sk.points) {
assert!(p.pos.dist(q.pos) < 1e-7, "{name}: {} moved {:?} → {:?}", p.id, q.pos, p.pos);
}
}
}
}
/// The seed only moves the start: through the crate-wide seed-on/seed-off comparison (`solve`
/// in tests), the edits give the same status, degrees of freedom and determined flags.
#[test]
fn seed_differential_on_every_family() {
let mut rnd = Lcg(5);
for (_, family) in families() {
for case in sample(&mut rnd, family, 0.5, 2.0, 15) {
let mut sk = edited(&case);
solve(&mut sk);
}
}
}
/// Every point of every family is placed by the seed (not left to LM), so edits solve in
/// few iterations.
#[test]
fn every_point_of_every_family_is_constructed() {
let mut rnd = Lcg(9);
for (name, family) in families() {
for case in sample(&mut rnd, family, 0.5, 2.0, 10) {
let sk = edited(&case);
let s = seed_of(&sk);
let used: Vec<usize> = sk.curves.iter().flat_map(|c| c.kind.point_ids()).collect();
for p in used {
assert!(s.placed_by[p].is_some_and(Placement::anchors), "{name}: point {p} ({}) not placed: {s:?}", sk.points[p].id);
}
}
}
}
/// The seed does not depend on the order of the constraints.
#[test]
fn seed_on_lines_and_rounds_is_independent_of_constraint_order() {
let mut rnd = Lcg(13);
for (name, family) in families() {
let case = sample(&mut rnd, family, 0.5, 2.0, 1).remove(0);
let mut sk = edited(&case);
let forward = seed_of(&sk);
sk.constraints.reverse();
assert_eq!(seed_of(&sk), forward, "{name}");
let n = sk.constraints.len();
sk.constraints.rotate_left(n / 3);
assert_eq!(seed_of(&sk), forward, "{name}");
}
}
/// The canonical bracket with the third dimension on a point the rigid triangle carries
/// through a constraint the decomposition reads from the shared loci: the midpoint of P R, or
/// the centre of a circle tangent to P R at a known distance from Q. No propagation order
/// reaches P, Q, R; the decomposition grows the triangle by that point and places all four.
#[test]
fn decomposition_grows_clusters_through_lines_and_tangency() {
for tangent in [false, true] {
let truth = [v(0.0, 0.0), v(60.0, 0.0), v(10.0, 30.0), v(45.0, 35.0), v(25.0, 55.0)];
let mut sk = Sketch::new();
let ids: Vec<usize> = truth
.iter()
.enumerate()
.map(|(i, t)| sk.add_point(*t + if i < 2 { v(0.0, 0.0) } else { v(0.7 * i as f64, -0.4 * i as f64) }, None).unwrap())
.collect();
let [o1, o2, p, q, r] = [ids[0], ids[1], ids[2], ids[3], ids[4]];
for x in [o1, o2] {
push(&mut sk, ConstraintKind::Fix { p: x });
}
let pr = sk.add_line_pts(p, r, None).unwrap();
for (a, b, ta, tb) in [(p, q, 2, 3), (q, r, 3, 4), (o1, p, 0, 2), (o1, r, 0, 4)] {
push(&mut sk, ConstraintKind::Distance { p: a, q: b, value: truth[ta].dist(truth[tb]) });
}
push(&mut sk, ConstraintKind::Distance { p, q: r, value: truth[2].dist(truth[4]) });
let m = if tangent {
// Circle of radius 6 tangent to P R on Q's side, 25 from Q.
let (a, b) = (truth[2], truth[4]);
let d = (b - a).normalized().unwrap();
let n = d.perp() * (d.cross(truth[3] - a).signum());
let foot = a + d * 6.0 + n * 6.0;
let k_at = {
// On the offset line, 25 from Q.
let rel = foot - truth[3];
let t = -rel.dot(d) + (625.0 - rel.cross(d).powi(2)).sqrt();
foot + d * t
};
let circle = sk.add_circle(k_at + v(0.5, 0.3), 6.0, None, None).unwrap();
let CurveKind::Circle { c, .. } = sk.curves[circle].kind else { panic!() };
push(&mut sk, ConstraintKind::Radius { c: circle, value: 6.0 });
push(&mut sk, ConstraintKind::Tangent { a: pr, b: circle });
push(&mut sk, ConstraintKind::Distance { p: q, q: c, value: 25.0 });
push(&mut sk, ConstraintKind::Distance { p: o2, q: c, value: truth[1].dist(k_at) });
c
} else {
let m = sk.add_point((truth[2] + truth[4]) * 0.5 + v(0.4, 0.2), None).unwrap();
push(&mut sk, ConstraintKind::Midpoint { p: m, l: pr });
push(&mut sk, ConstraintKind::Distance { p: o2, q: m, value: truth[1].dist((truth[2] + truth[4]) * 0.5) });
m
};
let out = seed_of(&sk);
for x in [p, q, r, m] {
assert_eq!(out.placed_by[x], Some(Placement::Cluster), "tangent {tangent}, point {x}: {out:?}");
}
let rep = solve(&mut sk);
assert!(rep.ok() && rep.dof == 0 && rep.iterations == 0, "tangent {tangent}: {rep:?}");
for (i, t) in [(p, 2), (q, 3), (r, 4)] {
assert!(sk.points[i].pos.dist(truth[t]) < 1e-9, "tangent {tangent}: {:?}", sk.points[i].pos);
}
}
}
/// A circle's radius known from a dimension is part of the seed: the circle's radius starts
/// there.
#[test]
fn a_dimensioned_radius_is_seeded() {
let mut rnd = Lcg(17);
let case = bolt_circle(&mut rnd, 2.0, 2.0).unwrap();
let sk = edited(&case);
let s = seed_of(&sk);
let ConstraintKind::Diameter { c, value } = sk.constraints[1].kind else { panic!() };
assert_eq!(s.radius[c], Some(value / 2.0));
}
/// Hostile curve references and values in the constraints the new loci read neither panic
/// nor change the report (every solve runs through the seed-on/seed-off comparison).
#[test]
fn hostile_lines_and_rounds_neither_panic_nor_change_the_report() {
let base = || tangent_chain(&mut Lcg(21), 0.5, 2.0).unwrap();
let raw = |sk: &mut Sketch, kind: ConstraintKind| {
sk.constraints.push(Constraint { id: format!("h{}", sk.constraints.len()), kind, param: None, driven: false, text: None })
};
let cases: Vec<Box<dyn Fn(&mut Sketch)>> = vec![
Box::new(|sk| raw(sk, ConstraintKind::Midpoint { p: 1, l: 999 })),
Box::new(|sk| raw(sk, ConstraintKind::Midpoint { p: usize::MAX, l: 0 })),
Box::new(|sk| raw(sk, ConstraintKind::Symmetric { p: 1, q: 2, l: usize::MAX })),
Box::new(|sk| raw(sk, ConstraintKind::Tangent { a: 0, b: 0 })),
Box::new(|sk| raw(sk, ConstraintKind::Tangent { a: 2, b: 77 })),
Box::new(|sk| raw(sk, ConstraintKind::Radius { c: 2, value: f64::NAN })),
Box::new(|sk| raw(sk, ConstraintKind::Radius { c: 2, value: -3.0 })),
Box::new(|sk| raw(sk, ConstraintKind::Diameter { c: 0, value: 1e300 })),
Box::new(|sk| raw(sk, ConstraintKind::PointOnCurve { p: 1, c: 2 })),
Box::new(|sk| raw(sk, ConstraintKind::PointOnCurve { p: 1, c: usize::MAX })),
Box::new(|sk| raw(sk, ConstraintKind::PointLineDistance { p: 1, l: 1, value: f64::INFINITY })),
Box::new(|sk| raw(sk, ConstraintKind::LineAngle { l: 0, value: f64::NAN })),
Box::new(|sk| raw(sk, ConstraintKind::Angle { a: 0, b: 1, value: 1e300, flip: true })),
Box::new(|sk| raw(sk, ConstraintKind::Parallel { a: 0, b: 0 })),
Box::new(|sk| raw(sk, ConstraintKind::Perpendicular { a: 0, b: 2 })),
Box::new(|sk| raw(sk, ConstraintKind::Equal { a: 0, b: 2 })),
Box::new(|sk| raw(sk, ConstraintKind::Concentric { a: 2, b: 2 })),
Box::new(|sk| raw(sk, ConstraintKind::SlotOverallLength { a: 2, b: 2, value: 5.0 })),
Box::new(|sk| raw(sk, ConstraintKind::LinearDiameter { p: 3, l: 0, value: -1.0 })),
Box::new(|sk| sk.points[2].pos = v(f64::NAN, 0.0)),
Box::new(|sk| sk.points[3].pos = v(1e300, -1e300)),
];
for (i, case) in cases.iter().enumerate() {
let mut c = base();
case(&mut c.sk);
let sk = edited(&c);
let s = seed(&sk, &given(&sk), Some(&[v(f64::NAN, 0.0)]), &points_on_curves(&sk), &mut Budget::full());
assert!(s.pos.iter().flatten().all(|p| p.is_finite()), "case {i}: {s:?}");
assert!(s.radius.iter().flatten().all(|r| r.is_finite() && *r > 0.0), "case {i}: {s:?}");
let mut t = c.sk.clone();
solve(&mut t);
}
}
/// An axis-aligned line angle turned half a turn (0° → 180°) with a line at an angle to it and
/// a tangent arc: the explicit angle decides the direction, not the reference's axis sign, so
/// the seed builds the chain forward along the new angle and nothing folds.
#[test]
fn an_axis_aligned_line_angle_turned_half_a_turn_is_built_along_it() {
let case = chain(&mut Lcg(31), 1.0, 1.0, 0.0, std::f64::consts::PI).unwrap();
let sk = edited(&case);
let (i0, i1) = sk.curves[0].kind.ends().unwrap();
let s = seed_of(&sk);
let (p0, e1) = (sk.points[i0].pos, s.pos[i1].unwrap_or_else(|| panic!("L1's end unplaced: {s:?}")));
assert!((e1 - p0).dot(Vec2::X) < 0.0, "L1 built along the reference's axis, not its angle: {p0:?} → {e1:?}");
assert_eq!(run(&case, true), (false, false));
}
/// Horizontal and vertical lines tied by a contradictory parallel (or a perpendicular between
/// two horizontal lines) are still read as horizontal and vertical one by one: their rows and
/// columns are seeded.
#[test]
fn contradictory_levels_still_read_each_line_on_its_own() {
for perpendicular in [false, true] {
let mut sk = Sketch::new();
let l1 = sk.add_line(v(0.0, 0.0), v(40.0, 0.5), None, None, None).unwrap();
let (a, b) = sk.curves[l1].kind.ends().unwrap();
let l2 = if perpendicular {
sk.add_line(v(0.0, 30.0), v(30.0, 30.4), None, None, None).unwrap()
} else {
sk.add_line(v(0.0, 30.0), v(0.3, 60.0), None, None, None).unwrap()
};
let (c, d) = sk.curves[l2].kind.ends().unwrap();
for p in [a, c] {
push(&mut sk, ConstraintKind::Fix { p });
}
push(&mut sk, ConstraintKind::Horizontal { l: l1 });
push(&mut sk, ConstraintKind::Length { l: l1, value: 40.0 });
push(&mut sk, if perpendicular { ConstraintKind::Horizontal { l: l2 } } else { ConstraintKind::Vertical { l: l2 } });
push(&mut sk, ConstraintKind::Length { l: l2, value: 30.0 });
push(&mut sk, if perpendicular { ConstraintKind::Perpendicular { a: l1, b: l2 } } else { ConstraintKind::Parallel { a: l1, b: l2 } });
let s = seed_of(&sk);
assert!(s.pos[b].is_some_and(|p| p.dist(v(40.0, 0.0)) < 1e-9), "perpendicular {perpendicular}: {s:?}");
let want = if perpendicular { v(30.0, 30.0) } else { v(0.0, 60.0) };
assert!(s.pos[d].is_some_and(|p| p.dist(want) < 1e-9), "perpendicular {perpendicular}: {s:?}");
}
}
/// A dimensioned circle's radius is seeded also when nothing is given to build on.
#[test]
fn a_dimensioned_radius_is_seeded_with_nothing_given() {
let mut sk = Sketch::new();
let circle = sk.add_circle(v(10.0, 5.0), 10.0, None, None).unwrap();
push(&mut sk, ConstraintKind::Diameter { c: circle, value: 30.0 });
let s = seed(&sk, &vec![false; sk.points.len()], None, &[], &mut Budget::full());
assert_eq!(s.radius[circle], Some(15.0));
}
/// A line's end constrained onto a circle is the tangency's touch point only through the
/// solve's point-on-curve index: with it the circle's centre is placed on the normal there,
/// without it only a touching pair of loci is left.
#[test]
fn the_touch_point_comes_from_the_point_on_curve_index() {
let mut sk = Sketch::new();
let l = sk.add_line(v(0.0, 0.0), v(40.0, 12.0), None, None, None).unwrap();
let (a, b) = sk.curves[l].kind.ends().unwrap();
let d = (v(40.0, 12.0) - v(0.0, 0.0)).normalized().unwrap();
let circle = sk.add_circle(v(40.0, 12.0) + d.perp() * 8.5, 8.0, None, None).unwrap();
let CurveKind::Circle { c, .. } = sk.curves[circle].kind else { panic!() };
push(&mut sk, ConstraintKind::Fix { p: a });
push(&mut sk, ConstraintKind::LineAngle { l, value: 12f64.atan2(40.0) });
push(&mut sk, ConstraintKind::Length { l, value: v(40.0, 12.0).len() });
push(&mut sk, ConstraintKind::Radius { c: circle, value: 8.0 });
push(&mut sk, ConstraintKind::Tangent { a: l, b: circle });
push(&mut sk, ConstraintKind::PointOnCurve { p: b, c: circle });
let on_curve = crate::solver::points_on_curves(&sk);
let with = seed(&sk, &given(&sk), None, &on_curve, &mut Budget::full());
assert!(with.placed_by[c].is_some_and(Placement::anchors), "{with:?}");
assert!(with.pos[c].unwrap().dist(v(40.0, 12.0) + d.perp() * 8.0) < 1e-9, "{with:?}");
let without = seed(&sk, &given(&sk), None, &[], &mut Budget::full());
assert!(!without.placed_by[c].is_some_and(Placement::anchors), "{without:?}");
}
/// Many pair clusters each grown by one shared point: a fixed point on every line from another
/// fixed point to a point at a distance from it. Each cluster grows the shared point and then
/// merges with the ground, and every growth requeues the clusters holding that point, so the
/// steps outnumber any multiple of the starting clusters: the merge loop is bounded by its
/// work budget, not by a count of steps, and places every point.
#[test]
fn long_growth_chains_are_not_starved() {
let (o, u) = (v(5.0, -3.0), Vec2::from_angle(0.4));
let mut sk = Sketch::new();
let p = sk.add_point(o, None).unwrap();
let z = sk.add_point(o + u * 12.0, None).unwrap();
for x in [p, z] {
push(&mut sk, ConstraintKind::Fix { p: x });
}
push(&mut sk, ConstraintKind::Distance { p, q: z, value: 12.0 });
let qs: Vec<usize> = (0..40)
.map(|j| {
let d = 20.0 + 3.0 * j as f64;
let q = sk.add_point(o + u * d + v(0.5, -0.4), None).unwrap();
let l = sk.add_line_pts(p, q, None).unwrap();
push(&mut sk, ConstraintKind::Distance { p, q, value: d });
push(&mut sk, ConstraintKind::PointOnCurve { p: z, c: l });
q
})
.collect();
let s = seed_of(&sk);
for (j, q) in qs.iter().enumerate() {
assert_eq!(s.placed_by[*q], Some(Placement::Cluster), "point {j}: {s:?}");
assert!(s.pos[*q].is_some_and(|at| at.dist(o + u * (20.0 + 3.0 * j as f64)) < 1e-9), "point {j}: {s:?}");
}
}
File diff suppressed because it is too large Load Diff
+343
View File
@@ -0,0 +1,343 @@
//! Dimension continuation (`solve_after_value_edit`): the triconnected linkage family (two
//! triangles joined by three links, left to the iteration by the construction) under random
//! dimension edits from its previous solved state, with every failure that remains proven
//! infeasible; and the solve without continuation when nothing it could help with changed.
use solvecraft_geom::Vec2;
use crate::construct::switch;
use crate::model::{ConstraintKind, Sketch};
use crate::solver::{DrivingValues, solve, solve_after_value_edit};
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> f64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(self.0 >> 11) as f64 / (1u64 << 53) as f64
}
fn range(&mut self, lo: f64, hi: f64) -> f64 {
lo + self.next() * (hi - lo)
}
}
/// The linkage's solved layout: base triangle 0 1 2 (0 fixed, 0–1 horizontal), top triangle
/// 3 4 5, links 0–3, 1–4, 2–5.
const LAYOUT: [(f64, f64); 6] = [(0.0, 0.0), (40.0, 0.0), (18.0, 30.0), (8.0, 55.0), (52.0, 48.0), (30.0, 80.0)];
const PAIRS: [(usize, usize); 9] = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)];
/// The linkage in its solved layout with every distance edited by `lo..hi`×, and the values
/// the layout was solved for (per constraint). With `lines`, each triangle side is a line of
/// its own (end points coincident with the corners) with a length dimension.
fn linkage(rng: &mut Lcg, lo: f64, hi: f64, lines: bool) -> (Sketch, DrivingValues) {
let mut sk = Sketch::new();
let ids: Vec<usize> = LAYOUT.iter().map(|p| sk.add_point(Vec2::new(p.0, p.1), None).unwrap()).collect();
sk.add_constraint(ConstraintKind::Fix { p: ids[0] }, None).unwrap();
let l = sk.add_line_pts(ids[0], ids[1], None).unwrap();
sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap();
for (k, (a, b)) in PAIRS.into_iter().enumerate() {
let (pa, pb) = (sk.points[ids[a]].pos, sk.points[ids[b]].pos);
if lines && k < 6 {
let l = sk.add_line(pa, pb, None, None, None).unwrap();
let (s, e) = sk.curves[l].kind.ends().unwrap();
sk.add_constraint(ConstraintKind::Coincident { p: s, q: ids[a] }, None).unwrap();
sk.add_constraint(ConstraintKind::Coincident { p: e, q: ids[b] }, None).unwrap();
sk.add_constraint(ConstraintKind::Length { l, value: pa.dist(pb) }, None).unwrap();
} else {
sk.add_constraint(ConstraintKind::Distance { p: ids[a], q: ids[b], value: pa.dist(pb) }, None).unwrap();
}
}
let old = sk.driving_values();
for c in &mut sk.constraints {
if let Some(v) = c.kind.value() {
c.kind.set_value(v * rng.range(lo, hi));
}
}
(sk, old)
}
/// The nine distances of a linkage sketch, in `PAIRS` order.
fn distances(sk: &Sketch) -> Vec<f64> {
sk.constraints.iter().filter_map(|c| c.kind.value()).collect()
}
/// Turn of both triangles (layout point `i` is sketch point `i + 1`, after the origin).
fn turns(sk: &Sketch) -> [bool; 2] {
let p = |i: usize| sk.points[i + 1].pos;
[(p(1) - p(0)).cross(p(2) - p(0)) > 0.0, (p(4) - p(3)).cross(p(5) - p(3)) > 0.0]
}
/// Both intersections of two circles; `None` when they miss by more than rounding.
fn circles(c0: Vec2, r0: f64, c1: Vec2, r1: f64) -> Option<[Vec2; 2]> {
let d = c0.dist(c1);
let a = (r0 * r0 - r1 * r1 + d * d) / (2.0 * d);
let h2 = r0 * r0 - a * a;
if h2 < -1e-9 * r0 * r0 {
return None;
}
let u = (c1 - c0) / d;
let m = c0 + u * a;
let h = h2.max(0.0).sqrt();
Some([m + u.perp() * h, m - u.perp() * h])
}
/// Whether the linkage with distances `d` (`PAIRS` order) has a real solution, decided by
/// branch and bound. With the base triangle placed (one of its four mirror images) and the
/// top triangle's shape fixed (one of two), a solution is an angle φ of link 0–3 and a turn θ
/// of the top triangle where the links 1–4 and 2–5 have their lengths. Over a cell of
/// half-width w in (φ, θ) the points move at most (d03 + top radius)·w, so a cell whose centre
/// misses both lengths by more than that holds no solution: when every cell is discarded so,
/// none exists. A cell that cannot be discarded down to w ≈ 1e-10 holds one within rounding.
/// `keep`: only solutions with both triangles turning so. `near`: only solutions with every
/// point within the distance of the given layout (discarded the same way).
fn feasible(d: &[f64], keep: Option<[bool; 2]>, near: Option<(&[Vec2], f64)>) -> bool {
let [d01, d12, d20, d34, d45, d53, d03, d14, d25] = d.try_into().unwrap();
let Some(tops) = circles(Vec2::ZERO, d53, Vec2::new(d34, 0.0), d45) else { return false };
let q4 = Vec2::new(d34, 0.0);
let lip = d03 + d34.max(d53);
let far = |i: usize, at: Vec2, slack: f64| near.is_some_and(|(layout, r)| layout[i].dist(at) > r + slack);
for s1 in [1.0, -1.0] {
let p1 = Vec2::new(s1 * d01, 0.0);
let Some(p2s) = circles(Vec2::ZERO, d20, p1, d12) else { return false };
for p2 in p2s.into_iter().filter(|p2| keep.is_none_or(|k| (p1.cross(*p2) > 0.0) == k[0])) {
if far(1, p1, 0.0) || far(2, p2, 0.0) {
continue;
}
for q5 in tops.into_iter().filter(|q5| keep.is_none_or(|k| (q4.cross(*q5) > 0.0) == k[1])) {
let miss = |phi: f64, theta: f64, w: f64| {
let rot = |q: Vec2| Vec2::new(q.x * theta.cos() - q.y * theta.sin(), q.x * theta.sin() + q.y * theta.cos());
let p3 = Vec2::from_angle(phi) * d03;
let (p4, p5) = (p3 + rot(q4), p3 + rot(q5));
if far(3, p3, lip * w) || far(4, p4, lip * w) || far(5, p5, lip * w) {
return f64::INFINITY;
}
(p1.dist(p4) - d14).abs().max((p2.dist(p5) - d25).abs())
};
let n = 64;
let w0 = std::f64::consts::PI / n as f64;
let mut stack: Vec<(f64, f64, f64)> = Vec::new();
for i in 0..n {
for j in 0..n {
stack.push(((2 * i + 1) as f64 * w0, (2 * j + 1) as f64 * w0, w0));
}
}
while let Some((phi, theta, w)) = stack.pop() {
if miss(phi, theta, w) > lip * w * (1.0 + 1e-9) + 1e-12 {
continue;
}
if w < 1e-10 {
return true;
}
let h = w / 2.0;
for (a, b) in [(-h, -h), (-h, h), (h, -h), (h, h)] {
stack.push((phi + a, theta + b, h));
}
}
}
}
}
false
}
/// The edit's distances `t` of the way from old to new.
fn along(old: &[f64], new: &[f64], t: f64) -> Vec<f64> {
old.iter().zip(new).map(|(o, n)| o + t * (n - o)).collect()
}
/// Outcome counts over the family.
#[derive(Debug, Default)]
struct Count {
one_jump_failed: usize,
one_jump_folded: usize,
/// Turned a triangle over (named in the report, for a value edit to refuse).
refused: usize,
/// Failed otherwise.
failed: usize,
/// Of the refused and failed: the new values have no solution.
infeasible: usize,
/// The new values are solved only with a triangle mirrored.
only_mirrored: usize,
/// The sketch's own solution ends on the straight value path: followed in 1/4096 steps
/// it fails or folds at some t < 1, and no orientation-keeping solution for the values at
/// t lies within 1 mm of where it was one step before (a fold of the path, or no solution).
branch_ends: usize,
/// Refused or failed cases none of the above explains: old and new distances.
unexplained: Vec<(Vec<f64>, Vec<f64>)>,
}
/// The linkage's distances set to `d`.
fn set_distances(sk: &mut Sketch, d: &[f64]) {
for (c, v) in sk.constraints.iter_mut().filter(|c| c.kind.value().is_some()).zip(d) {
c.kind.set_value(*v);
}
}
/// The value path from `old` to `new` in 1/4096 steps, each solved by the iteration alone
/// from the previous solution: where it fails or folds a triangle (t and the layout one step
/// before), or `None` when it reaches the new values.
fn fine_path(sk: &Sketch, old: &[f64], new: &[f64]) -> Option<(f64, Vec<Vec2>)> {
let mut s = sk.clone();
for (p, xy) in s.points.iter_mut().skip(1).zip(LAYOUT) {
p.pos = Vec2::new(xy.0, xy.1);
}
let before = turns(&s);
let n = 4096;
for k in 1..=n {
let t = k as f64 / n as f64;
let last: Vec<Vec2> = s.points.iter().skip(1).map(|p| p.pos).collect();
set_distances(&mut s, &along(old, new, t));
let rep = switch::without_differential(|| switch::without_seed(|| solve(&mut s)));
if !rep.ok() || turns(&s) != before {
return Some((t, last));
}
}
None
}
/// `n` edits of the family: the one-jump solve (LM from the layout) against
/// `solve_after_value_edit`, which must solve keeping both triangles' turn or refuse with the
/// layout kept; every refusal and failure is explained (see [`Count`]).
fn run_family(seed: u64, n: usize, lo: f64, hi: f64, lines: bool) -> Count {
let mut rng = Lcg(seed);
let mut count = Count::default();
for _ in 0..n {
let (sk, old) = linkage(&mut rng, lo, hi, lines);
let before = turns(&sk);
let (mut jump, mut cont) = (sk.clone(), sk.clone());
let (a, b) = switch::without_differential(|| (solve(&mut jump), solve_after_value_edit(&mut cont, &old)));
count.one_jump_failed += usize::from(!a.ok());
count.one_jump_folded += usize::from(a.ok() && turns(&jump) != before);
if a.ok() && turns(&jump) == before {
assert_eq!(jump.points, cont.points, "a one-jump solve that keeps its triangles is the result");
}
if b.keeps_orientation() {
assert_eq!(turns(&cont), before, "an accepted edit keeps every triangle's turn");
continue;
}
if b.folded.is_empty() {
count.failed += 1;
assert_eq!(cont.points, sk.points, "a failed solve keeps the layout");
} else {
// Solved, turned over: kept as the best result, named for a command to refuse.
count.refused += 1;
assert!(b.ok() && turns(&cont) != before, "{b:?}");
let dims: Vec<&str> = sk.constraints.iter().filter(|c| c.kind.value().is_some()).map(|c| c.id.as_str()).collect();
assert!(b.folded.iter().all(|id| dims.contains(&id.as_str()) && b.failing.contains(id)), "{b:?}");
}
let new = distances(&sk);
let old: Vec<f64> = sk.constraints.iter().filter_map(|c| old.get(&c.id).copied()).collect();
if !feasible(&new, None, None) {
count.infeasible += 1;
} else if !feasible(&new, Some(before), None) {
count.only_mirrored += 1;
} else if let Some((t, last)) = fine_path(&sk, &old, &new)
&& !feasible(&along(&old, &new, t), Some(before), Some((&last, 1.0)))
{
count.branch_ends += 1;
} else {
count.unexplained.push((old, new));
}
}
count
}
fn report(name: &str, c: &Count) {
eprintln!(
"{name}: one jump failed {} folded {}; continuation refused {} failed {} (infeasible {}, only mirrored {}, branch ends {}, unexplained {})",
c.one_jump_failed,
c.one_jump_folded,
c.refused,
c.failed,
c.infeasible,
c.only_mirrored,
c.branch_ends,
c.unexplained.len()
);
for u in c.unexplained.iter().take(3) {
eprintln!(" {u:?}");
}
}
#[test]
fn feasibility_check_agrees_with_the_layout() {
let d: Vec<f64> = PAIRS.iter().map(|&(a, b)| Vec2::new(LAYOUT[a].0, LAYOUT[a].1).dist(Vec2::new(LAYOUT[b].0, LAYOUT[b].1))).collect();
assert!(feasible(&d, None, None));
let mut long = d.clone();
long[7] = 500.0;
assert!(!feasible(&long, None, None));
let mut flat = d;
flat[0] = flat[1] + flat[2] + 1.0;
assert!(!feasible(&flat, None, None));
}
#[test]
fn linkage_edits_are_solved_by_continuation_or_refused_with_reason() {
// The first 40 edits of the two families of `linkage_family_counter`, with every refusal
// and failure explained.
let c = run_family(2026, 40, 0.8, 1.25, false);
report("linkage 0.8–1.25× (40)", &c);
assert!(c.unexplained.is_empty() && c.branch_ends > 0 && c.failed + c.refused < c.one_jump_failed + c.one_jump_folded, "{c:?}");
let c = run_family(7, 40, 0.5, 2.5, true);
report("linkage 0.5–2.5× lines (40)", &c);
assert!(c.unexplained.is_empty() && c.refused > 0, "{c:?}");
}
/// Measurement, not a gate: the two families, 500 edits each, every refusal and failure
/// explained (run with `--ignored --nocapture`).
#[test]
#[ignore]
fn linkage_family_counter() {
for (seed, lo, hi, lines) in [(2026, 0.8, 1.25, false), (7, 0.5, 2.5, true)] {
let c = run_family(seed, 500, lo, hi, lines);
report(&format!("linkage {lo}–{hi}× lines {lines} (500)"), &c);
assert!(c.unexplained.is_empty(), "{c:?}");
}
}
#[test]
fn edits_from_an_unsolved_state_are_not_continued() {
// Old values the layout is not solved for: no solved state to step from, nor orientations
// to keep; the one-jump result stands.
let mut rng = Lcg(2026);
let mut tried = 0;
for _ in 0..200 {
let (sk, old) = linkage(&mut rng, 0.8, 1.25, false);
let wrong: DrivingValues = old.iter().map(|(k, v)| (k.clone(), v * 1.1)).collect();
let (mut a, mut b) = (sk.clone(), sk.clone());
let (ra, rb) = (solve(&mut a), solve_after_value_edit(&mut b, &wrong));
if ra.ok() {
continue;
}
tried += 1;
assert_eq!(rb.continuations, 0, "{rb:?}");
assert!(rb.folded.is_empty());
assert_eq!(a.points, b.points);
}
assert!(tried > 0);
}
#[test]
fn unchanged_values_solve_as_without_old_values() {
let mut rng = Lcg(1);
for _ in 0..20 {
let (sk, _) = linkage(&mut rng, 0.8, 1.25, true);
let current = sk.driving_values();
let (mut a, mut b) = (sk.clone(), sk.clone());
let (ra, rb) = (solve(&mut a), solve_after_value_edit(&mut b, &current));
assert_eq!((ra.status, ra.iterations, ra.dof), (rb.status, rb.iterations, rb.dof));
assert_eq!(rb.continuations, 0);
assert_eq!(a.points, b.points);
}
}
#[test]
fn hostile_old_values_never_panic() {
let mut rng = Lcg(3);
let (sk, old) = linkage(&mut rng, 0.5, 2.5, false);
let all = |v: f64| -> DrivingValues { old.keys().map(|k| (k.clone(), v)).collect() };
let unknown: DrivingValues = (0..20).map(|i| (format!("nope{i}"), 1.0)).collect();
for bad in [DrivingValues::new(), all(f64::NAN), all(f64::INFINITY), all(-1e300), all(0.0), unknown, old.clone()] {
let mut s = sk.clone();
let rep = solve_after_value_edit(&mut s, &bad);
assert!(rep.max_residual.is_finite());
assert!(s.points.iter().all(|p| p.pos.x.is_finite() && p.pos.y.is_finite()));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -18
View File
@@ -11,6 +11,7 @@
use solvecraft_geom::Vec2;
use crate::model::{ConstraintKind, CurveKind, Sketch};
use crate::union_find::{find, union};
/// What a dimension measures, in a form that can be drawn.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -138,30 +139,14 @@ pub fn encode_text(f: &DimFrame, at: Vec2) -> Vec2 {
pub fn chain_centres(sk: &Sketch) -> Vec<Vec2> {
let n = sk.points.len();
let mut parent: Vec<usize> = (0..n).collect();
fn root(p: &mut [usize], mut i: usize) -> usize {
while let Some(&q) = p.get(i) {
if q == i {
break;
}
let g = p.get(q).copied().unwrap_or(q);
if let Some(s) = p.get_mut(i) {
*s = g;
}
i = q;
}
i
}
for c in &sk.curves {
let ids = c.kind.point_ids();
for w in ids.windows(2) {
let (a, b) = (root(&mut parent, w[0]), root(&mut parent, w[1]));
if let Some(s) = parent.get_mut(a) {
*s = b;
}
union(&mut parent, w[0], w[1]);
}
}
let mut sum = vec![(Vec2::ZERO, 0usize); n];
let roots: Vec<usize> = (0..n).map(|i| root(&mut parent, i)).collect();
let roots: Vec<usize> = (0..n).map(|i| find(&mut parent, i)).collect();
for (i, r) in roots.iter().enumerate() {
if let (Some(s), Some(p)) = (sum.get_mut(*r), sk.points.get(i)) {
s.0 += p.pos;
+22 -1
View File
@@ -5,14 +5,25 @@
//! The solver is our own clean-room implementation (residual functions per constraint, local
//! finite-difference Jacobians, damped Gauss–Newton per connected component, rank analysis by
//! Gaussian elimination). See `plan/architecture.md` §sketch.
//!
//! Before iterating, a constructive seed places every point that two loci on already placed
//! points pin down (circles, rows and columns, lines through placed points, offsets from
//! placed lines, tangency, midpoints, mirrored points, coincidence), on the branch that keeps
//! the orientation the point had before the solve, so dimension edits do not fold the sketch
//! into a mirrored solution or flip an arc out of its corner. The seed only moves the starting
//! point: the iteration still verifies every constraint and the rank analysis still decides
//! the degrees of freedom.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod construct;
mod curves;
mod decompose;
mod dims;
mod freeform_offset;
mod linalg;
mod link;
mod loci;
mod model;
mod offset;
mod offset_topology;
@@ -21,12 +32,14 @@ mod profiles;
mod shape;
mod solver;
mod text;
mod union_find;
mod wire;
pub use curves::{
MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, fit_beziers_mode, spline_end_tangent,
spline_end_tangent_mode, spline_point, spline_point_mode, spline_polyline, spline_polyline_mode,
};
pub use decompose::Budget;
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
@@ -36,10 +49,18 @@ pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRole
pub use periodic::periodic_beziers;
pub use profiles::{CurvePick, Profile, distinct_curves, find_drawn_profiles, find_profiles, merge_regions, profile_at_point, profile_by_curves};
pub use shape::{Shape, intersections};
pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding};
pub use solver::{
Analysis, DrivingValues, Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_after_value_edit, solve_holding, solve_with,
};
pub use text::{MAX_TEXT_CHARS, text_geometry};
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline};
#[cfg(test)]
mod construct_rounds_tests;
#[cfg(test)]
mod construct_tests;
#[cfg(test)]
mod continuation_tests;
#[cfg(test)]
mod tests;
+4 -6
View File
@@ -9,7 +9,7 @@
use serde::{Deserialize, Serialize};
use solvecraft_geom::{Vec2, Vec3};
use crate::model::{Curve, CurveKind, Result, Sketch, SketchError};
use crate::model::{Curve, CurveKind, Result, Sketch, SketchError, in_range, radius_in_range};
/// Points of one link closer than this are merged into one point.
const MERGE_TOL: f64 = 1e-7;
@@ -154,16 +154,14 @@ pub enum LinkGeom {
impl LinkGeom {
fn finite(&self) -> bool {
let ok = |p: &Vec2| p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9;
let ok = |p: &Vec2| in_range(*p);
match self {
LinkGeom::Point(p) => ok(p),
LinkGeom::Line(a, b) => ok(a) && ok(b) && a.dist(*b) > MERGE_TOL,
LinkGeom::Circle(c, r) => ok(c) && r.is_finite() && *r > 1e-9 && *r < 1e9,
LinkGeom::Circle(c, r) => ok(c) && radius_in_range(*r),
LinkGeom::Arc { c, a, b } => ok(c) && ok(a) && ok(b) && c.dist(*a) > 1e-9 && a.dist(*b) > MERGE_TOL,
LinkGeom::Conic { a, apex, b, rho } => ok(a) && ok(apex) && ok(b) && a.dist(*b) > MERGE_TOL && *rho > 1e-6 && *rho < 1.0 - 1e-6,
LinkGeom::Ellipse { c, major, minor } => {
ok(c) && ok(major) && c.dist(*major) > 1e-9 && minor.is_finite() && *minor > 1e-9 && *minor < 1e9
}
LinkGeom::Ellipse { c, major, minor } => ok(c) && ok(major) && c.dist(*major) > 1e-9 && radius_in_range(*minor),
LinkGeom::Spline { pts, control, degree, periodic } => {
(!*periodic || (!*control && *degree == 3 && crate::periodic_beziers(pts).is_ok()))
&& (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len())
File diff suppressed because it is too large Load Diff
+101 -21
View File
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
use solvecraft_geom::{Seg2, Vec2};
use solvecraft_geom::{Seg2, Vec2, Vec3};
use crate::union_find::{find, union};
/// Hard caps that keep hostile input from exhausting memory or time.
pub const MAX_POINTS: usize = 20_000;
@@ -7,6 +9,24 @@ pub const MAX_CONSTRAINTS: usize = 40_000;
/// Curves need points, so this follows the point cap.
pub const MAX_CURVES: usize = MAX_POINTS;
/// Largest coordinate or dimension value (mm) a sketch accepts.
pub(crate) const MAX_COORD: f64 = 1e9;
/// A position a sketch accepts: finite and within [`MAX_COORD`].
pub(crate) fn in_range(p: Vec2) -> bool {
p.is_finite() && p.x.abs() < MAX_COORD && p.y.abs() < MAX_COORD
}
/// [`in_range`] for a 3D position.
pub(crate) fn in_range3(p: Vec3) -> bool {
p.is_finite() && p.x.abs() < MAX_COORD && p.y.abs() < MAX_COORD && p.z.abs() < MAX_COORD
}
/// A radius a sketch accepts: finite, positive and within [`MAX_COORD`].
pub(crate) fn radius_in_range(r: f64) -> bool {
r.is_finite() && r > 1e-9 && r < MAX_COORD
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum SketchError {
#[error("unknown sketch entity `{0}`")]
@@ -367,7 +387,8 @@ impl ConstraintKind {
pub fn refers_to_point(&self, p: usize) -> bool {
self.indices().0.contains(&p)
}
fn indices(&self) -> (Vec<usize>, Vec<usize>) {
/// The points and the curves the constraint refers to.
pub fn indices(&self) -> (Vec<usize>, Vec<usize>) {
use ConstraintKind::*;
match *self {
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![p, q], vec![]),
@@ -607,7 +628,7 @@ impl Sketch {
}
fn check_pos(p: Vec2) -> Result<Vec2> {
if p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 { Ok(p) } else { Err(SketchError::Invalid(format!("point {:?}", [p.x, p.y]))) }
if in_range(p) { Ok(p) } else { Err(SketchError::Invalid(format!("point {:?}", [p.x, p.y]))) }
}
/// Add a free point (or reuse an existing one within `merge_tol` of `p` when `merge_tol > 0`).
@@ -679,7 +700,7 @@ impl Sketch {
}
pub fn add_circle(&mut self, center: Vec2, r: f64, c: Option<usize>, id: Option<&str>) -> Result<usize> {
if !(r.is_finite() && r > 1e-9 && r < 1e9) {
if !radius_in_range(r) {
return Err(SketchError::Invalid(format!("radius {r}")));
}
let id = self.curve_id(id, "c")?;
@@ -703,7 +724,7 @@ impl Sketch {
/// the circle through `p0`).
pub fn add_arc(&mut self, center: Vec2, p0: Vec2, p1: Vec2, pts: [Option<usize>; 3], id: Option<&str>) -> Result<usize> {
let r = center.dist(p0);
if !(r.is_finite() && r > 1e-9 && r < 1e9) {
if !radius_in_range(r) {
return Err(SketchError::Invalid("degenerate arc".into()));
}
let p1 = center + (p1 - center).normalized().ok_or_else(|| SketchError::Invalid("degenerate arc".into()))? * r;
@@ -838,7 +859,7 @@ impl Sketch {
_ => {}
}
if let Some(v) = k.value()
&& (!v.is_finite() || v.abs() > 1e9 || (v < 0.0 && !k.is_angle()))
&& (!v.is_finite() || v.abs() > MAX_COORD || (v < 0.0 && !k.is_angle()))
{
return Err(SketchError::Invalid(format!("dimension value {v}")));
}
@@ -858,7 +879,7 @@ impl Sketch {
|| self.curves.iter().any(|c| c.fixed && c.kind.uses(i))
}
/// [`Sketch::point_locked`] for every point at once.
/// [`Sketch::point_locked`] for every point at once: the points every solve keeps as they are.
pub fn locked_points(&self) -> Vec<bool> {
let mut out: Vec<bool> = self.points.iter().map(|p| p.fixed || p.link.is_some()).collect();
let mut set = |i: usize| {
@@ -887,21 +908,9 @@ impl Sketch {
}
// Points made one by coincident constraints.
let mut root: Vec<usize> = (0..self.points.len()).collect();
fn find(r: &mut [usize], mut i: usize) -> usize {
while let Some(&p) = r.get(i) {
if p == i {
break;
}
i = p;
}
i
}
for c in &self.constraints {
if let ConstraintKind::Coincident { p, q } = c.kind {
let (a, b) = (find(&mut root, p), find(&mut root, q));
if let Some(x) = root.get_mut(a) {
*x = b;
}
union(&mut root, p, q);
}
}
let ends: Vec<Vec<usize>> =
@@ -1007,6 +1016,77 @@ impl Sketch {
out
}
/// Every point a constraint touches: its points and the points of its curves.
pub fn constraint_points(&self, k: &ConstraintKind) -> Vec<usize> {
let (mut pts, curves) = k.indices();
pts.extend(curves.into_iter().flat_map(|c| self.curves.get(c).map(|x| x.kind.point_ids()).unwrap_or_default()));
pts
}
/// What a solve holding `points` and the radii of `radii` moves with them, as the solver
/// couples its variables: (per point, per curve radius). The held points, and the free
/// points and free circle/ellipse radii joined to them through curves and driving
/// constraints without passing through a constant (a held, fixed or projected point or
/// radius couples nothing).
pub fn coupled(&self, points: &[usize], radii: &[usize]) -> (Vec<bool>, Vec<bool>) {
let (n, m) = (self.points.len(), self.curves.len());
// Nodes: points, then curve radii, then one node standing for everything held.
let held = n + m;
let mut root: Vec<usize> = (0..=held).collect();
let mut seed = vec![false; n + m];
for v in points.iter().copied().chain(radii.iter().map(|c| n.saturating_add(*c))) {
if let Some(s) = seed.get_mut(v) {
*s = true;
}
}
let locked = self.locked_points();
let free = |v: usize| {
if seed.get(v).copied().unwrap_or(true) {
return false;
}
match v.checked_sub(n) {
None => !locked.get(v).copied().unwrap_or(true),
Some(c) => self
.curves
.get(c)
.is_some_and(|x| matches!(x.kind, CurveKind::Circle { .. } | CurveKind::Ellipse { .. }) && x.link.is_none() && !x.fixed),
}
};
let curve_nodes = |c: usize| {
let mut v = self.curves.get(c).map(|x| x.kind.point_ids()).unwrap_or_default();
v.push(n + c);
v
};
// What the solver's equations join (see `solve_with`): an arc's centre and ends, a
// periodic spline's first and last points, and each driving constraint's entities. A
// curve alone joins nothing (a circle's radius and centre move independently).
let own = self.curves.iter().filter_map(|x| match &x.kind {
CurveKind::Arc { c, a, b } => Some(vec![*c, *a, *b]),
CurveKind::Spline { pts, periodic: true, .. } => match (pts.first(), pts.last()) {
(Some(a), Some(b)) if a != b => Some(vec![*a, *b]),
_ => None,
},
_ => None,
});
let edges = own.chain(self.constraints.iter().filter(|c| !c.driven).map(|c| {
let (mut v, curves) = c.kind.indices();
v.extend(curves.into_iter().flat_map(curve_nodes));
v
}));
for e in edges {
let mut join = e.iter().copied().filter(|&v| free(v));
let first = if e.iter().any(|v| seed.get(*v).copied().unwrap_or(false)) { Some(held) } else { join.next() };
if let Some(first) = first {
for v in join {
union(&mut root, first, v);
}
}
}
let r = find(&mut root, held);
let mut coupled = |v: usize| seed.get(v).copied().unwrap_or(false) || (free(v) && find(&mut root, v) == r);
((0..n).map(&mut coupled).collect(), (n..n + m).map(&mut coupled).collect())
}
/// Is curve `c` held in place: fixed or projected (linked).
pub fn curve_locked(&self, c: usize) -> bool {
self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some() || self.freeform_offsets.iter().any(|offset| offset.output == c.id))
@@ -1139,7 +1219,7 @@ impl Sketch {
CurveKind::Circle { .. } => "c",
CurveKind::Arc { .. } => "a",
CurveKind::Ellipse { m, c, r } => {
if m == c || !(r.is_finite() && *r > 1e-9 && *r < 1e9) {
if m == c || !radius_in_range(*r) {
return Err(SketchError::Invalid("degenerate ellipse".into()));
}
"e"
+5 -4
View File
@@ -1,4 +1,5 @@
//! Explicit cyclic C2 fit splines. Missing periodic markers retain natural interpolation.
use crate::model::in_range;
use crate::{MAX_SPLINE_POINTS, Sketch, SketchError};
use solvecraft_geom::Vec2;
@@ -11,7 +12,7 @@ pub(crate) fn validate_periodic_points(points: &[Vec2]) -> Result<()> {
if points.len() < 4
|| points.len() > MAX_SPLINE_POINTS
|| points.first() != points.last()
|| points.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9)
|| points.iter().any(|p| !in_range(*p))
|| points.windows(2).any(|p| {
let h = p[0].dist(p[1]);
!h.is_finite() || h <= 1e-9
@@ -89,7 +90,7 @@ pub fn periodic_beziers(points: &[Vec2]) -> Result<Vec<[Vec2; 4]>> {
let t0 = chord - (second[i] * 2.0 + second[next]) * (h[i] * h[i] / 6.0);
let t1 = chord + (second[i] + second[next] * 2.0) * (h[i] * h[i] / 6.0);
let cubic = [points[i], points[i] + t0 / 3.0, points[i + 1] - t1 / 3.0, points[i + 1]];
if cubic.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9) {
if cubic.iter().any(|p| !in_range(*p)) {
return Err(bad());
}
out.push(cubic);
@@ -162,7 +163,7 @@ mod tests {
sk.add_curve(crate::CurveKind::Spline { pts: vec![first, a, b, seam], control: false, degree: 3, periodic: true }, None).unwrap();
sk.add_curve(crate::CurveKind::Spline { pts: vec![seam, c, d, last], control: false, degree: 3, periodic: true }, None).unwrap();
sk.points[first].pos = Vec2::new(0.2, 0.1);
assert!(crate::solve_holding(&mut sk, &crate::Hold { points: vec![first], radii: vec![] }).ok());
assert!(crate::solve_with(&mut sk, &crate::Hold { points: vec![first], radii: vec![] }, None, crate::Analysis::Full, None).ok());
assert_eq!(sk.point(first), sk.point(seam));
assert_eq!(sk.point(first), sk.point(last));
sk.validate_periodic_splines().unwrap();
@@ -176,7 +177,7 @@ mod tests {
let mut collapsed = sk;
collapsed.points[a].pos = collapsed.points[first].pos;
let before = collapsed.clone();
assert!(!crate::solve_holding(&mut collapsed, &crate::Hold { points: vec![first, a], radii: vec![] }).ok());
assert!(!crate::solve_with(&mut collapsed, &crate::Hold { points: vec![first, a], radii: vec![] }, None, crate::Analysis::Full, None).ok());
assert_eq!(collapsed, before);
}
+2 -16
View File
@@ -14,6 +14,7 @@ use solvecraft_geom::{Loop2, Region2, Seg2, Vec2};
use crate::model::{CurveKind, Sketch};
use crate::shape::{Shape, intersections};
use crate::union_find::{find, union};
const MERGE_TOL: f64 = 1e-6;
const MAX_EDGES: usize = 50_000;
@@ -399,16 +400,6 @@ impl Grid {
}
}
fn find(p: &mut [usize], mut i: usize) -> usize {
while let Some(&q) = p.get(i) {
if q == i {
break;
}
i = q;
}
i
}
/// Signed curvature at the start of a segment (left turns positive).
fn curvature(s: &Seg2) -> f64 {
match *s {
@@ -756,12 +747,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec<Profile> {
let mut comp: Vec<usize> = (0..n).collect();
for (e, a) in edges.iter().zip(&alive) {
if *a {
let (ru, rv) = (find(&mut comp, e.u), find(&mut comp, e.v));
if ru != rv
&& let Some(s) = comp.get_mut(rv)
{
*s = ru;
}
union(&mut comp, e.v, e.u);
}
}
+1227 -195
View File
File diff suppressed because it is too large Load Diff
+35 -1
View File
@@ -4,10 +4,44 @@ use solvecraft_geom::Vec2;
use crate::*;
fn v(x: f64, y: f64) -> Vec2 {
pub(crate) fn v(x: f64, y: f64) -> Vec2 {
Vec2::new(x, y)
}
/// Deterministic pseudo-random numbers for the solver harnesses.
pub(crate) struct Lcg(pub u64);
impl Lcg {
pub(crate) fn next(&mut self) -> f64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(self.0 >> 11) as f64 / (1u64 << 53) as f64
}
pub(crate) fn range(&mut self, lo: f64, hi: f64) -> f64 {
lo + self.next() * (hi - lo)
}
/// ±1, evenly.
pub(crate) fn sign(&mut self) -> f64 {
if self.next() < 0.5 { 1.0 } else { -1.0 }
}
}
/// Run `f` with the constructive seed on or off, without the seed-on/seed-off comparison (for
/// tests that measure the difference).
pub(crate) fn seeded<T>(on: bool, f: impl FnOnce() -> T) -> T {
use crate::construct::switch;
switch::without_differential(|| if on { f() } else { switch::without_seed(f) })
}
/// The points that are constants for a plain solve.
pub(crate) fn given(sk: &Sketch) -> Vec<bool> {
crate::solver::held_points(sk, &Hold::default())
}
/// The constructive seed of a plain solve.
pub(crate) fn seed_of(sk: &Sketch) -> crate::construct::Seed {
crate::construct::seed(sk, &given(sk), None, &crate::solver::points_on_curves(sk), &mut crate::decompose::Budget::full())
}
/// Rough rectangle from four lines sharing corner points.
fn rect(sk: &mut Sketch, p: [Vec2; 4]) -> [usize; 4] {
let a = sk.add_point(p[0], None).unwrap();
+43
View File
@@ -0,0 +1,43 @@
//! Disjoint sets over indices (`parent[i]` is `i` for a root), shared by the solver's
//! components, profile regions, the model's point groups and dimension placement's chains.
/// The root of `i`'s set, halving the path on the way. An index outside `parent` is its own
/// root.
pub(crate) fn find(parent: &mut [usize], mut i: usize) -> usize {
while let Some(&p) = parent.get(i) {
if p == i {
break;
}
let gp = parent.get(p).copied().unwrap_or(p);
if let Some(slot) = parent.get_mut(i) {
*slot = gp;
}
i = p;
}
i
}
/// Join the sets of `a` and `b`.
pub(crate) fn union(parent: &mut [usize], a: usize, b: usize) {
let (ra, rb) = (find(parent, a), find(parent, b));
if let Some(slot) = parent.get_mut(ra) {
*slot = rb;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn joins_and_halves_paths() {
let mut p: Vec<usize> = (0..6).collect();
union(&mut p, 0, 1);
union(&mut p, 1, 2);
union(&mut p, 4, 5);
assert_eq!(find(&mut p, 0), find(&mut p, 2));
assert_ne!(find(&mut p, 0), find(&mut p, 4));
assert_eq!(find(&mut p, 3), 3);
assert_eq!(find(&mut p, 99), 99);
}
}
+6 -6
View File
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use solvecraft_geom::Vec3;
use crate::link::{Link, LinkGeom, LinkKind, LinkSource};
use crate::model::{Result, Sketch, SketchError};
use crate::model::{Result, Sketch, SketchError, in_range3};
/// Most points one wire may have.
pub const MAX_WIRE_POINTS: usize = 100_000;
@@ -97,7 +97,7 @@ fn clean(w: &[Vec3]) -> Option<Vec<Vec3>> {
}
let mut out: Vec<Vec3> = Vec::new();
for p in w {
if !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9) {
if !in_range3(*p) {
return None;
}
if out.last().is_none_or(|l| l.dist(*p) > 1e-9) {
@@ -118,14 +118,14 @@ fn drawn_polyline(fit: &[Vec3]) -> Result<Vec<Vec3>> {
if fit.is_empty() || fit.len() > MAX_FIT_POINTS {
return Err(SketchError::Invalid(format!("a 3D curve needs 1 to {MAX_FIT_POINTS} points")));
}
if fit.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) {
if fit.iter().any(|p| !in_range3(*p)) {
return Err(SketchError::Invalid("3D point out of range".into()));
}
if fit.windows(2).any(|w| w[0].dist(w[1]) < 1e-9) {
return Err(SketchError::Invalid("the points of a 3D curve must differ".into()));
}
let pts = fit_polyline(fit);
if pts.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) {
if pts.iter().any(|p| !in_range3(*p)) {
return Err(SketchError::Invalid("3D spline samples out of range".into()));
}
Ok(pts)
@@ -163,7 +163,7 @@ impl Sketch {
|| w.pts.len() > MAX_WIRE_POINTS
|| w.fit.len() > MAX_FIT_POINTS
|| (w.pts.len() == 1 && w.fit.len() != 1)
|| w.pts.iter().chain(&w.fit).any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9))
|| w.pts.iter().chain(&w.fit).any(|p| !in_range3(*p))
{
return Err(SketchError::Invalid("3D curve arrays, ids or point bounds are invalid".into()));
}
@@ -241,7 +241,7 @@ impl Sketch {
/// Move a fit point of a drawn 3D curve, and every other drawn point at the same place
/// (curves drawn end to end stay joined). Returns how many points moved.
pub fn move_drawn_point(&mut self, wire: &str, index: usize, to: Vec3) -> Result<usize> {
if !(to.is_finite() && to.x.abs() < 1e9 && to.y.abs() < 1e9 && to.z.abs() < 1e9) {
if !in_range3(to) {
return Err(SketchError::Invalid("3D point out of range".into()));
}
let w = self.wire_index(wire).ok_or_else(|| SketchError::Unknown(wire.into()))?;
+2 -1
View File
@@ -193,7 +193,8 @@ pub fn picks(app: &SolveApp, command: &str, params: &Value) -> Vec<Vec3> {
// A profile feature without picks takes the profiles of the active (else the last) sketch:
// point at them.
if out.is_empty() && matches!(id, "solid.extrude" | "solid.revolve") {
let ss = app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty()));
let ss =
app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty()).map(|s| &**s));
if let Some(ss) = ss {
out.extend(ss.profiles.iter().take(4).map(|p| ss.plane.to_world(p.region.interior_point())));
}
+5 -5
View File
@@ -69,9 +69,9 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
let offset = if start.trim().is_empty() { 0.0 } else { app.session.doc.eval(start, ValueKind::Length).ok()? };
(at + n * (from + offset), Some(n))
}
Kind::Fillet { .. } | Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal),
// Shell thickness grows into the body.
Kind::Shell { .. } => (at, normal.map(|n| -n)),
Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal),
// Fillet radii, chamfer distances and shell thickness grow into the body.
Kind::Fillet { .. } | Kind::Shell { .. } => (at, normal.map(|n| -n)),
Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)),
Kind::Plastic(_) => (at, normal),
Kind::PatternRect { .. } => (at, d.inputs.get(1).and_then(|i| i.items.first()).and_then(|x| crate::dialogs::axis_of(app, x)).map(|a| a.1)),
@@ -98,8 +98,8 @@ fn reference_height(app: &SolveApp, d: &Dialog, label: &str, plane: &solvecraft_
Some(solvecraft_engine::doc::object_height(&app.session.world_state(), plane, centre, p))
}
/// The outward direction between the two faces at an edge (where a fillet's radius arrow
/// points).
/// The outward direction between the two faces at an edge (a fillet's radius arrow points the
/// other way, into the body).
fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
let faces = m.edge_faces.get(index)?;
let mut sum = Vec3::ZERO;
+1 -1
View File
@@ -1326,7 +1326,7 @@ fn track_hold(app: &mut SolveApp, ctx: &egui::Context) {
i.time,
)
});
let free = app.tool.is_none() && app.dialog.is_none();
let free = app.tool.is_none() && app.dialog.is_none() && app.preferences.nav != crate::prefs::OPENSCAD_NAV;
if pressed && free && app.viewport.context_menu.is_none() && app.viewport.mouse.is_some() {
app.menu.press = pos.map(|p| (p, time));
}
+54 -4
View File
@@ -2243,15 +2243,30 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne
let id = egui::Id::new(("pexpr", &p.name));
let mut e = ui.data(|dd| dd.get_temp::<String>(id)).unwrap_or_else(|| p.expr.clone());
let r = ui.add(egui::TextEdit::singleline(&mut e).desired_width(160.0));
let refused_id = egui::Id::new(("prefused", &p.name));
if r.changed() {
ui.data_mut(|dd| dd.insert_temp(id, e.clone()));
// Typing again clears the reason a previous change was refused.
ui.data_mut(|dd| {
dd.insert_temp(id, e.clone());
dd.remove::<String>(refused_id);
});
}
if r.lost_focus() && e != p.expr {
let _ = app.run("parameters.change", json!({"name": p.name, "expression": e}));
ui.data_mut(|dd| dd.remove::<String>(id));
match change_param(app, &p.name, &e) {
Ok(()) => ui.data_mut(|dd| {
dd.remove::<String>(id);
dd.remove::<String>(refused_id);
}),
// Refused (it would leave a sketch unsolved, say): the typed expression
// stays in the field with the reason beside it, nothing silently reverts.
Err(why) => ui.data_mut(|dd| {
dd.insert_temp(refused_id, why);
}),
}
}
let refused = ui.data(|dd| dd.get_temp::<String>(refused_id));
let v = vals.get(&p.name).map(|v| if p.unit == "deg" { format!("{:.3}°", v.v.to_degrees()) } else { format!("{:.4} {}", v.v, p.unit) });
match (v, errs.get(&p.name)) {
match (v, errs.get(&p.name).or(refused.as_ref())) {
(_, Some(e)) => ui.label(RichText::new(e).color(t.error)),
(Some(v), None) => ui.label(RichText::new(v).color(t.text_dim)),
_ => ui.label(""),
@@ -2273,6 +2288,14 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne
ui.end_row();
}
/// Set a parameter's expression from the parameters table: `Err` with the message to show
/// beside the field when the change is refused (the document keeps its values).
pub(crate) fn change_param(app: &mut SolveApp, name: &str, expression: &str) -> Result<(), String> {
app.run("parameters.change", json!({"name": name, "expression": expression}))
.map(|_| ())
.map_err(|e| crate::friendly_error("parameters.change", &e))
}
/// Start a sketch on a picked plane or planar face and turn the view to face it.
pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> {
let plane = match sel {
@@ -3751,3 +3774,30 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
d.error = None;
Some(d)
}
#[cfg(test)]
mod param_refusal_tests {
use super::*;
use solvecraft_engine::Session;
#[test]
fn a_refused_parameter_change_shows_why() {
// A triangle whose sides are parameters: a side longer than the other two together
// has no solution, so the change is refused and the table shows the reason.
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY", "name": "Tri"})).unwrap();
s.execute("sketch.line", &json!({"points": [[0, 0], [40, 0], [20, 30]], "closed": true})).unwrap();
for l in ["l1", "l2", "l3"] {
s.execute("sketch.dimension", &json!({"entities": [l]})).unwrap();
}
s.execute("sketch.finish", &json!({})).unwrap();
let name = s.doc.params.iter().find(|p| p.model).map(|p| p.name.clone()).unwrap();
let before = s.doc.param(&name).map(|p| p.expr.clone());
let mut app = SolveApp::new(s, Default::default());
let why = change_param(&mut app, &name, "500 mm").unwrap_err();
assert!(why.contains("Tri") && why.contains("conflict"), "{why}");
assert_eq!(app.session.doc.param(&name).map(|p| p.expr.clone()), before);
assert!(app.status.as_ref().is_some_and(|(m, _, error)| *error && m.contains("Tri")), "{:?}", app.status);
assert!(change_param(&mut app, &name, "41 mm").is_ok());
}
}
+69 -8
View File
@@ -63,6 +63,9 @@ struct DimensionDraft {
preview_doc: Arc<solvecraft_engine::doc::Document>,
preview: Arc<solvecraft_engine::doc::ModelState>,
error: Option<String>,
/// A dimension that would over-constrain the sketch, placed as a driven (reference) one:
/// it has no parameter and no value to edit (#320).
driven: bool,
}
impl DimensionDraft {
@@ -102,8 +105,15 @@ pub fn cancel() {
/// Infer the measured value through the same command as scripts, without creating a real
/// parameter or an undo entry. Only accepting the editor executes the command on the design.
pub fn begin_dimension(app: &SolveApp, entities: Vec<String>, at: Vec2) -> Result<(), String> {
/// A driven dimension has no value to accept, so it is added at once.
pub fn begin_dimension(app: &mut SolveApp, entities: Vec<String>, at: Vec2) -> Result<(), String> {
let (param, expr, draft) = dimension_draft(app, entities, at)?;
if draft.driven {
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
app.run("sketch.dimension", json!({"entities": draft.entities, "text_at": [at.x, at.y], "driven": true}))?;
app.set_status("That dimension would over-constrain the sketch, so it was added as a driven (reference) dimension", false);
return Ok(());
}
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft) }));
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
Ok(())
@@ -114,22 +124,41 @@ fn dimension_draft(app: &SolveApp, entities: Vec<String>, at: Vec2) -> Result<(S
if entities.is_empty() || entities.len() > 2 || !at.x.is_finite() || !at.y.is_finite() {
return Err("Pick one edge or two endpoints, then place the dimension".into());
}
// A dimension the sketch can't take (it would over-constrain it) becomes a driven one,
// which only measures; when that fails too, the first refusal is the one to report.
draft_of(app, sketch, &entities, at, false).or_else(|error| draft_of(app, sketch, &entities, at, true).map_err(|_| error))
}
/// The draft of one dimension: its parameter (for a driven one, its constraint id, since it
/// has no parameter), the parameter's expression, and the scratch session it was added in.
fn draft_of(app: &SolveApp, sketch: u64, entities: &[String], at: Vec2, driven: bool) -> Result<(String, String, DimensionDraft), String> {
let mut scratch = app.session.scratch();
let result = scratch.execute("sketch.dimension", &json!({"entities": entities, "text_at": [at.x, at.y]})).map_err(|e| e.to_string())?;
let param = result.get("param").and_then(serde_json::Value::as_str).ok_or("This dimension has no editable value")?.to_string();
let expr = scratch.doc.param(&param).ok_or("The dimension value is unavailable")?.expr.clone();
let result =
scratch.execute("sketch.dimension", &json!({"entities": entities, "text_at": [at.x, at.y], "driven": driven})).map_err(|e| e.to_string())?;
let (param, expr) = if driven {
let before =
app.session.doc.sketch(sketch).ok().map(|sk| sk.constraints.iter().map(|c| c.id.clone()).collect::<Vec<_>>()).unwrap_or_default();
let added =
scratch.doc.sketch(sketch).ok().and_then(|sk| sk.constraints.iter().find(|c| c.driven && !before.contains(&c.id)).map(|c| c.id.clone()));
(added.ok_or("The driven dimension is unavailable")?, String::new())
} else {
let param = result.get("param").and_then(serde_json::Value::as_str).ok_or("This dimension has no editable value")?.to_string();
let expr = scratch.doc.param(&param).ok_or("The dimension value is unavailable")?.expr.clone();
(param, expr)
};
let draft = DimensionDraft {
baseline: app.session.doc.clone(),
sketch,
component: app.session.active_component,
path: app.session.active_occurrence_path.clone(),
pending: app.session.pending_moves.clone(),
entities,
entities: entities.to_vec(),
at,
checked_expr: expr.clone(),
preview: scratch.world_state(),
preview_doc: scratch.doc.clone(),
error: None,
driven,
};
if !draft.preview.sketch(sketch).is_some_and(|solved| solved.report.ok()) {
return Err("That dimension conflicts with the sketch constraints".into());
@@ -258,9 +287,10 @@ pub fn double_click(app: &mut SolveApp, pos: Pos2) -> bool {
}
/// Start dragging the text of the dimension under `press` (true: a dimension took the drag).
pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2) -> bool {
/// Its lines take it only off sketch geometry (`on_geometry`: a point or curve is under `press`).
pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2, on_geometry: bool) -> bool {
let Some(d) = DRAWN.with(|d| {
let id = hit(press)?;
let id = hit_text(press).or_else(|| if on_geometry { None } else { hit(press) })?;
d.borrow().iter().find(|x| x.id == id).cloned()
}) else {
return false;
@@ -367,7 +397,9 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
let draft_centres = draft_sketch.map(|solved| chain_centres(&solved.sketch));
let mut constraints = sk.constraints.iter().map(|c| (ss, c, false)).collect::<Vec<_>>();
if let (Some(source), Some((param, _))) = (draft_sketch, draft.as_ref()) {
constraints.extend(source.sketch.constraints.iter().filter(|c| c.param.as_deref() == Some(param.as_str())).map(|c| (source, c, true)));
// A driven draft is keyed by its constraint id (it has no parameter).
let ours = |c: &&solvecraft_engine::sketch::Constraint| c.param.as_deref() == Some(param.as_str()) || (c.driven && c.id == *param);
constraints.extend(source.sketch.constraints.iter().filter(ours).map(|c| (source, c, true)));
}
for (source, c, is_draft) in constraints {
if ss.sketch.view.hide_dimensions && !is_draft {
@@ -604,6 +636,35 @@ mod tests {
cancel();
}
/// Placing a dimension the sketch can't take previews it as driven: in the draft it is a
/// driven constraint with no parameter, labelled in parentheses (#320).
#[test]
fn an_over_constraining_placement_previews_a_driven_dimension() {
cancel();
let mut h = crate::scenario::Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false})).unwrap();
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":"20 mm"})).unwrap();
h.call("ui.view", json!({"view":"top"}));
let baseline = h.app.session.doc.to_json();
h.step(&json!({"start":"sketch.dimension"})).unwrap();
h.step(&json!({"click":{"sketch":[20,10]}})).unwrap();
h.step(&json!({"move":{"sketch":[20,20]}})).unwrap();
h.settle();
let (id, draft) = placement_preview(&h.app).expect("an over-constraining dimension still previews");
assert!(draft.driven);
let sk = &draft.preview.sketch(draft.sketch).unwrap().sketch;
let c = sk.constraints.iter().find(|c| c.id == id).unwrap();
assert!(c.driven && c.param.is_none(), "{c:?}");
assert!(matches!(c.kind, ConstraintKind::Length {value,..} if (value-20.).abs()<1e-6));
assert_eq!(label(&c.kind, None, c.driven, "mm", 2), "(20.00)");
assert!(!editing());
assert_eq!(h.app.session.doc.to_json(), baseline);
h.step(&json!({"key":"Escape"})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
cancel();
}
#[test]
fn placement_follows_real_pointer_without_editing_or_mutating_design() {
cancel();
@@ -323,3 +323,58 @@ fn inch_dimension_entry_survives_unit_changes_undo_and_reopen() {
assert_eq!(reopened.world_state().sketch(sketch).unwrap().sketch, before);
crate::dim_view::cancel();
}
/// Placing the dimension of two unaligned points above them gives the horizontal distance,
/// beside them the vertical one, and across from them the distance between them (#257).
#[test]
fn placing_a_two_point_dimension_picks_horizontal_vertical_or_aligned() {
// What each placement fixes at 10 mm: the X span, the Y span, or the length.
let spans = |a: solvecraft_engine::geom::Vec2, b: solvecraft_engine::geom::Vec2| [(b.x - a.x).abs(), (b.y - a.y).abs(), a.dist(b)];
for (at, measured) in [([6., 25.], 0), ([-10., 1.], 1), ([-5., 12.], 2)] {
let mut h = line([12., 16.]);
place(&mut h, &[[0., 0.], [12., 16.]], at);
type_value(&mut h, "10 mm");
h.step(&json!({"key":"Enter"})).unwrap();
assert!(!crate::dim_view::editing());
let state = h.app.session.world_state();
let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
let a = sk.point(sk.resolve_point("l1.start").unwrap()).unwrap();
let b = sk.point(sk.resolve_point("l1.end").unwrap()).unwrap();
let spans = spans(a, b);
assert!((spans[measured] - 10.0).abs() < 1e-6, "placed at {at:?}: {a:?} {b:?}");
// A horizontal or vertical dimension leaves the other span as drawn.
if measured < 2 {
assert!((spans[1 - measured] - [12.0, 16.0][1 - measured]).abs() < 1e-6, "placed at {at:?}: {a:?} {b:?}");
}
}
}
/// A dimension that would over-constrain the sketch is placed as a driven (reference) one
/// instead of failing: added with no parameter or value editor, and removed again by one
/// undo (#320).
#[test]
fn an_over_constraining_dimension_is_placed_as_a_driven_one() {
let mut h = line([20., 0.]);
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":"20 mm"})).unwrap();
h.settle();
let original = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let params = h.app.session.doc.params.len();
h.step(&json!({"start":"sketch.dimension"})).unwrap();
h.step(&json!({"click":{"world":[10,0,0]}})).unwrap();
h.step(&json!({"move":{"world":[10,6,0]}})).unwrap();
h.settle();
assert_eq!(crate::dim_view::drawn().len(), 1, "the preview is not a selectable dimension");
h.step(&json!({"click":{"world":[10,6,0]}})).unwrap();
assert!(!crate::dim_view::editing(), "a driven dimension has no value to edit");
assert_eq!(h.app.session.undo.len(), undo + 1);
assert_eq!(h.app.session.doc.params.len(), params, "a driven dimension adds no parameter");
let state = h.app.session.world_state();
let solved = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert!(solved.report.ok());
let dims: Vec<_> = solved.sketch.constraints.iter().filter(|c| c.kind.is_dimension()).collect();
assert_eq!(dims.len(), 2);
assert_eq!(dims.iter().filter(|c| c.driven && c.param.is_none()).count(), 1, "{dims:?}");
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), original);
}
+7 -7
View File
@@ -7,7 +7,7 @@
use std::cell::{Cell, RefCell};
use egui::{Align2, FontId, Shape, Stroke};
use serde_json::json;
use serde_json::{Value, json};
use solvecraft_engine::geom::Vec2;
use solvecraft_engine::sketch::{CurveKind, Sketch};
@@ -195,18 +195,18 @@ pub fn moving_points(sk: &Sketch, entity: &str) -> (Vec<usize>, Vec<usize>) {
(own, ex)
}
/// The drag let go: the constraint of the snap it ended on (none for lining up).
pub fn release(app: &mut SolveApp) {
let Some(s) = current() else { return };
/// The drag let go: the constraint of the snap it ended on (none for lining up), as
/// `sketch.drag_end` takes it.
pub fn release() -> Option<Value> {
let s = current()?;
clear();
let (cmd, params) = match &s.target {
Target::Point(t) => ("sketch.constraint.coincident", json!({"a": s.point, "b": t})),
Target::Mid(l) => ("sketch.constraint.midpoint", json!({"point": s.point, "line": l})),
Target::On(c) => ("sketch.constraint.coincident", json!({"a": s.point, "b": c})),
Target::Align => return,
Target::Align => return None,
};
// A snap that would over-constrain the sketch is simply not kept.
let _ = app.session.execute(cmd, &params);
Some(json!({"command": cmd, "params": params}))
}
/// The snap of the drag in progress: its dashed guides, a ring on the target and its label.

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