Compare commits

..
Author SHA1 Message Date
Lasse Lian b685b7301f Preferences: reverse horizontal pan direction (#2007) (#2047)
Thanks @lasselian.
2026-10-18 03:12:23 -04:00
cronos 8f768b65c8 Avoid per-frame mesh copies in both timeline layouts (#1907)
Thanks @crono2250.
2026-10-18 02:34:05 -04:00
JIJINBEI 932efbf341 Sketch drag: move only what the constraints need (#2060)
Thanks @jijinbei.
2026-10-18 02:23:24 -04:00
Karl Von Richter b8d7b42808 contributing(docs): clarify draft PR and bot claims (#2317)
Document the maintainer-confirmed workflow: open a draft PR to establish ownership, then comment /claim to mark the issue as taken. Align AGENTS.md and CONTRIBUTING.md and distinguish the existing takeover policy from the bot's automatic cleanup deadlines.

Signed-off-by: Karl Von Richter<karl@embeint.com>
2026-10-18 08:11:29 +02:00
cronos a8e8d82f90 Add regression coverage for mouse picking during 3D mouse navigation (#1786)
Thanks @crono2250.
2026-10-18 01:33:09 -04:00
DuBearte 33cd031701 Sketch: the arc's sweep box no longer covers the end-point pick (#2201) (#2242)
Thanks @DuBearte.
2026-10-18 01:10:53 -04:00
selune 6422ef84d5 Drawing: share length display formatting (#2054) (#2086)
Thanks @0xlillith.
2026-10-18 00:29:15 -04:00
selune 7c9f8a5895 Drawing: validate section geometry contracts (#2053) (#2085)
Thanks @0xlillith.
2026-10-18 00:29:12 -04:00
bherbruckandClaude Opus 5.5 36b6496ad5 CI: give fork builds on the untrusted pool 90 minutes (#2339)
Fork Build tests jobs were hitting the 45-minute limit while uploading their archive, after
the build had finished, and every one had to start over.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:28:18 -04:00
48986041ed Add complete Italian UI localization (#1941)
* Add complete Italian UI localization

Translate all 1509 English catalog entries originally, register Italiano, guard complete coverage and exercise Italian labels and UI rendering.

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

* i18n: Italian is a partial catalog

it.json lacks 19 messages main has gained since this branch began, so as a
complete catalog it would fail every_complete_catalog_carries_every_english_message.
Like the other community languages, Italian is now partial: its gaps fall
back to English and new labels don't require an Italian string. The docs
line goes back to main's wording, with Italian among the partial catalogs.

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

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Ben Herbruck <bherbruck@herbrucks.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:28:04 -04:00
bherbruckandClaude Opus 5.5 c39bd23d07 CI: run all four test shards on the trusted self-hosted runners (#2220)
The trusted runs' test archive is still built on GitHub's ubuntu-24.04
runners; minisforum's newer glibc runs it unchanged, so shards 3-4 can join
1-2 on the self-hosted runners.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:11:35 -04:00
bherbruckandClaude Opus 5.5 a33c5a7861 CI: restore main's Rust cache in fork pull request jobs on the untrusted runners (#2254)
Fork pull requests now run on the untrusted self-hosted runners, which start
every job empty. Let them restore main's rust-cache like GitHub's runners do
(save-if stays main only, so a fork job never writes a cache).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:11:31 -04:00
5c95b0496d Spanish (es-ES): catch up with en.json (#2101)
Brings assets/locales/es.json back to full coverage of en.json: 60 new
messages translated (motion study, CAM, DXF export progress, sweep rails,
threads, grid follows the view) plus 13 terminology revisions (Loft,
Boss, timeline marker, viewport, ellipsis), all following the glossary.

It also unifies the snap label, as asked in the review of this pull
request: ui.snap (Snap) and cmd.sketch_snap (Snap Inference) now share a
single name, "Referencia a objetos".

Built on top of current main, so the three messages upstream had already
translated in es.json (Direction, In plane, Normal to sketch) are kept.

Co-authored-by: carlymx <24634598+carlymx@users.noreply.github.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-17 21:53:14 -04:00
72aa77dabd French: catch up with main (10 messages) (#2203)
The Clearance report, Explode Text, the continuity legend, the Grid follows
the view preference and inspect.silhouette, so fr.json covers en.json again
(1518/1518).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-17 21:51:06 -04:00
243db6cb8f README: list the crash_reports MCP tool (#1821) (#1830)
* claim: #1821

* claim: #1821

* docs: README lists the crash_reports MCP tool (#1821)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-17 21:48:19 -04:00
83c31d77c4 surface.boundary: surfaces through boundary curves and curve networks (#763) (#2129)
* feat(geom): surfaces through curve networks, and spline spaces to build them

Adds spline_space (clamped B-spline spaces, Hermite interpolation, exact
embedding by Bézier degree elevation and knot insertion, a monotone cubic
for reparameterising) and network (loft, Coons and Gordon surfaces through
curves in one or two directions, with G1/G2 cross-boundary conditions
against a neighbouring face). Part of #763.

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

* feat(kernel): boundary surfaces from sketch curves, 3D curves and edges

kernel::boundary_network turns sketch chains, 3D sketch polylines and edge chains
into network curves, gives G1 frames of the faces along open edges
(EdgeFaces), builds the B-spline face (sides split at chain vertices so a
join shares the body's edges) and joins it into a surface body by stitching.
G2 is refused for now (not supported yet). Part of #763.

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

* fix(kernel): second derivatives of B-spline surfaces in the vendored truck-geometry

BSplineSurface::uuder and vvder multiplied the second differences by p
instead of p·(p − 1), as BSplineCurve::der2 does, so every surface of
degree p ≥ 3 (NURBS too, through its homogeneous surface) reported
1/(p − 1) of its second derivative: a cubic surface's curvature read as
half. This is why a G2 boundary surface on a cylinder measured half the
curvature it carries (#763). New tests hold both to finite differences
for degrees 2 to 5.

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

* feat(kernel): G2 boundary surfaces against a body's face

The network always carried the face's curvature across a G2 edge; the
refusal came from measuring it with truck's halved surface second
derivatives (fixed in the previous commit). EdgeFaces now reads the
face's second fundamental form from the exact second derivatives
instead of differencing the normal. A new test holds the curvature
across a cylinder's edge to the cylinder's within 0.1%, also with a
sheared far curve; G3 is refused (a network holds up to G2).

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

* test(kernel): a boundary patch with straight corners measures its own area

A stadium's arcs meet its lines at 180°, so the Coons patch is singular
at its corners. From such a corner the reference mesher's boundary walk
ran onto the B-spline's extrapolation past its domain (a planar spline
passes through every point of its plane there), and the face measured
289 mm² for its 158.5 (#763). The vendored mesher's bounded projection
(nearest_projection, #2043) keeps the walk on the domain; this holds it
there, by the faces, the measuring mesh and the display mesh. The
display path (#1653) was never affected.

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

* feat(geom,kernel): three-sided boundary surfaces (one side collapsed to a point)

A network curve may be a PointCurve: the apex where the two sides beside
it meet. The curves through it cross it at the point itself, its row of
the net collapses there, and the kernel's face leaves that side out of
its loop (three edges, one vertex at the apex). One point per patch, as
a side; refused elsewhere, when it moves, or with a continuity
condition. Tests: a triangle and a quarter disc filled flat with their
analytic areas (40 mm², 25π), by geom and by the kernel's faces and
meshes.

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

* feat(doc): boundary surfaces through curve networks and loops of three or four

FeatureKind::SurfaceNetwork saves a network as `u` and `v` curves, each
a sketch chain (planar or 3D), a chain of a body's edges with an
optional G1/G2 continuity, or a point (the apex of a three-sided
patch), and an `operation` that can join the surface into the body of
its edges. A SurfaceBoundary of three or four sketch curves, refused
until now, builds as the flat Coons patch of its loop (three collapse
the side opposite the first curve). The format change is one pending
file; feature-walk goldens record the new variant's sketches, bodies
and points.

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

* feat(engine): surface.boundary through loops of three or four and curve networks

The command keeps its loop form (two to four curves of one sketch; three
and four now build the flat patch of the loop, crown refused there) and
gains a network form: `u` and `v`, each curve a sketch curve id, a
{sketch?, curves} chain (planar or 3D), a {body, edges, continuity?}
edge chain (g1/g2 with the face beside it) or a {point} for a
three-sided patch, with `operation` new or join. Edits may move between
the two forms (timeline.redefine accepts the command now that it has a
dialog).

Tests: analytic areas of loop patches and of a hyperbolic paraboloid
bounded by four 3D lines (every mesh vertex on z = xy/10), a loft
across two sketches, G1 and G2 measured across a cylinder's edge with
inspect.continuity (angle < 0.1°; curvature jump 1e-16 with G2 against
0.027 with G1), 26 hostile networks refused without panicking, and u/v
added to hostile_params_never_panic.

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

* feat(ui): Boundary Surface dialog

Curves and Cross curves take sketch curves and edges (picks that meet
end to end in one sketch or body are one chain); Continuity and
Operation (New Body or Join). Two to four sketch curves of one sketch
with nothing across are the loop patch; anything else is a network.
Edit Feature brings either back (per-side orders, crown and stations
the dialog does not show are kept). Strings in en, ja and zh.

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

* docs: ROADMAP M8 and the bridge patch's notes for boundary networks (#763)

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

* test(oracle): a boundary surface through a loop, thickened (#763)

Case 83: four sketch curves (a square's three sides and a half circle
for its top) make one boundary surface, thickened 2 mm; analytic values
from tools/oracle-analytic/cases_surfaces.py (V = 2A, area 2A + 2P,
centroid by moments). Recipes gain boundary_surface, thicken and remove.

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

* refactor: boundary surfaces, review cleanups

Project onto the face once per derivative evaluation (the frame is
passed to natural() and reused by scale()), keep each track's
finite-difference step instead of rescanning its table, export the
kernel's edge-pick and chain caps and the default station count for
the engine, doc and dialog to share, map TooLarge spline errors to
KernelError::TooLarge, reuse the engine's operation parser, and skip
the dialog's sketch lookup when the picks are no loop.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-17 21:47:38 -04:00
bherbruckandClaude Opus 5.5 ae6a33bf96 CI: run the list checks inside CI's repository-checks job on PRs (#2292)
Lists and Packaging lint each took their own runner slot on every PR push. The list checks now run
in the existing checks job; Lists stays on main. Packaging lint skips PRs that only touch icons.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:00:34 -04:00
bherbruckandClaude Opus 5.5 675c865431 CI: fork PRs' light jobs on GitHub, their heavy jobs on the self-hosted pool (#2284)
With Windows off pull requests, GitHub's 20 runners sat idle while every
fork job queued for the self-hosted pool. Select checks, repository
checks, Clippy, doc tests and the summary go back to GitHub's runners;
Build tests and the test shards stay on the isolated self-hosted pool.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:49:16 -04:00
bherbruckandClaude Opus 5.5 2f31138f9d CI: a pull request fast lane; the heavy checks run on main (#2278)
Pull requests now run the repository checks, Clippy, the affected
crates' tests in 2 shards, and the doc tests. Windows, the reference
tests and the robustness goldens run on pushes to main and in the
nightly, where a break is fixed forward. This is the split Bevy, Zed
and Ruff use, and it cuts each pull request's CI to about a third.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:25:13 -04:00
8c503ad1be Revert "CI: run untrusted and GitHub-hosted Linux jobs on Blacksmith's Ubuntu 24.04 runners (#2237)" (#2277)
Blacksmith isn't provisioning runners: its dashboard says the account
"requires additional verification", so every job labelled blacksmith-*
stays queued with no runner. Fork PRs' Linux jobs go back to the
self-hosted-untrusted pool (#2249/#2252) and the rest to GitHub's runners
until Blacksmith verifies the account; then #2237 can be re-applied.

This reverts commit dc40f051b1.


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

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:23:57 -04:00
dc40f051b1 CI: run untrusted and GitHub-hosted Linux jobs on Blacksmith's Ubuntu 24.04 runners (#2237)
Fork pull requests, the nightly run, Run workflow, the test archive and
test shards 3-4 move from ubuntu-24.04 to blacksmith-<N>vcpu-ubuntu-2404
(16 vCPU for Clippy and the test archive, 8 for test shards, doc tests,
reference tests and cross-compiles). Trusted runs keep the self-hosted
routing. Blacksmith runners count as self-hosted to GitHub, so steps now
tell our own machines apart by runner name.

TEMPORARY: this branch routes its own run like a fork pull request.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
2026-10-17 20:04:20 -04:00
bherbruckandClaude Opus 5.5 712b427a9c CI: a fork PR's Select checks and ci jobs run on the untrusted runners too (#2252)
Every fork run waited in GitHub's shared queue just to start. The gate
and summary jobs now run on the isolated runners as well; the local
output stays false for forks so later jobs never pick the trusted pool.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:37:10 -04:00
bherbruckandClaude Opus 5.5 e48e553758 CI: fork PRs run their Linux jobs on the isolated self-hosted runners (#2249)
Fork PRs were waiting hours for GitHub's shared runners. Their Linux
jobs (checks, Clippy, Build tests, the test shards, Doc tests, Reference
tests) now run on the ephemeral, network-jailed self-hosted-untrusted
runners, without the trusted runners' warm build directories.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:27:13 -04:00
github-actions[bot] dc8edead22 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-17 23:22:01 +00:00
JIJINBEI 2acc344aa4 Sketch: show open and untied profile ends (#2000) (#2001)
Thanks @jijinbei.
2026-10-17 18:52:12 -04:00
Koschtetnix 487aa64e10 Multi profile extrusion performance (#2064)
Thanks @Koschtetnix.
2026-10-17 18:30:52 -04:00
Mikalai Kukhta 180e62c981 Macos space mouse support (#1815)
Thanks @NecroKote.
2026-10-17 18:15:15 -04:00
Karl Von Richter 11a196e6f8 sketch(feat): add dimension references and parameter tools (#1548)
Show each dimension's parameter name while creating or editing it, and let
users insert another driving dimension at the expression cursor or selection.
Use the available editor width and keep Apply aligned with the right edge.

Add a tools dropdown with Measure, named user parameter expressions, and
access to the existing parameter table for adding and editing parameters.
Preserve the unfinished dimension expression while either window is open.

Cover reference insertion, dependent updates, parameter selection, dialog
round trips, applying and cancellation with UI integration tests. Update the
parameter guide.

Signed-off-by: Karl Von Richter<karl@embeint.com>
2026-10-18 00:00:35 +02:00
Thomas Kroll a11446fd17 Web: a launch behind an expired sign-in shows the sign-in instead of the kept build (#2151)
Thanks @nofuturekid.
2026-10-17 17:54:29 -04:00
DuBearte 7a3221d2dd Updates: 0.11–0.14 must install 0.15 by hand once (#2136) (#2141)
Thanks @DuBearte.
2026-10-17 17:28:04 -04:00
bherbruck e8776d8583 Toolbar fills the available width instead of leaving a gap (#2108) (#2109)
The toolbar gives up room in small steps (labels shorten, then icons only, then panels hand buttons to their drop-down one at a time, then trailing panels go to the » menu) instead of collapsing whole panels, so medium-width windows no longer lose icons or leave a blank gap.
2026-10-17 17:08:51 -04:00
Pablo López cb857f1fa7 Rib/Web: grow along the sketch normal (#2056) (#2057)
Thanks @pablopeza.
2026-10-17 14:15:03 -04:00
bherbruckandClaude Opus 5.5 e347f820e6 Fix red main: give inspect.silhouette an icon; scroll to Colour vision in its scenario (#2134)
Preferences > Graphics grew a row (Grid follows the view, #2035), so the Colour vision
control now sits below the fold of the fixed-size page; the scenario scrolls to it.
The page itself already scrolls for real users.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:53:01 -04:00
rpromas 6597ffe072 Self-host the web app with Docker; publish the image to ghcr.io (#2033)
Thanks @rpromas.
2026-10-17 13:49:07 -04:00
Mateusz Sych 4a70c03deb Revolve: a profile that comes back to the axis along a curve revolves right, and cuts with it work (#1853) (#1866)
Thanks @SychPL.
2026-10-17 13:38:41 -04:00
fkirill f480898158 Extrude: join extensions of ribs on curved bases (#2059) (#2082)
Thanks @fkirill.
2026-10-17 13:33:21 -04:00
Moritz 9be8bb5e6b Fix Steam Frame STEP trims and spline projection (#1974) (#2043)
Thanks @moritzuehling.
2026-10-17 13:33:17 -04:00
JEREMY 843fa0dac6 Grid: hysteresis and no wall swap at the 45° crossing behind Grid follows the view (#1993) (#2050)
Thanks @jeremymiribung-blip.
2026-10-17 12:51:38 -04:00
fkirill 2c924fd385 Sweep: close circular tools before cutting a torus (#2005) (#2010)
Thanks @fkirill.
2026-10-17 12:46:21 -04:00
bherbruckandClaude Opus 5.5 8be736c33e CI: trusted pull requests use the self-hosted runners too (#2012)
Pull requests from branches of this repository share the work with the self-hosted runners like main does; fork pull requests stay on GitHub's runners. Select checks and ci run on a light self-hosted runner; the repository checks, Clippy, doc tests, reference tests and test shards 1-2 on the fast ones. The test archive is still built on GitHub's runners (glibc).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:00:38 -04:00
selune f3494efbbb Fix complete Japanese and Chinese grid-follow-view messages (#2092)
* claim: complete Japanese and Chinese grid-follow-view messages

* fix(i18n): complete grid-follow-view messages in Japanese and Chinese
2026-10-17 17:56:39 +02:00
bherbruckandClaude Opus 5.5 6fe16b790b Link the Discord invite directly (#2094)
Some security filters block the solidcraft.io/discord redirect.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:55:21 -04:00
bherbruck 979614e60f Release 0.15.0 (#2039)
Version 0.15.0; pending design-format changes folded into format 26.
2026-10-17 11:55:05 -04:00
Nathan DaneandClaude Sonnet 5.5 8a31738fe5 docs: ribbon redesign proposal (#1439)
* docs: ribbon UX redesign proposal (mockups and notes)

Seven mockup images and a short description of the proposed ribbon shell, with floating and
compact layout options. Pictures only; no app changes. The sketch variant with tab locks is
left out.

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

* docs: update the UX mockups to the current command set

Adds the Sketch Pattern panel, Perforate, new joints and motion commands, Fair and Match Surface,
and the Render workspace; the workspace switcher now matches the app (Design and Render available,
Animation, Simulation, Manufacture and Drawing later). Command map covers all 389 commands.

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

* docs: draw the UX mockups with the reviewed icon SVGs and accents

Ribbon, browser, history and navigation icons now come from the icon system in #853, with the
Sketch tab in the Sketch green and constraints in red.

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

* docs: address review on the UX proposal README

Describe the current toolbar in our own terms, and list only Design and Render as the workspaces in
scope.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 11:36:43 -04:00
f7343e9161 CI: a main push the merge queue already tested runs only repository checks (#1860)
The merge queue runs the full suite on exactly the commit it then puts on
main, and main's push run then ran the full suite again on the same files.
Select checks now asks the Actions API whether a passing merge_group CI run
exists for the pushed commit; if so the push runs only the repository checks.
A push that skipped the queue, or an API that cannot answer, still runs
everything. The nightly still runs the whole suite.

Pull requests also run less: the reference tests only when solidcraft-cli (or
anything it depends on) is affected, and doc tests only for the affected
packages that have a library. The ci check requires the reference tests only
when they were selected.


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

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:21:08 -04:00
156 changed files with 13001 additions and 515 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Questions and chat
url: https://solidcraft.io/discord
url: https://discord.gg/FtU4NQNVDH
about: Ask questions and talk with users and contributors on the SolidCraft Discord.
+1
View File
@@ -1,3 +1,4 @@
self-hosted-runner:
labels:
- self-hosted-fast
- self-hosted-light
+119 -40
View File
@@ -10,14 +10,22 @@
# and app startup) run on pull requests only when what they test changes (the GPU crate, the app,
# the viewport's GPU glue, the graphics dependencies, their scripts or this file); main and
# nightly always run them. Only main saves the build caches; pull requests restore them.
# The merge queue runs the full suite on exactly the commit it then puts on main, so a push to
# main that a passing merge-queue run already tested runs only the repository checks; a push
# that skipped the queue still runs everything. Pull requests run the reference tests when the
# command line (or anything it depends on) changes, and doc tests for the affected packages.
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
# Pushes to main and merge-queue groups use both pools: Clippy, the doc tests and test shards
# 1-2 run on the self-hosted runner (`self-hosted-fast`, warm build directories via
# scripts/ci/warm-target.sh), everything else on GitHub's runners. The test archive is built on
# GitHub's runners: the self-hosted machine has a newer glibc, so its binaries wouldn't start
# on ubuntu-24.04, while the archive runs there unchanged (same /home/runner/work checkout
# path). Pull requests, the nightly run and Run workflow use GitHub's runners only; the
# self-hosted runner refuses any job that isn't a push to main or a merge-queue group.
# Trusted runs use both pools: pushes to main, merge-queue groups and pull requests from a branch
# of this repository (people with write access; fork pull requests never run on this machine).
# There, Select checks and ci run on the self-hosted light runner (`self-hosted-light`, quick
# jobs, so they never wait behind a build), and the repository checks, Clippy, the doc tests,
# the reference tests and all four test shards on the self-hosted runners (`self-hosted-fast`, warm
# build directories via scripts/ci/warm-target.sh). Fork PRs' Linux jobs run on the isolated, ephemeral
# `self-hosted-untrusted` runners (no warm caches); everything else runs on GitHub's runners.
# The test archive is always built on GitHub's runners: the self-hosted machine has a newer
# glibc, so its binaries wouldn't start on ubuntu-24.04, while the archive runs there unchanged
# (same /home/runner/work checkout path). Fork pull requests, the nightly run and Run workflow
# use GitHub's runners only; the self-hosted runners refuse any other job.
name: CI
on:
@@ -63,13 +71,23 @@ jobs:
changes:
name: Select checks
runs-on: ubuntu-24.04
# Trusted runs (see the top of this file) use the self-hosted runners.
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-light' || 'ubuntu-24.04' }}
permissions:
contents: read
# To ask whether the merge queue already tested a commit pushed to main.
actions: read
outputs:
native: ${{ steps.paths.outputs.native }}
kernel: ${{ steps.paths.outputs.kernel }}
windows: ${{ steps.paths.outputs.windows }}
full: ${{ steps.paths.outputs.full }}
packages: ${{ steps.paths.outputs.packages }}
oracle: ${{ steps.paths.outputs.oracle }}
# Whether the other Linux jobs use the self-hosted runners too.
# Trusted self-hosted runs only: a fork PR's checks run on the untrusted pool and must never
# route the jobs after them to the trusted runners.
local: ${{ runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -80,13 +98,30 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PUSH_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
run: |
native=false
kernel=false
windows=false
full=true
packages=
if [ "$EVENT_NAME" != pull_request ]; then
if [ "$EVENT_NAME" = push ]; then
# The queue lands the very commit its merge_group run tested. If that run passed,
# testing the push again proves nothing new; otherwise (a merge that skipped the
# queue, or the API cannot answer) test everything.
# curl and grep only: Select checks of a push runs on the self-hosted runner.
tested=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$REPO/actions/workflows/ci.yml/runs?event=merge_group&head_sha=$PUSH_SHA&status=success&per_page=1" \
| grep -o '"total_count": *[0-9]*' | grep -o '[0-9]*$' || echo 0)
if [ "${tested:-0}" -gt 0 ]; then
echo "The merge queue already tested $PUSH_SHA: repository checks only."
else
native=true
windows=true
fi
elif [ "$EVENT_NAME" != pull_request ]; then
native=true
windows=true
else
@@ -121,24 +156,40 @@ jobs:
# Code changed but nothing was selected: be safe and test everything.
if [ "$native" = true ] && [ "$full" != true ] && [ -z "$packages" ]; then full=true; fi
fi
# The reference tests replay recipes through solidcraft-cli: needed when it, or anything
# it depends on, is selected.
oracle=$native
if [ "$EVENT_NAME" = pull_request ] && [ "$full" != true ]; then
case " $packages " in *" solidcraft-cli "*) ;; *) oracle=false ;; esac
fi
# The pull request fast lane: Windows and the reference tests run on main (and the nightly),
# not on every pull request push; a break there is fixed forward on main.
if [ "$EVENT_NAME" = pull_request ]; then windows=false; oracle=false; fi
{
echo "native=$native"
echo "kernel=$kernel"
echo "windows=$windows"
echo "oracle=$oracle"
echo "full=$full"
echo "packages=$packages"
} >> "$GITHUB_OUTPUT"
checks:
name: Format and repository checks
runs-on: ubuntu-24.04
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-fast' || 'ubuntu-24.04' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Warm build directory (self-hosted runner)
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
run: scripts/ci/warm-target.sh checks
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: repo-checks
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -152,6 +203,10 @@ jobs:
run: cargo xtask licences --check
- name: Dependency layers
run: cargo xtask layers
- name: Sorted lists
run: cargo xtask lists
- name: Design format pending changes
run: bash scripts/test-format-pending.sh
- name: Web app offline use and updates (headless Chrome)
run: cargo xtask web-offline-test
- name: Affected-package selection self-test
@@ -163,18 +218,19 @@ jobs:
name: Native Linux Clippy
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && 'self-hosted-fast' || 'ubuntu-24.04' }}
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Warm build directory (self-hosted runner)
if: runner.environment == 'self-hosted'
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
run: scripts/ci/warm-target.sh clippy
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
# The self-hosted runner keeps its build directory and Cargo home instead.
- if: runner.environment == 'github-hosted'
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-clippy
@@ -193,8 +249,9 @@ jobs:
name: Build tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04' }}
# Fork builds compile inside gVisor on shared boxes; give them room to finish and upload.
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 90 || 45 }}
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
@@ -236,12 +293,13 @@ jobs:
tests:
name: Native Linux tests (${{ matrix.shard }}/4)
needs: [changes, build-tests]
runs-on: ${{ ((github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && matrix.shard <= 2) && 'self-hosted-fast' || 'ubuntu-24.04' }}
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
# Pull requests test their affected crates in 2 shards; main and the nightly use 4.
shard: ${{ fromJSON(github.event_name == 'pull_request' && '[1, 2]' || '[1, 2, 3, 4]') }}
env:
# Pictures (snapshots, screenshots, ui.render, the viewport's offscreen tests) are drawn on
# the GPU. The runner has none: Mesa's lavapipe (software Vulkan) draws them, and a test that
@@ -266,6 +324,7 @@ jobs:
env:
EVENT_NAME: ${{ github.event_name }}
SHARD: ${{ matrix.shard }}
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
run: |
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
all=()
@@ -277,17 +336,7 @@ jobs:
# 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" --no-tests=pass "${all[@]}"
- name: Robustness goldens sample (pull requests that touch the kernel)
# Every 8th seed of tests/robustness/goldens.json (each seed is a test), a quarter per
# shard; pushes to main and the nightly run every seed in the step above.
if: github.event_name == 'pull_request' && needs.changes.outputs.kernel == 'true'
env:
SHARD: ${{ matrix.shard }}
run: |
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --ignore-default-filter --partition "slice:$SHARD/4" --no-tests=pass \
-E 'package(solidcraft-engine) & test(/^tests::robustness_goldens::seed_(00|08|16|24|32|40|48|56|64|72|80|88)$/)'
--profile ci --partition "slice:$SHARD/$SHARDS" --no-tests=pass "${all[@]}"
- name: Upload test timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
@@ -392,7 +441,7 @@ jobs:
name: Doc tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && 'self-hosted-fast' || 'ubuntu-24.04' }}
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
timeout-minutes: 45
env:
CARGO_INCREMENTAL: "0"
@@ -402,11 +451,12 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Warm build directory (self-hosted runner)
if: runner.environment == 'self-hosted'
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
run: scripts/ci/warm-target.sh doc-tests
- uses: dtolnay/rust-toolchain@stable
# The self-hosted runner keeps its build directory and Cargo home instead.
- if: runner.environment == 'github-hosted'
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: doc-tests
@@ -416,13 +466,33 @@ jobs:
with:
version: v0.16.0
# nextest does not run doc tests.
- run: cargo test --workspace --profile ci --locked --doc
- name: Doc tests
env:
FULL: ${{ needs.changes.outputs.full }}
PACKAGES: ${{ needs.changes.outputs.packages }}
run: |
# Pull requests test the affected packages that have a library (only libraries have doc
# tests); everything else, the whole workspace.
scope=(--workspace)
if [ "$FULL" != true ]; then
scope=()
libs=$(cargo metadata --no-deps --format-version 1 --locked \
| python3 -I -c 'import json, sys; print(" ".join(p["name"] for p in json.load(sys.stdin)["packages"] if any("lib" in t["kind"] for t in p["targets"])))')
for p in $PACKAGES; do
case " $libs " in *" $p "*) scope+=(-p "$p") ;; esac
done
fi
if [ ${#scope[@]} -eq 0 ]; then
echo "No affected package has a library: no doc tests to run."
exit 0
fi
cargo test "${scope[@]}" --profile ci --locked --doc
oracle:
name: Reference tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
if: needs.changes.outputs.oracle == 'true'
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
timeout-minutes: 60
env:
CARGO_INCREMENTAL: "0"
@@ -431,8 +501,14 @@ jobs:
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Warm build directory (self-hosted runner)
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
run: scripts/ci/warm-target.sh oracle
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: oracle
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -443,7 +519,7 @@ jobs:
- name: Build the command line
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/solidcraft-cli
run: cargo xtask oracle --cli "${CARGO_TARGET_DIR:-target}/ci/solidcraft-cli"
- name: Upload the report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
@@ -512,7 +588,7 @@ jobs:
ci:
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
if: always()
runs-on: ubuntu-24.04
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'ubuntu-24.04' }}
steps:
- name: Verify required checks
env:
@@ -520,6 +596,7 @@ jobs:
CHECKS: ${{ needs.checks.result }}
NATIVE: ${{ needs.changes.outputs.native }}
WINDOWS: ${{ needs.changes.outputs.windows }}
ORACLE_RUN: ${{ needs.changes.outputs.oracle }}
CLIPPY: ${{ needs.clippy.result }}
BUILD: ${{ needs.build-tests.result }}
TESTS: ${{ needs.tests.result }}
@@ -535,6 +612,8 @@ jobs:
test "$BUILD" = success
test "$TESTS" = success
test "$DOCS" = success
fi
if [ "$ORACLE_RUN" = true ]; then
test "$ORACLE" = success
fi
if [ "$WINDOWS" = true ]; then
+105
View File
@@ -0,0 +1,105 @@
# The web app as a container image on GitHub's container registry, for self-hosting
# (packaging/docker; the book's "Self-hosting with Docker"):
#
# ghcr.io/<owner>/solidcraft-web:latest the latest published release
# ghcr.io/<owner>/solidcraft-web:0.14.0 a release (also 0.14); pre-releases get only their version
# ghcr.io/<owner>/solidcraft-web:edge main
#
# For linux/amd64 and linux/arm64. The site is built here, as Pages and Release build it, and
# passed to the Dockerfile as its `site` stage. Pull requests that touch the image build and
# smoke-test it without publishing. Releases published by an agent or maintainer trigger this;
# the nightly pre-releases (made with GITHUB_TOKEN) do not.
name: Docker
on:
push:
branches: [main]
release:
types: [published]
pull_request:
paths: ["packaging/docker/**", ".github/workflows/docker.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
TRUNK_VERSION: v0.21.14
jobs:
image:
if: ${{ !github.event.repository.private }}
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: docker-web
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install trunk
run: |
mkdir -p "$HOME/.local/bin"
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Build the web app
working-directory: apps/solidcraft-web
run: trunk build --release
- name: Image name
id: image
# Registry names are lower case; a fork's owner may not be.
run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/solidcraft-web" >> "$GITHUB_OUTPUT"
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
id: meta
with:
images: ${{ steps.image.outputs.name }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=edge,branch=main
type=ref,event=pr
labels: |
org.opencontainers.image.title=SolidCraft (web)
org.opencontainers.image.description=SolidCraft, open-source parametric CAD, in the browser: the web app served by nginx on port 8080
org.opencontainers.image.licenses=MIT OR Apache-2.0
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Build for the smoke test
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: packaging/docker/Dockerfile
build-contexts: site=dist/web
platforms: linux/amd64
load: true
tags: solidcraft-web:test
- name: Smoke test
run: bash packaging/docker/smoke-test.sh solidcraft-web:test
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish
if: github.event_name != 'pull_request'
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: packaging/docker/Dockerfile
build-contexts: site=dist/web
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
+2 -1
View File
@@ -1,9 +1,10 @@
# Files many pull requests add to are kept so that additions don't conflict: one file per change
# or one sorted line per item (CONTRIBUTING.md, "Lists many changes add to"). Fast, no build.
# Pull requests get the same checks in CI's "Format and repository checks" job (one runner slot
# per PR instead of three); this copy keeps main honest.
name: Lists
on:
pull_request:
push:
branches: [main]
+1 -1
View File
@@ -8,7 +8,7 @@ on:
branches: [main]
paths: [".github/workflows/**", "packaging/**", "assets/**"]
pull_request:
paths: [".github/workflows/**", "packaging/**", "assets/**"]
paths: [".github/workflows/**", "packaging/**", "assets/platforms/**"]
workflow_dispatch:
permissions:
+5 -1
View File
@@ -132,9 +132,13 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
issue wins**; if yours lost, close it. Don't claim by commenting or by editing files.
issue wins**; if yours lost, close it. A comment or file edit alone does not replace the draft PR.
- After opening the draft PR, comment `/claim` on the issue so the bot records the claimant and
adds the `in progress` label. **Do both:** the draft PR establishes ownership and `/claim` marks
the issue as taken. Reference `#N` in the PR so the bot can find it.
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
comment on it first, then open your own claim (you may start from its branch).
This takeover policy is separate from the bot's automatic cleanup deadlines in CONTRIBUTING.md.
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
and wait while it is over 24; build with `nice`.
+9 -1
View File
@@ -32,6 +32,13 @@ Editable drawing code, native projects and shipped exports are collected under `
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
| docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon (2× playback) | MIT OR Apache-2.0 |
| docs/screenshots/rover-build.mp4 | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon | MIT OR Apache-2.0 |
| docs/design/ux-redesign/model-floating.png | SolidCraft contributors | original UX mockup (Model tab, floating layout) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
| docs/design/ux-redesign/model-compact.png | SolidCraft contributors | original UX mockup (Model tab, compact docked layout) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
| docs/design/ux-redesign/sketch.png | SolidCraft contributors | original UX mockup (Sketch tab) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
| docs/design/ux-redesign/sheet-metal.png | SolidCraft contributors | original UX mockup (Sheet Metal contextual tab) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
| docs/design/ux-redesign/start-page.png | SolidCraft contributors | original UX mockup (start page (File view)) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
| docs/design/ux-redesign/workspaces.png | SolidCraft contributors | original UX mockup (workspace switcher) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
| docs/design/ux-redesign/command-map.png | SolidCraft contributors | original UX mockup (ribbon command map) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
| assets/branding/web/source/og.png | SolidCraft contributors | original 1280 × 640 social preview card (the SolidCraft mark, name and tagline), served by the docs site for link previews | MIT OR Apache-2.0 |
| docs/book/src/generated/sample-plate.png | SolidCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/bracket.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
@@ -86,6 +93,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/gl.json | Pablo López (pablopeza) | Galician translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/it.json | SolidCraft contributors | Original Italian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
@@ -455,7 +463,7 @@ copied into SolidCraft.
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) |
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters (vendor/README.md) |
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters; surface second derivatives (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
## Vendored renderer compatibility fix
+4 -2
View File
@@ -27,8 +27,10 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
## Never crash
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate
(which denies it instead), have no safe API: two functions in
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in
`crates/threedx-client` (which deny it instead), have no safe API: the macOS-only
`crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time
and calls through its function pointers), two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers) and the Windows-only
+13 -8
View File
@@ -10,7 +10,7 @@ Help is welcome, whether that's a one-line fix or owning a whole area. This page
build it, how the code is laid out, the few rules that aren't negotiable, and where help is
needed most.
Questions, ideas or want to say what you're working on? Join the [Discord](https://solidcraft.io/discord).
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
## Get it running (about 10 minutes on a fast machine)
@@ -110,9 +110,10 @@ The [README](README.md#architecture) has the layer table. The short version:
`cargo xtask assets` enforces this.
4. **Never crash.** People trust a CAD app with their work.
- Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data.
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events)
and `apps/solidcraft/src/native_crash.rs` (Windows native crash capture), and the compiler
denies it.
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events),
`apps/solidcraft/src/native_crash.rs` (Windows native crash capture) and
`crates/threedx-client/src/macos.rs` (the 3Dconnexion driver's client API on macOS), and the
compiler denies it.
- An unsupported case returns an error ("not supported yet: …"); it never panics.
- Every call into truck goes through `solidcraft_kernel::guard`.
- Every crash fix comes with a regression test.
@@ -257,6 +258,9 @@ and a fresh CI run:
open a **draft** PR titled `<summary> (#N)`, e.g. `Fillet: variable radius along a chain (#123)`.
- **The lowest-numbered open PR for an issue wins.** If yours lost, close it (or offer to help
on the other one).
- Then comment `/claim` on the issue so the bot records the claimant and adds the `in progress`
label. **Do both:** the draft PR establishes ownership and `/claim` marks the issue as taken.
Reference `#N` in the PR so the bot can find it; a comment alone does not replace the draft PR.
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
comment on it first, then open your own claim.
- Mark the PR ready for review when it is done.
@@ -266,10 +270,11 @@ and a fresh CI run:
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
the expected ones).
Comment `/claim` on an issue to take it: you are assigned, it gets the `in progress` label, and
the bot replies with the deadline. Open a draft PR referencing the issue within 72 hours, or the
claim lapses and the issue is freed again; a claim with a PR is freed after 14 days without
pushes. `/unclaim` releases it early (only you or a maintainer can). Issues a maintainer has
The `/claim` bot assigns you, adds the `in progress` label, and replies with its automatic cleanup
deadline. It frees a claim with no linked PR after 72 hours, or a claim with a PR after 14 days
without pushes. These automatic cleanup deadlines are separate from the daily progress and
72-hour takeover policy above; follow the draft PR then `/claim` sequence before starting work.
`/unclaim` releases the bot claim early (only you or a maintainer can). Issues a maintainer has
taken are never freed automatically.
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
Generated
+5
View File
@@ -4069,6 +4069,7 @@ dependencies = [
"ron",
"rustls",
"serde_json",
"solidcraft-3dx-client",
"solidcraft-engine",
"solidcraft-space-mouse",
"solidcraft-ui-egui",
@@ -4077,6 +4078,10 @@ dependencies = [
"winit",
]
[[package]]
name = "solidcraft-3dx-client"
version = "0.15.0"
[[package]]
name = "solidcraft-cli"
version = "0.15.0"
+1
View File
@@ -26,6 +26,7 @@ solidcraft-ui-egui = { path = "crates/ui-egui" }
solidcraft-space-mouse = { path = "crates/space-mouse" }
solidcraft-mcp = { path = "crates/mcp" }
solidcraft-derive = { path = "crates/derive" }
solidcraft-3dx-client = { path = "crates/threedx-client" }
# foundation
thiserror = "2"
+17 -6
View File
@@ -26,7 +26,7 @@
<a href="https://solidcraft.io/download">Download</a> ·
<a href="https://solidcraft.io/app">Try it in the browser</a> ·
<a href="https://docs.solidcraft.io/">Documentation</a> ·
<a href="https://solidcraft.io/discord">Discord</a> ·
<a href="https://discord.gg/FtU4NQNVDH">Discord</a> ·
<a href="https://github.com/bherbruck/solidcore">SolidCore</a>
</p>
@@ -35,7 +35,7 @@
<a href="https://github.com/tangency-labs/solidcraft/releases/latest"><img src="https://img.shields.io/github/v/release/tangency-labs/solidcraft?label=release" alt="Latest release" /></a>
<a href="https://github.com/tangency-labs/solidcraft/releases"><img src="https://img.shields.io/github/downloads/tangency-labs/solidcraft/total?label=downloads" alt="Downloads" /></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/tangency-labs/solidcraft" alt="Licence" /></a>
<a href="https://solidcraft.io/discord"><img src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/FtU4NQNVDH"><img src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://remade.wiki/projects/solidcraft/"><img src="https://remade.wiki/badges/listed-on-remade-classic.svg" alt="Listed on Remade" /></a>
<a href="https://github.com/tangency-labs/solidcraft/stargazers"><img src="https://img.shields.io/github/stars/tangency-labs/solidcraft?style=flat" alt="Stars" /></a>
</p>
@@ -63,6 +63,10 @@
The downloads aren't signed yet. On Windows, if SmartScreen warns you, choose **More info**, then
**Run anyway**. On macOS, the first time, right-click SolidCraft in Applications and choose **Open**.
**On SolidCraft 0.11 to 0.14?** Its **Update now** can't install 0.15.0 or later, because the
releases moved to a new address. Download the new version once from the links above; after that,
updating from inside SolidCraft works again ([details](https://docs.solidcraft.io/guide/updates.html#updating-from-011-to-014)).
Older versions, checksums and other formats are on the [releases page](https://github.com/tangency-labs/solidcraft/releases).
Install details, upgrading from earlier builds and building from source are in
[Getting started](https://docs.solidcraft.io/guide/getting-started.html).
@@ -90,7 +94,7 @@ Install details, upgrading from earlier builds and building from source are in
**Documentation:** the [SolidCraft book](https://docs.solidcraft.io/) (user guide,
developer guide and command reference; sources in [docs/book](docs/book)).
**Community:** chat with users and contributors on [Discord](https://solidcraft.io/discord).
**Community:** chat with users and contributors on [Discord](https://discord.gg/FtU4NQNVDH).
**Status: early but usable for simple parts.** 75 of 76 public reference parts
match their analytic or FreeCAD-computed volume, area, centre of mass and bounding box
@@ -215,7 +219,7 @@ unaccepted artwork Open; run `cargo xtask icons` to check the collection.
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
On Windows and Linux, SolidCraft detects a connected 3D mouse through its HID multi-axis
On Windows, macOS and Linux, SolidCraft detects a connected 3D mouse through its HID multi-axis
controller interface: a 3Dconnexion SpaceNavigator or SpaceMouse, or a homemade device that
declares itself one. 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
@@ -253,6 +257,13 @@ lays them out: translation and rotation in separate reports (3Dconnexion's wired
in one (its wireless ones and most homemade devices), after the buttons, or narrower than 16 bits.
Each axis is scaled from the range the descriptor declares.
On macOS SolidCraft talks to the 3Dconnexion driver (3DxWare) when it is installed, through the
driver's client API (`crates/threedx-client`; the framework is loaded at run time, never bundled).
The driver keeps the device to itself, and sends it only to a program it can identify: SolidCraft
as an `.app` (the packaged app). Without the driver, or with its helper not running, SolidCraft reads the device through IOKit like on Windows.
SolidCraft does not follow 3DxWare's profile on macOS. Its default is 3DxWare's object mode.
In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or
WebGL2 with `?webgl`; `?sample` opens the sample part).
@@ -278,8 +289,8 @@ A script is a JSON list of commands, the same ones the UI runs:
stdio. Headless it runs its own design session; with `--connect 127.0.0.1:PORT` it drives the app
started with `solidcraft --control PORT`, so you can watch the agent model. Tools:
`list_commands`, `execute`, `batch`, `inspect_design`, `measure`, `body_topology`,
`set_parameter`, `screenshot` (returns the image), `export`, `undo`, `redo`, `new_design`, `open`,
`save`. Tool calls reach files only in the open design's folder (when it lies inside your home
`set_parameter`, `screenshot` (returns the image), `export`, `crash_reports`, `undo`, `redo`,
`new_design`, `open`, `save`. Tool calls reach files only in the open design's folder (when it lies inside your home
folder, never home itself or a hidden folder), SolidCraft's recovery folder and folders given with
`--allow-path DIR`. Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Details, client
configuration and an example session: [docs/mcp.md](docs/mcp.md).
+25 -1
View File
@@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
@@ -73,6 +73,11 @@ hierarchies are shared; bounded desktop preparation and occlusion use up to eigh
Curves are approximated; drawing sheets,
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
Drawing foundations: [RFC #860](https://github.com/tangency-labs/solidcraft/issues/860)
and the [foundation ledger](docs/drawing-foundations.md) track the bounded implementation
packages. The first geometry increment defines validated section records in a plane-local
frame; the ledger separates that contract from section producers and the Drawing workspace.
Desktop view DXF export runs in the file-operation worker with five-stage progress and
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
@@ -274,6 +279,25 @@ completed drawings publish atomically. The browser still exports synchronously.
## 0.2.1 critical fixes
- Separated multi-profile Join/Cut extrusions into one solid target batch their tools using
conservative construction bounds, avoiding a Boolean rebuild for every profile. Live
extrusion previews build only their tools; confirmation validates the final Boolean.
- Coplanar selected body faces share one linked sketch and one extrusion. Automatic Join/Cut
keeps the evaluator's participant scope, including parts of an empty imported component. Desktop OK/Enter
computes on a scratch session in the background, with atomic acceptance and cancellation.
Planar section stitching projects lines/arcs analytically and removes duplicate endpoints.
- Imported rotated face groups can check all full start-face seams in one guarded
construction. Separated tools may batch a Join when each meets the same single body;
nearby uninvolved bodies retain the original contact decisions. Regression comparisons
cover all four extrusion operations.
- Inward multi-face cuts retain every eligible target and subtract separated tools together.
Safe interior classification handles roundoff at shared imported line/arc endpoints.
Straight extrusion feedback caches existing face/profile triangles and updates the display
directly, including a newly opened STEP's single identity component placement; other
extents, moved or repeated components and curved sketch profiles keep the Engine preview.
- The extrusion fixes retain tracked Boolean history and the current `solidcraft` packages.
Confirmation and tool previews use main's shared background calculation. Main's collapsed
blend-wall refusal and its tests are retained, including smaller valid radii and mirrored parts.
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
the command palette open, at normal and enlarged display scales. Fit still accounts for
dialog coverage; covered controls do not receive clicks through dialogs.
+18
View File
@@ -423,6 +423,24 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
"sketch": profile_sketch(f), "profiles": profiles_of(f), "path_sketch": path.get("sketch"), "path": path.get("curves"),
"operation": op(f), "name": name, "body_names": bodies}}));
}
// A boundary surface: a loop of one sketch's curves (`sketch`, `curves`), or a network
// (`u`, `v`), as `surface.boundary` takes them.
Some("boundary_surface") => {
let mut p = json!({"name": name});
for k in ["sketch", "curves", "continuity", "u", "v", "operation"] {
if let Some(v) = f.get(k) {
p[k] = v.clone();
}
}
if let Some(b) = bodies.as_array().and_then(|a| a.first()) {
p["body_name"] = b.clone();
}
out.push(json!({"command": "surface.boundary", "params": p}));
}
Some("thicken") => out.push(json!({"command": "surface.thicken", "params": {
"bodies": f.get("bodies"), "thickness": f.get("thickness"), "symmetric": f.get("symmetric").cloned().unwrap_or(json!(false)),
"operation": op(f), "name": name}})),
Some("remove") => out.push(json!({"command": "solid.remove", "params": {"bodies": f.get("bodies"), "name": name}})),
Some("shell") => {
let faces: Vec<Value> = f.get("faces_removed").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
out.push(json!({"command": "solid.shell", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}}));
+12 -4
View File
@@ -13,8 +13,10 @@
// unchanged, the kept build starts; when a new build is deployed, it is downloaded first
// (waiting at most UPDATE_TIMEOUT_MS; the download goes on in the background after that) and
// started. When the site has no `build.json` (the app was removed), launches go to the network,
// the kept build answering when that fails. Each page then gets its files from the build it
// started (remembered in PAGES), never from another build with a file of the same name.
// the kept build answering when that fails. So they do when `build.json` is redirected or
// refused (a sign-in in front of the app): the browser then shows the sign-in. Each page then
// gets its files from the build it started (remembered in PAGES), never from another build
// with a file of the same name.
//
// A cache is only deleted when nothing uses it: a download holds its lock (Web Locks, shared,
// in any tab or worker; the browser releases it when the download ends or its worker stops), and
@@ -158,9 +160,14 @@ function within(ms, promise) {
/** No app is deployed here any more (the server says `build.json` doesn't exist). */
const GONE = "gone";
/** The deployed build's version and files, GONE, or null (unknown: offline, failing, invalid). */
/** The site wants a sign-in first (`build.json` is redirected, often to another origin, or refused). */
const SIGN_IN = "sign-in";
/** The deployed build's version and files, GONE, SIGN_IN, or null (unknown: offline, failing, invalid). */
async function deployed() {
const response = await fetch(url("build.json"), { cache: "no-cache" });
// `manual`: a redirect to another origin would only fail (CORS), looking like being offline.
const response = await fetch(url("build.json"), { cache: "no-cache", redirect: "manual" });
if (response.type === "opaqueredirect" || response.status === 401 || response.status === 403) return SIGN_IN;
if (response.status === 404 || response.status === 410) return GONE;
const build = response.ok ? await response.json() : null;
const valid = build && typeof build.version === "string" && Array.isArray(build.files);
@@ -182,6 +189,7 @@ async function launch(event) {
if (!kept) return [await fetch(event.request), NETWORK]; // Nothing kept yet (the first visit).
extend(event, sweep());
const build = await within(NAVIGATE_TIMEOUT_MS, deployed());
if (build === SIGN_IN) return [await fetch(event.request), NETWORK];
if (build === GONE) {
const page = await fetch(event.request).catch(() => null);
return page && page.ok ? [page, NETWORK] : [kept, CACHE];
+6 -1
View File
@@ -41,7 +41,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
# Keychain on macOS, Credential Manager on Windows, the Secret Service (pure-Rust D-Bus) elsewhere.
keyring = { version = "3.6", default-features = false, features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] }
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
[target.'cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))'.dependencies]
quick-xml = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
@@ -65,6 +65,11 @@ windows-sys = { version = "0.61", features = [
] }
[target.'cfg(target_os = "macos")'.dependencies]
# The 3D mouse through the 3Dconnexion driver, which keeps the device to itself (src/space_mouse.rs).
solidcraft-3dx-client = { workspace = true }
# The 3D mouse (src/space_mouse.rs), in the shared-device open mode so that a running 3DxWare
# driver does not shut the app out.
hidapi = { version = "2.6.7", default-features = false, features = ["macos-shared-device"] }
# Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder"] }
+6 -6
View File
@@ -25,7 +25,7 @@ mod native_crash;
mod native_frame;
#[cfg(target_os = "macos")]
mod open_documents;
#[cfg(any(target_os = "windows", target_os = "linux"))]
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
mod space_mouse;
mod startup;
@@ -38,7 +38,7 @@ struct App {
starting: Option<std::path::PathBuf>,
/// Frames begun; the second one means the first was drawn (startup is over).
frames: u32,
#[cfg(any(target_os = "windows", target_os = "linux"))]
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
space_mouse: space_mouse::SpaceMouse,
}
@@ -68,7 +68,7 @@ impl eframe::App for App {
self.solve.open_path_async(&f);
}
self.solve.logic(ctx);
#[cfg(any(target_os = "windows", target_os = "linux"))]
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
if !self.solve.file_picker_busy() {
self.space_mouse.tick(&mut self.solve, ctx);
}
@@ -78,7 +78,7 @@ impl eframe::App for App {
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
// Before the app's own hook, which adds the control channel's events.
#[cfg(any(target_os = "windows", target_os = "linux"))]
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
self.space_mouse.raw_input_hook(self.solve.preferences.space_mouse_enabled, raw);
self.solve.raw_input_hook(raw);
}
@@ -655,14 +655,14 @@ fn main() -> eframe::Result {
if !notes.is_empty() {
app.set_status(notes.join(" "), true);
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
solidcraft_engine::crash::phase("first frame");
Ok(Box::new(App {
solve: app,
starting,
frames: 0,
#[cfg(any(target_os = "windows", target_os = "linux"))]
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
space_mouse,
}))
}),
+57 -6
View File
@@ -1,4 +1,4 @@
//! 3D mouse navigation on Windows (through hidapi) and Linux (through spacenavd when it runs, else
//! 3D mouse navigation on Windows and macOS (through hidapi) and Linux (through spacenavd when it runs, else
//! hidraw). Read directly, any HID multi-axis controller works: 3Dconnexion's and homemade ones.
//! The device reader never touches the document or UI; it publishes the latest axes, and the egui
//! thread applies them once per frame.
@@ -8,11 +8,12 @@ mod hidraw;
#[cfg(target_os = "linux")]
mod spacenavd;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use egui::Context;
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "macos"))]
use hidapi::{DeviceInfo, HidApi};
use quick_xml::Reader;
use quick_xml::events::Event;
@@ -21,6 +22,14 @@ use solidcraft_ui_egui::SolveApp;
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame};
const STALE_AFTER: Duration = Duration::from_millis(150);
/// Whether the 3Dconnexion driver (macOS) has the device: it then reports the cap, and reading
/// the HID device would fail or report it twice.
static DRIVER_CONNECTED: AtomicBool = AtomicBool::new(false);
/// Whether this app registered with the 3Dconnexion driver (macOS). The driver then has the device
/// whether or not it has said so yet, so a failure to open it by HID is no news.
#[cfg(any(target_os = "windows", target_os = "macos"))]
static DRIVER_REGISTERED: AtomicBool = AtomicBool::new(false);
const RETRY_AFTER: Duration = Duration::from_secs(2);
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
/// Room for one input report: the 4096 bytes `solidcraft_space_mouse` places fields in, plus the
@@ -188,7 +197,7 @@ impl Reading {
/// The Generic Desktop "Multi-Axis Controller" usage names a 3D mouse's motion interface, whoever
/// made it, and excludes ordinary mice and the keyboard interfaces of 3D mice.
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn is_3d_mouse(info: &DeviceInfo) -> bool {
info.usage_page() == 0x01 && info.usage() == 0x08
}
@@ -210,6 +219,9 @@ fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Inst
/// Publishes one input report from the device to the egui thread.
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
if DRIVER_CONNECTED.load(Ordering::Relaxed) {
return;
}
if let Ok(mut reading) = shared.lock()
&& decode_report(report, layout, &mut reading, Instant::now())
{
@@ -234,7 +246,7 @@ fn reset(ctx: &Context, shared: &Mutex<Reading>) {
ctx.request_repaint();
}
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
let Ok(mut api) = HidApi::new() else {
log::warn!("3D mouse: HID initialization failed");
@@ -244,7 +256,16 @@ 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, layout)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| {
let device = info.open_device(&api).ok()?;
let device = info
.open_device(&api)
.inspect_err(|error| {
// On macOS: missing Input Monitoring permission, or the 3DxWare helper holding the device.
// The driver having the device is the reason it cannot be opened, not a problem.
if !DRIVER_CONNECTED.load(Ordering::Relaxed) && !DRIVER_REGISTERED.load(Ordering::Relaxed) {
log::warn!("3D mouse: cannot open the device: {error}");
}
})
.ok()?;
let layout = device_layout(&device, info.vendor_id())?;
Some((device, layout))
}) {
@@ -270,7 +291,7 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
/// read, or it's empty), a 3Dconnexion device (Logitech's id for the old SpaceNavigator,
/// 3Dconnexion's own for the rest) gets 3Dconnexion's layout, and any other device is left alone
/// rather than guessed at.
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn device_layout(device: &hidapi::HidDevice, vendor_id: u16) -> Option<Layout> {
let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE];
let read = device.get_report_descriptor(&mut descriptor);
@@ -335,6 +356,34 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
}
}
/// macOS: the 3Dconnexion driver gives the device to its own clients only, so SolidCraft registers
/// as one. Without the driver (or with its helper not running) the HID reader above takes over.
#[cfg(target_os = "macos")]
fn start_driver_client(ctx: &Context, shared: &Arc<Mutex<Reading>>) {
use solidcraft_3dx_client::Event;
let (ctx, shared) = (ctx.clone(), Arc::clone(shared));
let started = solidcraft_3dx_client::start("SolidCraft", move |event| match event {
Event::DeviceAdded => DRIVER_CONNECTED.store(true, Ordering::Relaxed),
Event::DeviceRemoved => {
DRIVER_CONNECTED.store(false, Ordering::Relaxed);
reset(&ctx, &shared);
}
Event::Axes { translation, rotation } => {
if let Ok(mut reading) = shared.lock() {
let now = Instant::now();
reading.axes = Axes { translation, rotation };
reading.at = [now; 6];
ctx.request_repaint();
}
}
Event::Buttons(_) => {}
});
match started {
Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed),
Err(error) => log::info!("3D mouse: no driver client: {error}"),
}
}
pub struct SpaceMouse {
shared: Arc<Mutex<Reading>>,
last_frame: Instant,
@@ -354,6 +403,8 @@ impl SpaceMouse {
if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
log::warn!("3D mouse: reader could not start: {error}");
}
#[cfg(target_os = "macos")]
start_driver_client(ctx, &shared);
Self {
shared,
last_frame: now,
+3
View File
@@ -82,6 +82,7 @@
"Density": "Hustota",
"Designs you open or save appear here.": "Zde se zobrazí návrhy, které otevřete nebo uložíte.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Určeno volbou K objektu. Pro úpravu úhlového rozsahu zvolte Úhel.",
"Direction": "Směr",
"Discard": "Zahodit",
"Do not export hidden edges": "Neexportovat skryté hrany",
"Double-click to rename": "Dvojklikem přejmenujete",
@@ -121,6 +122,7 @@
"Home view": "Výchozí pohled",
"In This Design": "V tomto návrhu",
"In place (surfaces)": "Na místě (plochy)",
"In plane": "V rovině",
"Inside": "Uvnitř",
"Interferes": "Koliduje",
"Issue title": "Název hlášení",
@@ -154,6 +156,7 @@
"No matching appearances": "Žádné odpovídající vzhledy",
"No visible bodies to export.": "Žádná viditelná tělesa k exportu.",
"None": "Žádné",
"Normal to sketch": "Kolmo ke skice",
"Not available yet": "Zatím není k dispozici",
"Notch": "Zářez",
"Opacity": "Neprůhlednost",
+3
View File
@@ -26,6 +26,7 @@
"Current orthographic view (1:1 mm)": "Aktuelle orthogonale Ansicht (1:1 mm)",
"Current perspective view": "Aktuelle perspektivische Ansicht",
"Curvature (G2)": "Krümmung (G2)",
"Direction": "Richtung",
"Do not export hidden edges": "Verdeckte Kanten nicht exportieren",
"Don't ask again": "Nicht mehr fragen",
"Drive Joint": "Gelenk antreiben",
@@ -47,6 +48,7 @@
"Holes": "Löcher",
"Holes (edges on them)": "Löcher (ihre Kanten)",
"In place (surfaces)": "An Ort und Stelle (Flächen)",
"In plane": "In der Ebene",
"Inside": "Innen",
"Length": "Länge",
"Max": "Max",
@@ -55,6 +57,7 @@
"New study": "Neue Untersuchung",
"No visible bodies to export.": "Keine sichtbaren Körper zum Exportieren.",
"None": "Keine",
"Normal to sketch": "Normal zur Skizze",
"Notch": "Ausklinkung",
"Outside": "Außen",
"Overall length": "Gesamtlänge",
+15
View File
@@ -31,6 +31,7 @@
"Arc length": "Arc length",
"Area": "Area",
"Attachment": "Attachment",
"BOUNDARY SURFACE": "BOUNDARY SURFACE",
"BROWSER": "BROWSER",
"Bend Line Position": "Bend Line Position",
"Bend Position": "Bend Position",
@@ -73,6 +74,7 @@
"Counterbore depth": "Counterbore depth",
"Countersink angle": "Countersink angle",
"Create Issue — review draft": "Create Issue — review draft",
"Cross curves": "Cross curves",
"Cube": "Cube",
"Current orthographic view (1:1 mm)": "Current orthographic view (1:1 mm)",
"Current perspective view": "Current perspective view",
@@ -86,6 +88,7 @@
"Density": "Density",
"Designs you open or save appear here.": "Designs you open or save appear here.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Determined by To Object. Choose Angle to edit the angular extent.",
"Direction": "Direction",
"Discard": "Discard",
"Do not export hidden edges": "Do not export hidden edges",
"Don't ask again": "Don't ask again",
@@ -128,6 +131,7 @@
"Home view": "Home view",
"In This Design": "In This Design",
"In place (surfaces)": "In place (surfaces)",
"In plane": "In plane",
"Inside": "Inside",
"Interferes": "Interferes",
"Issue title": "Issue title",
@@ -163,6 +167,7 @@
"No matching appearances": "No matching appearances",
"No visible bodies to export.": "No visible bodies to export.",
"None": "None",
"Normal to sketch": "Normal to sketch",
"Not available yet": "Not available yet",
"Notch": "Notch",
"Opacity": "Opacity",
@@ -555,6 +560,8 @@
"cmd.sketch_isoparametric_curve": "Isoparametric Curve",
"cmd.sketch_line3d": "3D Line",
"cmd.sketch_line_midpoint": "Midpoint Line",
"cmd.sketch_loose_ends": "Loose Ends",
"cmd.sketch_loose_ends_tie": "Tie Loose Ends",
"cmd.sketch_move": "Move/Copy Sketch Entities",
"cmd.sketch_move3d": "Move 3D Point",
"cmd.sketch_move_point": "Drag Sketch Point",
@@ -794,6 +801,8 @@
"sketch.degrees_of_freedom": "{count} degrees of freedom",
"sketch.draw_curves": "Draw as {linetype}",
"sketch.make_selection": "Convert the selection to {linetype}",
"sketch.open_ends": "Open {count}",
"sketch.untied_ends": "Untied {count}",
"sketch_text.align_center": "Center",
"sketch_text.align_left": "Left",
"sketch_text.align_right": "Right",
@@ -1298,6 +1307,7 @@
"ui.one_side": "One Side",
"ui.open": "Open",
"ui.open_ellipsis": "Open...",
"ui.open_ends_tip": "Touches nothing",
"ui.open_preferences": "Open Preferences",
"ui.open_recent": "Open Recent",
"ui.operation": "Operation",
@@ -1359,6 +1369,8 @@
"ui.reset_the_settings_on_this_page": "Reset the settings on this page",
"ui.restore": "Restore",
"ui.restore_defaults": "Restore Defaults",
"ui.reverse_horizontal_pan_direction": "Reverse horizontal pan direction",
"ui.reverse_horizontal_pan_tip": "Horizontal two-finger scrolling and the thumb wheel pan the other way",
"ui.reverse_zoom_direction": "Reverse zoom direction",
"ui.revolve": "Revolve",
"ui.right": "Right",
@@ -1440,6 +1452,8 @@
"ui.theme": "Theme",
"ui.thickness": "Thickness",
"ui.thread": "Thread",
"ui.tie_ends": "Tie",
"ui.tie_ends_tip": "Constrain all untied ends",
"ui.timeline_bottom": "Bottom timeline",
"ui.timeline_left_note": "The left layout shows the rollback bar above the parts and scene browser.",
"ui.timeline_left_sidebar": "Left sidebar",
@@ -1462,6 +1476,7 @@
"ui.units_of_new_designs": "Units of new designs",
"ui.unlock": "Unlock",
"ui.unsuppress": "Unsuppress",
"ui.untied_ends_tip": "Touches by position only",
"ui.untitled": "Untitled",
"ui.up_axis_note": "Which axis points up in the views (axis view, Home, Look At). Designs are unchanged.",
"ui.updateToCurrentView": "Update to Current View",
+77 -14
View File
@@ -7,6 +7,7 @@
"A new configuration, a copy of the active one": "Una configuración nueva, copia de la activa",
"AI": "AI",
"About SolidCraft": "Acerca de SolidCraft",
"Acceleration": "Aceleración",
"Active": "Activo",
"Active rule": "Regla activa",
"Add": "Añadir",
@@ -14,8 +15,10 @@
"Add Mid Radius": "Añadir radio intermedio",
"Add Row": "Añadir fila",
"Add condition": "Añadir condición",
"Against": "Contra",
"Alignment": "Alineación",
"All Sketch Points": "Todos los puntos del boceto",
"All components": "Todos los componentes",
"All open designs": "Todos los diseños abiertos",
"Allowed expressions, separated by semicolons": "Expresiones permitidas, separadas por punto y coma",
"Also deletes": "También elimina",
@@ -57,6 +60,7 @@
"Configuration rules": "Reglas de configuración",
"Construction": "Construcción",
"Contact": "Contacto",
"Contact sets": "Conjuntos de contacto",
"Continuity": "Continuidad",
"Copy Error Message": "Copiar mensaje de error",
"Copy Warning Message": "Copiar mensaje de advertencia",
@@ -82,16 +86,21 @@
"Density": "Densidad",
"Designs you open or save appear here.": "Aquí aparecen los diseños que abras o guardes.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por «Hasta objeto». Elige «Ángulo» para modificar la extensión angular.",
"Direction": "Dirección",
"Discard": "Descartar",
"Do not export hidden edges": "No exportar aristas ocultas",
"Don't ask again": "No volver a preguntar",
"Double-click to rename": "Doble clic para cambiar el nombre",
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Doble clic con el botón central en la vista: Inicio (vista inicial que encuadra lo visible), Ajustar (encuadrar sin cambiar la orientación) o nada",
"Drive Joint": "Accionar unión",
"Duration": "Duración",
"Edges": "Aristas",
"Edit Profile Sketch": "Editar boceto del perfil",
"Enable rule": "Activar regla",
"Enabled": "Activado",
"Explode": "Explosionar",
"Explode Text": "Descomponer texto",
"Export Animation…": "Exportar animación…",
"Export Current View as DXF": "Exportar vista actual como DXF",
"Export Current View as DXF…": "Exportar vista actual como DXF…",
"Export DXF…": "Exportar DXF…",
@@ -120,6 +129,7 @@
"Home view": "Vista de inicio",
"In This Design": "En este diseño",
"In place (surfaces)": "En su posición (superficies)",
"In plane": "En el plano",
"Inside": "Interior",
"Interferes": "Interfiere",
"Issue title": "Título de la incidencia",
@@ -149,10 +159,13 @@
"Move earlier": "Mover antes",
"Move later": "Mover después",
"Name": "Nombre",
"Nearest": "Más cercano",
"New study": "Nuevo estudio",
"No crash reports.": "No hay informes de fallos.",
"No matching appearances": "No hay apariencias coincidentes",
"No visible bodies to export.": "No hay cuerpos visibles para exportar.",
"None": "Ninguno",
"Normal to sketch": "Normal al boceto",
"Not available yet": "Todavía no disponible",
"Notch": "Muesca",
"Opacity": "Opacidad",
@@ -167,16 +180,20 @@
"Per Edge": "Por arista",
"Pick the tool bodies": "Selecciona los cuerpos herramienta",
"Picked": "Seleccionado",
"Picked point": "Punto seleccionado",
"Place at the origin instead": "Colocar en el origen",
"Playback": "Reproducción",
"Plot": "Graficar",
"Position": "Posición",
"Position (0 to 1)": "Posición (de 0 a 1)",
"Position (G0)": "Posición (G0)",
"Position along the edge, 0 at the start end, 1 at the other": "Posición a lo largo de la arista: 0 en el extremo inicial y 1 en el otro",
"Preferences › Design turns the offer back on": "Preferencias › Diseño vuelve a activar la oferta",
"Preview Row": "Previsualizar fila",
"Preview leaves the current design unchanged.": "La vista previa no modifica el diseño actual.",
"Profiles/Curves": "Perfiles/curvas",
"Projection": "Proyección",
"Quantity": "Cantidad",
"RECENT": "RECIENTES",
"RULED SURFACE": "SUPERFICIE REGLADA",
"Radius Fit": "Ajuste de radio",
@@ -207,10 +224,11 @@
"Revert Position": "Revertir posición",
"Review the report and screenshot before opening GitHub.": "Revisa el informe y la captura antes de abrir GitHub.",
"Rim Edge": "Arista del borde",
"Roll History Marker Here": "Desplazar marcador del historial hasta aquí",
"Roll History Marker Here": "Desplazar marcador de la línea de tiempo hasta aquí",
"Round": "Redondo",
"Rule": "Regla",
"Save Rule": "Guardar regla",
"Save the study as an animated GIF, seen as the viewport shows it": "Guardar el estudio como GIF animado, tal como se ve en el visor",
"Saved preview. Geometry is being checked before editing.": "Vista previa guardada. Se está comprobando la geometría antes de editarla.",
"Scale factor": "Factor de escala",
"Search": "Buscar",
@@ -241,6 +259,7 @@
"Stand this part up?": "¿Poner esta pieza en vertical?",
"Step": "Paso",
"Steps": "Pasos",
"Study": "Estudio",
"Style": "Estilo",
"Suppressed": "Suprimido",
"Swap Ends": "Intercambiar extremos",
@@ -260,6 +279,7 @@
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta cara no permite una vinculación persistente. Elige otra cara o utiliza expresamente un plano fijo.",
"To Offset": "Hasta desfase",
"Tolerance": "Tolerancia",
"Too close": "Demasiado cerca",
"Turn off section analysis before exporting the view.": "Desactiva el análisis de sección antes de exportar la vista.",
"Type": "Tipo",
"UNTRIM": "ANULAR RECORTE",
@@ -270,6 +290,7 @@
"Use selected face as fixed plane": "Usar la cara seleccionada como plano fijo",
"VIEW": "VISTA",
"Value": "Valor",
"Velocity": "Velocidad",
"Vertices": "Vértices",
"View navigator": "Navegador de vistas",
"Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Solo cuerpos visibles. Las curvas utilizan segmentos rectos (tolerancia de 0,01 mm).",
@@ -293,6 +314,13 @@
"browser.precision": "Precisión: {precision}",
"browser.sketch_name": "Boceto{number}",
"browser.units": "Unidades: {units}",
"cam.flat": "Cara plana",
"cam.follower": "Seguidor",
"cam.knife": "Arista de cuchilla",
"cam.needs_joints": "necesita una unión que gire y otra que deslice o bascule",
"cam.roller": "Rodillo",
"cam.roller_pick": "Rodillo (opcional)",
"cam.tip_pick": "Punta (opcional)",
"cmd.appearance_assign": "Apariencia",
"cmd.appearance_library": "Biblioteca de apariencias",
"cmd.appearance_list": "Listar apariencias",
@@ -385,6 +413,7 @@
"cmd.file_slicers": "Laminadores instalados",
"cmd.inspect_accessibility": "Análisis de accesibilidad",
"cmd.inspect_center_of_mass": "Centro de masas",
"cmd.inspect_clearance": "Holgura",
"cmd.inspect_compare": "Comparar cuerpos",
"cmd.inspect_continuity": "Análisis de continuidad",
"cmd.inspect_curvature_comb": "Análisis de peine de curvatura",
@@ -394,8 +423,10 @@
"cmd.inspect_interference": "Interferencia",
"cmd.inspect_isocurve": "Análisis de isocurvas",
"cmd.inspect_minimum_radius": "Análisis de radio mínimo",
"cmd.inspect_silhouette": "Silueta",
"cmd.inspect_zebra": "Análisis de franjas cebra",
"cmd.joint_as_built": "Unión según posición actual",
"cmd.joint_cam": "Leva",
"cmd.joint_create": "Unión",
"cmd.joint_delete": "Eliminar unión",
"cmd.joint_drive": "Accionar uniones",
@@ -426,8 +457,11 @@
"cmd.model_threads": "Listar roscas",
"cmd.motion_delete": "Eliminar estudio de movimiento",
"cmd.motion_export": "Exportar estudio de movimiento",
"cmd.motion_export_animation": "Exportar animación del movimiento",
"cmd.motion_interference": "Interferencia de movimiento",
"cmd.motion_list": "Listar estudios de movimiento",
"cmd.motion_play": "Reproducir estudio de movimiento",
"cmd.motion_plot": "Graficar estudio de movimiento",
"cmd.motion_study": "Estudio de movimiento",
"cmd.motion_trace": "Trayectoria de movimiento",
"cmd.motion_trace_keep": "Conservar trayectoria de movimiento",
@@ -453,7 +487,7 @@
"cmd.parts_library": "Piezas normalizadas",
"cmd.parts_resize": "Cambiar tamaño de pieza",
"cmd.plastic_assign_rule": "Asignar regla de plástico",
"cmd.plastic_boss": "Resalte",
"cmd.plastic_boss": "Saliente",
"cmd.plastic_lip": "Labio",
"cmd.plastic_manage_rules": "Gestionar reglas de plástico",
"cmd.plastic_rest": "Apoyo",
@@ -545,7 +579,7 @@
"cmd.sketch_slot_center_point": "Ranura por punto central",
"cmd.sketch_slot_center_to_center": "Ranura entre centros",
"cmd.sketch_slot_overall": "Ranura por longitud total",
"cmd.sketch_snap": "Detección de referencias",
"cmd.sketch_snap": "Referencia a objetos",
"cmd.sketch_solve": "Resolver boceto",
"cmd.sketch_spline3d": "Spline 3D",
"cmd.sketch_spline_control_point": "Spline por puntos de control",
@@ -570,7 +604,7 @@
"cmd.surface_extrude": "Extruir superficie",
"cmd.surface_fair": "Suavizar superficie",
"cmd.surface_fillet": "Redondeo de superficie",
"cmd.surface_loft": "Transición de superficies",
"cmd.surface_loft": "Solevación de superficies",
"cmd.surface_match": "Igualar superficie",
"cmd.surface_offset": "Desfasar superficie",
"cmd.surface_patch": "Parche",
@@ -592,7 +626,7 @@
"cmd.timeline_redefine": "Editar operación",
"cmd.timeline_rename": "Cambiar nombre",
"cmd.timeline_reorder": "Reordenar operación",
"cmd.timeline_roll_to": "Desplazar marcador del historial",
"cmd.timeline_roll_to": "Desplazar marcador de la línea de tiempo",
"cmd.timeline_rollback": "Mover marcador de la línea de tiempo",
"cmd.timeline_suppress": "Suprimir operación",
"cmd.view_apply": "Restaurar vista guardada",
@@ -631,6 +665,18 @@
"drive.stopped_by_contact": "Detenido en {value}: {a} atravesaría {b}",
"drive.stopped_by_loop": "Detenido en {value}: el bucle que pasa por {joint} no puede cerrarse más",
"drive.unconnected": "No mueve nada: {part} no está conectado a ningún elemento fijado",
"dxf.cancel_wait": "Esperando a que se detenga la exportación…",
"dxf.cancelled": "Exportación DXF cancelada",
"dxf.cancelling": "Cancelando la exportación DXF",
"dxf.exporting": "Exportando DXF",
"dxf.invalid_tolerance": "La tolerancia de curva debe estar entre 0,0001 y 1 mm.",
"dxf.progress.index": "Preparando superficies (2/5)",
"dxf.progress.meshes": "Preparando mallas (1/5)",
"dxf.progress.occlusion": "Resolviendo líneas ocultas (4/5)",
"dxf.progress.projection": "Proyectando geometría (3/5)",
"dxf.progress.write": "Escribiendo DXF (5/5)",
"dxf.tolerance": "Tolerancia de curva",
"dxf.tolerance_help": "Solo cuerpos visibles. Las curvas usan segmentos rectos. Una tolerancia menor da curvas más finas y consume más tiempo y memoria.",
"expression, e.g. 25 mm": "expresión, p. ej., 25 mm",
"fits the face, e.g. M8": "se ajusta a la cara, p. ej., M8",
"from the rule": "de la regla",
@@ -662,6 +708,10 @@
"joint.from_bodies_hint": "Cada cuerpo seleccionado pasa a un componente nuevo propio y conserva su posición.",
"joint.one_body": "Las dos selecciones pertenecen a {body}. Selecciona la segunda en otra pieza.",
"joint.one_component": "Estas piezas son cuerpos de un mismo componente: {bodies}. Una unión mueve un componente respecto a otro, por lo que cada pieza debe tener su propio componente.",
"motion.checked_to": "Comprobado solo hasta el paso {step}: la comprobación agotó su presupuesto de trabajo",
"motion.collision": "{a} · {b}: pasos {steps}",
"motion.duration_hint": "segundos (vacío: por paso)",
"motion.no_collisions": "Sin colisiones durante el estudio",
"nav.touchpad.help": "Mantén pulsado Mayús y mueve el puntero para encuadrar; mantén Alt/Opción y muévelo para orbitar. No hace falta pulsar ningún botón del ratón. El desplazamiento vertical hace zoom.",
"nav.touchpad.label": "Panel táctil",
"nav.touchpad.orbit": "Orbitar (mantén Alt/Opción y mueve el puntero; sin pulsar botones)",
@@ -724,17 +774,17 @@
"render.use_preview": "Usar vista previa integrada",
"sample.bracket.about": "Brida base con pestañas laterales y reborde, según una regla de 2 mm",
"sample.bracket.name": "Soporte de chapa",
"sample.enclosure.about": "Caja vaciada con labio, alojamientos para tornillos, apoyo y cierre a presión",
"sample.enclosure.about": "Caja vaciada con labio, salientes para tornillos, apoyo y encaje a presión",
"sample.enclosure.name": "Carcasa de plástico",
"sample.flange.about": "Cubo y brida con patrón circular controlado por un parámetro",
"sample.flange.name": "Brida atornillada",
"sample.hinge.about": "Dos componentes y una unión de revolución accionada hasta 60°",
"sample.hinge.name": "Conjunto de bisagra",
"sample.plate.about": "Placa paramétrica con redondeos, agujeros y un resalte perforado",
"sample.plate.about": "Placa paramétrica con redondeos, agujeros y un saliente perforado",
"sample.plate.name": "Placa de ejemplo",
"sample.vents.about": "Ranuras en un patrón rectangular: las filas, las columnas y el paso son parámetros",
"sample.vents.name": "Placa de ventilación",
"settings.display_quality_note": "Los niveles más finos muestran las caras y aristas curvas con mayor suavidad, pero tardan más en generar la malla. Solo cambia la vista, nunca el diseño ni las exportaciones.",
"settings.display_quality_note": "Los niveles más finos muestran las caras y aristas curvas con mayor suavidad, pero tardan más en generar la malla. Solo cambia el visor, nunca el diseño ni las exportaciones.",
"settings.frame_rate_note": "Muestra los fotogramas por segundo en la esquina inferior derecha de la vista. Mientras esté activado, la vista se redibuja continuamente.",
"settings.measure_axis_distances": "Mostrar distancias X/Y/Z",
"settings.measure_axis_distances_help": "Mostrar las cotas de cada eje global junto a la distancia total mientras está abierta la herramienta Medir.",
@@ -760,6 +810,10 @@
"sketch_text.path_hint": "Curva del boceto que seguirá el texto: haz clic aquí y después en la curva",
"sketch_text.use_font_file": "Usar el archivo de fuente {path}",
"the parts library is not available": "la biblioteca de piezas no está disponible",
"thread.automatic_size": "Tamaño automático",
"thread.designation": "Designación",
"thread.iso_metric": "Métrica ISO",
"thread.select_size": "Seleccionar tamaño",
"timeline.group": "Nueva carpeta de operaciones",
"timeline.move_many": "Mover elementos de la línea de tiempo",
"timeline.move_to_group": "Mover operaciones a la carpeta",
@@ -1005,6 +1059,9 @@
"ui.construct": "Construir",
"ui.construction": "Construcción",
"ui.context_menu": "Menú contextual",
"ui.continuity_g0": "G0 arista",
"ui.continuity_g1": "G1 tangencia",
"ui.continuity_g2": "G2 curvatura",
"ui.copy": "Copiar",
"ui.corner": "Esquina",
"ui.corners": "Esquinas",
@@ -1066,7 +1123,7 @@
"ui.equivalent_radius": "Radio equivalente",
"ui.expand": "Expandir",
"ui.exportDxf": "Exportar DXF…",
"ui.export_ellipsis": "Exportar...",
"ui.export_ellipsis": "Exportar…",
"ui.export_ellipsis_keymap": "Exportar…",
"ui.export_keymap_tip": "Exportar las teclas, los favoritos de la búsqueda S y la barra de herramientas como archivo",
"ui.extend": "Extender",
@@ -1092,6 +1149,8 @@
"ui.fully_constrained": "Totalmente restringido",
"ui.general": "General",
"ui.graphics": "Gráficos",
"ui.grid_follows_view": "La cuadrícula sigue a la vista",
"ui.grid_follows_view_hint": "Las vistas laterales muestran la cuadrícula en el plano vertical que tienen enfrente, en lugar del suelo",
"ui.ground_shadow": "Sombra en el suelo",
"ui.groupSelected": "Agrupar selección",
"ui.heading_add_ins": "COMPLEMENTOS",
@@ -1118,7 +1177,7 @@
"ui.heading_home": "INICIO",
"ui.heading_insert": "INSERTAR",
"ui.heading_inspect": "INSPECCIONAR",
"ui.heading_loft": "TRANSICIÓN",
"ui.heading_loft": "SOLEVACIÓN",
"ui.heading_make": "FABRICAR",
"ui.heading_measure": "MEDIR",
"ui.heading_mesh": "MALLA",
@@ -1188,7 +1247,7 @@
"ui.line": "Línea",
"ui.linetype": "Tipo de línea",
"ui.lock": "Bloquear",
"ui.loft": "Transición",
"ui.loft": "Solevación",
"ui.look_at": "Mirar hacia",
"ui.look_at_anim_desc": "Vista de eje, Mirar hacia, Inicio y Ajustar",
"ui.manufacture": "Fabricación",
@@ -1241,7 +1300,7 @@
"ui.offset_plane": "Plano desfasado",
"ui.one_side": "Un lado",
"ui.open": "Abrir",
"ui.open_ellipsis": "Abrir...",
"ui.open_ellipsis": "Abrir…",
"ui.open_preferences": "Abrir preferencias",
"ui.open_recent": "Abrir reciente",
"ui.operation": "Operación",
@@ -1286,6 +1345,8 @@
"ui.radial_context_menu": "Menú contextual radial",
"ui.radius": "Radio",
"ui.radius_type": "Tipo de radio",
"ui.rail": "Línea guía",
"ui.rail_fit": "Ajuste a la guía",
"ui.reachable": "Alcanzable",
"ui.rectangle": "Rectángulo",
"ui.rectangular_pattern": "Patrón rectangular",
@@ -1306,12 +1367,13 @@
"ui.right": "Derecha",
"ui.save": "Guardar",
"ui.save_as": "Guardar como",
"ui.save_as_ellipsis": "Guardar como...",
"ui.save_as_ellipsis": "Guardar como…",
"ui.save_mesh_no_slicer": "No se ha encontrado ningún laminador: elige Personalizado… y selecciona tu laminador, o desactiva Enviar al laminador",
"ui.save_mesh_pick_bodies": "Selecciona los cuerpos que se van a guardar",
"ui.save_mesh_temp_failed": "No se ha podido crear {folder}: {error}",
"ui.saved_to": "Guardado en {path}",
"ui.scale": "Escala",
"ui.second_rail": "Segunda línea guía",
"ui.section_analysis": "Análisis de sección",
"ui.select": "Seleccionar",
"ui.selectOther": "Seleccionar otro…",
@@ -1351,7 +1413,7 @@
"ui.smooth": "Suave",
"ui.smooth_wheel_zoom": "Zoom suave con la rueda",
"ui.smooth_wheel_zoom_tip": "Desactívalo para aplicar cada movimiento de la rueda inmediatamente, sin suavizado posterior",
"ui.snap": "Ajustar a referencias",
"ui.snap": "Referencia a objetos",
"ui.space_mouse_hint": "Desplaza y gira la vista a la vez; los bocetos solo permiten desplazamiento y zoom.",
"ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse",
"ui.spacing": "Espaciado",
@@ -1362,6 +1424,7 @@
"ui.stand_up_tip": "Tras abrir un archivo STEP o IGES con Z hacia arriba, ofrecer un cuarto de vuelta alrededor de X",
"ui.start": "Iniciar",
"ui.start_radius": "Radio inicial",
"ui.stretch": "Estirar",
"ui.supersampling": "Supermuestreo (SSAA)",
"ui.supersampling_adaptive": "Solo cuando la vista está inmóvil",
"ui.supersampling_off": "Desactivado (1×)",
+13
View File
@@ -86,6 +86,7 @@
"Density": "Masse volumique",
"Designs you open or save appear here.": "Les conceptions que vous ouvrez ou enregistrez apparaissent ici.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Déterminé par Jusqu'à l'objet. Choisir Angle pour modifier l'étendue angulaire.",
"Direction": "Direction",
"Discard": "Abandonner",
"Do not export hidden edges": "Ne pas exporter les arêtes cachées",
"Don't ask again": "Ne plus demander",
@@ -98,6 +99,7 @@
"Enable rule": "Activer la règle",
"Enabled": "Activé",
"Explode": "Éclater",
"Explode Text": "Décomposer le texte",
"Export Animation…": "Exporter l'animation…",
"Export Current View as DXF": "Exporter la vue actuelle en DXF",
"Export Current View as DXF…": "Exporter la vue actuelle en DXF…",
@@ -127,6 +129,7 @@
"Home view": "Vue d'accueil",
"In This Design": "Dans cette conception",
"In place (surfaces)": "En place (surfaces)",
"In plane": "Dans le plan",
"Inside": "Intérieur",
"Interferes": "Interfère",
"Issue title": "Titre du ticket",
@@ -156,11 +159,13 @@
"Move earlier": "Déplacer plus tôt",
"Move later": "Déplacer plus tard",
"Name": "Nom",
"Nearest": "Corps le plus proche",
"New study": "Nouvelle étude",
"No crash reports.": "Aucun rapport de plantage.",
"No matching appearances": "Aucune apparence correspondante",
"No visible bodies to export.": "Aucun corps visible à exporter.",
"None": "Aucun",
"Normal to sketch": "Normale à l'esquisse",
"Not available yet": "Pas encore disponible",
"Notch": "Encoche",
"Opacity": "Opacité",
@@ -274,6 +279,7 @@
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Cette face ne peut pas fournir de rattachement durable. Choisissez une autre face ou utilisez explicitement un plan fixe.",
"To Offset": "Jusqu'au décalage",
"Tolerance": "Tolérance",
"Too close": "Trop proches",
"Turn off section analysis before exporting the view.": "Désactivez l'analyse de coupe avant d'exporter la vue.",
"Type": "Type",
"UNTRIM": "ANNULER L'AJUSTEMENT",
@@ -407,6 +413,7 @@
"cmd.file_slicers": "Slicers installés",
"cmd.inspect_accessibility": "Analyse d'accessibilité",
"cmd.inspect_center_of_mass": "Centre de gravité",
"cmd.inspect_clearance": "Jeu",
"cmd.inspect_compare": "Comparer les corps",
"cmd.inspect_continuity": "Analyse de continuité",
"cmd.inspect_curvature_comb": "Analyse par peigne de courbure",
@@ -416,6 +423,7 @@
"cmd.inspect_interference": "Interférence",
"cmd.inspect_isocurve": "Analyse des isocourbes",
"cmd.inspect_minimum_radius": "Analyse du rayon minimal",
"cmd.inspect_silhouette": "Silhouette",
"cmd.inspect_zebra": "Analyse zébrée",
"cmd.joint_as_built": "Liaison telle que construite",
"cmd.joint_cam": "Came",
@@ -1051,6 +1059,9 @@
"ui.construct": "Construire",
"ui.construction": "Construction",
"ui.context_menu": "Menu contextuel",
"ui.continuity_g0": "G0 angle vif",
"ui.continuity_g1": "G1 tangence",
"ui.continuity_g2": "G2 courbure",
"ui.copy": "Copier",
"ui.corner": "Angle",
"ui.corners": "Angles",
@@ -1138,6 +1149,8 @@
"ui.fully_constrained": "Entièrement contrainte",
"ui.general": "Général",
"ui.graphics": "Graphisme",
"ui.grid_follows_view": "La grille suit la vue",
"ui.grid_follows_view_hint": "Dans les vues de côté, la grille s'affiche sur le plan vertical qui leur fait face plutôt que sur le sol",
"ui.ground_shadow": "Ombre au sol",
"ui.groupSelected": "Grouper la sélection",
"ui.heading_add_ins": "COMPLÉMENTS",
+3
View File
@@ -82,6 +82,7 @@
"Density": "Densidade",
"Designs you open or save appear here.": "Os deseños que abras ou gardes aparecen aquí.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por Ata o obxecto. Escolle Ángulo para editar a extensión angular.",
"Direction": "Dirección",
"Discard": "Descartar",
"Do not export hidden edges": "Non exportar as arestas ocultas",
"Double-click to rename": "Dobre clic para renomear",
@@ -121,6 +122,7 @@
"Home view": "Vista de inicio",
"In This Design": "Neste deseño",
"In place (surfaces)": "No sitio (superficies)",
"In plane": "No plano",
"Inside": "Dentro",
"Interferes": "Interfire",
"Issue title": "Título da incidencia",
@@ -154,6 +156,7 @@
"No matching appearances": "Non hai aparencias coincidentes",
"No visible bodies to export.": "Non hai corpos visibles para exportar.",
"None": "Ningún",
"Normal to sketch": "Normal ao bosquexo",
"Not available yet": "Aínda non dispoñible",
"Notch": "Entalle",
"Opacity": "Opacidade",
File diff suppressed because it is too large. Load diff
+17
View File
@@ -31,6 +31,7 @@
"Arc length": "円弧の長さ",
"Area": "表面積",
"Attachment": "アタッチメント",
"BOUNDARY SURFACE": "境界サーフェス",
"BROWSER": "ブラウザ",
"Bend Line Position": "曲げ線位置",
"Bend Position": "曲げ位置",
@@ -73,6 +74,7 @@
"Counterbore depth": "座ぐり深さ",
"Countersink angle": "皿穴角度",
"Create Issue — review draft": "Issueを作成 — 下書きの確認",
"Cross curves": "交差する曲線",
"Cube": "キューブ",
"Current orthographic view (1:1 mm)": "現在の平行投影(実寸・mm)",
"Current perspective view": "現在の透視投影",
@@ -86,6 +88,7 @@
"Density": "密度",
"Designs you open or save appear here.": "開いたデザインや保存したデザインがここに表示されます。",
"Determined by To Object. Choose Angle to edit the angular extent.": "「対象オブジェクトまで」により決定されます。角度範囲を編集するには「角度」を選択してください。",
"Direction": "方向",
"Discard": "破棄",
"Do not export hidden edges": "出力しない",
"Don't ask again": "今後確認しない",
@@ -128,6 +131,7 @@
"Home view": "ホームビュー",
"In This Design": "このデザイン内",
"In place (surfaces)": "その場で(サーフェス)",
"In plane": "平面内",
"Inside": "内側",
"Interferes": "干渉あり",
"Issue title": "Issueタイトル",
@@ -163,6 +167,7 @@
"No matching appearances": "一致する外観がありません",
"No visible bodies to export.": "出力する表示中のボディがありません。",
"None": "なし",
"Normal to sketch": "スケッチに垂直",
"Not available yet": "まだ利用できません",
"Notch": "ノッチ",
"Opacity": "不透明度",
@@ -555,6 +560,8 @@
"cmd.sketch_isoparametric_curve": "アイソパラメトリック曲線",
"cmd.sketch_line3d": "3D 線分",
"cmd.sketch_line_midpoint": "中点線分",
"cmd.sketch_loose_ends": "端点の診断",
"cmd.sketch_loose_ends_tie": "未拘束の接点を拘束",
"cmd.sketch_move": "スケッチ要素を移動/コピー",
"cmd.sketch_move3d": "3D 点を移動",
"cmd.sketch_move_point": "スケッチ点をドラッグ",
@@ -794,6 +801,8 @@
"sketch.degrees_of_freedom": "自由度 {count}",
"sketch.draw_curves": "{linetype}として描く",
"sketch.make_selection": "選択を{linetype}にする",
"sketch.open_ends": "開放 {count}",
"sketch.untied_ends": "未拘束 {count}",
"sketch_text.align_center": "中央",
"sketch_text.align_left": "左",
"sketch_text.align_right": "右",
@@ -1146,6 +1155,8 @@
"ui.fully_constrained": "完全拘束",
"ui.general": "一般",
"ui.graphics": "グラフィックス",
"ui.grid_follows_view": "グリッドをビューに追従させる",
"ui.grid_follows_view_hint": "側面ビューでは、グリッドを地面ではなく、そのビューに向いた垂直平面に表示します",
"ui.ground_shadow": "地面の影",
"ui.groupSelected": "選択をグループ化",
"ui.heading_add_ins": "アドイン",
@@ -1296,6 +1307,7 @@
"ui.one_side": "片側",
"ui.open": "開く",
"ui.open_ellipsis": "開く…",
"ui.open_ends_tip": "どこにも接していない",
"ui.open_preferences": "環境設定を開く",
"ui.open_recent": "最近使った項目を開く",
"ui.operation": "操作",
@@ -1357,6 +1369,8 @@
"ui.reset_the_settings_on_this_page": "このページの設定をリセット",
"ui.restore": "復元",
"ui.restore_defaults": "初期値に戻す",
"ui.reverse_horizontal_pan_direction": "水平パンの方向を反転",
"ui.reverse_horizontal_pan_tip": "水平方向の2本指スクロールとサムホイールで、反対方向にパンします",
"ui.reverse_zoom_direction": "ズーム方向を反転",
"ui.revolve": "回転",
"ui.right": "右側面",
@@ -1438,6 +1452,8 @@
"ui.theme": "テーマ",
"ui.thickness": "厚さ",
"ui.thread": "ねじ",
"ui.tie_ends": "拘束",
"ui.tie_ends_tip": "未拘束をすべて拘束",
"ui.timeline_bottom": "下部タイムライン",
"ui.timeline_left_note": "左レイアウトでは、ロールバックバーがパーツとシーンブラウザーの上に表示されます。",
"ui.timeline_left_sidebar": "左サイドバー",
@@ -1460,6 +1476,7 @@
"ui.units_of_new_designs": "新規デザインの単位",
"ui.unlock": "ロック解除",
"ui.unsuppress": "抑制を解除",
"ui.untied_ends_tip": "位置だけで接している",
"ui.untitled": "無題",
"ui.up_axis_note": "ビュー(軸ビュー、ホーム、正面視)で上向きにする軸を指定します。デザインデータ自体は変更されません。",
"ui.updateToCurrentView": "現在のビューに更新",
+3
View File
@@ -26,6 +26,7 @@
"Current orthographic view (1:1 mm)": "현재 직교 투영 뷰 (1:1 mm)",
"Current perspective view": "현재 원근 투영 뷰",
"Curvature (G2)": "곡률 (G2)",
"Direction": "방향",
"Do not export hidden edges": "숨겨진 모서리 내보내지 않기",
"Don't ask again": "다시 묻지 않기",
"Drive Joint": "조인트 구동",
@@ -47,6 +48,7 @@
"Holes": "구멍",
"Holes (edges on them)": "구멍 (구멍의 모서리)",
"In place (surfaces)": "제자리에서 (서피스)",
"In plane": "평면 내",
"Inside": "안쪽",
"Length": "길이",
"Max": "최대",
@@ -55,6 +57,7 @@
"New study": "새 연구",
"No visible bodies to export.": "표시된 바디가 없어 내보낼 수 없습니다.",
"None": "없음",
"Normal to sketch": "스케치에 수직",
"Notch": "노치",
"Outside": "바깥쪽",
"Overall length": "전체 길이",
+3
View File
@@ -82,6 +82,7 @@
"Density": "Densidade",
"Designs you open or save appear here.": "Os projetos que você abrir ou salvar aparecem aqui.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por Até o objeto. Escolha Ângulo para editar a extensão angular.",
"Direction": "Direção",
"Discard": "Descartar",
"Do not export hidden edges": "Não exportar arestas ocultas",
"Double-click to rename": "Clique duas vezes para renomear",
@@ -121,6 +122,7 @@
"Home view": "Vista inicial",
"In This Design": "Neste projeto",
"In place (surfaces)": "No lugar (superfícies)",
"In plane": "No plano",
"Inside": "Dentro",
"Interferes": "Interfere",
"Issue title": "Título da issue",
@@ -154,6 +156,7 @@
"No matching appearances": "Nenhuma aparência correspondente",
"No visible bodies to export.": "Nenhum corpo visível para exportar.",
"None": "Nenhum",
"Normal to sketch": "Normal ao esboço",
"Not available yet": "Ainda não disponível",
"Notch": "Entalhe",
"Opacity": "Opacidade",
+3
View File
@@ -76,6 +76,7 @@
"Density": "Плотность",
"Designs you open or save appear here.": "Здесь появляются открытые и сохранённые дизайны.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Определяется параметром «До объекта». Выберите «Угол», чтобы задать угловой предел.",
"Direction": "Направление",
"Discard": "Отменить",
"Double-click to rename": "Двойной щелчок — переименовать",
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Двойной щелчок средней кнопкой в виде: «Главная» (главный вид, кадрирует показанное), «Вписать» (кадрирует, сохраняя ориентацию) или ничего",
@@ -106,6 +107,7 @@
"Height": "Высота",
"Home view": "Главный вид",
"In This Design": "В этом дизайне",
"In plane": "В плоскости",
"Interferes": "Пересекается",
"Issue title": "Заголовок Issue",
"Joint Origin1": "Точка сопряжения1",
@@ -134,6 +136,7 @@
"New study": "Новое исследование",
"No crash reports.": "Нет отчётов о сбоях.",
"No matching appearances": "Нет подходящих внешних видов",
"Normal to sketch": "По нормали к эскизу",
"Not available yet": "Пока недоступно",
"Notch": "Вырез",
"Opacity": "Непрозрачность",
+3
View File
@@ -80,6 +80,7 @@
"Density": "Yoğunluk",
"Designs you open or save appear here.": "Açtığınız veya kaydettiğiniz tasarımlar burada görünür.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Nesneye kadar seçeneğiyle belirlenir. Açısal kapsamı düzenlemek için Açı seçeneğini seçin.",
"Direction": "Yön",
"Discard": "At",
"Double-click to rename": "Yeniden adlandırmak için çift tıklayın",
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Görünümde orta tuşla çift tıklama: Başlangıç (gösterileni çerçeveleyen başlangıç görünümü), Sığdır (yönü koruyarak gösterileni çerçeveler) veya hiçbir şey",
@@ -114,6 +115,7 @@
"Home view": "Başlangıç görünümü",
"In This Design": "Bu tasarımda",
"In place (surfaces)": "Yerinde (yüzeyler)",
"In plane": "Düzlemde",
"Inside": "İç",
"Interferes": "Girişim var",
"Issue title": "Sorun başlığı",
@@ -147,6 +149,7 @@
"No crash reports.": "Çökme raporu yok.",
"No matching appearances": "Eşleşen görünüm yok",
"None": "Hiçbiri",
"Normal to sketch": "Eskize dik",
"Not available yet": "Henüz kullanılamıyor",
"Opacity": "Opaklık",
"Open GitHub draft": "GitHub taslağını aç",
+3
View File
@@ -86,6 +86,7 @@
"Density": "Густина",
"Designs you open or save appear here.": "Проєкти, які ви відкриваєте чи зберігаєте, з'являються тут.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Визначається об'єктом призначення. Виберіть «Кут», щоб редагувати кутову протяжність.",
"Direction": "Напрямок",
"Discard": "Відхилити",
"Do not export hidden edges": "Не експортувати приховані ребра",
"Double-click to rename": "Двічі клацніть, щоб перейменувати",
@@ -126,6 +127,7 @@
"Home view": "Головний вид",
"In This Design": "У цьому проєкті",
"In place (surfaces)": "На місці (поверхні)",
"In plane": "У площині",
"Inside": "Усередину",
"Interferes": "Перетинається",
"Issue title": "Заголовок повідомлення",
@@ -160,6 +162,7 @@
"No matching appearances": "Немає відповідних виглядів",
"No visible bodies to export.": "Немає видимих тіл для експорту.",
"None": "Немає",
"Normal to sketch": "За нормаллю до ескізу",
"Not available yet": "Ще недоступно",
"Notch": "Виріз",
"Opacity": "Непрозорість",
+17
View File
@@ -31,6 +31,7 @@
"Arc length": "弧长",
"Area": "面积",
"Attachment": "附着",
"BOUNDARY SURFACE": "边界曲面",
"BROWSER": "浏览器",
"Bend Line Position": "弯折线位置",
"Bend Position": "折弯位置",
@@ -73,6 +74,7 @@
"Counterbore depth": "沉孔深度",
"Countersink angle": "沉头角度",
"Create Issue — review draft": "创建 Issue——审阅草稿",
"Cross curves": "交叉曲线",
"Cube": "立方体",
"Current orthographic view (1:1 mm)": "当前正交视图(1:1毫米)",
"Current perspective view": "当前透视视图",
@@ -86,6 +88,7 @@
"Density": "密度",
"Designs you open or save appear here.": "你打开或保存的设计会显示在此处。",
"Determined by To Object. Choose Angle to edit the angular extent.": "由“到对象”确定。选择“角度”可编辑角度范围。",
"Direction": "方向",
"Discard": "放弃",
"Do not export hidden edges": "不导出隐藏线",
"Don't ask again": "不再询问",
@@ -128,6 +131,7 @@
"Home view": "主视图",
"In This Design": "在此设计中",
"In place (surfaces)": "原位(曲面)",
"In plane": "在平面内",
"Inside": "内侧",
"Interferes": "发生干涉",
"Issue title": "Issue 标题",
@@ -163,6 +167,7 @@
"No matching appearances": "没有匹配的外观",
"No visible bodies to export.": "没有可导出的可见实体。",
"None": "无",
"Normal to sketch": "垂直于草图",
"Not available yet": "暂不可用",
"Notch": "缺口",
"Opacity": "不透明度",
@@ -555,6 +560,8 @@
"cmd.sketch_isoparametric_curve": "等参数曲线",
"cmd.sketch_line3d": "三维直线",
"cmd.sketch_line_midpoint": "中点线",
"cmd.sketch_loose_ends": "松散端点",
"cmd.sketch_loose_ends_tie": "约束松散端点",
"cmd.sketch_move": "移动/复制草图图元",
"cmd.sketch_move3d": "移动三维点",
"cmd.sketch_move_point": "拖动草图点",
@@ -794,6 +801,8 @@
"sketch.degrees_of_freedom": "{count} 个自由度",
"sketch.draw_curves": "绘制为 {linetype}",
"sketch.make_selection": "将选择转换为 {linetype}",
"sketch.open_ends": "开放 {count}",
"sketch.untied_ends": "未约束 {count}",
"sketch_text.align_center": "居中",
"sketch_text.align_left": "左对齐",
"sketch_text.align_right": "右对齐",
@@ -1146,6 +1155,8 @@
"ui.fully_constrained": "完全约束",
"ui.general": "常规",
"ui.graphics": "图形",
"ui.grid_follows_view": "网格跟随视图",
"ui.grid_follows_view_hint": "在侧视图中,将网格显示在朝向该视图的竖直平面上,而不是地面上",
"ui.ground_shadow": "地面阴影",
"ui.groupSelected": "将所选编组",
"ui.heading_add_ins": "加载项",
@@ -1296,6 +1307,7 @@
"ui.one_side": "单侧",
"ui.open": "打开",
"ui.open_ellipsis": "打开...",
"ui.open_ends_tip": "未接触任何曲线",
"ui.open_preferences": "打开首选项",
"ui.open_recent": "打开最近",
"ui.operation": "操作",
@@ -1357,6 +1369,8 @@
"ui.reset_the_settings_on_this_page": "重置此页面上的设置",
"ui.restore": "还原",
"ui.restore_defaults": "恢复默认值",
"ui.reverse_horizontal_pan_direction": "反转水平平移方向",
"ui.reverse_horizontal_pan_tip": "水平双指滚动和拇指滚轮向相反方向平移",
"ui.reverse_zoom_direction": "反转缩放方向",
"ui.revolve": "旋转",
"ui.right": "右视",
@@ -1438,6 +1452,8 @@
"ui.theme": "主题",
"ui.thickness": "厚度",
"ui.thread": "螺纹",
"ui.tie_ends": "约束",
"ui.tie_ends_tip": "约束全部未约束端点",
"ui.timeline_bottom": "底部时间轴",
"ui.timeline_left_note": "左侧布局下,回退条显示在零件和场景浏览器的上方。",
"ui.timeline_left_sidebar": "左侧边栏",
@@ -1460,6 +1476,7 @@
"ui.units_of_new_designs": "新设计的单位",
"ui.unlock": "解锁",
"ui.unsuppress": "取消抑制",
"ui.untied_ends_tip": "仅位置重合",
"ui.untitled": "无标题",
"ui.up_axis_note": "视图中朝上的轴(轴视图、主视图、注视)。设计本身不变。",
"ui.updateToCurrentView": "更新为当前视图",
+9 -1
View File
@@ -864,7 +864,15 @@ mod tests {
fn solved(mut sketch: solidcraft_sketch::Sketch) -> SolvedSketch {
let report = solidcraft_sketch::solve(&mut sketch);
SolvedSketch { feature: 1, name: "sources".into(), plane: solidcraft_geom::Plane::XY, sketch, report, profiles: Vec::new() }
SolvedSketch {
feature: 1,
name: "sources".into(),
plane: solidcraft_geom::Plane::XY,
sketch,
report,
profiles: Vec::new(),
ends: Default::default(),
}
}
#[test]
+123
View File
@@ -0,0 +1,123 @@
//! The existing length display policy, shared by windowed and headless consumers.
//!
//! Values are stored in millimetres and displayed in the five design units. Unknown unit
//! names retain the millimetre fallback; expression units have a separate, broader vocabulary.
//! These functions format values, not validate them: non-finite inputs retain Rust's `NaN`,
//! `inf` and `-inf` text. Callers that require finite measurements must validate them first.
/// Units per millimetre and the unit's name. Unknown names use millimetres.
pub fn unit_scale(units: &str) -> (f64, &'static str) {
match units {
"cm" => (0.1, "cm"),
"m" => (0.001, "m"),
"in" => (1.0 / 25.4, "in"),
"ft" => (1.0 / 304.8, "ft"),
_ => (1.0, "mm"),
}
}
/// Inch labels retain a meaningful fourth digit without promoting floating-point noise.
/// `value` is already converted to the requested display unit, not a millimetre value.
pub fn length_precision(value: f64, units: &str, decimals: u8) -> usize {
let base = usize::from(decimals.min(8));
if units != "in" {
return base;
}
let needs_four = ((value * 10_000.0).round() - (value * 1000.0).round() * 10.0).abs() >= 0.5;
base.max(if needs_four { 4 } else { 3 })
}
/// A millimetre length in the requested display unit, without a unit suffix.
pub fn length_number(mm: f64, units: &str, decimals: u8) -> String {
let value = mm * unit_scale(units).0;
format!("{:.*}", length_precision(value, units, decimals), value)
}
/// A length in millimetres in `units`, with `decimals` (for display).
pub fn format_length(mm: f64, units: &str, decimals: u8) -> String {
let (_, u) = unit_scale(units);
format!("{} {u}", length_number(mm, units, decimals))
}
#[cfg(test)]
mod tests {
use super::{format_length, length_number, length_precision, unit_scale};
#[test]
fn all_design_units_keep_bare_and_suffixed_labels() {
for (mm, units, bare, suffixed) in [
(12.5, "mm", "12.50", "12.50 mm"),
(125.0, "cm", "12.50", "12.50 cm"),
(12_500.0, "m", "12.50", "12.50 m"),
(317.5, "in", "12.500", "12.500 in"),
(3810.0, "ft", "12.50", "12.50 ft"),
] {
assert_eq!(length_number(mm, units, 2), bare, "{units}");
assert_eq!(format_length(mm, units, 2), suffixed, "{units}");
}
}
#[test]
fn inch_labels_keep_three_or_four_digits_without_promoting_noise() {
for (mm, decimals, expected) in [
(25.4, 0, "1.000"),
(25.4 * 1.234, 2, "1.234"),
(25.4 * 1.2345, 0, "1.2345"),
(-25.4 * 1.2345, 3, "-1.2345"),
(25.4 * 0.001, 2, "0.001"),
(25.4 * (1.0 + 1e-12), 2, "1.000"),
(25.4, 4, "1.0000"),
(25.4, 5, "1.00000"),
(0.0, 0, "0.000"),
] {
assert_eq!(length_number(mm, "in", decimals), expected, "{mm} mm, {decimals} places");
}
}
#[test]
fn precision_uses_the_converted_value_and_caps_requested_places() {
assert_eq!(length_precision(1.2345, "in", 2), 4);
assert_eq!(length_precision(1.234, "in", 2), 3);
assert_eq!(length_precision(1.2345, "mm", 2), 2);
assert_eq!(length_precision(1.2345, "in", 6), 6);
assert_eq!(length_precision(1.2345, "in", u8::MAX), 8);
assert_eq!(length_precision(1.2345, "mm", u8::MAX), 8);
assert_eq!(length_number(12.5, "mm", u8::MAX), "12.50000000");
assert_eq!(length_number(25.4, "in", u8::MAX), "1.00000000");
}
#[test]
fn rounding_and_signed_zero_keep_the_existing_display_policy() {
for (mm, decimals, expected) in [
(0.0, 2, "0.00"),
(-0.0, 2, "-0.00"),
(-0.0001, 2, "-0.00"),
(-12.5, 2, "-12.50"),
(12.5, 0, "12"),
(13.5, 0, "14"),
(-12.5, 0, "-12"),
(-13.5, 0, "-14"),
(1.25, 1, "1.2"),
(1.75, 1, "1.8"),
] {
assert_eq!(length_number(mm, "mm", decimals), expected, "{mm} mm, {decimals} places");
}
assert_eq!(format_length(-0.0, "in", 2), "-0.000 in");
}
#[test]
fn unknown_display_units_keep_the_millimetre_fallback() {
for units in ["", "inch", " in ", "IN", "unsupported"] {
assert_eq!(unit_scale(units), (1.0, "mm"), "{units}");
assert_eq!(length_number(25.4, units, 2), "25.40", "{units}");
assert_eq!(format_length(25.4, units, 2), "25.40 mm", "{units}");
}
}
#[test]
fn non_finite_text_is_preserved_without_claiming_validation() {
assert_eq!(length_number(f64::NAN, "mm", 2), "NaN");
assert_eq!(format_length(f64::INFINITY, "in", u8::MAX), "inf in");
assert_eq!(format_length(f64::NEG_INFINITY, "cm", 2), "-inf cm");
}
}
+46 -4
View File
@@ -901,7 +901,8 @@ pub enum FeatureKind {
targets: Vec<BodyName>,
},
/// A thin wall from open sketch curves: `thickness` across the sketch plane, filling from
/// the curves toward the body (to the next face, or `depth`). A web makes one per curve.
/// the curves toward the body (to the next face, or `depth`), or with `normal` along the
/// sketch normal. A web makes one per curve.
Rib {
sketch: SketchId,
curves: Vec<String>,
@@ -917,6 +918,10 @@ pub enum FeatureKind {
/// Web: each curve is its own wall.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
web: bool,
/// Grow the wall along the sketch normal (thickness across the curves, in the sketch
/// plane) instead of across the plane from the curves.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
normal: bool,
},
/// Move planar faces (at the given points) onto a target plane parallel to them.
ReplaceFace {
@@ -1460,6 +1465,19 @@ pub enum FeatureKind {
#[serde(default = "boundary_samples")]
samples: usize,
},
/// One surface through a curve network (`surface.boundary` with `u` and `v`, issue #763): the
/// curves `u` run one way (two or more: the surface lofts through them) and `v` cross them the
/// other way (two each way: a Coons patch; more: a Gordon surface through every curve). A side
/// may be a point (a three-sided patch). A curve that is a chain of a surface's open edges may
/// carry G1 or G2 with the face beside it. `operation` join sews the surface into the body of
/// its first edge curve (a solid when it closes the gap there).
SurfaceNetwork {
u: Vec<NetworkCurve>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
v: Vec<NetworkCurve>,
#[serde(default)]
operation: Operation,
},
/// One face of a surface body rebuilt against another body (`surface.match`): the rebuilt
/// boundary nearest the chain of `edges` on `target` takes that chain's place and repeats the
/// target's own shape along the seam up to `order`, while the boundary opposite it, and the
@@ -1687,6 +1705,27 @@ pub struct BlendSide {
pub edges: Vec<Position>,
}
/// One curve of a boundary surface's network ([`FeatureKind::SurfaceNetwork`]): a chain of a
/// sketch's `curves` (planar or 3D, meeting end to end), a chain of `body`'s edges picked by points
/// on them (`edges`), or a side collapsed to the point `point` (the apex of a three-sided patch).
/// A chain of a surface's open edges may carry `continuity` with the face beside it (G0, the
/// default, G1 or G2).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct NetworkCurve {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sketch: Option<SketchId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub curves: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<BodyName>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edges: Vec<Position>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub point: Option<Position>,
#[serde(default = "patch_g0", skip_serializing_if = "is_g0")]
pub continuity: Continuity,
}
/// How a patch on a loop of edges meets the faces beside the loop: every edge with `all`, but the
/// edges picked in `edges` with their own.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
@@ -1728,9 +1767,12 @@ fn is_g0(g: &Continuity) -> bool {
/// The continuity a blend repeats by default: the tangent plane on both sides.
/// The stations a boundary patch carries by default.
fn boundary_samples() -> usize {
25
BOUNDARY_SAMPLES
}
/// The stations a boundary patch carries when a design does not say.
pub const BOUNDARY_SAMPLES: usize = 25;
fn blend_orders() -> [Continuity; 2] {
[Continuity::G1, Continuity::G1]
}
@@ -2083,7 +2125,7 @@ impl FeatureKind {
FeatureKind::SurfaceSweep { .. } => "SurfaceSweepFeature",
FeatureKind::SurfaceLoft { .. } => "SurfaceLoftFeature",
FeatureKind::SurfaceBlend { .. } => "BlendFeature",
FeatureKind::SurfaceBoundary { .. } => "BoundaryFeature",
FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. } => "BoundaryFeature",
FeatureKind::SurfaceMatch { .. } => "MatchFeature",
FeatureKind::SurfaceFair { .. } => "FairFeature",
FeatureKind::Stitch { .. } => "StitchFeature",
@@ -2163,7 +2205,7 @@ impl FeatureKind {
FeatureKind::SurfaceSweep { .. } => "SurfaceSweep",
FeatureKind::SurfaceLoft { .. } => "SurfaceLoft",
FeatureKind::SurfaceBlend { .. } => "Blend",
FeatureKind::SurfaceBoundary { .. } => "Boundary",
FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. } => "Boundary",
FeatureKind::SurfaceMatch { .. } => "Match",
FeatureKind::SurfaceFair { .. } => "Fair",
FeatureKind::Stitch { .. } => "Stitch",
+210 -4
View File
@@ -165,6 +165,8 @@ pub struct SolvedSketch {
pub sketch: solidcraft_sketch::Sketch,
pub report: SolveReport,
pub profiles: Vec<Profile>,
/// Open and untied ends of its profile curves (where a profile can fail).
pub ends: solidcraft_sketch::EndReport,
}
/// A cosmetic thread: on which cylinder, its size, and the threaded part of the axis.
@@ -1033,6 +1035,20 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
}
return hash.finish();
}
// A network through 3D sketch curves reads their points through the components the
// feature and those sketches are placed in, as a sweep's wired path does.
if let FeatureKind::SurfaceNetwork { u, v, .. } = &f.kind {
let sketches: Vec<crate::SketchId> = u.iter().chain(v).filter_map(|c| c.sketch).collect();
if sketches.iter().any(wired) {
let mut hash = std::collections::hash_map::DefaultHasher::new();
doc.component_transform(f.component).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
for sketch in &sketches {
let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0);
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
}
return hash.finish();
}
}
// To / From Object finds its target where that target's component is placed (#928).
let to_object = match &f.kind {
FeatureKind::Extrude { extent, .. } => extent.to.is_some() || extent.from.is_some(),
@@ -1104,7 +1120,15 @@ impl Model {
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 solved = Arc::new(SolvedSketch {
feature,
name: last.name.clone(),
plane: last.plane,
profiles: find_profiles(&sketch),
ends: solidcraft_sketch::loose_ends(&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;
@@ -1581,6 +1605,29 @@ fn apply_beside_sheet_cut(st: &mut ModelState, f: &Feature, tools: Vec<Traced>,
apply_traced(st, f, tools, op, &rest)
}
/// Only one target meets every separated tool. A tool with no contact must retain
/// Join's first-target fallback; tools touching different targets must retain absorption order.
fn common_join_target(state: &ModelState, indices: &[usize], tools: &[Traced]) -> Result<Option<usize>> {
if indices.len() > 64 {
return Ok(None);
}
let bodies: Vec<_> = tools.iter().map(|tool| tool.body.clone()).collect();
let mut contacts = vec![None; tools.len()];
for &index in indices {
let body = state.bodies.get(index).ok_or_else(|| DocError::Invalid("join target disappeared".into()))?;
for (contact, meets) in contacts.iter_mut().zip(body.body.meets_each(&bodies)?) {
if meets {
if contact.is_some() {
return Ok(None);
}
*contact = Some(index);
}
}
}
let common = contacts.first().copied().flatten();
Ok(common.filter(|index| contacts.iter().all(|contact| *contact == Some(*index))))
}
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
apply_traced(state, f, tools.into_iter().map(Traced::unnamed).collect(), op, targets)
}
@@ -1636,6 +1683,41 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
}
}
}
// Batching solid targets must preserve Join's absorption order,
// sheet material tracking and the separate bodies of a New Body extrusion.
if tools.len() > 1
&& matches!(f.kind, FeatureKind::Extrude { .. })
&& matches!(op, Operation::Join | Operation::Cut)
&& !target_idx.is_empty()
&& target_idx.iter().all(|i| state.bodies.get(*i).is_some_and(|b| !b.body.is_mesh() && !state.sheets.iter().any(|s| s.body == b.name)))
&& let Ok(Some(compound)) = kernel::compound_disjoint_extrusions(&tools.iter().map(|tool| tool.body.clone()).collect::<Vec<_>>())
{
let target = if target_idx.len() == 1 {
target_idx.first().copied()
} else if op == Operation::Join {
common_join_target(state, &target_idx, &tools).ok().flatten()
} else {
None
};
// The optimization must not reject geometry the original sequential path can
// handle. A failed batch leaves the model untouched and retries the original tools.
let batch_targets = if op == Operation::Cut {
// Subtracting a union equals subtracting its separated tools in order.
// Keep ALL original participants; no sampled contact test excludes a body.
Some(target_idx.iter().filter_map(|index| state.bodies.get(*index).map(|body| body.name.clone())).collect::<Vec<_>>())
} else {
target.and_then(|index| state.bodies.get(index)).map(|body| vec![body.name.clone()])
};
if let Some(names) = batch_targets {
let mut batched = state.clone();
// Preserve the original participants. For Join, the compound's larger
// size must not expand the contact tolerance and absorb a nearby body.
if apply_traced(&mut batched, f, vec![Traced::step(compound, &tools.iter().collect::<Vec<_>>())], op, &names).is_ok() {
*state = batched;
return Ok(());
}
}
}
match op {
Operation::Join => {
// Each tool joins every target it meets into one body, kept under the first one's
@@ -2141,6 +2223,46 @@ fn extend_for_coplanar(sur: &Surroundings, plane: &Plane, r: &Region2, lo: f64,
(lo2, hi2)
}
/// Remove ends of a join tool already wholly inside its targets. A tab extending
/// a rib otherwise shares the rib's side faces through its buried portion; on a
/// chamfered base those coincident faces can defeat every boolean retry (#2059).
/// Only straight profiles are trimmed, after checking the entire swept material
/// against the targets' faces, with the same clearance used when extending ends.
fn trim_embedded_join_ends(sur: &Surroundings, plane: &Plane, r: &Region2, lo: f64, hi: f64) -> (f64, f64) {
if sur.op != Operation::Join
|| !std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).all(|s| matches!(s, solidcraft_geom::Seg2::Line { .. }))
{
return (lo, hi);
}
let pieces = r.triangulate(1e-6);
let covered: f64 = pieces.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).abs() * 0.5).sum();
if pieces.is_empty() || (covered - r.area()).abs() > 1e-7 * r.area() {
return (lo, hi);
}
let n = plane.normal();
let mut levels: Vec<f64> = sur
.bodies
.iter()
.flat_map(|(_, mesh, _)| {
mesh.triangles.iter().filter_map(|t| {
let [a, b, c] = mesh.tri(t)?;
let normal = (b - a).cross(c - a).normalized()?;
(normal.dot(n).abs() > 1.0 - 1e-9).then(|| (a - plane.origin).dot(n))
})
})
.filter(|h| *h > lo + sur.skin && *h < hi - sur.skin)
.collect();
levels.sort_by(f64::total_cmp);
levels.dedup_by(|a, b| (*a - *b).abs() < sur.skin);
if levels.len() > 256 {
return (lo, hi);
}
let start = levels.iter().rev().copied().find(|h| sur.harmless(plane, &pieces, -sur.skin, lo + sur.skin, h - sur.skin)).unwrap_or(lo);
let end =
levels.iter().copied().find(|h| *h > start + sur.skin && sur.harmless(plane, &pieces, -sur.skin, h + sur.skin, hi - sur.skin)).unwrap_or(hi);
(start, end)
}
/// Whether a tapered tool, run on by `depth` behind the plane it starts in (`dir` the way it
/// extrudes), stays where running on changes nothing: in the targets for a join, out of them for
/// a cut. Its sides keep their slope, so the section there is the profile grown by up to `widen`
@@ -3179,8 +3301,63 @@ fn feature_tools_with_constructor(
// Each tool's extent along the normal (they differ where one is run on into a face).
let mut extents: Vec<(f64, f64)> = Vec::with_capacity(regions.len());
let plain_targets: Vec<String> = crate::plain(targets);
let surroundings = Surroundings::of(st, *operation, &plain_targets);
for r in &regions {
let exact_planar_cut = *operation == Operation::Cut
&& ss.sketch.links.len() > 1
&& ss.sketch.links.iter().all(|link| matches!(&link.source, solidcraft_sketch::LinkSource::Face { .. }))
&& st
.bodies
.iter()
.filter(|body| plain_targets.is_empty() || plain_targets.iter().any(|target| target == "*" || target == &body.name))
.all(|body| kernel::is_planar(&body.body));
let mut touching_batch = false;
// Generated face profiles have full source seams. Check all of them in
// one guarded construction; arbitrary sketch profiles retain their existing path.
if *operation == Operation::Join
&& regions.len() > 1
&& ss.sketch.links.len() > 1
&& ss.sketch.links.iter().all(|link| matches!(&link.source, solidcraft_sketch::LinkSource::Face { .. }))
{
let exact = regions
.iter()
.map(|region| centered_extrude(&ss.plane, region, lo + off, hi + off, Some(&ss)))
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect::<Vec<_>>();
if let Ok(Some(compound)) = kernel::compound_disjoint_extrusions(&exact.iter().map(|tool| tool.body.clone()).collect::<Vec<_>>())
&& st
.bodies
.iter()
.filter(|body| plain_targets.is_empty() || plain_targets.contains(&body.name))
.any(|body| kernel::can_join_all_touching_faces(&body.body, &compound))
{
tools = exact;
touching_batch = true;
}
}
let surroundings = if exact_planar_cut || touching_batch { None } else { Surroundings::of(st, *operation, &plain_targets) };
let join_surroundings = if *operation == Operation::Join && !touching_batch {
// Trimming may discard a tool's material, so only bodies this feature
// actually joins can establish that the discarded end is already filled.
let participants = if plain_targets.is_empty() { &f.participants } else { &plain_targets };
let trim_targets: Vec<String> = st
.bodies
.iter()
.filter(|b| {
if participants.is_empty() {
b.component == f.component || b.feature == f.id
} else {
participants.iter().any(|t| t == "*" || t == &b.name)
}
})
.map(|b| b.name.clone())
.collect();
if trim_targets.is_empty() { None } else { Surroundings::of(st, *operation, &trim_targets) }
} else {
None
};
for r in regions.iter().filter(|_| !touching_batch) {
if *operation == Operation::Join {
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?;
if exact.iter().all(|tool| {
@@ -3198,6 +3375,12 @@ fn feature_tools_with_constructor(
}
}
let (l2, h2) = surroundings.as_ref().map_or((lo + off, hi + off), |sur| extend_for_coplanar(sur, &ss.plane, r, lo + off, hi + off));
let (l2, h2) = join_surroundings.as_ref().map_or((l2, h2), |sur| {
// Trim only material in the requested extent. A run-on added
// above to resolve a touching cap must keep doing its job.
let (l, h) = trim_embedded_join_ends(sur, &ss.plane, r, lo + off, hi + off);
(if l > lo + off { l } else { l2 }, if h < hi + off { h } else { h2 })
});
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
let original = r;
let grown = |gains: &dyn Fn(&[[Vec2; 3]]) -> bool| match &surroundings {
@@ -4522,6 +4705,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::SurfaceLoft { .. }
| FeatureKind::SurfaceBlend { .. }
| FeatureKind::SurfaceBoundary { .. }
| FeatureKind::SurfaceNetwork { .. }
| FeatureKind::SurfaceMatch { .. }
| FeatureKind::SurfaceFair { .. }
| FeatureKind::SurfaceOffset { .. }
@@ -4576,8 +4760,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
sk.validate_periodic_splines()?;
let profiles = find_profiles(&sk);
let ends = solidcraft_sketch::loose_ends(&sk);
st.sketches.retain(|s| s.feature != f.id);
st.sketches.push(Arc::new(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, ends }));
Ok(())
}
FeatureKind::Extrude { operation, targets, .. }
@@ -5734,6 +5919,27 @@ mod body_name_tests {
mod coplanar_growth_tests {
use super::*;
#[test]
fn buried_join_ends_stop_at_material_boundaries() {
let block = kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 8.0)).unwrap();
let profile = rect(2.0, 2.0, 4.0, 4.0);
let st = state(vec![block.clone()]);
let sur = Surroundings::of(&st, Operation::Join, &[]).unwrap();
assert_eq!(trim_embedded_join_ends(&sur, &Plane::XY, &profile, 0.0, 16.0), (8.0, 16.0));
assert_eq!(trim_embedded_join_ends(&sur, &Plane::XY, &profile, -8.0, 8.0), (-8.0, 0.0));
// A thin sealed void between the old start and the rib's top must still
// be filled by the join. The remaining tool must start before that void.
let void = kernel::box_solid(Vec3::new(1.0, 1.0, 3.01), Vec3::new(5.0, 5.0, 3.02)).unwrap();
let hollow = kernel::boolean(&block, &void, BoolOp::Cut).unwrap().unwrap();
let st = state(vec![hollow]);
let sur = Surroundings::of(&st, Operation::Join, &[]).unwrap();
let (lo, hi) = trim_embedded_join_ends(&sur, &Plane::XY, &profile, 0.0, 16.0);
assert!(lo <= 3.01, "trimming past {lo} would discard material the join adds in the void");
assert_eq!(hi, 16.0);
let cut = Surroundings::of(&st, Operation::Cut, &[]).unwrap();
assert_eq!(trim_embedded_join_ends(&cut, &Plane::XY, &profile, 0.0, 16.0), (0.0, 16.0));
}
fn state(bodies: Vec<Body>) -> ModelState {
ModelState { bodies: bodies.into_iter().enumerate().map(|(i, b)| ModelBody::new(format!("Body{i}"), b, 0)).collect(), ..Default::default() }
}
+79 -2
View File
@@ -69,7 +69,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
apply_op(st, f, tools, if *deboss { Operation::Cut } else { Operation::Join }, &crate::plain(targets))
}
FeatureKind::Rib { sketch, curves, thickness, depth, flip, web } => {
FeatureKind::Rib { sketch, curves, thickness, depth, flip, web, normal } => {
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
let t = val(vals, thickness, Kind::Length)?;
if !(t > 0.0) {
@@ -88,7 +88,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
let mut tools = Vec::new();
for g in &groups {
tools.push(rib_tool(st, &ss, g, t, depth, *flip)?);
tools.push(if *normal { rib_tool_normal(st, &ss, g, t, depth, *flip)? } else { rib_tool(st, &ss, g, t, depth, *flip)? });
}
apply_op(st, f, tools, Operation::Join, &[])
}
@@ -286,6 +286,83 @@ fn rib_tool(st: &ModelState, ss: &SolvedSketch, ids: &[String], t: f64, depth: O
tools.into_iter().next().ok_or_else(|| DocError::Invalid("the rib made no solid".into()))
}
/// A rib wall along the sketch normal: the curves widened to `t` in the sketch plane, run from
/// the sketch toward the body by `depth` (or until the whole wall reaches the body).
fn rib_tool_normal(st: &ModelState, ss: &SolvedSketch, ids: &[String], t: f64, depth: Option<f64>, flip: bool) -> Result<Body> {
let pts = chain_points(ss, ids)?;
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
if !(len > 1e-9) {
return Err(DocError::Invalid("a zero-length rib curve".into()));
}
let samples = ((len / (t * 0.5).max(len / 400.0)).ceil() as usize).clamp(2, 400);
let along: Vec<Vec3> = (0..=samples).map(|k| ss.plane.to_world(point_on_polyline(&pts, len * k as f64 / samples as f64))).collect();
let normal = ss.plane.normal();
let reach = st.bodies.iter().map(|b| b.mesh().bounds()).fold(0.0f64, |acc, bb| acc.max((bb.max - bb.min).len()));
let inside = |q: Vec3| st.bodies.iter().any(|b| b.mesh().contains(q));
// Where the body begins from each point of the curves, along `sign` × the sketch normal.
let limit = reach * 2.0 + 1.0;
let step = (limit / 400.0).max(1e-3);
let hits = |sign: f64| -> Vec<f64> {
let mut out = Vec::new();
for q in &along {
let Some(k) = (1..=400).find(|k| inside(*q + normal * (sign * *k as f64 * step))) else { continue };
let (mut lo, mut hi) = ((k - 1) as f64 * step, k as f64 * step);
for _ in 0..30 {
let m = 0.5 * (lo + hi);
if inside(*q + normal * (sign * m)) { hi = m } else { lo = m }
}
out.push(hi);
}
out
};
// Toward the material: the side of the sketch where the curves meet the bodies (unless flipped).
let (front, back) = (hits(1.0), hits(-1.0));
let mut sign = if back.len() > front.len() { -1.0 } else { 1.0 };
let mut reached = if sign > 0.0 { front } else { back };
if flip {
sign = -sign;
reached = hits(sign);
}
let h = match depth {
Some(d) => d,
None => {
let far = reached.iter().copied().fold(f64::NAN, f64::max);
if !far.is_finite() {
return Err(DocError::Invalid("the rib curves do not face a body along the sketch normal (give a depth)".into()));
}
far + (t * 0.5).max(step)
}
};
// The curves widened to `t`: each point offset by half the thickness along the miter of its
// neighbours, both sides joined into one band.
let half = t / 2.0;
let mut left = Vec::with_capacity(pts.len());
let mut right = Vec::with_capacity(pts.len());
for (i, p) in pts.iter().enumerate() {
let prev = i.checked_sub(1).and_then(|j| pts.get(j)).and_then(|q| (*p - *q).normalized());
let next = pts.get(i + 1).and_then(|q| (*q - *p).normalized());
let (n, scale) = match (prev, next) {
(Some(a), Some(b)) => {
let m = (a.perp() + b.perp()).normalized().unwrap_or(a.perp());
(m, 1.0 / m.dot(a.perp()).max(0.25))
}
(Some(a), None) | (None, Some(a)) => (a.perp(), 1.0),
(None, None) => continue,
};
left.push(*p + n * (half * scale));
right.push(*p - n * (half * scale));
}
left.extend(right.into_iter().rev());
let mut lp = Loop2::polygon(&left);
if lp.signed_area() < 0.0 {
lp = lp.reversed();
}
let region = Region2 { outer: lp, holes: Vec::new() };
let (a, b) = if sign > 0.0 { (0.0, h) } else { (-h, 0.0) };
let tools = kernel::extrude(&ss.plane, &[region], a, b)?;
tools.into_iter().next().ok_or_else(|| DocError::Invalid("the rib made no solid".into()))
}
/// The point `d` along a polyline.
fn point_on_polyline(pts: &[Vec2], d: f64) -> Vec2 {
let mut left = d.max(0.0);
+168 -2
View File
@@ -151,12 +151,49 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
let boundaries = boundary_frames(&ss, curves, orders, *samples)?;
let body = kernel::boundary_patch(&boundaries, *crown, *samples)?;
// Two curves are the two sides of a bridge. Three or four run round a loop: the Coons
// patch of the loop, which lies in the sketch's plane with them, so every order holds.
let body = if curves.len() >= 3 {
if *crown != 0.0 {
return Err(DocError::Invalid(
"not supported yet: crown on a patch through three or four curves (it is the flat patch of its sides)".into(),
));
}
loop_patch(&ss, curves)?
} else {
let boundaries = boundary_frames(&ss, curves, orders, *samples)?;
kernel::boundary_patch(&boundaries, *crown, *samples)?
};
let name = new_name(st, f, 0);
st.bodies.push(ModelBody::new(name, body, f.id));
Ok(())
}
FeatureKind::SurfaceNetwork { u, v, operation } => {
if !matches!(operation, crate::Operation::NewBody | crate::Operation::Join) {
return Err(DocError::Invalid("a boundary surface is a new body or joins the body of its edges".into()));
}
let us = network_sources(doc, st, f, u, "U")?;
let vs = network_sources(doc, st, f, v, "V")?;
let made = kernel::boundary_surface(&network_curves(&us), &network_curves(&vs))?;
if *operation == crate::Operation::Join {
// Into the body of the first curve picked on a body's edges.
let target = u
.iter()
.chain(v)
.find_map(|c| c.body.clone())
.ok_or_else(|| DocError::Invalid("join needs a curve on a body's edges: the surface is sewn into that body".into()))?;
let i = st.bodies.iter().position(|b| b.name == target).ok_or_else(|| DocError::Unknown(format!("body `{target}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let joined = kernel::boundary_join(&mb.body, &made)?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, joined, mb.feature);
}
return Ok(());
}
let name = new_name(st, f, 0);
st.bodies.push(ModelBody::new(name, made, f.id));
Ok(())
}
FeatureKind::SurfaceMatch { body, faces, target, edges, order } => {
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
@@ -439,6 +476,135 @@ fn push_sheet(st: &mut ModelState, f: &Feature, traced: crate::lineage::Traced)
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
}
/// The most curves one direction of a network may list (the kernel's own cap).
const MAX_NETWORK_CURVES: usize = solidcraft_geom::network::MAX_NETWORK_CURVES;
/// A loop of three or four sketch curves as one patch: four give the Coons patch of opposite pairs
/// (the first and third one way, the second and fourth the other); three collapse the side
/// opposite the first curve to the corner where the other two meet.
fn loop_patch(ss: &SolvedSketch, curves: &[String]) -> Result<kernel::Body> {
let mut chains: Vec<Vec<Seg2>> = Vec::with_capacity(curves.len());
for id in curves {
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("sketch curve `{id}`")))?;
let segs = ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci));
if segs.is_empty() {
return Err(DocError::Invalid(format!("curve `{id}` has no geometry to build on")));
}
chains.push(segs);
}
let side = |k: usize| -> Result<kernel::BoundaryCurve<'static>> {
let (Some(id), Some(segs)) = (curves.get(k), chains.get(k)) else { return Err(DocError::Invalid("a boundary curve is missing".into())) };
Ok(kernel::BoundaryCurve {
label: format!("curve `{id}`"),
source: kernel::CurveSource::Sketch { plane: ss.plane, segs: segs.clone() },
continuity: kernel::Continuity::G0,
})
};
match curves.len() {
4 => Ok(kernel::boundary_surface(&[side(0)?, side(2)?], &[side(1)?, side(3)?])?),
3 => {
// The corner the second and third curves share, whichever way each was drawn.
let ends = |k: usize| -> Option<[Vec2; 2]> {
let c = chains.get(k)?;
Some([c.first()?.start(), c.last()?.end()])
};
let (Some(b), Some(c)) = (ends(1), ends(2)) else { return Err(DocError::Invalid("a boundary curve is missing".into())) };
let mut best = (f64::INFINITY, Vec2::new(0.0, 0.0));
for p in b {
for q in c {
if p.dist(q) < best.0 {
best = (p.dist(q), (p + q) * 0.5);
}
}
}
let apex = kernel::BoundaryCurve {
label: "the corner opposite the first curve".into(),
source: kernel::CurveSource::Point(ss.plane.to_world(best.1)),
continuity: kernel::Continuity::G0,
};
Ok(kernel::boundary_surface(&[side(0)?, apex], &[side(1)?, side(2)?])?)
}
_ => Err(DocError::Invalid("a loop patch takes three or four curves".into())),
}
}
/// A network curve resolved in the feature's frame, owning what it is built from.
enum NetSource {
Sketch(solidcraft_geom::Plane, Vec<Seg2>),
Points(Vec<Vec3>),
Edges(kernel::Body, Vec<Vec3>),
Point(Vec3),
}
/// The curves of one direction of a network, resolved: each one sketch chain, 3D sketch chain,
/// edge chain or point, labelled `U1`, `U2`, … (with `dir` "U") for messages.
fn network_sources(
doc: &Document,
st: &ModelState,
f: &Feature,
list: &[crate::NetworkCurve],
dir: &str,
) -> Result<Vec<(String, NetSource, kernel::Continuity)>> {
if list.len() > MAX_NETWORK_CURVES {
return Err(DocError::Invalid(format!("at most {MAX_NETWORK_CURVES} curves each way")));
}
let mut out = Vec::with_capacity(list.len());
for (k, c) in list.iter().enumerate() {
let label = format!("{dir}{}", k + 1);
let given = [c.sketch.is_some() || !c.curves.is_empty(), c.body.is_some() || !c.edges.is_empty(), c.point.is_some()];
if given.iter().filter(|g| **g).count() != 1 {
return Err(DocError::Invalid(format!("{label} must be sketch curves, a body's edges or a point")));
}
let source = if let Some(p) = c.point {
NetSource::Point(p.0)
} else if let Some(name) = &c.body {
if c.edges.is_empty() {
return Err(DocError::Invalid(format!("{label} needs points on the edges of {name}")));
}
let b = st.body(name).ok_or_else(|| DocError::Unknown(format!("body `{name}`")))?;
NetSource::Edges(b.body.clone(), crate::plain(&c.edges))
} else {
let sk = c.sketch.ok_or_else(|| DocError::Invalid(format!("{label} needs the sketch its curves are in")))?;
if c.curves.is_empty() || c.curves.len() > MAX_CHAIN_CURVES {
return Err(DocError::Invalid(format!("{label} needs 1 to {MAX_CHAIN_CURVES} curves of its sketch")));
}
let raw = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
// 3D sketch curves are followed through their points; planar ones exactly.
let mapped = path_in_frame(doc, raw, f.component)?;
match path_points(&mapped, &c.curves, Vec3::new(f64::NAN, f64::NAN, f64::NAN))? {
Some(pts) => NetSource::Points(pts),
None => {
let ss = sketch_in_feature_frame(doc, raw, f.component)?;
let mut chains = curve_chains(&ss, &c.curves)?.into_iter();
let (Some(chain), None) = (chains.next(), chains.next()) else {
return Err(DocError::Invalid(format!("{label}: its curves must meet end to end in one chain")));
};
NetSource::Sketch(ss.plane, chain)
}
}
};
out.push((label, source, c.continuity));
}
Ok(out)
}
/// The kernel's curves over resolved sources (borrowing their bodies).
fn network_curves(sources: &[(String, NetSource, kernel::Continuity)]) -> Vec<kernel::BoundaryCurve<'_>> {
sources
.iter()
.map(|(label, src, g)| kernel::BoundaryCurve {
label: label.clone(),
source: match src {
NetSource::Sketch(plane, segs) => kernel::CurveSource::Sketch { plane: *plane, segs: segs.clone() },
NetSource::Points(pts) => kernel::CurveSource::Points(pts.clone()),
NetSource::Edges(body, at) => kernel::CurveSource::Edges { body, at: at.clone() },
NetSource::Point(p) => kernel::CurveSource::Point(*p),
},
continuity: *g,
})
.collect()
}
/// The boundaries `surface.boundary` builds its patch from: each picked curve's own stations in
/// the sketch's frame, every one of them leaving the boundary square into the loop — the direction
/// from the curve towards the middle of the loop, taken across its tangent — over the plane the
@@ -164,6 +164,10 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
// The Class-A surface family (surface.blend/boundary/match/fair).
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
("surface_boundary", json!({"type": "surface_boundary", "sketch": 1, "curves": ["l1", "l2"]})),
(
"surface_network",
json!({"type": "surface_network", "u": [{"sketch": 1, "curves": ["l1"]}, {"body": "B", "edges": [o], "continuity": "g1"}], "v": [{"sketch": 2, "curves": ["l1"]}, {"point": o}], "operation": "join"}),
),
("surface_match", json!({"type": "surface_match", "body": "B", "faces": [o], "target": "C", "edges": [o]})),
("surface_fair", json!({"type": "surface_fair", "body": "B", "faces": [o], "tolerance": "sfair.tolerance"})),
("unstitch", json!({"type": "unstitch", "body": "B"})),
@@ -342,6 +342,7 @@ surface_offset
expressions: soff.distance, soff.tolerance
surface_blend
surface_boundary
surface_network
surface_match
surface_fair
inputs:
@@ -157,6 +157,8 @@ surface_offset
surface_blend
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
surface_boundary
surface_network
moves: u[1].edges[0][0], u[1].edges[0][1], u[1].edges[0][2], v[1].point[0], v[1].point[1], v[1].point[2]
surface_match
moves: edges[0][0], edges[0][1], edges[0][2], faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
@@ -182,6 +182,10 @@ surface_blend
surface_boundary
sketches: [1]
remap_refs: sketch
surface_network
sketches: [1, 2]
remap_refs: u[0].sketch, v[0].sketch
remap_bodies: u[1].body
surface_match
remap_bodies: body
surface_fair
@@ -0,0 +1,7 @@
//! A `SurfaceNetwork` (Boundary Surface through a curve network, #763) saves `u` and `v`, each a
//! list of curves: a `sketch` and its `curves`, a `body` and points on its `edges`, or a `point`
//! (the apex of a three-sided patch), with an optional `continuity` (`g1` or `g2` with the face
//! beside an edge chain), and an `operation` (`new_body`, or `join` into the body of its edges).
//! A `SurfaceBoundary` of three or four curves, which older builds refused, now builds and saves
//! as it always did. Older designs need no transformation; the last release asks for an update
//! instead of meeting a feature it can't read.
@@ -0,0 +1,4 @@
//! A Rib or Web can grow along the sketch normal (`normal: true`): the wall is `thickness` wide
//! across its curves in the sketch plane and runs from the sketch toward the body. Older designs
//! need no transformation: a rib without `normal` grows across the sketch plane as before; 0.14.x
//! asks for an update instead of building a normal rib in the plane.
+1
View File
@@ -17,6 +17,7 @@ pub use clipboard::{clone_expression, remap_topology};
pub mod config;
pub mod construct;
pub mod display_quality;
pub mod display_units;
mod document;
mod eval;
pub mod expr;
+1 -1
View File
@@ -370,7 +370,7 @@ mod tests {
sketch.add_circle(Vec2::new(0., 0.), 3., None, Some("circle")).unwrap();
let report = solve(&mut sketch);
let profiles = find_profiles(&sketch);
let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles };
let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles, ends: Default::default() };
assert_eq!(ss.profiles.len(), 1);
let indices = vec![0; 600];
let selection = ProfileSel::Indices { indices: indices.clone() };
@@ -45,6 +45,64 @@ fn volume(s: &Session, body: &str) -> f64 {
s.model.state().body(body).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0)
}
/// #2059: tabs exactly as wide as their supporting ribs share the ribs' side
/// planes. The base's remote conical chamfer must not prevent their join.
#[test]
fn eighteen_tabs_join_the_ribs_of_a_chamfered_base() {
let s = replay(include_str!("../../../examples/regressions/tabs_on_chamfered_ribbed_base.json"));
let check = |s: &Session| {
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
assert_eq!(s.model.state().bodies.len(), 1);
let state = s.model.state();
let body = &state.body("Base").unwrap().body;
assert_eq!(body.shell_count(), 1);
assert!(body.validity().is_empty(), "{:?}", body.validity());
let mesh = body.tessellate(1e-4).unwrap();
// R40 x 4 disc less its 1 x 1 chamfer, six 32 x 2 ribs
// standing 4 above it, and eighteen 1 x 2 tabs another 8 higher.
let expected = std::f64::consts::PI * (6400.0 - 40.0 + 1.0 / 3.0) + 1536.0 + 288.0;
let got = mesh.measure().volume;
assert!((got - expected).abs() < 0.1, "{got} vs {expected}");
println!("#2059 volume {got:.6} / {expected:.6} mm³; one valid shell, eighteen tabs");
for k in 0..6 {
let angle = k as f64 * std::f64::consts::TAU / 6.0;
let p = |x: f64, y: f64, z| solidcraft_geom::Vec3::new(x * angle.cos() - y * angle.sin(), x * angle.sin() + y * angle.cos(), z);
for x in [15.5, 25.5, 35.5] {
assert!(mesh.contains(p(x, 0.0, 12.0)), "tab {k} at {x}");
assert!(!mesh.contains(p(x, 1.2, 12.0)), "tab {k} grew sideways at {x}");
}
assert!(mesh.contains(p(20.0, 0.0, 6.0)), "rib {k}");
assert!(mesh.contains(p(39.5, 0.0, 2.0)), "rim {k}");
assert!(!mesh.contains(p(39.75, 0.0, 3.75)), "chamfer {k}");
}
assert!((mesh.bounds().max.z - 16.0).abs() < 1e-8);
};
check(&s);
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
check(&reopened);
}
#[test]
fn buried_join_end_ignores_bodies_in_other_components() {
for own_body in [false, true] {
let mut s = Session::default();
let run = |s: &mut Session, command: &str, params: Value| s.execute(command, &params).unwrap();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 8, "body_name": "Other"}));
run(&mut s, "component.create", json!({"name": "Child"}));
if own_body {
run(&mut s, "solid.box", json!({"corner": [50, 0, 0], "length": 10, "width": 10, "height": 8, "body_name": "Child"}));
}
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [2, 2], "p1": [4, 4]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 16, "operation": "join", "body_name": "Tab"}));
let got = volume(&s, if own_body { "Child" } else { "Tab" });
let expected = 64.0 + if own_body { 800.0 } else { 0.0 };
assert!((got - expected).abs() < 1e-6, "{got}");
assert!((volume(&s, "Other") - 800.0).abs() < 1e-6);
}
}
/// Slab less a 557.68 × 100 × 120 pocket and the loft below its floor (556.68 × 99 down 4 mm to
/// 80 × 80; prismatoid: h/6 · (A_top + A_bottom + 4 A_mid)).
fn slab_pocket_loft() -> (f64, f64) {
@@ -475,3 +533,278 @@ fn box_intersect_splits_the_end_off_a_raked_wedge_after_its_end_rounds() {
}
}
}
/// #1853: half a slot drawn on a plate's top face, cut along its centreline, revolved about a
/// construction line on that centreline as a cut. Half a turn into the plate and a full turn
/// both failed with "no result": the revolve came out wrong where its arcs end on the axis (a
/// fifth too much or too little), and the cut could not intersect it there. Both now take the
/// half of the capsule below the face.
#[test]
fn revolve_cut_of_a_half_slot_about_its_centreline_in_the_face() {
let pi = std::f64::consts::PI;
let inside = (pi * 9.0 * 14.0 + 4.0 / 3.0 * pi * 27.0) / 2.0;
for angle in ["-180 deg", "360 deg"] {
let mut s = Session::default();
let steps = [
("solid.box", json!({"corner": [0, 0, 0], "length": 40, "width": 20, "height": 10, "body_name": "Plate"})),
("sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Half"})),
("sketch.line", json!({"points": [[10, 10], [30, 10]]})),
("sketch.line", json!({"points": [[27, 13], [13, 13]]})),
("sketch.arc.center_point", json!({"center": [13, 10], "start": [13, 13], "end": [10, 10]})),
("sketch.arc.center_point", json!({"center": [27, 10], "start": [30, 10], "end": [27, 13]})),
("sketch.line", json!({"points": [[5, 10], [35, 10]], "construction": true, "ids": ["axis"]})),
("sketch.finish", json!({})),
];
for (command, params) in steps {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command}: {e}"));
}
let whole = volume(&s, "Plate");
s.execute("solid.revolve", &json!({"sketch": "Half", "axis": "axis", "angle": angle, "operation": "cut"}))
.unwrap_or_else(|e| panic!("revolve {angle}: {e}"));
let bad = s.model.state().body("Plate").map(|b| b.body.validity()).unwrap_or_default();
assert!(bad.is_empty(), "{angle}: {bad:?}");
let removed = whole - volume(&s, "Plate");
println!("#1853 revolve {angle}: removed {removed:.6} mm³ of the {inside:.6} below the face");
assert!((removed - inside).abs() < 1e-3 * inside, "{angle}: removed {removed} instead of {inside}");
}
}
/// #2005: changing a circular sweep from a separate body to a half-turn cut must create the
/// internal channel, rather than failing the Boolean on coincident end caps.
#[test]
fn closed_sweep_redefines_to_a_half_turn_torus_cut() {
let s = replay(include_str!("../../../examples/regressions/closed_sweep_torus_cut.json"));
assert_eq!(s.model.state().bodies.len(), 1);
let body = &s.model.state().bodies[0].body;
assert!(body.validity().is_empty(), "{:?}", body.validity());
let mesh = body.tessellate(0.02).unwrap();
let expected = (std::f64::consts::PI * 64.0 - 64.0) * std::f64::consts::TAU * 30.0;
assert!((mesh.measure().volume - expected).abs() < expected * 0.015);
for (point, material) in [(solidcraft_geom::Vec3::new(30.0, 0.0, 0.0), false), (solidcraft_geom::Vec3::new(30.0, 0.0, 7.0), true)] {
assert_eq!(mesh.contains(point), material, "material at {point:?}");
}
}
#[test]
fn many_extrusion_profiles_preserve_material_and_body_identity() {
for operation in ["join", "cut", "new"] {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20, "body_name": "Stock"})).unwrap();
s.execute("sketch.create", &json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})).unwrap();
for i in 0..6 {
for j in 0..4 {
let (x, y) = (2 + i * 6, 2 + j * 7);
s.execute("sketch.rectangle.two_point", &json!({"p0": [x, y], "p1": [x + 4, y + 4]})).unwrap();
}
}
s.execute("sketch.finish", &json!({})).unwrap();
let distance = if operation == "cut" { -5 } else { 5 };
let before = s.revision;
s.execute("solid.extrude", &json!({"distance": distance, "operation": operation})).unwrap();
let state = s.model.state();
assert_eq!(state.bodies.len(), if operation == "new" { 25 } else { 1 });
let expected = 24_000.0
+ match operation {
"join" => 1920.0,
"cut" => -1920.0,
_ => 0.0,
};
assert!((volume(&s, "Stock") - expected).abs() < 1e-6);
let stock = state.body("Stock").unwrap();
for (point, present) in [([3.0, 3.0, 22.0], operation == "join"), ([3.0, 3.0, 18.0], operation != "cut"), ([7.0, 3.0, 18.0], true)] {
assert_eq!(stock.mesh().contains(solidcraft_geom::Vec3::from(point)), present, "{operation}: {point:?}");
}
assert!(stock.body.validity().is_empty());
assert!(s.revision > before);
s.execute("edit.undo", &json!({})).unwrap();
assert!((volume(&s, "Stock") - 24_000.0).abs() < 1e-6);
s.execute("edit.redo", &json!({})).unwrap();
assert!((volume(&s, "Stock") - expected).abs() < 1e-6);
}
}
#[test]
fn many_circular_extrusion_profiles_cut_a_curved_target() {
let mut s = Session::default();
s.execute("solid.cylinder", &json!({"radius": 20, "height": 20, "body_name": "Stock"})).unwrap();
s.execute("sketch.create", &json!({"plane": {"origin": [0, 0, -1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})).unwrap();
for x in [-9, -3, 3, 9] {
for y in [-9, -3, 3, 9] {
s.execute("sketch.circle.center", &json!({"center": [x, y], "radius": 1})).unwrap();
}
}
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.extrude", &json!({"distance": 22, "operation": "cut", "targets": ["Stock"]})).unwrap();
// The rebuilt caps mesh more finely than the original cylinder. Compare with the
// analytic material volume, rather than subtracting two differently faceted meshes.
let expected = (400.0 - 16.0) * std::f64::consts::PI * 20.0;
let actual = volume(&s, "Stock");
assert!((actual - expected).abs() < expected * 1e-5, "{actual} vs {expected}");
let state = s.model.state();
assert_eq!(state.bodies.len(), 1);
let stock = state.body("Stock").unwrap();
assert!(stock.body.validity().is_empty());
let mesh = stock.mesh();
assert!(mesh.contains(solidcraft_geom::Vec3::new(0.0, 0.0, 10.0)));
for x in [-9.0, -3.0, 3.0, 9.0] {
for y in [-9.0, -3.0, 3.0, 9.0] {
assert!(!mesh.contains(solidcraft_geom::Vec3::new(x, y, 10.0)), "hole at {x}, {y}");
}
}
}
#[test]
fn coplanar_body_faces_extrude_together_and_replay_their_links() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":64,"width":64,"height":20,"body_name":"Plate"})).unwrap();
s.execute("sketch.create", &json!({"plane":{"origin":[0,0,20],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
let mut faces = Vec::new();
for i in 0..8 {
for j in 0..8 {
let (x, y) = (2 + i * 7, 2 + j * 7);
s.execute("sketch.rectangle.two_point", &json!({"p0":[x,y],"p1":[x+4,y+4]})).unwrap();
faces.push(json!([x + 2, y + 2, 26]));
}
}
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.extrude", &json!({"distance":6,"operation":"join"})).unwrap();
let (features, undo) = (s.doc.features.len(), s.undo.len());
s.execute("solid.extrude", &json!({"faces":faces,"distance":-7,"operation":"cut"})).unwrap();
assert_eq!(s.doc.features.len(), features + 2, "one linked sketch and one extrusion");
assert_eq!(s.undo.len(), undo + 1);
let actual = volume(&s, "Plate");
let state = s.model.state();
let sketch = state.sketch(4).unwrap();
assert!((actual - 80_896.0).abs() < 1e-5, "volume {actual}; plane {:?}; profiles {}", sketch.plane, sketch.profiles.len());
assert_eq!(s.model.state().bodies.len(), 1);
let document = serde_json::from_value(serde_json::to_value(s.doc.as_ref()).unwrap()).unwrap();
let mut reopened = Session { doc: std::sync::Arc::new(document), ..Session::default() };
reopened.refresh();
assert!((volume(&reopened, "Plate") - 80_896.0).abs() < 1e-5);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc.features.len(), features);
s.execute("edit.redo", &json!({})).unwrap();
assert!((volume(&s, "Plate") - 80_896.0).abs() < 1e-5);
}
#[test]
fn unsupported_face_groups_are_atomic() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
let before = serde_json::to_value(s.doc.as_ref()).unwrap();
let (revision, undo) = (s.revision, s.undo.len());
for faces in [
json!([]),
json!([[5, 5, 10], [5, 5, 0]]),
json!([[5, 5, 10], [5, 0, 5]]),
json!([[5, 5, 10], [1000, 1000, 1000]]),
json!(vec![[5, 5, 10]; 1025]),
] {
assert!(s.execute("solid.extrude", &json!({"faces":faces,"distance":5,"operation":"join"})).is_err());
assert_eq!(serde_json::to_value(s.doc.as_ref()).unwrap(), before);
assert_eq!((s.revision, s.undo.len()), (revision, undo));
}
}
#[test]
fn imported_rotated_face_extrusions_preserve_other_operations_and_bodies() {
use solidcraft_geom::Vec3;
let mut source = Session::default();
source.execute("solid.box", &json!({"corner":[-16,-16,-3],"length":32,"width":32,"height":1,"body_name":"Other"})).unwrap();
source.execute("solid.cylinder", &json!({"radius":14,"height":8,"body_name":"Stock"})).unwrap();
let mut outlines = Vec::new();
let mut centers = Vec::new();
for i in 0..12 {
let angle = i as f64 * std::f64::consts::TAU / 12.;
let (c, s) = (angle.cos(), angle.sin());
let outline: Vec<_> =
[(-2., -2.), (2., -2.), (2., 2.), (-2., 2.)].into_iter().map(|(x, y)| [10.7 * c + x * c - y * s, 10.7 * s + x * s + y * c]).collect();
outlines.push(outline);
centers.push([10.7 * c, 10.7 * s, 12.]);
}
let draw = |session: &mut Session, height: f64| {
session.execute("sketch.create", &json!({"plane":{"origin":[0,0,height],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
for outline in &outlines {
session.execute("sketch.line", &json!({"points":outline,"closed":true,"infer":false})).unwrap();
}
session.execute("sketch.finish", &json!({})).unwrap();
};
draw(&mut source, 8.);
source.execute("solid.extrude", &json!({"distance":4,"operation":"join"})).unwrap();
// An original public-safe STEP example, with rotated full-face seams and a
// curved target. No vendor/user design is part of this regression.
let path = std::env::temp_dir().join(format!("solidcraft-face-extrusion-{}.step", std::process::id()));
source.execute("file.export", &json!({"path":path.to_string_lossy()})).unwrap();
let mut imported = Session::default();
imported.execute("doc.open", &json!({"path":path.to_string_lossy()})).unwrap();
std::fs::remove_file(path).unwrap();
let original = imported.model.state();
let other = original.body("Other").unwrap().body.clone();
for (operation, distance) in [("join", 2.), ("cut", -1.), ("new", 2.), ("intersect", -1.)] {
let mut grouped = imported.scratch();
grouped.execute("solid.extrude", &json!({"faces":centers,"distance":distance,"operation":operation})).unwrap();
let mut reference = imported.scratch();
draw(&mut reference, 12.);
if matches!(operation, "join" | "cut") {
// Individual tools retain the original Boolean and participant path.
for index in 0..12 {
reference
.execute("solid.extrude", &json!({"sketch":"Sketch1","profiles":[index],"distance":distance,"operation":operation}))
.unwrap();
}
} else {
reference.execute("solid.extrude", &json!({"distance":distance,"operation":operation})).unwrap();
}
let actual = grouped.model.state();
let expected = reference.model.state();
assert_eq!(actual.bodies.len(), expected.bodies.len(), "{operation}");
for body in &actual.bodies {
let reference = expected.body(&body.name).unwrap();
// Fixed fine chord tolerance: cap subdivision can change the default
// adaptive mesh's cylinder faceting without changing its analytic surface.
let (a, b) = (body.body.tessellate(1e-5).unwrap().measure().volume, reference.body.tessellate(1e-5).unwrap().measure().volume);
assert!((a - b).abs() < b.max(1.) * 1e-6, "{operation}: {} {a} vs {b}", body.name);
assert!(body.body.validity().is_empty(), "{operation}: {}", body.name);
let (a, b) = (body.mesh(), reference.mesh());
for center in &centers {
for z in [7., 11.5, 13.] {
let point = Vec3::new(center[0], center[1], z);
assert_eq!(a.contains(point), b.contains(point), "{operation}: {} {point:?}", body.name);
}
}
}
if operation != "intersect" {
let unchanged = &actual.body("Other").unwrap().body;
assert_eq!(unchanged.face_count(), other.face_count());
assert_eq!(unchanged.color(), other.color());
assert_eq!(unchanged.tessellate(0.01).unwrap().positions, other.tessellate(0.01).unwrap().positions, "unaffected imported body");
}
}
}
#[test]
fn multi_target_join_keeps_nearby_bodies_and_original_absorption_order() {
for (second_x, expected_count) in [(10.0015, 2), (9., 1)] {
let mut session = Session::default();
session.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Stock"})).unwrap();
session.execute("solid.box", &json!({"corner":[second_x,8,10],"length":1,"width":1,"height":2,"body_name":"Other"})).unwrap();
session.execute("sketch.create", &json!({"plane":{"origin":[0,0,10],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
for origin in [[0., 0.], [8., 8.]] {
session.execute("sketch.rectangle.two_point", &json!({"p0":origin,"p1":[origin[0]+2.,origin[1]+2.]})).unwrap();
}
session.execute("sketch.finish", &json!({})).unwrap();
let mut reference = session.scratch();
for index in 0..2 {
reference.execute("solid.extrude", &json!({"sketch":"Sketch1","profiles":[index],"distance":2,"operation":"join"})).unwrap();
}
session.execute("solid.extrude", &json!({"distance":2,"operation":"join"})).unwrap();
assert_eq!(session.model.state().bodies.len(), expected_count);
assert_eq!(
session.model.state().bodies.iter().map(|body| body.name.clone()).collect::<Vec<_>>(),
reference.model.state().bodies.iter().map(|body| body.name.clone()).collect::<Vec<_>>()
);
for body in &session.model.state().bodies {
assert!((volume(&session, &body.name) - volume(&reference, &body.name)).abs() < 1e-6);
}
}
}
+5 -1
View File
@@ -978,6 +978,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
.chain(super::features_more::COMMANDS)
.chain(super::construct::COMMANDS)
.chain(super::surface::COMMANDS)
.chain(super::surface_boundary::COMMANDS)
.any(|candidate| candidate.id == spec.id)
|| matches!(
spec.id,
@@ -1049,7 +1050,10 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
{
*after = before.clone();
}
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) {
// A boundary surface's loop of sketch curves and its curve network are one command's two
// forms: an edit may turn one into the other.
let boundary = |k: &FeatureKind| matches!(k, FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. });
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) && !(boundary(&new.kind) && boundary(&old.kind)) {
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
}
// Optional inputs can move other inputs' positions (e.g. Distance 2 goes before Taper).
+78
View File
@@ -84,6 +84,10 @@ type FacePick = (std::sync::Arc<Mesh>, u32, f64, String);
/// equally near (bodies touching flush), the pick is ambiguous: `Err` names those bodies.
fn face_at_in(s: &Session, p: Vec3, only: Option<&str>) -> std::result::Result<Option<FacePick>, Vec<String>> {
let st = s.model.state();
face_at_state(&st, p, only)
}
pub(super) fn face_at_state(st: &solidcraft_doc::ModelState, p: Vec3, only: Option<&str>) -> std::result::Result<Option<FacePick>, Vec<String>> {
let mut best: Option<FacePick> = None;
let mut tied: Vec<String> = Vec::new();
for b in st.bodies.iter().filter(|b| only.is_none_or(|n| b.name == n)) {
@@ -127,6 +131,80 @@ fn xy(v: Vec2) -> Value {
json!([v.x, v.y])
}
/// Coplanar faces share one linked profile sketch and one Extrude feature. Resolve every
/// pick before editing the document, so later picks never search an already modified body.
pub(super) fn sketch_of_faces(s: &mut Session, points: &[Vec3], cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec<Vec2>, String)> {
let first = points.first().copied().ok_or_else(|| bad(cmd, "faces must not be empty"))?;
if points.len() > 1024 {
return Err(bad(cmd, "faces exceed the 1024-face limit"));
}
let state = s.model.state();
let mut picked = Vec::new();
let mut common: Option<(String, solidcraft_geom::Plane)> = None;
for &point in points {
if !point.is_finite() {
return Err(bad(cmd, "faces must be finite points"));
}
let (mesh, index, distance, name) = face_at_in(s, point, face_body)
.map_err(|_| bad(cmd, "ambiguous face; specify face_body"))?
.ok_or_else(|| bad(cmd, "no body face at a selected point"))?;
let body = state.body(&name).ok_or_else(|| bad(cmd, "selected body disappeared"))?;
let plane = body.body.planar_face(index as usize).ok_or_else(|| bad(cmd, "faces must be planar"))?;
let tolerance = (body.body.size() * 1e-9).max(1e-8);
if distance > (mesh.bounds().diagonal() * 1e-3).max(1e-3) * 10.0 {
return Err(bad(cmd, "no body face at a selected point"));
}
if let Some((previous, base)) = &common {
if *previous != name || base.normal().dot(plane.normal()) < 1.0 - 1e-9 || base.height(plane.origin).abs() > tolerance {
return Err(bad(cmd, "faces must be coplanar, equally oriented faces of one body"));
}
} else {
common = Some((name.clone(), plane));
}
if picked.iter().any(|(face, _)| *face == index as usize) {
continue;
}
let inside = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, face)| **face == index)
.filter_map(|(triangle, _)| mesh.tri(triangle))
.max_by(|[a, b, c], [x, y, z]| ((*b - *a).cross(*c - *a).len()).total_cmp(&((*y - *x).cross(*z - *x).len())))
.map(|[a, b, c]| (a + b + c) / 3.0)
.ok_or_else(|| bad(cmd, "degenerate face"))?;
picked.push((index as usize, inside));
}
let (id, first_inside, _) = sketch_of_face(s, first, cmd, face_body)?;
let (body, _) = common.ok_or_else(|| bad(cmd, "no faces selected"))?;
let doc = s.placed_document();
let source = state.body(&body).ok_or_else(|| bad(cmd, "source body disappeared"))?;
let component = doc.model_body_component(source);
let path = if component == s.active_component {
s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(component))
} else {
let placements = super::component::properties::placements(&doc, cmd)?;
if placements.iter().filter(|(owner, _, _)| *owner == component).count() != 1 {
return Err(bad(cmd, "activate the selected occurrence before extruding its faces"));
}
doc.first_path(component)
};
let placement = EmbossFace::component_frame(&doc, component, &path)?;
let placement = doc.model_body_offset(source).map(|offset| mat_mul(&placement, offset)).unwrap_or(placement);
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component));
let author = EmbossFace::component_frame(&doc, s.active_component, &author_path)?;
let inverse = mat_inverse(&author).ok_or_else(|| bad(cmd, "invalid face profile author frame"))?;
let into_author = mat_mul(&inverse, &placement);
drop(doc);
let indices: Vec<_> = picked.iter().skip(1).map(|(index, _)| *index).collect();
super::sketch_project::project_internal_faces(s, id, &body, &indices)?;
s.refresh();
let plane = s.model.state().sketch(id).map(|sketch| sketch.plane).ok_or_else(|| bad(cmd, "face profile sketch disappeared"))?;
let mut selected = vec![first_inside];
selected.extend(picked.iter().skip(1).map(|(_, point)| plane.to_local(apply_point(&into_author, *point))));
Ok((id, selected, body))
}
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
/// the sketch id, a point inside the face (sketch coordinates), and the picked body's name.
/// The sketch is left active.
+52 -14
View File
@@ -18,7 +18,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("extrude")
.key("E")
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
@@ -118,7 +118,7 @@ pub static COMMANDS: &[CommandSpec] = &[
/// Where an extrude starts and which way is positive: a point inside the (first) profile or
/// face, and the unit normal.
fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
if let Some(fp) = p.get("face").and_then(vec3) {
if let Some(fp) = p.get("face").or_else(|| p.get("faces").and_then(Value::as_array).and_then(|faces| faces.first())).and_then(vec3) {
return Some((fp, super::face::face_normal_in(s, fp, str_(p, "face_body"))?));
}
let sid = feature_sketch(s, p, "Extrude").ok()?;
@@ -167,19 +167,38 @@ const START_PROBE_MM: f64 = 0.1;
/// direction it grows, or halfway along it. With the halfway point alone, a cut dragged far
/// past a body's far side read as a Join (#1257).
pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
let d = extrude_reach(s, p)?;
auto_operation_in(s, p, true)
}
fn auto_operation_in(s: &Session, p: &Value, world_faces: bool) -> Option<&'static str> {
let symmetric = p.get("to").is_none() && str_(p, "direction").is_some_and(|d| d.eq_ignore_ascii_case("symmetric"));
let (base, n) = extrude_base(s, p)?;
let st = s.model.state();
// Imported face groups can belong to a child of an empty active component.
// Probe in the same coordinate frame as the face picks.
let grouped = p.get("faces").and_then(Value::as_array).and_then(|faces| faces.first()).and_then(vec3);
let st = if grouped.is_some() && world_faces { s.world_state() } else { s.model.state() };
let (base, n) = if let Some(point) = grouped {
let (mesh, index, distance, name) = super::face::face_at_state(&st, point, str_(p, "face_body")).ok().flatten()?;
if distance > (mesh.bounds().diagonal() * 1e-3).max(1e-3) * 10.0 {
return None;
}
let normal = st.body(&name)?.body.planar_face(index as usize)?.normal();
(point, normal)
} else {
extrude_base(s, p)?
};
let d = if grouped.is_some() && p.get("to").is_some() { (p.get("to").and_then(vec3)? - base).dot(n) } else { extrude_reach(s, p)? };
if st.bodies.is_empty() || !d.is_finite() {
return Some("new");
}
// Only the active component's bodies take part (or the ones named as participants).
// Match the evaluator's implicit participants: a grouped cut from an empty
// assembly component considers every body; otherwise keep the active scope.
// Explicit participants override either default.
let parts = string_list(p, "participants");
let implicit_all = grouped.is_some() && !st.bodies.iter().any(|body| s.doc.model_body_component(body) == s.active_component);
let bodies: Vec<_> = st
.bodies
.iter()
.filter(|b| if parts.is_empty() { s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
.filter(|b| if parts.is_empty() { implicit_all || s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
.collect();
if bodies.is_empty() {
return Some("new");
@@ -470,7 +489,8 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
// takes part in every body instead, which it changes only where it meets them.
let participants = if participants.is_empty()
&& !primitive
&& matches!(str_(p, "operation").and_then(Operation::parse), Some(Operation::Cut | Operation::Intersect))
&& (matches!(str_(p, "operation").and_then(Operation::parse), Some(Operation::Cut | Operation::Intersect))
|| p.get("faces").is_some() && matches!(&kind, FeatureKind::Extrude { operation: Operation::Cut | Operation::Intersect, .. }))
&& {
let st = s.model.state();
!st.bodies.is_empty() && !st.bodies.iter().any(|b| s.doc.model_body_component(b) == s.active_component)
@@ -545,13 +565,31 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.extrude";
let grouped = if let Some(value) = p.get("faces") {
if p.get("face").is_some() || p.get("sketch").is_some() || p.get("profiles").is_some() {
return Err(bad(cmd, "faces cannot be combined with face, sketch or profiles"));
}
let faces = value
.as_array()
.filter(|faces| !faces.is_empty() && faces.len() <= 1024)
.ok_or_else(|| bad(cmd, "faces must contain between 1 and 1024 points"))?;
let points = faces.iter().map(|point| vec3(point).ok_or_else(|| bad(cmd, "faces must be points [x,y,z]"))).collect::<Result<Vec<_>>>()?;
let (id, points, body) = super::face::sketch_of_faces(s, &points, cmd, str_(p, "face_body"))?;
Some((id, Some(ProfileSel::Points { points }), Some(body)))
} else {
None
};
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
let (sketch, face_profile, face_body) = match p.get("face").and_then(vec3) {
Some(fp) => {
let (id, inside, body) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
(id, Some(ProfileSel::Points { points: vec![inside] }), Some(body))
let (sketch, face_profile, face_body) = if let Some(grouped) = grouped {
grouped
} else {
match p.get("face").and_then(vec3) {
Some(fp) => {
let (id, inside, body) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
(id, Some(ProfileSel::Points { points: vec![inside] }), Some(body))
}
None => (feature_sketch(s, p, cmd)?, None, None),
}
None => (feature_sketch(s, p, cmd)?, None, None),
};
let extent = extent_param(s, p, cmd)?;
let thin = match bool_(p, "thin") {
@@ -565,7 +603,7 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
_ => None,
};
let operation = match str_(p, "operation") {
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation_in(s, p, false).unwrap_or("new")).unwrap_or(Operation::NewBody),
_ => operation(p, cmd)?,
};
let mut targets = string_list(p, "targets");
+8 -3
View File
@@ -25,11 +25,11 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.rib", "Rib", rib)
.at("SOLID", "CREATE")
.icon("rib")
.params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane); depth? (default: to the next face); flip?: bool (fill to the other side)"),
.params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane, or across the curves when normal); depth? (default: to the next face); flip?: bool (fill to the other side); direction?: in_plane|normal (grow across the sketch plane, the default, or along its normal)"),
CommandSpec::new("solid.web", "Web", web)
.at("SOLID", "CREATE")
.icon("web")
.params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool"),
.params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool; direction?: in_plane|normal"),
CommandSpec::new("solid.emboss", "Emboss", emboss)
.at("SOLID", "CREATE")
.icon("emboss")
@@ -133,7 +133,12 @@ fn rib_like(s: &mut Session, p: &Value, cmd: &str, web: bool) -> Result<Value> {
check_expr(s, d, Kind::Length, cmd, "depth")?;
}
let flip = bool_(p, "flip").unwrap_or(false);
add_feature(s, p, FeatureKind::Rib { sketch: SketchId(sketch), curves, thickness, depth, flip, web })
let normal = match str_(p, "direction").map(str::to_ascii_lowercase).as_deref() {
None | Some("in_plane" | "plane") => false,
Some("normal") => true,
Some(o) => return Err(bad(cmd, format!("unknown direction `{o}` (in_plane or normal)"))),
};
add_feature(s, p, FeatureKind::Rib { sketch: SketchId(sketch), curves, thickness, depth, flip, web, normal })
}
fn rib(s: &mut Session, p: &Value) -> Result<Value> {
@@ -2,6 +2,63 @@ use serde_json::{Value, json};
use crate::Session;
#[test]
fn grouped_face_auto_operation_keeps_implicit_and_explicit_participants() {
let mut s = Session::default();
let part = run(&mut s, "component.create", json!({"name":"Part"}));
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"}));
run(&mut s, "component.activate", json!({"component":0}));
for offset in [0., 30.] {
if offset != 0. {
run(&mut s, "occurrence.move", json!({"occurrence":part["occurrence"],"translate":[offset,0,0]}));
}
// To Object follows the target's side, even when the distance and
// direction fields still describe the opposite extrusion.
for (height, expected) in [(8., "cut"), (12., "join")] {
let params = json!({"faces":[[offset+2.,2,10],[offset+8.,8,10]],"face_body":"Stock",
"to":[offset+5.,5,height],"direction":"negative"});
assert_eq!(crate::auto_operation(&s, &params), Some(expected));
}
for distance in [-2., 2.] {
let mut params = json!({"faces":[[offset+2.,2,10],[offset+8.,8,10]],"distance":distance});
let expected = if distance < 0. { "cut" } else { "join" };
assert_eq!(crate::auto_operation(&s, &params), Some(expected));
params["face_body"] = json!("Stock");
assert_eq!(crate::auto_operation(&s, &params), Some(expected));
let mut actual = s.scratch();
if offset != 0. {
run(&mut actual, "component.activate", json!({"component":part["component"]}));
}
params["operation"] = json!("auto");
let made = run(&mut actual, "solid.extrude", params);
let body = actual.model.state().body("Stock").unwrap().body.clone();
let volume = solidcraft_kernel::measure(&body).unwrap().volume;
assert!(
(volume - (1000. + 100. * distance)).abs() < 1e-6,
"offset={offset}, distance={distance}, volume={volume}, feature={:?}",
actual.doc.feature(made["feature"].as_u64().unwrap())
);
}
if offset != 0. {
// Outside the edited component, grouping keeps the original explicit
// face command's coordinate and participant behavior.
let mut grouped = s.scratch();
let mut single = s.scratch();
run(&mut grouped, "solid.extrude", json!({"faces":[[32,2,10],[38,8,10]],"face_body":"Stock","distance":-2,"operation":"cut"}));
run(&mut single, "solid.extrude", json!({"face":[32,2,10],"face_body":"Stock","distance":-2,"operation":"cut"}));
let volume = |session: &Session| solidcraft_kernel::measure(&session.model.state().body("Stock").unwrap().body).unwrap().volume;
assert!((volume(&grouped) - volume(&single)).abs() < 1e-6);
}
}
run(&mut s, "solid.box", json!({"corner":[100,0,0],"length":10,"width":10,"height":10,"body_name":"Other"}));
let mut params = json!({"faces":[[32,2,10],[38,8,10]],"face_body":"Stock","distance":-2});
assert_eq!(crate::auto_operation(&s, &params), Some("join"), "an existing active-component body keeps its original participant scope");
params["participants"] = json!(["Stock"]);
assert_eq!(crate::auto_operation(&s, &params), Some("cut"));
params["participants"] = json!(["Other"]);
assert_eq!(crate::auto_operation(&s, &params), Some("join"));
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
@@ -28,6 +85,43 @@ fn volume(s: &mut Session) -> f64 {
measure(s)["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
#[test]
fn separated_profile_cut_preserves_participants_and_removes_only_consumed_bodies() {
for explicit in [false, true] {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 10), ("C", 30)] {
run(&mut s, "solid.box", json!({"length":5,"width":5,"height":10,"corner":[x,0,0],"body_name":name}));
}
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Cutters"}));
for (a, b) in [([-1, -1], [6, 6]), ([12, 2], [14, 4]), ([32, 2], [34, 4])] {
run(&mut s, "sketch.rectangle.two_point", json!({"p0":a,"p1":b}));
}
run(&mut s, "sketch.finish", json!({}));
let before = s.doc.to_json();
let mut params = json!({"sketch":"Cutters","profiles":"all","distance":10,"operation":"cut"});
if explicit {
params["participants"] = json!(["A", "B"]);
}
run(&mut s, "solid.extrude", params);
let check = |session: &Session| {
let state = session.model.state();
assert_eq!(state.bodies.len(), 2);
assert!(state.body("A").is_none());
for (name, expected) in [("B", 210.), ("C", if explicit { 250. } else { 210. })] {
let body = &state.body(name).unwrap().body;
assert!((solidcraft_kernel::measure(body).unwrap().volume - expected).abs() < 1e-6);
assert!(body.validity().is_empty());
}
};
check(&s);
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
run(&mut s, "edit.redo", json!({}));
check(&s);
}
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
@@ -77,6 +171,28 @@ fn rib_with_a_depth_and_web() {
assert!(e.is_err_and(|e| e.to_string().contains("do not face a body")));
}
#[test]
fn rib_along_the_sketch_normal_reaches_the_body() {
// A 20 x 20 x 10 box; a line along X on a sketch 10 in front of it (y = -10), whose plane
// the box does not cross, so only a rib along the sketch normal can reach it.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [0, 0, 0]}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, -10, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Front"}));
run(&mut s, "sketch.line", json!({"points": [[2, 5], [18, 5]], "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
let e = s.execute("solid.rib", &json!({"sketch": "Front", "curves": ["l1"], "thickness": 2}));
assert!(e.is_err_and(|e| e.to_string().contains("do not face a body")));
run(&mut s, "solid.rib", json!({"sketch": "Front", "curves": ["l1"], "thickness": 2, "direction": "normal"}));
// 16 long, 2 thick, 10 from the sketch to the box.
assert!(rel(volume(&mut s), 4000.0 + 320.0) < 1e-6, "{}", volume(&mut s));
assert_eq!(measure(&mut s)["body_count"], 1);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.rib", json!({"sketch": "Front", "curves": ["l1"], "thickness": 2, "direction": "normal", "depth": 4}));
assert!(rel(volume(&mut s), 4000.0 + 128.0) < 1e-6, "{}", volume(&mut s));
let e = s.execute("solid.rib", &json!({"sketch": "Front", "curves": ["l1"], "thickness": 2, "direction": "sideways"}));
assert!(e.is_err_and(|e| e.to_string().contains("unknown direction")));
}
#[test]
fn emboss_and_deboss_on_a_face() {
let mut s = Session::default();
+2
View File
@@ -69,6 +69,7 @@ mod sketch_canvas;
pub mod sketch_clipboard;
mod sketch_constraints;
mod sketch_create;
mod sketch_ends;
mod sketch_freeform;
mod sketch_import;
mod sketch_inspect;
@@ -236,6 +237,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(sketch_constraints::COMMANDS.iter());
v.extend(sketch_import::COMMANDS.iter());
v.extend(sketch_inspect::COMMANDS.iter());
v.extend(sketch_ends::COMMANDS.iter());
v.extend(sketch_canvas::COMMANDS.iter());
v.extend(sketch_3d::COMMANDS.iter());
v.extend(surface_analysis::COMMANDS.iter());
+1 -1
View File
@@ -12,7 +12,7 @@ const CMD: &str = "inspect.silhouette";
const MAX_PIXELS: usize = 4 * 1024 * 1024;
// For agents comparing the model with a picture; nothing to show a person, so no toolbar button.
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Silhouette", run).noundo().params(
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Silhouette", run).icon("project").noundo().params(
"view?: front|back|left|right|top|bottom|iso (default front); or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (mm, radians; fov 0 means parallel); bodies?: [world body names] (default visible bodies, named hidden bodies included); rows?: mask height in pixels (default 240, clamped to 16..1024) → tightly framed run-length mask, box_mm, mm_per_px, right/up, projection, body and triangle counts",
)];
+97
View File
@@ -0,0 +1,97 @@
//! Loose ends of a sketch's profile curves: the ones that touch nothing (open) and the ones that
//! touch another curve only by position (untied), and tying the untied ones.
use serde_json::{Value, json};
use solidcraft_sketch::{ConstraintKind, EndReport, EndTouch, Sketch, loose_ends};
use super::sketch::{add_c, edit, target_sketch};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, string_list};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.loose_ends", "Loose Ends", loose_ends_cmd).icon("c_coincident").enabled(in_sketch).noundo().params(
"sketch?: id|name (default active): the ends of profile curves (not construction, projected or text) that can make a profile fail. open: [{point, curve, at}] ends that touch no other profile curve (no profile runs through them); untied: [{point, curve, at, touches: {point}|{curve}}] ends that touch another curve only by position (the profile closes now, but nothing keeps it closed when a dimension changes)",
),
CommandSpec::new("sketch.loose_ends.tie", "Tie Loose Ends", tie_cmd).icon("c_coincident").enabled(in_sketch).params(
"sketch?: id|name (default active); points?: [point ids] (default: every untied end): ties each untied end to what it touches, coincident with another curve's end or on another curve. Returns constraints: [ids added], and the untied ends left",
),
];
fn point_id(sk: &Sketch, i: usize) -> Value {
sk.points.get(i).map_or(Value::Null, |p| json!(p.id))
}
fn curve_id(sk: &Sketch, i: usize) -> Value {
sk.curves.get(i).map_or(Value::Null, |c| json!(c.id))
}
fn at(sk: &Sketch, i: usize) -> Value {
sk.point(i).map_or(Value::Null, |v| json!([v.x, v.y]))
}
/// The point that is an end of curve `c` (for `curve` in the report).
fn end_curve(sk: &Sketch, p: usize) -> Value {
let c = sk.curves.iter().position(|c| c.kind.ends().is_some_and(|(a, b)| a == p || b == p) && !(c.construction || c.centerline || c.infinite));
c.map_or(Value::Null, |c| curve_id(sk, c))
}
fn report_json(sk: &Sketch, r: &EndReport) -> Value {
let open: Vec<Value> = r.open.iter().map(|&p| json!({"point": point_id(sk, p), "curve": end_curve(sk, p), "at": at(sk, p)})).collect();
let untied: Vec<Value> = r
.untied
.iter()
.map(|u| {
let touches = match u.touches {
EndTouch::Point(q) => json!({"point": point_id(sk, q)}),
EndTouch::Curve(c) => json!({"curve": curve_id(sk, c)}),
};
json!({"point": point_id(sk, u.point), "curve": curve_id(sk, u.curve), "at": at(sk, u.point), "touches": touches})
})
.collect();
json!({"open": open, "untied": untied})
}
fn loose_ends_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let id = target_sketch(s, p, "sketch.loose_ends")?;
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch has not been evaluated".into()))?;
Ok(report_json(&ss.sketch, &ss.ends))
}
fn tie_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.loose_ends.tie";
let only = match p.get("points") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) if a.iter().all(Value::is_string) => Some(string_list(p, "points")),
Some(_) => return Err(bad(cmd, "`points` must be a list of point ids")),
};
let (added, info) = edit(s, p, cmd, true, |sk, _| {
let wanted = match &only {
None => None,
Some(ids) => Some(
ids.iter().map(|r| sk.resolve_point(r).ok_or_else(|| bad(cmd, format!("unknown point `{r}`")))).collect::<Result<Vec<usize>>>()?,
),
};
let report = loose_ends(sk);
let mut added = Vec::new();
for u in &report.untied {
if wanted.as_ref().is_some_and(|w| !w.contains(&u.point)) {
continue;
}
let kind = match u.touches {
EndTouch::Point(q) => ConstraintKind::Coincident { p: u.point, q },
EndTouch::Curve(c) => ConstraintKind::PointOnCurve { p: u.point, c },
};
added.push(add_c(sk, kind)?);
}
let left = report_json(sk, &loose_ends(sk)).get("untied").cloned().unwrap_or(Value::Null);
Ok((added, left))
})?;
let (added, left) = added;
Ok(json!({"constraints": added, "sketch": info, "untied": left}))
}
#[cfg(test)]
#[path = "sketch_ends_tests.rs"]
mod tests;
@@ -0,0 +1,94 @@
//! `sketch.loose_ends` and `sketch.loose_ends.tie`: ends that touch nothing or only touch by
//! position, and tying them so the profile stays closed when the sketch changes.
use serde_json::{Value, json};
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 square of four separate lines: closed by position only.
fn loose_square(s: &mut Session) -> Vec<String> {
run(s, "sketch.create", json!({"plane": "XY"}));
let c = [[0, 0], [10, 0], [10, 10], [0, 10]];
(0..4)
.map(|i| {
let r = run(s, "sketch.line", json!({"points": [c[i], c[(i + 1) % 4]], "infer": false, "connect": false}));
r["curves"][0].as_str().unwrap().to_string()
})
.collect()
}
#[test]
fn a_square_closed_by_position_has_four_untied_corners() {
let mut s = Session::default();
loose_square(&mut s);
assert_eq!(run(&mut s, "sketch.solve", json!({}))["profiles"], 1);
let r = run(&mut s, "sketch.loose_ends", json!({}));
assert_eq!(r["open"].as_array().unwrap().len(), 0, "{r}");
let untied = r["untied"].as_array().unwrap();
assert_eq!(untied.len(), 4, "{r}");
assert!(untied.iter().all(|u| u["touches"]["point"].is_string() && u["at"].is_array()), "{r}");
}
#[test]
fn tying_keeps_the_profile_closed_when_a_side_moves() {
let mut s = Session::default();
let lines = loose_square(&mut s);
let r = run(&mut s, "sketch.loose_ends.tie", json!({}));
assert_eq!(r["constraints"].as_array().unwrap().len(), 4, "{r}");
assert_eq!(r["untied"].as_array().unwrap().len(), 0, "{r}");
assert!(run(&mut s, "sketch.loose_ends", json!({}))["untied"].as_array().unwrap().is_empty());
// Lengthen the bottom side: the corners follow it and the square stays one profile.
run(&mut s, "sketch.dimension", json!({"entities": [lines[0]], "type": "length", "value": 14}));
assert_eq!(run(&mut s, "sketch.solve", json!({}))["profiles"], 1);
// Tying is one undo step.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(run(&mut s, "sketch.loose_ends", json!({}))["untied"].as_array().unwrap().len(), 4);
}
#[test]
fn tie_takes_only_the_points_asked_for() {
let mut s = Session::default();
loose_square(&mut s);
let r = run(&mut s, "sketch.loose_ends", json!({}));
let first = r["untied"][0]["point"].clone();
let t = run(&mut s, "sketch.loose_ends.tie", json!({"points": [first]}));
assert_eq!(t["constraints"].as_array().unwrap().len(), 1, "{t}");
assert_eq!(t["untied"].as_array().unwrap().len(), 3, "{t}");
}
#[test]
fn a_gap_shows_two_open_ends() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0], [10, 10], [0, 9.9]], "infer": false}));
let r = run(&mut s, "sketch.loose_ends", json!({}));
assert_eq!(r["open"].as_array().unwrap().len(), 2, "{r}");
assert!(r["untied"].as_array().unwrap().is_empty(), "{r}");
}
#[test]
fn hostile_tie_params_are_errors() {
let mut s = Session::default();
loose_square(&mut s);
for p in [json!({"points": 3}), json!({"points": ["nope"]}), json!({"points": [null, 1e308]}), json!({"sketch": "missing"})] {
assert!(s.execute("sketch.loose_ends.tie", &p).is_err(), "{p}");
}
assert!(s.execute("sketch.loose_ends", &json!({"sketch": "missing"})).is_err());
// Nothing changed.
assert_eq!(run(&mut s, "sketch.loose_ends", json!({}))["untied"].as_array().unwrap().len(), 4);
}
#[test]
fn outside_a_sketch_they_are_refused() {
let mut s = Session::default();
assert!(s.execute("sketch.loose_ends", &json!({})).is_err());
assert!(s.execute("sketch.loose_ends.tie", &json!({})).is_err());
}
+19 -6
View File
@@ -520,21 +520,34 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
/// Private compatibility projection for generated face profiles. Named legacy links follow
/// edits, but cannot provide the construction certificate required by public attachments.
pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result<String> {
project_internal_faces(s, id, body, &[index])?.pop().ok_or_else(|| bad("face profile", "empty face projection"))
}
/// Capture individual replay names together, against the same unchanged source body.
pub(super) fn project_internal_faces(s: &mut Session, id: u64, body: &str, indices: &[usize]) -> Result<Vec<String>> {
if indices.len() > 1024 {
return Err(bad("face profile", "too many faces"));
}
let mut doc = (*s.doc).clone();
let raw = s.model.state_before(id);
let component = doc.feature(id).map(|feature| feature.component).unwrap_or(0);
let state = solidcraft_doc::state_in_frame_at(&doc, &raw, component, owner_frame(s, &doc, component));
let plane = sketch_plane(&doc, id, &state)?;
let resolved = solidcraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?;
let face_name = state.body(body).and_then(|body| solidcraft_doc::naming::face_names(body).get(index).cloned());
let names = state.body(body).map(solidcraft_doc::naming::face_names).unwrap_or_default();
let mut sk = doc.sketch(id)?.clone();
let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?;
if let Some(entry) = sk.links.iter_mut().find(|entry| entry.id == link) {
entry.face_name = face_name;
let mut links = Vec::with_capacity(indices.len());
for &index in indices {
let resolved = solidcraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?;
let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?;
if let Some(entry) = sk.links.iter_mut().find(|entry| entry.id == link) {
entry.face_name = names.get(index).cloned();
}
links.push(link);
}
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
Ok(link)
Ok(links)
}
/// Project the edges of the face a new sketch was created on (Fusion's default).
+165 -25
View File
@@ -1,38 +1,56 @@
//! Boundary surfaces: one patch through a network of two to four sketch curves (`surface.boundary`,
//! issue #763). The kernel builds the face from the curves' own frames; this is the command's
//! parameters and its checks, so that a loop it cannot hold is refused before anything is built.
//! Boundary surfaces (`surface.boundary`, issue #763): one patch through two to four sketch
//! curves in loop order, or one surface through a network of curves in two directions (sketch
//! curves, 3D sketch curves, a body's edges, or a point for a three-sided patch). The kernel
//! builds the face; this is the command's parameters and its checks, so that what it cannot hold
//! is refused before anything is built.
use serde_json::Value;
use solidcraft_doc::{FeatureKind, SketchId};
use solidcraft_doc::{BodyName, FeatureKind, NetworkCurve, Operation, Position, SketchId};
use solidcraft_kernel::Continuity;
use super::CommandSpec;
use super::features::{add_feature, feature_sketch};
use crate::params::{bad, string_list};
use super::features::{add_feature, feature_sketch, sketch_id};
use crate::params::{bad, str_, string_list, vec3};
use crate::{Result, Session};
/// The stations a boundary carries by default (the patch's resolution along the loop).
const DEFAULT_SAMPLES: usize = 25;
const DEFAULT_SAMPLES: usize = solidcraft_doc::BOUNDARY_SAMPLES;
/// The fewest stations a patch can hold the widest continuity pair with: a G3 side needs four rows
/// at each of two opposite sides, so eight.
const MIN_SAMPLES: usize = 8;
/// The most stations a boundary may carry (the kernel's own cap).
const MAX_SAMPLES: usize = 512;
/// The most curves one direction of a network may list (the kernel's own cap).
const MAX_NETWORK_CURVES: usize = solidcraft_geom::network::MAX_NETWORK_CURVES;
/// The most sketch curves one network curve may chain, and points picking one chain of edges
/// (the kernel's own caps).
use solidcraft_kernel::{MAX_CHAIN_CURVES as MAX_CHAIN, MAX_EDGE_PICKS as MAX_PICKS};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("surface.boundary", "Boundary Surface", boundary)
.at("SURFACE", "CREATE")
.icon("surface_boundary")
.params(
"sketch?: id|name (default: the sketch being edited); curves: [2 to 4 sketch curve ids in loop order] (each one ends where the next begins and the last where the first does; the patch runs through them and leaves each boundary along the tangent across it); continuity?: g0|g1|g2|g3 | [one per curve] (default g1: how much of the surface across each boundary the patch repeats — a sketch curve's own plane, so g1 and up hold exactly and g0 lets the middle bow); crown?: number -0.9…3 (default 0: how full the middle is, without touching a boundary); samples?: 8…512 stations per boundary (default 25); name?, body_name? (a surface body)",
"Either a loop of one sketch's curves — sketch?: id|name (default: the sketch being edited); curves: [2 to 4 sketch curve ids in loop order] (each one ends where the next begins and the last where the first does; two: the patch spans between them and leaves each boundary along the tangent across it; three or four: the flat patch of the loop, three collapsing the side opposite the first curve to the corner there); continuity?: g0|g1|g2|g3 | [one per curve] (default g1: how much of the surface across each boundary the patch repeats — a sketch curve's own plane, so g1 and up hold exactly and g0 lets the middle bow); crown?: number -0.9…3 (default 0: how full the middle is, without touching a boundary; two curves only); samples?: 8…512 stations per boundary (default 25) — or a curve network — u: [curve] (two or more one way: the surface lofts through them), v?: [curve] (crossing every u-curve once: two each way give a Coons patch, more a Gordon surface through every curve; a curve that misses another by more than 0.01 mm is refused, naming the pair), each curve \"id\" (a curve of `sketch`) | {sketch?: id|name, curves: [ids meeting end to end]} (planar or 3D sketch curves) | {body: name, edges: [[x,y,z]] (points on a chain of its edges), continuity?: g0|g1|g2 (default g0; g1 the surface leaves tangent to the face beside a surface's open edges, g2 with its curvature too; on curves of one direction only)} | {point: [x,y,z]} (a side collapsed to a point: a three-sided patch); operation?: new|join (default new; join sews the surface into the body of its first edge curve, a solid when it closes the gap). name?, body_name? (a surface body)",
)];
/// `surface.boundary`: one patch through a network of two to four sketch curves.
/// `surface.boundary`: a loop of sketch curves, or a network when `u` is given.
fn boundary(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("u").is_some_and(|v| !v.is_null()) {
return network(s, p);
}
loop_patch(s, p)
}
/// `surface.boundary` with `curves`: one patch through two to four sketch curves in loop order.
fn loop_patch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.boundary";
if p.get("v").is_some_and(|v| !v.is_null()) || p.get("operation").is_some_and(|v| !v.is_null()) {
return Err(bad(cmd, "`v` and `operation` belong to a curve network: give its curves as `u` and `v`"));
}
let sketch = feature_sketch(s, p, cmd)?;
let curves = string_list(p, "curves");
if !(2..=4).contains(&curves.len()) {
return Err(bad(cmd, "`curves` must list 2 to 4 sketch curves in loop order"));
return Err(bad(cmd, "`curves` must list 2 to 4 sketch curves in loop order (or give a network as `u` and `v`)"));
}
// The curves must be the sketch's own, and they must run on into one another: the last station
// of each is the first of the next, and the last boundary's end is the first one's start.
@@ -98,9 +116,134 @@ fn boundary(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| bad(cmd, format!("`samples` must be a whole number of stations between {MIN_SAMPLES} and {MAX_SAMPLES}")))?
}
};
if crown != 0.0 && curves.len() >= 3 {
return Err(bad(cmd, "not supported yet: `crown` on a patch through three or four curves (it is the flat patch of its sides)"));
}
add_feature(s, p, FeatureKind::SurfaceBoundary { sketch: SketchId(sketch), curves, orders, crown, samples })
}
/// `surface.boundary` with `u` (and `v`): one surface through a curve network.
fn network(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.boundary";
for key in ["curves", "continuity", "crown", "samples"] {
if p.get(key).is_some_and(|v| !v.is_null()) {
return Err(bad(cmd, format!("`{key}` belongs to a loop of sketch curves: a network gives each curve its own continuity")));
}
}
let operation = super::features::operation(p, cmd)?;
if !matches!(operation, Operation::NewBody | Operation::Join) {
return Err(bad(cmd, "`operation` must be new or join"));
}
// The sketch a bare curve id belongs to, found only when one is used.
let mut default_sketch: Option<u64> = None;
let u = network_curves(s, p, "u", &mut default_sketch)?;
let v = network_curves(s, p, "v", &mut default_sketch)?;
if u.is_empty() {
return Err(bad(cmd, "`u` must list the curves of one direction"));
}
if u.len() + v.len() < 2 {
return Err(bad(cmd, "a boundary surface needs two curves in one direction, or crossing curves"));
}
if operation == Operation::Join && !u.iter().chain(&v).any(|c| c.body.is_some()) {
return Err(bad(cmd, "`operation` join needs a curve on a body's edges: the surface is sewn into that body"));
}
add_feature(s, p, FeatureKind::SurfaceNetwork { u, v, operation })
}
/// One direction of a network (`key` is `u` or `v`), checked: each curve sketch curves that exist
/// (planar or 3D), a body's edges with points on them, or a point; a continuity only on edges.
fn network_curves(s: &Session, p: &Value, key: &str, default_sketch: &mut Option<u64>) -> Result<Vec<NetworkCurve>> {
let cmd = "surface.boundary";
let list = match p.get(key) {
None | Some(Value::Null) => return Ok(Vec::new()),
Some(Value::Array(a)) => a,
Some(_) => return Err(bad(cmd, format!("`{key}` must list curves"))),
};
if list.len() > MAX_NETWORK_CURVES {
return Err(bad(cmd, format!("`{key}` may list at most {MAX_NETWORK_CURVES} curves")));
}
let mut out = Vec::with_capacity(list.len());
for (k, item) in list.iter().enumerate() {
let name = format!("{key}[{k}]");
let mut curve = NetworkCurve { sketch: None, curves: Vec::new(), body: None, edges: Vec::new(), point: None, continuity: Continuity::G0 };
let obj = match item {
Value::String(id) => {
curve.curves = vec![id.clone()];
None
}
Value::Object(_) => Some(item),
_ => return Err(bad(cmd, format!("{name} must be a curve id or {{sketch?, curves}}, {{body, edges}} or {{point}}"))),
};
if let Some(o) = obj {
let has = |k: &str| o.get(k).is_some_and(|v| !v.is_null());
let kinds = [has("curves"), has("body") || has("edges"), has("point")];
if kinds.iter().filter(|x| **x).count() != 1 {
return Err(bad(cmd, format!("{name} must be one of {{sketch?, curves}}, {{body, edges}} or {{point}}")));
}
if has("point") {
curve.point = Some(Position(o.get("point").and_then(vec3).ok_or_else(|| bad(cmd, format!("{name}.point must be [x, y, z]")))?));
} else if kinds[1] {
let body = str_(o, "body").ok_or_else(|| bad(cmd, format!("{name}.body must name a body")))?;
if s.model.state().body(body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
let edges =
o.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("{name}.edges must list points on the edges")))?;
if edges.is_empty() || edges.len() > MAX_PICKS {
return Err(bad(cmd, format!("{name}.edges must list 1 to {MAX_PICKS} points on a chain of edges")));
}
let pts: Vec<Position> = edges
.iter()
.map(|e| vec3(e).map(Position).ok_or_else(|| bad(cmd, format!("{name}.edges: a point must be [x, y, z]"))))
.collect::<Result<_>>()?;
curve.body = Some(BodyName::from(body.to_string()));
curve.edges = pts;
} else {
curve.curves = string_list(o, "curves");
if o.get("sketch").is_some_and(|v| !v.is_null()) {
curve.sketch = Some(SketchId(sketch_id(s, o.get("sketch"), cmd, &format!("{name}.sketch"))?));
}
}
if let Some(g) = o.get("continuity").filter(|v| !v.is_null()) {
let g = g.as_str().and_then(Continuity::parse).ok_or_else(|| bad(cmd, format!("{name}.continuity must be g0, g1 or g2")))?;
if g == Continuity::G3 {
return Err(bad(cmd, format!("not supported yet: G3 on {name} (a curve network holds up to g2)")));
}
if g != Continuity::G0 && curve.body.is_none() {
return Err(bad(cmd, format!("{name}: g1 and g2 apply to a curve on a body's edges (a surface's open edges)")));
}
curve.continuity = g;
}
}
if curve.point.is_none() && curve.body.is_none() {
if curve.curves.is_empty() || curve.curves.len() > MAX_CHAIN {
return Err(bad(cmd, format!("{name} must list 1 to {MAX_CHAIN} sketch curves")));
}
let sketch = match curve.sketch {
Some(id) => id.0,
None => match *default_sketch {
Some(id) => id,
None => {
let id = feature_sketch(s, p, cmd)?;
*default_sketch = Some(id);
id
}
},
};
let state = s.model.state();
let sk = state.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch has not been solved"))?;
for id in &curve.curves {
if sk.sketch.curve_index(id).is_none() && sk.sketch.wire_index(id).is_none() {
return Err(bad(cmd, format!("{name}: no sketch curve `{id}`")));
}
}
curve.sketch = Some(SketchId(sketch));
}
out.push(curve);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
@@ -154,27 +297,19 @@ mod tests {
}
#[test]
fn a_boundary_patch_refuses_a_loop_and_values_it_cannot_hold() {
fn a_boundary_patch_refuses_values_it_cannot_hold() {
let mut s = new_sketch();
let curves = two_walls(&mut s);
let extra = ids(&run(&mut s, "sketch.line", json!({"points": [[30, 30], [40, 40]]}))["created"]["curves"])[0].clone();
// A loop of three or four: the kernel refuses it with its own reason (a tensor-product patch
// cannot hold two sides' data at a corner, issue #763) rather than building it approximately.
let triangle = ["l1", "l2", "l3"].map(|id| {
let _ = id;
String::new()
});
let _ = triangle;
// A loop of three: the crown is the bridge's, not the flat loop patch's.
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["created"]["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 0], [10, 15]]}))["created"]["curves"])[0].clone();
let c = ids(&run(&mut s, "sketch.line", json!({"points": [[10, 15], [0, 0]]}))["created"]["curves"])[0].clone();
let e = s.execute("surface.boundary", &json!({"curves": [a, b, c], "continuity": "g2"})).unwrap_err().to_string();
assert!(e.contains("not supported yet") && e.contains("#763"), "three boundaries: {e}");
// A closed square of four lines, in loop order.
let sq = [[[0, 0], [10, 0]], [[10, 0], [10, 10]], [[10, 10], [0, 10]], [[0, 10], [0, 0]]];
let square: Vec<String> = sq.iter().map(|p| ids(&run(&mut s, "sketch.line", json!({"points": p}))["created"]["curves"])[0].clone()).collect();
let e = s.execute("surface.boundary", &json!({"curves": square})).unwrap_err().to_string();
assert!(e.contains("not supported yet") && e.contains("#763"), "four boundaries: {e}");
let e = s.execute("surface.boundary", &json!({"curves": [a, b, c], "crown": 1})).unwrap_err().to_string();
assert!(e.contains("not supported yet") && e.contains("crown"), "three boundaries with a crown: {e}");
// Curves out of loop order.
let e = s.execute("surface.boundary", &json!({"curves": [curves[0], curves[1], extra]})).unwrap_err().to_string();
assert!(e.contains("loop order"), "{e}");
// Parameters and curves that make no sense.
let cases: Vec<(Value, &str)> = vec![
(json!({"curves": [curves[0]]}), "2 to 4"),
@@ -186,6 +321,7 @@ mod tests {
(json!({"curves": curves, "crown": "full"}), "crown"),
(json!({"curves": curves, "samples": 3}), "samples"),
(json!({"curves": curves, "samples": 2.5}), "samples"),
(json!({"curves": curves, "operation": "join"}), "network"),
];
for (params, want) in cases {
let e = s.execute("surface.boundary", &params).unwrap_err().to_string();
@@ -195,3 +331,7 @@ mod tests {
assert!(s.model.state().bodies.is_empty(), "a refused patch left a body behind");
}
}
#[cfg(test)]
#[path = "surface_boundary_tests.rs"]
mod network_tests;
@@ -0,0 +1,267 @@
//! `surface.boundary` through loops of three or four sketch curves and through curve networks
//! (#763): analytic areas and shapes, G1 and G2 measured across the edge a surface shares with a
//! body (`inspect.continuity`), and refusals of hostile networks that never panic.
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn first(v: &Value) -> String {
v.as_array().and_then(|a| a.first()).and_then(Value::as_str).map(str::to_string).unwrap_or_default()
}
/// A sketch line's id.
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String {
first(&run(s, "sketch.line", json!({"points": [a, b]}))["created"]["curves"])
}
/// A 3D sketch line's id (the active sketch must have 3D Sketch on).
fn line3d(s: &mut Session, a: [f64; 3], b: [f64; 3]) -> String {
first(&run(s, "sketch.line3d", json!({"points": [a, b]}))["curves"])
}
/// The area of the one face of `body`, and the area `inspect.measure` gives for the whole model.
fn areas(s: &mut Session, body: &str) -> (f64, f64) {
let f = run(s, "model.faces", json!({"body": body}));
let faces = f["faces"].as_array().cloned().unwrap_or_default();
assert_eq!(faces.len(), 1, "one face: {f}");
let face = faces[0]["area"].as_f64().unwrap_or(f64::NAN);
let m = run(s, "inspect.measure", json!({}));
(face, m["total"]["area_mm2"].as_f64().unwrap_or(f64::NAN))
}
/// Three and four sketch curves in loop order: the flat patch of the loop. A triangle and a square
/// of lines, and a square whose top is a half circle bulging out, each measure their own area.
#[test]
fn three_and_four_curve_loops_are_flat_patches() {
use std::f64::consts::PI;
let cases: Vec<(Vec<[[f64; 2]; 2]>, f64, f64)> = vec![
(vec![[[0.0, 0.0], [20.0, 0.0]], [[20.0, 0.0], [10.0, 15.0]], [[10.0, 15.0], [0.0, 0.0]]], 150.0, 1e-6),
(vec![[[0.0, 0.0], [10.0, 0.0]], [[10.0, 0.0], [10.0, 10.0]], [[10.0, 10.0], [0.0, 10.0]], [[0.0, 10.0], [0.0, 0.0]]], 100.0, 1e-6),
];
for (lines, want, tol) in cases {
for continuity in [json!("g0"), json!("g1"), json!("g3")] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let curves: Vec<String> = lines.iter().map(|[a, b]| line(&mut s, *a, *b)).collect();
let r = run(&mut s, "surface.boundary", json!({"curves": curves, "continuity": continuity}));
let body = first(&r["created_bodies"]);
let (face, total) = areas(&mut s, &body);
assert!(
(face - want).abs() < tol * want && (total - want).abs() < tol * want,
"{} sides, {continuity}: {face} / {total} vs {want}",
lines.len()
);
}
}
// A square with a half circle for its top, bulging out: 100 + 12.5π.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let bottom = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let right = line(&mut s, [10.0, 0.0], [10.0, 10.0]);
let arc = first(&run(&mut s, "sketch.arc.three_point", json!({"start": [10, 10], "end": [0, 10], "through": [5, 15]}))["created"]["curves"]);
let left = line(&mut s, [0.0, 10.0], [0.0, 0.0]);
let r = run(&mut s, "surface.boundary", json!({"curves": [bottom, right, arc, left]}));
let (face, total) = areas(&mut s, &first(&r["created_bodies"]));
let want = 100.0 + 12.5 * PI;
assert!((face - want).abs() < 2e-3 * want && (total - want).abs() < 2e-3 * want, "{face} / {total} vs {want}");
}
/// Simpson's rule over [0, a] × [0, a] with n (even) intervals each way.
fn simpson2(f: impl Fn(f64, f64) -> f64, a: f64, n: usize) -> f64 {
let h = a / n as f64;
let w = |k: usize| {
if k == 0 || k == n {
1.0
} else if k % 2 == 1 {
4.0
} else {
2.0
}
};
let mut sum = 0.0;
for i in 0..=n {
for j in 0..=n {
sum += w(i) * w(j) * f(i as f64 * h, j as f64 * h);
}
}
sum * h * h / 9.0
}
/// Four straight 3D sketch lines bounding the hyperbolic paraboloid z = x·y/10 over the 10 × 10
/// square: the network's Coons patch is that surface (every vertex of its mesh on it), and its
/// area is the paraboloid's, ∫∫ √(1 + (x/10)² + (y/10)²).
#[test]
fn a_network_of_3d_lines_is_a_hyperbolic_paraboloid() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.options", json!({"sketch_3d": true}));
let a = line3d(&mut s, [0.0, 0.0, 0.0], [10.0, 0.0, 0.0]);
let c = line3d(&mut s, [0.0, 10.0, 0.0], [10.0, 10.0, 10.0]);
let d = line3d(&mut s, [0.0, 0.0, 0.0], [0.0, 10.0, 0.0]);
let b = line3d(&mut s, [10.0, 0.0, 0.0], [10.0, 10.0, 10.0]);
let r = run(&mut s, "surface.boundary", json!({"u": [a, c], "v": [d, b]}));
let body = first(&r["created_bodies"]);
let want = simpson2(|x, y| (1.0 + (x / 10.0).powi(2) + (y / 10.0).powi(2)).sqrt(), 10.0, 200);
let (face, total) = areas(&mut s, &body);
// The face's own area comes from the coarse reference mesh (a chord of a fiftieth of a per
// cent of the body); the measuring mesh is refined onto the surface.
assert!((face - want).abs() < 1e-2 * want && (total - want).abs() < 1e-3 * want, "{face} / {total} vs {want}");
let st = s.world_state();
let mesh = st.body(&body).map(|b| b.body.tessellate(0.01)).and_then(Result::ok).unwrap_or_default();
assert!(!mesh.positions.is_empty());
for p in &mesh.positions {
assert!((p.z - p.x * p.y / 10.0).abs() < 1e-6, "{p:?} is off the paraboloid");
}
}
/// Curves of two planar sketches, one picked by a bare id in the sketch being edited: the loft
/// between a line on the ground and one 10 mm across and 5 mm up is the flat strip between them.
#[test]
fn a_network_takes_curves_from_several_sketches() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Low"}));
let low = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 5, "name": "High"}));
let high = line(&mut s, [0.0, 10.0], [10.0, 10.0]);
let r = run(&mut s, "surface.boundary", json!({"u": [{"sketch": "Low", "curves": [low]}, high]}));
let (face, _) = areas(&mut s, &first(&r["created_bodies"]));
let want = 10.0 * 125f64.sqrt();
assert!((face - want).abs() < 1e-6 * want, "{face} vs {want}");
}
/// A quarter cylinder sheet (R = 10, axis along x, its straight top edge at y = 10, z = 0 open)
/// and a line 3D-sketched above it; returns the sheet's name and the line's id.
fn cylinder_and_line(s: &mut Session) -> (String, String) {
run(s, "sketch.create", json!({"plane": "YZ", "name": "Arc"}));
let h = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
let arc = first(&run(s, "sketch.arc.three_point", json!({"start": [0, -10], "end": [10, 0], "through": [h, -h]}))["created"]["curves"]);
run(s, "sketch.finish", json!({}));
let sheet = first(&run(s, "surface.extrude", json!({"sketch": "Arc", "curves": [arc], "distance": 10}))["created_bodies"]);
run(s, "sketch.create", json!({"plane": "XY", "name": "Top"}));
run(s, "sketch.options", json!({"sketch_3d": true}));
let top = line3d(s, [0.0, 3.0, 12.0], [10.0, 3.0, 12.0]);
run(s, "sketch.finish", json!({}));
(sheet, top)
}
/// The edge a surface built from the cylinder's top edge shares with it, measured with
/// `inspect.continuity`: (normal angle in degrees, curvature jump per mm).
fn seam(s: &mut Session, body: &str) -> (f64, f64) {
let v = run(s, "inspect.continuity", json!({"body": body, "edges": [[5.0, 10.0, 0.0]]}));
let e = v["bodies"][0]["edges"].as_array().and_then(|a| a.first()).cloned().unwrap_or_default();
(e["normal_angle_deg"]["value"].as_f64().unwrap_or(f64::NAN), e["curvature_jump_per_mm"]["value"].as_f64().unwrap_or(f64::NAN))
}
/// G1 and G2 against a body: a network from the cylinder's open top edge to a line above, joined
/// into the cylinder. Across the shared edge the normals agree within 0.1° for G1 and G2; the
/// curvature jumps by much of the cylinder's 1/R with G1 and by under 1% of it with G2.
#[test]
fn g1_and_g2_networks_meet_a_body_across_its_edge() {
let mut jumps = Vec::new();
for g in ["g1", "g2"] {
let mut s = Session::default();
let (sheet, top) = cylinder_and_line(&mut s);
let r = run(
&mut s,
"surface.boundary",
json!({"u": [{"body": sheet, "edges": [[5.0, 10.0, 0.0]], "continuity": g}, {"sketch": "Top", "curves": [top]}], "operation": "join"}),
);
assert!(r["created_bodies"].as_array().is_none_or(Vec::is_empty), "a join makes no new body: {r}");
let f = run(&mut s, "model.faces", json!({"body": sheet}));
assert_eq!(f["faces"].as_array().map(Vec::len), Some(2), "the surface is sewn into the sheet: {f}");
let (angle, jump) = seam(&mut s, &sheet);
assert!(angle < 0.1, "{g}: normals {angle}° apart across the edge");
jumps.push(jump);
}
let r = 10.0;
assert!(jumps[0] > 0.2 / r, "G1 leaves the curvature behind: jump {}", jumps[0]);
assert!(jumps[1] < 0.01 / r, "G2 carries the cylinder's curvature across: jump {}", jumps[1]);
}
/// Networks that cannot be built are refused with a reason — by the command's own checks or by the
/// kernel — and leave nothing behind; none of them panics.
#[test]
fn hostile_networks_are_refused() {
let mut s = Session::default();
let (sheet, top) = cylinder_and_line(&mut s);
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 40, 0]}));
let boxed = s.world_state().bodies.last().map(|b| b.name.clone()).unwrap_or_default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Flat"}));
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let b = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
let far = line(&mut s, [0.0, 20.0], [10.0, 20.0]);
let ring = first(&run(&mut s, "sketch.circle.center", json!({"center": [50, 50], "radius": 3}))["created"]["curves"]);
let bodies = s.world_state().bodies.len();
let edge = |g: &str| json!({"body": sheet, "edges": [[5.0, 10.0, 0.0]], "continuity": g});
let many: Vec<Value> = (0..33).map(|_| json!(a)).collect();
let cases: Vec<(Value, &str)> = vec![
(json!({"u": "l1"}), "list"),
(json!({"u": [42, a]}), "curve id"),
(json!({"u": [{}, a]}), "one of"),
(json!({"u": [{"curves": [], "sketch": "Flat"}, a]}), "sketch curves"),
(json!({"u": [{"curves": ["nope"]}, a]}), "no sketch curve"),
(json!({"u": [{"curves": [a], "sketch": "Nope"}, b]}), "no sketch"),
(json!({"u": [{"body": "Nope", "edges": [[0, 0, 0]]}, a]}), "no body"),
(json!({"u": [{"body": sheet, "edges": []}, a]}), "edges"),
(json!({"u": [{"body": sheet, "edges": [["x", 0, 0]]}, a]}), "[x, y, z]"),
(json!({"u": [{"body": sheet, "edges": [[1e300, 0, 0]]}, a]}), "[x, y, z]"),
(json!({"u": [{"point": [0, 0]}, a]}), "point"),
(json!({"u": [{"curves": [a], "body": sheet, "edges": [[5, 10, 0]]}, b]}), "one of"),
(json!({"u": [{"curves": [a], "continuity": "g1"}, b]}), "body's edges"),
(json!({"u": [edge("g3"), {"sketch": "Top", "curves": [top]}]}), "not supported yet"),
(json!({"u": [edge("g9"), {"sketch": "Top", "curves": [top]}]}), "continuity"),
(json!({"u": many}), "at most"),
(json!({"u": [a, b], "curves": [a, b]}), "loop"),
(json!({"u": [a, b], "crown": 1}), "loop"),
(json!({"u": [a, b], "operation": "cut"}), "operation"),
(json!({"u": [a, b], "operation": "join"}), "join"),
(json!({"u": [a]}), "two curves"),
(json!({"u": [], "v": [a, b]}), "`u`"),
// The kernel's refusals: curves that miss, a closed curve, G1 on an edge between two faces.
(json!({"u": [a, b], "v": [{"curves": [far]}, {"curves": [a]}]}), "don't meet"),
(json!({"u": [{"curves": [ring]}, b]}), "closed"),
(json!({"u": [{"body": boxed, "edges": [[45, 40, 0]], "continuity": "g1"}, b]}), "open edge"),
(json!({"u": [a, {"point": [5, 2, 0]}, b]}), "point"),
];
for (params, want) in cases {
let e = match s.execute("surface.boundary", &params) {
Ok(v) => panic!("{params} was built: {v}"),
Err(e) => e,
};
assert!(!matches!(e, crate::EngineError::Internal(..)), "{params}: {e}");
assert!(e.to_string().contains(want), "{params}: {e}");
}
assert_eq!(s.world_state().bodies.len(), bodies, "a refused network left a body behind");
}
/// An edit (`timeline.redefine`) may turn a loop of sketch curves into the network of the same
/// curves and back: the feature keeps its place and its name, and the surface its area.
#[test]
fn an_edit_turns_a_loop_into_a_network_and_back() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Square"}));
let sides: Vec<String> = [[[0.0, 0.0], [10.0, 0.0]], [[10.0, 0.0], [10.0, 10.0]], [[10.0, 10.0], [0.0, 10.0]], [[0.0, 10.0], [0.0, 0.0]]]
.iter()
.map(|[a, b]| line(&mut s, *a, *b))
.collect();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "surface.boundary", json!({"sketch": "Square", "curves": sides, "name": "Patch"}));
let network = json!({"sketch": "Square", "u": [sides[0], sides[2]], "v": [sides[1], sides[3]]});
run(&mut s, "timeline.redefine", json!({"feature": "Patch", "command": "surface.boundary", "params": network}));
let kind = |s: &Session| s.doc.find_feature("Patch").map(|f| f.kind.clone());
assert!(matches!(kind(&s), Some(solidcraft_doc::FeatureKind::SurfaceNetwork { .. })), "{:?}", kind(&s));
let body = s.world_state().bodies.first().map(|b| b.name.clone()).unwrap_or_default();
assert!((areas(&mut s, &body).0 - 100.0).abs() < 1e-6);
run(&mut s, "timeline.redefine", json!({"feature": "Patch", "command": "surface.boundary", "params": {"sketch": "Square", "curves": sides}}));
assert!(matches!(kind(&s), Some(solidcraft_doc::FeatureKind::SurfaceBoundary { .. })), "{:?}", kind(&s));
assert!((areas(&mut s, &body).0 - 100.0).abs() < 1e-6);
}
+3 -1
View File
@@ -46,7 +46,9 @@ use K::*;
/// The parameters each command takes in world coordinates.
fn table(id: &str) -> &'static [(&'static str, K)] {
match id {
"solid.extrude" => &[("face", Pick), ("face_body", Body), ("from", Make), ("to", Make), ("targets", Body), ("participants", Body)],
"solid.extrude" => {
&[("face", Pick), ("faces", Picks), ("face_body", Body), ("from", Make), ("to", Make), ("targets", Body), ("participants", Body)]
}
"solid.revolve" => &[("face", Pick), ("axis", Axis), ("to", Make), ("targets", Body), ("participants", Body)],
"solid.box" | "solid.sphere" => &[("corner", Make), ("center", Make)],
"solid.cylinder" | "solid.coil" | "solid.torus" => &[("base", Make), ("center", Make), ("axis", Dir)],
+2
View File
@@ -807,6 +807,8 @@ fn hostile_params_never_panic() {
"continuity",
"crown",
"faces",
"u",
"v",
];
// Commands are independent; worker threads take them one at a time (their costs vary a lot).
let ids: Vec<&str> = command_specs()
+41 -5
View File
@@ -54,6 +54,24 @@ fn differs(want: &Value, got: &Value) -> Option<String> {
(!out.is_empty()).then(|| out.join("; "))
}
/// Platform records only override the seeds explicitly verified on that platform.
fn expected<'a>(file: &'a Value, seed: u64, platform: &str) -> &'a Value {
let key = seed.to_string();
file.get("platforms")
.and_then(|platforms| platforms.get(platform))
.and_then(|record| record.get("seeds"))
.and_then(|seeds| seeds.get(&key))
.unwrap_or(&file["seeds"][key])
}
#[test]
fn platform_records_keep_shared_references_for_every_other_seed_and_platform() {
let file = json!({"seeds":{"1":{"volume":10},"2":{"volume":20}},"platforms":{"windows":{"seeds":{"1":{"volume":11}}}}});
assert_eq!(expected(&file, 1, "windows")["volume"], 11);
assert_eq!(expected(&file, 1, "linux")["volume"], 10);
assert_eq!(expected(&file, 2, "windows")["volume"], 20);
}
fn check(seed: u64) {
let got: BTreeMap<u64, Value> = BTreeMap::from([(seed, fuzz::golden(seed, MAX_STEPS))]);
let _lock = FILE.lock().unwrap_or_else(|e| e.into_inner());
@@ -61,7 +79,7 @@ fn check(seed: u64) {
let changed: Vec<(u64, String)> = got
.iter()
.filter_map(|(seed, g)| {
let w = &file["seeds"][seed.to_string()];
let w = expected(&file, *seed, std::env::consts::OS);
if w.is_null() { Some((*seed, "new".to_string())) } else { differs(w, g).map(|d| (*seed, d)) }
})
.collect();
@@ -75,20 +93,34 @@ fn check(seed: u64) {
"What each robustness seed builds (crates/engine/src/tests/robustness_goldens.rs, docs/robustness.md). Record changes with ROBUSTNESS_GOLDENS_BLESS=\"why\"."
);
}
let platform = std::env::consts::OS;
let platform_record = file["platforms"][platform]["seeds"].is_object();
for (seed, g) in &got {
file["seeds"][seed.to_string()] = g.clone();
if platform_record {
if changed.iter().any(|(changed, _)| changed == seed) {
file["platforms"][platform]["seeds"][seed.to_string()] = g.clone();
}
} else {
file["seeds"][seed.to_string()] = g.clone();
}
}
let mut notes = file["notes"].as_array().cloned().unwrap_or_default();
let seeds: Vec<u64> = changed.iter().map(|(s, _)| *s).collect();
// The parts of one record (the tests run side by side) share one note.
match notes.last_mut() {
Some(last) if last["reason"] == json!(reason) => {
Some(last) if last["reason"] == json!(reason) && (!platform_record || last["platform"] == json!(platform)) => {
let mut all: Vec<u64> = last["seeds"].as_array().into_iter().flatten().filter_map(Value::as_u64).chain(seeds).collect();
all.sort_unstable();
all.dedup();
last["seeds"] = json!(all);
}
_ => notes.push(json!({"reason": reason, "seeds": seeds})),
_ => {
let mut note = json!({"reason": reason, "seeds": seeds});
if platform_record {
note["platform"] = json!(platform);
}
notes.push(note);
}
}
file["notes"] = Value::Array(notes);
// Seeds in order, one per line, so a change is a small diff.
@@ -100,7 +132,11 @@ fn check(seed: u64) {
s += &format!(" \"{k}\": {}{}\n", v, if i + 1 < n { "," } else { "" });
}
let notes: Vec<String> = file["notes"].as_array().into_iter().flatten().map(|n| format!(" {n}")).collect();
s += &format!(" }},\n \"notes\": [\n{}\n ]\n}}\n", notes.join(",\n"));
s += " },\n";
if let Some(platforms) = file.get("platforms") {
s += &format!(" \"platforms\": {platforms},\n");
}
s += &format!(" \"notes\": [\n{}\n ]\n}}\n", notes.join(",\n"));
std::fs::write(path(), s).unwrap_or_else(|e| panic!("{}: {e}", path().display()));
return;
}
+53 -13
View File
@@ -131,22 +131,62 @@ pub fn visible_bounds(s: &Session, sketches: bool) -> Aabb3 {
b
}
/// The two axes (in the camera's Z-up frame) of the plane the grid lies in, for a view whose
/// direction toward the eye is `back` (in that frame). The ground (frame XY) unless it is seen
/// nearly edge-on, as in the Front or Right view; then the upright origin plane that faces the
/// view more, so the grid stays visible (#1470).
pub fn grid_plane(back: Vec3) -> (Vec3, Vec3) {
// About 11.5° above or below the ground.
const EDGE_ON: f64 = 0.2;
if back.z.abs() >= EDGE_ON || !back.z.is_finite() {
(Vec3::X, Vec3::Y)
} else if back.y.abs() >= back.x.abs() {
(Vec3::X, Vec3::Z)
} else {
(Vec3::Y, Vec3::Z)
/// The origin plane a grid lies on.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum GridPlane {
/// The ground: the camera frame's XY.
#[default]
Ground,
/// Upright, facing a view along ±Y (the Front or Back view): the frame's XZ.
Xz,
/// Upright, facing a view along ±X (the Left or Right view): the frame's YZ.
Yz,
}
impl GridPlane {
/// The plane's two axes, in the camera's Z-up frame.
pub fn axes(self) -> (Vec3, Vec3) {
match self {
GridPlane::Ground => (Vec3::X, Vec3::Y),
GridPlane::Xz => (Vec3::X, Vec3::Z),
GridPlane::Yz => (Vec3::Y, Vec3::Z),
}
}
}
/// The plane to draw the grid on for a view whose direction toward the eye is `back` (in the
/// camera's Z-up frame), given the plane the last frame drew (`last`).
///
/// The ground (frame XY) unless it is seen nearly edge-on, as in the Front or Right view; then
/// the upright origin plane facing the view, so the grid stays visible (#1470). Both thresholds
/// are further for leaving than for entering, so a view hovering at one does not flicker the
/// grid, and the upright plane changes to the other only close to a side view, never at the 45°
/// crossing (#1993).
pub fn grid_plane(back: Vec3, last: GridPlane) -> GridPlane {
// About 11.5° above or below the ground to leave it, and 15° to return.
const EDGE_ON: f64 = 0.2;
const EDGE_LEAVE: f64 = 0.26;
// About 15° from the Front/Back or Left/Right view: only that close does the wall change.
const SIDE_VIEW: f64 = 0.966;
let horizontal = (back.x * back.x + back.y * back.y).sqrt();
if !back.x.is_finite() || !back.y.is_finite() || !back.z.is_finite() || !horizontal.is_finite() {
return GridPlane::Ground;
}
if last == GridPlane::Ground && back.z.abs() <= EDGE_ON && horizontal > 0.0 {
return if back.y.abs() >= back.x.abs() { GridPlane::Xz } else { GridPlane::Yz };
}
if last != GridPlane::Ground && back.z.abs() <= EDGE_LEAVE && horizontal > 0.0 {
if back.x.abs() / horizontal >= SIDE_VIEW {
return GridPlane::Yz;
}
if back.y.abs() / horizontal >= SIDE_VIEW {
return GridPlane::Xz;
}
return last;
}
GridPlane::Ground
}
/// Grid spacing (minor, major) for a view of the given half height.
pub fn grid_step(half_height: f64) -> (f64, f64) {
let raw = (half_height / 10.0).max(1e-3);
+6 -2
View File
@@ -1,7 +1,8 @@
//! Geometry foundation for SolidCraft: 2D/3D vectors, sketch planes, axis-aligned boxes,
//! triangle meshes with their measures (volume, area, centroid), and freeform surface maths
//! (B-spline basis and NURBS surfaces in [`bspline`], one-surface fills of closed loops in
//! [`fill`], T-splines in [`tspline`]). Units are millimetres.
//! (B-spline basis and NURBS surfaces in [`bspline`], spline spaces and the operators that make
//! splines compatible in [`spline_space`], one-surface fills of closed loops in [`fill`],
//! surfaces through curve networks in [`network`], T-splines in [`tspline`]). Units are millimetres.
//!
//! This crate has no knowledge of sketches, documents or a modelling backend; every other crate
//! builds on it.
@@ -19,8 +20,11 @@ pub mod fill;
pub mod fill_general;
mod mesh;
pub mod mesh_distance;
pub mod network;
mod plane;
mod profile;
pub mod section;
pub mod spline_space;
pub mod tess;
pub mod tspline;
mod vec;
File diff suppressed because it is too large. Load diff
+291
View File
@@ -0,0 +1,291 @@
//! Backend-neutral section geometry in a model-space plane, in millimetres and radians.
//!
//! Curve and wire indices identify items in this result only, not persistent model geometry.
//! Producers own source identity, topology and accuracy evidence. [`SectionGeometry::validate_structure`]
//! checks bounded payloads, references and connected wires; it does not certify simple loops,
//! winding, hole nesting, region separation, source coincidence or an approximation bound.
//! Material producers must supply CCW outer loops and CW holes in the plane's x/y basis,
//! whose normal is x cross y. Conversion preserves those supplied orientations.
//!
//! Stored coordinates are finite and at most 1e12 mm in absolute value. Radii and conic
//! weights lie in 1e-12..=1e12; arc starts in -1e6..=1e6 radians and absolute sweeps in
//! 1e-12..=one turn (with 1e-12 radians of allowance). Positive conic weights are the supported
//! subset. These arithmetic limits do not promise a small absolute error at extreme magnitudes.
//! Closure tolerance is 1e-9..=1 mm, independent of approximation accuracy.
//!
//! Validate after deserialization and before use or serialization. The DTOs deliberately retain
//! [`Plane`]'s existing serde representation and construction behavior. Validation after parsing
//! cannot bound parsing allocations: an encoded-input boundary must also limit bytes and nesting.
//! The per-wire/per-region conversions revalidate the whole result on each call; they are not an
//! optimized batch-conversion API. Existing profile area/containment methods are not general
//! exact or certified measures for conics and freeform curves. Profile values contain geometry
//! only: callers retain the original result's accuracy declarations and local identities.
use std::f64::consts::TAU;
use serde::{Deserialize, Serialize};
use crate::{Loop2, MAX_BEZIER_DEGREE, Plane, Region2, Seg2, Vec2, Vec3};
/// Aggregate input records/references, and the selected conversion's output items, at most.
/// Input counts curves, wires, regions, contacts, curve uses and hole references together.
pub const MAX_ITEMS: usize = 100_000;
const MAX_NUMBER: f64 = 1e12;
const MIN_POSITIVE: f64 = 1e-12;
const MAX_START_ANGLE: f64 = 1e6;
const FRAME_TOLERANCE: f64 = 1e-8;
/// A section's geometric result, without source identity, freshness or persistence semantics.
/// Empty, open-wire and contact-only results remain distinct; no material is inferred from them.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SectionGeometry {
pub plane: Plane,
pub curves: Vec<SectionCurve>,
pub wires: Vec<SectionWire>,
pub regions: Vec<SectionRegion>,
/// Isolated contacts in plane coordinates, not invented closed regions.
pub contacts: Vec<Vec2>,
}
/// One curve and the producer's declared accuracy relative to its source.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SectionCurve {
pub geometry: Seg2,
pub accuracy: CurveAccuracy,
}
/// How the producer represents its source; structure validation cannot certify this declaration.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum CurveAccuracy {
/// An analytic representation without fitting; not a certificate about the source or topology.
Analytic,
/// A producer-established bound, not merely a requested fitting tolerance.
Bounded { max_deviation_mm: f64 },
/// A deviation measured at samples only, with no bound between them. Zero remains sampled.
Sampled { max_sample_deviation_mm: f64 },
}
/// An ordered connected chain. `closed` is explicit, never inferred or repaired.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SectionWire {
pub curves: Vec<CurveUse>,
pub closed: bool,
}
/// An oriented use of a curve by its result-local index.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CurveUse {
pub curve: u32,
pub forward: bool,
}
/// A producer-declared material region referring to closed wires. Shared wires are permitted.
/// The outer wire cannot also be a hole, and a hole cannot occur twice in this one region.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SectionRegion {
pub outer: u32,
pub holes: Vec<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("section geometry: {0}")]
pub struct Invalid(pub &'static str);
type Result<T> = std::result::Result<T, Invalid>;
fn require(condition: bool, message: &'static str) -> Result<()> {
if condition { Ok(()) } else { Err(Invalid(message)) }
}
struct Budget(usize);
impl Budget {
fn add(&mut self, n: usize) -> Result<()> {
self.0 = self.0.checked_add(n).ok_or(Invalid("item budget"))?;
require(self.0 <= MAX_ITEMS, "item budget")
}
}
fn numbers(values: impl IntoIterator<Item = f64>) -> Result<()> {
require(values.into_iter().all(|v| v.is_finite() && v.abs() <= MAX_NUMBER), "non-finite or excessive coordinate")
}
fn point(p: Vec2) -> Result<()> {
numbers([p.x, p.y])
}
fn vector(p: Vec3) -> Result<()> {
numbers([p.x, p.y, p.z])
}
/// Strictly check an existing plane without re-orthonormalizing it or changing its serde format.
/// Unit-length and orthogonality checks use 1e-8; the normal is derived as x cross y.
pub fn validate_plane(plane: &Plane) -> Result<()> {
vector(plane.origin)?;
vector(plane.x)?;
vector(plane.y)?;
require(
(plane.x.len() - 1.0).abs() <= FRAME_TOLERANCE
&& (plane.y.len() - 1.0).abs() <= FRAME_TOLERANCE
&& plane.x.dot(plane.y).abs() <= FRAME_TOLERANCE,
"nonorthonormal plane",
)
}
fn controls(points: &[Vec2]) -> Result<()> {
for p in points {
point(*p)?;
}
let Some((first, rest)) = points.split_first() else { return Err(Invalid("empty controls")) };
require(rest.iter().any(|p| p != first), "collapsed curve")
}
fn curve(curve: &SectionCurve) -> Result<()> {
match curve.geometry {
Seg2::Line { a, b } => controls(&[a, b])?,
Seg2::Arc { center, radius, start, sweep } => {
point(center)?;
require(radius.is_finite() && (MIN_POSITIVE..=MAX_NUMBER).contains(&radius), "arc radius")?;
require(start.is_finite() && start.abs() <= MAX_START_ANGLE, "arc start angle")?;
require(sweep.is_finite() && (MIN_POSITIVE..=TAU + 1e-12).contains(&sweep.abs()), "arc sweep")?;
}
Seg2::Cubic { p0, p1, p2, p3 } => controls(&[p0, p1, p2, p3])?,
Seg2::Conic { a, apex, b, w } => {
controls(&[a, apex, b])?;
require(w.is_finite() && (MIN_POSITIVE..=MAX_NUMBER).contains(&w), "conic weight")?;
}
Seg2::Bezier { n, ref p } => {
require((4..=MAX_BEZIER_DEGREE).contains(&usize::from(n)), "Bezier degree")?;
// The unused slots are serialized too: reject hidden non-finite/excessive values.
for q in p {
point(*q)?;
}
controls(p.get(..=usize::from(n)).ok_or(Invalid("Bezier degree"))?)?;
}
}
require(curve.geometry.start().is_finite() && curve.geometry.end().is_finite(), "non-finite curve endpoint")?;
match curve.accuracy {
CurveAccuracy::Analytic => Ok(()),
CurveAccuracy::Bounded { max_deviation_mm } => deviation(max_deviation_mm),
CurveAccuracy::Sampled { max_sample_deviation_mm } => deviation(max_sample_deviation_mm),
}
}
fn deviation(value: f64) -> Result<()> {
require(value.is_finite() && (0.0..=MAX_NUMBER).contains(&value), "accuracy deviation")
}
impl SectionGeometry {
/// Check finite supported payloads, aggregate counts, references and wire connectivity.
/// This does not certify material validity, winding, nesting, source identity or accuracy.
/// Count preflight precedes geometry evaluation; encoded input needs separate parsing limits.
pub fn validate_structure(&self, closure_tolerance_mm: f64) -> Result<()> {
let mut budget = Budget(0);
budget.add(self.curves.len())?;
budget.add(self.wires.len())?;
budget.add(self.regions.len())?;
budget.add(self.contacts.len())?;
for wire in &self.wires {
budget.add(wire.curves.len())?;
}
for region in &self.regions {
budget.add(region.holes.len())?;
}
require(closure_tolerance_mm.is_finite() && (1e-9..=1.0).contains(&closure_tolerance_mm), "closure tolerance must be between 1e-9 and 1 mm")?;
validate_plane(&self.plane)?;
for c in &self.curves {
curve(c)?;
}
for p in &self.contacts {
point(*p)?;
}
for wire in &self.wires {
require(!wire.curves.is_empty(), "empty wire")?;
let mut first = None;
let mut last: Option<Vec2> = None;
for use_ in &wire.curves {
let c = &self.curves.get(use_.curve as usize).ok_or(Invalid("curve reference"))?.geometry;
let (a, b) = (c.start(), c.end());
let (start, end) = if use_.forward { (a, b) } else { (b, a) };
require(last.is_none_or(|p| p.dist(start) <= closure_tolerance_mm), "disconnected wire")?;
first.get_or_insert(start);
last = Some(end);
}
if wire.closed {
require(first.zip(last).is_some_and(|(a, b)| a.dist(b) <= closure_tolerance_mm), "open closed wire")?;
}
}
// One stamp array for all regions: neither pairwise duplicate checks nor clearing the
// entire array per region. Sharing the same wire between different regions is allowed.
let mut seen = vec![usize::MAX; self.wires.len()];
for (stamp, region) in self.regions.iter().enumerate() {
for id in std::iter::once(&region.outer).chain(&region.holes) {
let wire = self.wires.get(*id as usize).ok_or(Invalid("wire reference"))?;
require(wire.closed, "region boundary is not closed")?;
let mark = seen.get_mut(*id as usize).ok_or(Invalid("wire reference"))?;
require(*mark != stamp, "duplicate region boundary")?;
*mark = stamp;
}
}
Ok(())
}
/// Copy one closed wire to a profile loop, preserving its oriented curve uses.
/// Revalidates the whole result; an open wire is never silently closed. Floating-point
/// reversal must also keep the converted loop connected within the same tolerance.
pub fn to_loop(&self, wire: u32, closure_tolerance_mm: f64) -> Result<Loop2> {
self.validate_structure(closure_tolerance_mm)?;
let wire = self.wires.get(wire as usize).ok_or(Invalid("wire reference"))?;
require(wire.closed, "wire is not closed")?;
let mut budget = Budget(0);
budget.add(1)?;
budget.add(wire.curves.len())?;
self.copy_loop(wire, closure_tolerance_mm)
}
/// Copy one declared region to a profile, preserving winding and hole order.
/// Revalidates the whole result and preflights expanded output before allocating it.
/// This does not certify the producer's region. The original result is unchanged; the
/// returned profile contains no accuracy declarations or result-local identities.
pub fn to_region(&self, region: u32, closure_tolerance_mm: f64) -> Result<Region2> {
self.validate_structure(closure_tolerance_mm)?;
let region = self.regions.get(region as usize).ok_or(Invalid("region reference"))?;
let mut budget = Budget(0);
budget.add(1)?;
budget.add(region.holes.len())?;
for id in std::iter::once(&region.outer).chain(&region.holes) {
let wire = self.wires.get(*id as usize).ok_or(Invalid("wire reference"))?;
budget.add(wire.curves.len())?;
}
let outer = self.copy_loop(self.wires.get(region.outer as usize).ok_or(Invalid("wire reference"))?, closure_tolerance_mm)?;
let mut holes = Vec::with_capacity(region.holes.len());
for id in &region.holes {
holes.push(self.copy_loop(self.wires.get(*id as usize).ok_or(Invalid("wire reference"))?, closure_tolerance_mm)?);
}
Ok(Region2 { outer, holes })
}
// Called only while the publicly validated result remains immutably borrowed and the
// selected output's expansion has been counted. Index access stays fallible nonetheless.
fn copy_loop(&self, wire: &SectionWire, closure_tolerance_mm: f64) -> Result<Loop2> {
let mut segs = Vec::with_capacity(wire.curves.len());
for use_ in &wire.curves {
let c = self.curves.get(use_.curve as usize).ok_or(Invalid("curve reference"))?.geometry;
segs.push(if use_.forward { c } else { c.reversed() });
}
let loop_ = Loop2 { segs };
// Reversing an arc changes its stored start to start + sweep. Subtracting the sweep
// at its new end need not recover the original angle exactly at large radii.
require(loop_.is_closed(closure_tolerance_mm), "converted loop is not closed within tolerance")?;
Ok(loop_)
}
}
#[cfg(test)]
#[path = "section_tests.rs"]
mod tests;
+409
View File
@@ -0,0 +1,409 @@
use std::f64::consts::PI;
use super::*;
use crate::{MAX_BEZIER_CTRL, bezier};
const TOL: f64 = 1e-6;
fn empty(plane: Plane) -> SectionGeometry {
SectionGeometry { plane, curves: vec![], wires: vec![], regions: vec![], contacts: vec![] }
}
fn analytic(geometry: Seg2) -> SectionCurve {
SectionCurve { geometry, accuracy: CurveAccuracy::Analytic }
}
fn used(curve: u32, forward: bool) -> CurveUse {
CurveUse { curve, forward }
}
fn plate() -> SectionGeometry {
let p = Vec2::new;
SectionGeometry {
plane: Plane::XY.offset(10.0),
curves: vec![
// Deliberately stored backwards: the wire's use restores the CCW rectangle.
analytic(Seg2::Line { a: p(100.0, 0.0), b: p(0.0, 0.0) }),
analytic(Seg2::Line { a: p(100.0, 0.0), b: p(100.0, 60.0) }),
analytic(Seg2::Line { a: p(100.0, 60.0), b: p(0.0, 60.0) }),
analytic(Seg2::Line { a: p(0.0, 60.0), b: p(0.0, 0.0) }),
analytic(Seg2::Arc { center: p(50.0, 30.0), radius: 5.0, start: 0.0, sweep: PI }),
analytic(Seg2::Arc { center: p(50.0, 30.0), radius: 5.0, start: PI, sweep: PI }),
],
wires: vec![
SectionWire { curves: vec![used(0, false), used(1, true), used(2, true), used(3, true)], closed: true },
SectionWire { curves: vec![used(5, false), used(4, false)], closed: true },
],
regions: vec![SectionRegion { outer: 0, holes: vec![1] }],
contacts: vec![],
}
}
#[test]
fn section_frame_preserves_named_translated_tilted_offset_and_flip_conventions() {
for plane in [Plane::XY, Plane::XZ, Plane::YZ] {
validate_plane(&plane).unwrap();
}
assert_eq!(Plane::XZ.normal(), Vec3::Y);
assert_eq!(Plane::XZ.to_world(Vec2::new(3.0, 4.0)), Vec3::new(3.0, 0.0, -4.0));
let plane = Plane::from_normal(Vec3::new(13.0, -7.0, 19.0), Vec3::new(2.0, -3.0, 5.0)).unwrap();
validate_plane(&plane).unwrap();
let local = Vec2::new(7.0, -11.0);
let height = 4.5;
let point = plane.to_world(local) + plane.normal() * height;
assert!(plane.to_local(point).dist(local) < 1e-12);
assert!((plane.height(point) - height).abs() < 1e-12);
let moved = plane.offset(8.0);
validate_plane(&moved).unwrap();
assert!((plane.height(moved.origin) - 8.0).abs() < 1e-12);
for about_y in [false, true] {
let flipped = moved.flipped(about_y);
validate_plane(&flipped).unwrap();
assert_eq!(flipped.origin, moved.origin);
assert_eq!(flipped.normal(), -moved.normal());
assert_eq!(flipped.flipped(about_y), moved);
let on_plane = moved.to_world(local);
let mirrored = if about_y { Vec2::new(-local.x, local.y) } else { Vec2::new(local.x, -local.y) };
assert!(flipped.to_local(on_plane).dist(mirrored) < 1e-12);
assert!((flipped.height(point) + moved.height(point)).abs() < 1e-12);
}
}
#[test]
fn strict_frame_validation_does_not_repair_raw_or_deserialized_axes() {
let bad = Plane { x: Vec3::X * 2.0, ..Plane::XY };
assert!(Plane::new(bad.origin, bad.x, bad.y).is_some());
assert_eq!(validate_plane(&bad), Err(Invalid("nonorthonormal plane")));
assert_eq!(bad.x, Vec3::X * 2.0);
let restored: Plane = serde_json::from_value(serde_json::json!({
"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [1, 1, 0]
}))
.unwrap();
assert_eq!(validate_plane(&restored), Err(Invalid("nonorthonormal plane")));
for plane in [
Plane { origin: Vec3::new(f64::NAN, 0.0, 0.0), ..Plane::XY },
Plane { origin: Vec3::new(1e12 + 1.0, 0.0, 0.0), ..Plane::XY },
Plane { x: Vec3::new(f64::MAX, 0.0, 0.0), ..Plane::XY },
Plane { y: Vec3::new(0.0, f64::INFINITY, 0.0), ..Plane::XY },
Plane { x: Vec3::ZERO, ..Plane::XY },
Plane { y: Vec3::X, ..Plane::XY },
] {
assert!(validate_plane(&plane).is_err());
}
validate_plane(&Plane { origin: Vec3::new(1e12, -1e12, 1e12), ..Plane::XY }).unwrap();
}
#[test]
fn line_arc_region_conversion_preserves_winding_and_analytic_area() {
let section = plate();
let before = section.clone();
section.validate_structure(TOL).unwrap();
let outer = section.to_loop(0, TOL).unwrap();
let hole = section.to_loop(1, TOL).unwrap();
assert!(outer.is_closed(TOL) && hole.is_closed(TOL));
assert!((outer.signed_area() - 6000.0).abs() < 1e-10);
assert!((hole.signed_area() + 25.0 * PI).abs() < 1e-10);
let region = section.to_region(0, TOL).unwrap();
assert_eq!(region.outer, outer);
assert_eq!(region.holes, vec![hole]);
assert!((region.area() - (6000.0 - 25.0 * PI)).abs() < 1e-10);
assert_eq!(section, before);
}
#[test]
fn empty_contacts_and_open_wires_remain_distinct_without_inferred_material() {
let empty = empty(Plane::XY);
empty.validate_structure(TOL).unwrap();
assert_eq!(empty.to_region(0, TOL), Err(Invalid("region reference")));
let mut contact = empty.clone();
contact.contacts.push(Vec2::new(2.0, 3.0));
contact.validate_structure(TOL).unwrap();
assert_ne!(contact, empty);
let mut open = empty.clone();
open.curves = vec![analytic(Seg2::Line { a: Vec2::ZERO, b: Vec2::X }), analytic(Seg2::Line { a: Vec2::X, b: Vec2::new(1.0, 1.0) })];
open.wires = vec![SectionWire { curves: vec![used(0, true), used(1, true)], closed: false }];
open.validate_structure(TOL).unwrap();
assert_eq!(open.to_loop(0, TOL), Err(Invalid("wire is not closed")));
open.regions.push(SectionRegion { outer: 0, holes: vec![] });
assert_eq!(open.validate_structure(TOL), Err(Invalid("region boundary is not closed")));
// Even a geometrically closed chain remains explicitly open when the producer says so.
let mut declared_open = plate();
declared_open.regions.clear();
declared_open.wires[0].closed = false;
declared_open.validate_structure(TOL).unwrap();
assert_eq!(declared_open.to_loop(0, TOL), Err(Invalid("wire is not closed")));
}
#[test]
fn bad_references_empty_disconnected_and_unclosed_wires_fail() {
let mut section = plate();
section.wires[0].curves[0].curve = u32::MAX;
assert_eq!(section.validate_structure(TOL), Err(Invalid("curve reference")));
let mut section = plate();
section.wires[0].curves.clear();
assert_eq!(section.validate_structure(TOL), Err(Invalid("empty wire")));
let mut section = plate();
section.wires[0].curves[1].forward = false;
assert_eq!(section.validate_structure(TOL), Err(Invalid("disconnected wire")));
let mut section = plate();
section.wires[0].curves.pop();
assert_eq!(section.validate_structure(TOL), Err(Invalid("open closed wire")));
let mut section = plate();
section.regions[0].outer = u32::MAX;
assert_eq!(section.validate_structure(TOL), Err(Invalid("wire reference")));
let mut section = plate();
section.regions[0].holes[0] = u32::MAX;
assert_eq!(section.validate_structure(TOL), Err(Invalid("wire reference")));
assert_eq!(plate().to_loop(u32::MAX, TOL), Err(Invalid("wire reference")));
assert_eq!(plate().to_region(u32::MAX, TOL), Err(Invalid("region reference")));
}
#[test]
fn duplicate_boundaries_are_local_to_a_region_and_large_reference_tables_are_bounded() {
let mut section = plate();
section.regions[0].holes.push(1);
assert_eq!(section.validate_structure(TOL), Err(Invalid("duplicate region boundary")));
let mut section = plate();
section.regions[0].holes.push(0);
assert_eq!(section.validate_structure(TOL), Err(Invalid("duplicate region boundary")));
let mut section = plate();
section.regions.push(section.regions[0].clone());
section.validate_structure(TOL).unwrap();
assert_eq!(section.to_region(0, TOL).unwrap(), section.to_region(1, TOL).unwrap());
// Deliberately coincident boundaries: this tests a large legal reference graph, not material.
let mut many = empty(Plane::XY);
many.curves.push(analytic(Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.0, sweep: TAU }));
many.wires = vec![SectionWire { curves: vec![used(0, true)], closed: true }; 201];
many.regions = vec![SectionRegion { outer: 0, holes: (1..=200).collect() }; 200];
many.validate_structure(TOL).unwrap();
let last = many.regions.last_mut().unwrap();
*last.holes.last_mut().unwrap() = 1;
assert_eq!(many.validate_structure(TOL), Err(Invalid("duplicate region boundary")));
}
#[test]
fn aggregate_counts_fail_before_geometry_or_reference_inspection() {
let mut section = empty(Plane::XY);
section.contacts = vec![Vec2::new(f64::NAN, 0.0); MAX_ITEMS + 1];
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
let mut section = empty(Plane::XY);
section.wires.push(SectionWire { curves: vec![used(u32::MAX, false); MAX_ITEMS], closed: false });
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
let mut section = empty(Plane::XY);
section.regions.push(SectionRegion { outer: u32::MAX, holes: vec![u32::MAX; MAX_ITEMS] });
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
let mut section = empty(Plane::XY);
section.contacts = vec![Vec2::ZERO; MAX_ITEMS / 2];
section.wires.push(SectionWire { curves: vec![used(u32::MAX, false); MAX_ITEMS / 2], closed: false });
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
// Exactly the item limit is accepted, independently of these points' material meaning.
let mut boundary = empty(Plane::XY);
boundary.contacts = vec![Vec2::ZERO; MAX_ITEMS];
boundary.validate_structure(TOL).unwrap();
let mut count = Budget(MAX_ITEMS);
assert_eq!(count.add(usize::MAX), Err(Invalid("item budget")));
}
#[test]
fn closure_tolerance_is_bounded_and_not_used_to_collapse_short_curves() {
for tolerance in [f64::NAN, f64::INFINITY, -1.0, 0.0, 1e-10, 1.01] {
assert!(plate().validate_structure(tolerance).is_err());
}
for tolerance in [1e-9, 1.0] {
plate().validate_structure(tolerance).unwrap();
}
let mut section = empty(Plane::XY);
section.curves.push(analytic(Seg2::Line { a: Vec2::ZERO, b: Vec2::new(1e-10, 0.0) }));
section.validate_structure(1.0).unwrap();
let mut gap = plate();
if let Seg2::Line { b, .. } = &mut gap.curves[3].geometry {
b.x = 5e-7;
}
gap.validate_structure(1e-6).unwrap();
assert_eq!(gap.validate_structure(1e-8), Err(Invalid("open closed wire")));
}
#[test]
fn curve_number_and_domain_errors_are_rejected_before_evaluation() {
let p = Vec2::new;
let arc = |radius, start, sweep| Seg2::Arc { center: Vec2::ZERO, radius, start, sweep };
let conic = |w| Seg2::Conic { a: Vec2::X, apex: p(1.0, 1.0), b: Vec2::Y, w };
let invalid = [
Seg2::Line { a: Vec2::ZERO, b: Vec2::ZERO },
Seg2::Line { a: Vec2::ZERO, b: p(f64::NAN, 0.0) },
Seg2::Line { a: Vec2::ZERO, b: p(f64::MAX, 0.0) },
arc(-1.0, 0.0, PI),
arc(0.0, 0.0, PI),
arc(1e-13, 0.0, PI),
arc(1e12 + 1.0, 0.0, PI),
arc(f64::INFINITY, 0.0, PI),
arc(1.0, f64::NAN, PI),
arc(1.0, 1e6 + 1.0, PI),
arc(1.0, 0.0, 0.0),
arc(1.0, 0.0, 1e-13),
arc(1.0, 0.0, TAU + 1e-8),
arc(1.0, 0.0, f64::INFINITY),
conic(-0.5),
conic(0.0),
conic(1e-13),
conic(1e12 + 1.0),
conic(f64::NAN),
Seg2::Cubic { p0: Vec2::X, p1: Vec2::X, p2: Vec2::X, p3: Vec2::X },
Seg2::Conic { a: Vec2::X, apex: Vec2::X, b: Vec2::X, w: 1.0 },
];
for geometry in invalid {
let mut section = empty(Plane::XY);
section.curves.push(analytic(geometry));
assert!(section.validate_structure(TOL).is_err(), "accepted {geometry:?}");
}
let mut section = empty(Plane::XY);
section.contacts.push(p(f64::INFINITY, 0.0));
assert!(section.validate_structure(TOL).is_err());
}
#[test]
fn bezier_degree_and_every_serialized_control_slot_are_validated() {
for degree in [4u8, 9] {
let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
for (i, point) in p.iter_mut().enumerate().take(usize::from(degree) + 1) {
*point = Vec2::new(i as f64, (i % 2) as f64);
}
let mut section = empty(Plane::XY);
section.curves.push(analytic(Seg2::Bezier { n: degree, p }));
section.validate_structure(TOL).unwrap();
for wrong in [0, 3, 10, u8::MAX] {
section.curves[0].geometry = Seg2::Bezier { n: wrong, p };
assert_eq!(section.validate_structure(TOL), Err(Invalid("Bezier degree")));
}
}
let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
p[4] = Vec2::X;
for value in [f64::NAN, f64::INFINITY, 1e12 + 1.0] {
p[MAX_BEZIER_CTRL - 1] = Vec2::new(value, 0.0);
let mut section = empty(Plane::XY);
section.curves.push(analytic(Seg2::Bezier { n: 4, p }));
assert_eq!(section.validate_structure(TOL), Err(Invalid("non-finite or excessive coordinate")));
}
let mut section = empty(Plane::XY);
section.curves.push(analytic(Seg2::Bezier { n: 4, p: [Vec2::ZERO; MAX_BEZIER_CTRL] }));
assert_eq!(section.validate_structure(TOL), Err(Invalid("collapsed curve")));
}
#[test]
fn supported_curve_limits_keep_evaluation_finite_without_claiming_accuracy() {
let p = Vec2::new;
let mut section = empty(Plane::XY);
section.curves = vec![
analytic(Seg2::Line { a: p(-1e12, 1e12), b: p(1e12, -1e12) }),
analytic(Seg2::Arc { center: p(1e12, 1e12), radius: 1e12, start: 1e6, sweep: -TAU }),
analytic(Seg2::Arc { center: Vec2::ZERO, radius: 1e-12, start: 0.0, sweep: 1e-12 }),
analytic(Seg2::Conic { a: p(-1e12, 0.0), apex: p(0.0, 1e12), b: p(1e12, 0.0), w: 1e12 }),
analytic(Seg2::Conic { a: p(-1e12, 0.0), apex: p(0.0, 1e12), b: p(1e12, 0.0), w: 1e-12 }),
analytic(bezier(&[p(0.0, 0.0), p(1e12, 0.0), p(0.0, 1e12), p(-1e12, 0.0)]).unwrap()),
];
let controls: Vec<Vec2> = (0..=9).map(|i| p(i as f64 * 1e11, if i % 2 == 0 { 1e12 } else { -1e12 })).collect();
section.curves.push(analytic(bezier(&controls).unwrap()));
section.validate_structure(TOL).unwrap();
for curve in &section.curves {
for t in [0.0, 0.5, 1.0] {
assert!(curve.geometry.point_at(t).is_finite());
assert!(curve.geometry.derivative(t).is_finite());
assert!(curve.geometry.derivatives(t, 4).is_some_and(|d| d.iter().all(|v| v.is_finite())));
}
}
}
#[test]
fn transient_serialization_preserves_accuracy_tags_and_plane_names() {
let mut section = plate();
section.curves[1].accuracy = CurveAccuracy::Bounded { max_deviation_mm: 0.01 };
section.curves[2].accuracy = CurveAccuracy::Sampled { max_sample_deviation_mm: 0.0 };
section.validate_structure(TOL).unwrap();
let value = serde_json::to_value(&section).unwrap();
assert!(value["plane"].get("x_dir").is_some());
assert!(value["plane"].get("y_dir").is_some());
assert_eq!(value["curves"][0]["accuracy"], serde_json::json!({"kind": "analytic"}));
assert_eq!(value["curves"][1]["accuracy"], serde_json::json!({"kind": "bounded", "max_deviation_mm": 0.01}));
assert_eq!(value["curves"][2]["accuracy"], serde_json::json!({"kind": "sampled", "max_sample_deviation_mm": 0.0}));
let restored: SectionGeometry = serde_json::from_value(value.clone()).unwrap();
restored.validate_structure(TOL).unwrap();
assert_eq!(section, restored);
let mut unknown = value;
unknown["certificate"] = serde_json::json!(true);
assert!(serde_json::from_value::<SectionGeometry>(unknown).is_err());
assert!(serde_json::from_value::<CurveAccuracy>(serde_json::json!({"kind": "bounded", "max_deviation_mm": 0.1, "exact": true})).is_err());
assert!(serde_json::from_value::<CurveAccuracy>(serde_json::json!({"kind": "exact"})).is_err());
}
#[test]
fn accuracy_values_are_validated_without_promoting_zero_sampled_error() {
for value in [f64::NAN, f64::INFINITY, -0.01, 1e12 + 1.0] {
for accuracy in [CurveAccuracy::Bounded { max_deviation_mm: value }, CurveAccuracy::Sampled { max_sample_deviation_mm: value }] {
let mut section = plate();
section.curves[0].accuracy = accuracy;
assert_eq!(section.validate_structure(TOL), Err(Invalid("accuracy deviation")));
}
}
for value in [0.0, 1e12] {
let mut section = plate();
section.curves[0].accuracy = CurveAccuracy::Sampled { max_sample_deviation_mm: value };
section.validate_structure(TOL).unwrap();
section.to_region(0, TOL).unwrap();
assert_eq!(section.curves[0].accuracy, CurveAccuracy::Sampled { max_sample_deviation_mm: value });
}
}
#[test]
fn conversion_revalidates_mutated_data_and_does_not_trust_an_earlier_success() {
let mut section = plate();
section.validate_structure(TOL).unwrap();
section.curves[0].geometry = Seg2::Line { a: Vec2::ZERO, b: Vec2::new(f64::NAN, 0.0) };
assert_eq!(section.to_loop(0, TOL), Err(Invalid("non-finite or excessive coordinate")));
assert_eq!(section.to_region(0, TOL), Err(Invalid("non-finite or excessive coordinate")));
let mut section = plate();
section.to_region(0, TOL).unwrap();
section.contacts = vec![Vec2::ZERO; MAX_ITEMS];
assert_eq!(section.to_region(0, TOL), Err(Invalid("item budget")));
}
#[test]
fn reversed_large_arc_conversion_rechecks_actual_connectivity_without_snapping() {
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 1e12, start: 0.1, sweep: 1.0 };
let mut section = empty(Plane::XY);
section.curves = vec![analytic(arc), analytic(Seg2::Line { a: arc.start(), b: arc.end() })];
section.wires.push(SectionWire { curves: vec![used(0, false), used(1, true)], closed: true });
section.regions.push(SectionRegion { outer: 0, holes: vec![] });
let before = section.clone();
// Source endpoints coincide exactly, but (start + sweep) - sweep changes the reversed
// arc's endpoint by more than this tolerance at this radius.
section.validate_structure(1e-9).unwrap();
assert!(arc.reversed().end().dist(arc.start()) > 1e-9);
assert_eq!(section.to_loop(0, 1e-9), Err(Invalid("converted loop is not closed within tolerance")));
assert_eq!(section.to_region(0, 1e-9), Err(Invalid("converted loop is not closed within tolerance")));
assert_eq!(section, before);
// A caller's explicit larger tolerance can accept the same result without moving endpoints.
let converted = section.to_loop(0, 0.01).unwrap();
assert_eq!(converted.segs[0], arc.reversed());
assert!(converted.is_closed(0.01));
}
#[test]
fn connected_bow_tie_and_wrong_winding_are_not_certified_or_silently_repaired() {
let p = Vec2::new;
let loop_ = Loop2::polygon(&[p(0.0, 0.0), p(2.0, 2.0), p(0.0, 2.0), p(2.0, 0.0)]);
let mut bow = empty(Plane::XY);
bow.curves = loop_.segs.iter().copied().map(analytic).collect();
bow.wires.push(SectionWire { curves: (0..4).map(|i| used(i, true)).collect(), closed: true });
bow.regions.push(SectionRegion { outer: 0, holes: vec![] });
bow.validate_structure(TOL).unwrap();
assert_eq!(bow.to_loop(0, TOL).unwrap(), loop_);
// A successful structural check does not establish a material region, including its winding.
let mut reversed = plate();
reversed.wires[0].curves.reverse();
for use_ in &mut reversed.wires[0].curves {
use_.forward = !use_.forward;
}
reversed.validate_structure(TOL).unwrap();
let converted = reversed.to_region(0, TOL).unwrap();
assert!((converted.outer.signed_area() + 6000.0).abs() < 1e-10);
}
+584
View File
@@ -0,0 +1,584 @@
//! Spline function spaces and the linear operators surface construction is built from: a clamped
//! B-spline space ([`Space`]), interpolation in it with derivative (Hermite) conditions
//! ([`Interpolant`]), carrying a spline into a larger space that contains it ([`embed`]: Bézier
//! degree elevation and Boehm knot insertion, exact), and a monotone piecewise cubic through
//! increasing data ([`Monotone`], for reparameterising curves).
//!
//! These are the pieces that make several splines *compatible* (one degree, one knot vector) so
//! that their control points can be combined linearly: lofting, Coons and Gordon surfaces, and
//! cross-boundary derivative conditions all reduce to them.
//!
//! References: Piegl & Tiller, *The NURBS Book* (2nd ed.) — §2.2–2.3 (basis functions and
//! their derivatives), §5.2 (knot insertion), §5.5 (degree elevation; the Bézier case), §9.2.1
//! (global interpolation, knots by averaging the parameters) and §9.2.2 (interpolation with
//! end derivatives); Fritsch & Carlson 1980, "Monotone piecewise cubic interpolation" (SIAM J.
//! Numer. Anal. 17), with the weighted harmonic-mean slopes of Fritsch & Butland 1984.
//!
//! Input is hostile: constructors check sizes and finiteness and return [`SplineError`]; no
//! function here panics.
use crate::Vec3;
use crate::bspline::{HPoint, MAX_SPLINE_DEGREE, Side, SplineError, SplineResult, check_knots, find_span, insert_knot, invalid, local_basis};
/// Most basis functions a space used for interpolation may have (its systems are solved densely).
pub const MAX_SPACE_DIMENSION: usize = 2048;
/// Values a spline can carry: numbers and points (vectors).
pub trait Linear: Copy {
fn zero() -> Self;
fn plus(self, o: Self) -> Self;
fn times(self, s: f64) -> Self;
/// The value as a homogeneous point of weight one (knot insertion works on those).
fn to_h(self) -> HPoint;
fn from_h(h: HPoint) -> Self;
fn is_finite(self) -> bool;
}
impl Linear for f64 {
fn zero() -> Self {
0.0
}
fn plus(self, o: Self) -> Self {
self + o
}
fn times(self, s: f64) -> Self {
self * s
}
fn to_h(self) -> HPoint {
HPoint { x: self, y: 0.0, z: 0.0, w: 1.0 }
}
fn from_h(h: HPoint) -> Self {
h.x
}
fn is_finite(self) -> bool {
f64::is_finite(self)
}
}
impl Linear for Vec3 {
fn zero() -> Self {
Vec3::ZERO
}
fn plus(self, o: Self) -> Self {
self + o
}
fn times(self, s: f64) -> Self {
self * s
}
fn to_h(self) -> HPoint {
HPoint::weighted(self, 1.0)
}
fn from_h(h: HPoint) -> Self {
h.xyz()
}
fn is_finite(self) -> bool {
Vec3::is_finite(self)
}
}
/// A clamped B-spline space: a degree and a knot vector whose first and last `degree + 1` knots
/// are the domain's ends, interior knots repeated at most `degree` times (the space's splines
/// are continuous).
#[derive(Clone, Debug, PartialEq)]
pub struct Space {
degree: usize,
knots: Vec<f64>,
}
impl Space {
pub fn new(degree: usize, knots: Vec<f64>) -> SplineResult<Space> {
if degree == 0 || degree > MAX_SPLINE_DEGREE {
return Err(SplineError::Unsupported(format!("degree {degree} (1 to {MAX_SPLINE_DEGREE})")));
}
check_knots(&knots)?;
let n = knots.len().checked_sub(degree + 1).filter(|n| *n > degree).ok_or_else(|| SplineError::Invalid("too few knots".into()))?;
if n > MAX_SPACE_DIMENSION {
return Err(SplineError::TooLarge(format!("{n} basis functions (at most {MAX_SPACE_DIMENSION})")));
}
let (a, b) = (knots[0], knots[knots.len() - 1]);
if !(a < b) || knots[..=degree].iter().any(|k| *k != a) || knots[n..].iter().any(|k| *k != b) {
return invalid("the knot vector must be clamped on a non-empty domain");
}
let inner = &knots[degree + 1..n];
let mut run = 0;
for w in inner.windows(2) {
run = if w[0] == w[1] { run + 1 } else { 0 };
if run + 1 > degree {
return invalid("an interior knot is repeated more often than the degree");
}
}
if inner.first().is_some_and(|k| *k == a) || inner.last().is_some_and(|k| *k == b) {
return invalid("an interior knot lies on a domain end");
}
Ok(Space { degree, knots })
}
/// Polynomials of `degree` on `[a, b]` (one Bézier piece).
pub fn polynomial(degree: usize, a: f64, b: f64) -> SplineResult<Space> {
let mut knots = vec![a; degree + 1];
knots.extend(std::iter::repeat_n(b, degree + 1));
Space::new(degree, knots)
}
/// Splines of `degree` with a simple knot at every interior break: `breaks` increase
/// strictly, the first and last are the domain's ends.
pub fn with_breaks(degree: usize, breaks: &[f64]) -> SplineResult<Space> {
let (Some(&a), Some(&b)) = (breaks.first(), breaks.last()) else { return invalid("no breaks") };
if breaks.len() > MAX_SPACE_DIMENSION || breaks.windows(2).any(|w| !(w[0] < w[1])) {
return invalid("the breaks must increase");
}
let mut knots = vec![a; degree + 1];
knots.extend(breaks.iter().skip(1).take(breaks.len().saturating_sub(2)).copied());
knots.extend(std::iter::repeat_n(b, degree + 1));
Space::new(degree, knots)
}
pub fn degree(&self) -> usize {
self.degree
}
pub fn knots(&self) -> &[f64] {
&self.knots
}
/// The number of basis functions (control points of a spline in the space).
pub fn count(&self) -> usize {
self.knots.len() - self.degree - 1
}
/// The domain `[a, b]`.
pub fn domain(&self) -> [f64; 2] {
[self.knots[0], self.knots[self.knots.len() - 1]]
}
/// The `der`-th derivatives at `t` (clamped to the domain) of the basis functions that do not
/// vanish there: the index of the first one and their values.
pub fn basis(&self, t: f64, der: usize) -> SplineResult<(usize, Vec<f64>)> {
let [a, b] = self.domain();
if !t.is_finite() {
return invalid("a parameter is not a finite number");
}
let t = t.clamp(a, b);
let (p, n) = (self.degree, self.count());
let span = find_span(&self.knots, p, n, t).ok_or_else(|| SplineError::Invalid(format!("{t} is outside the domain")))?;
let side = if t >= b { Side::Left } else { Side::Right };
let first = span - p;
let values =
(first..=span).map(|i| self.knots.get(i..i + p + 2).map_or(0.0, |k| local_basis(k, t, side, der).get(der).copied().unwrap_or(0.0)));
Ok((first, values.collect()))
}
/// The `der`-th derivative at `t` of the spline with control values `ctrl`.
pub fn eval<T: Linear>(&self, ctrl: &[T], t: f64, der: usize) -> SplineResult<T> {
if ctrl.len() != self.count() {
return invalid(format!("{} control values for {} basis functions", ctrl.len(), self.count()));
}
let (first, values) = self.basis(t, der)?;
Ok(values.iter().zip(ctrl.iter().skip(first)).fold(T::zero(), |acc, (n, c)| acc.plus(c.times(*n))))
}
/// The splines of this space with `breaks` added as simple knots (those already knots are
/// left as they are). The result contains this space.
pub fn refined(&self, breaks: &[f64]) -> SplineResult<Space> {
let [a, b] = self.domain();
let mut knots = self.knots.clone();
for &x in breaks {
if x > a && x < b && !knots.contains(&x) {
knots.push(x);
}
}
knots.sort_by(f64::total_cmp);
Space::new(self.degree, knots)
}
}
/// A condition on a spline: the value (`der` = 0) or a derivative at `t`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Cond {
pub t: f64,
pub der: usize,
}
/// Interpolation in a space: the spline whose values and derivatives at the conditions are given
/// (as many conditions as the space has basis functions). The system is factored once; each
/// [`Interpolant::fit`] solves it for new data, so one interpolant serves many curves and the
/// rows of a surface alike (the operator is linear).
#[derive(Clone, Debug)]
pub struct Interpolant {
space: Space,
conds: Vec<Cond>,
lu: Lu,
}
impl Interpolant {
pub fn new(space: Space, conds: Vec<Cond>) -> SplineResult<Interpolant> {
let n = space.count();
if conds.len() != n {
return invalid(format!("{} conditions for {n} basis functions", conds.len()));
}
let mut a = vec![0.0; n * n];
for (r, c) in conds.iter().enumerate() {
let (first, values) = space.basis(c.t, c.der)?;
for (k, v) in values.iter().enumerate() {
if let Some(x) = a.get_mut(r * n + first + k) {
*x = *v;
}
}
}
let lu = Lu::new(n, a).map_err(|_| SplineError::Invalid("the interpolation conditions do not determine a spline".into()))?;
Ok(Interpolant { space, conds, lu })
}
/// Interpolation by a single polynomial piece of the lowest degree that meets `conds` (degree
/// one less than their number, at least one: a lone value is held constant), on `[a, b]`;
/// with more than four conditions, a cubic spline whose interior knots average the
/// conditions' parameters (taken as a sequence with one entry per condition, NURBS Book eq.
/// 9.8 applied to that confluent sequence, which keeps every basis function's support over
/// the condition it is matched with).
pub fn lowest(conds: Vec<Cond>, a: f64, b: f64) -> SplineResult<Interpolant> {
let mut conds = conds;
if conds.iter().any(|c| !c.t.is_finite() || c.t < a || c.t > b) {
return invalid("an interpolation parameter is outside the domain");
}
if conds.len() == 1
&& let Some(c) = conds.first().copied()
{
// A constant: the value and a zero slope (the caller supplies zero data for it).
conds.push(Cond { t: c.t, der: 1 });
}
let k = conds.len();
if k <= 4 {
return Interpolant::new(Space::polynomial(k.saturating_sub(1).max(1), a, b)?, conds);
}
let mut tau: Vec<f64> = conds.iter().map(|c| c.t).collect();
tau.sort_by(f64::total_cmp);
let mut breaks = vec![a];
for j in 1..k - 3 {
breaks.push((tau[j] + tau[j + 1] + tau[j + 2]) / 3.0);
}
breaks.push(b);
if breaks.windows(2).any(|w| !(w[0] < w[1])) {
return invalid("the interpolation parameters repeat too often");
}
Interpolant::new(Space::with_breaks(3, &breaks)?, conds)
}
pub fn space(&self) -> &Space {
&self.space
}
pub fn conds(&self) -> &[Cond] {
&self.conds
}
/// The control values of the spline meeting the conditions with `data` (one per condition).
pub fn fit<T: Linear>(&self, data: &[T]) -> SplineResult<Vec<T>> {
if data.len() != self.conds.len() {
return invalid(format!("{} values for {} conditions", data.len(), self.conds.len()));
}
let out = self.lu.solve(data);
if out.iter().any(|x| !x.is_finite()) {
return invalid("interpolation did not give finite control values");
}
Ok(out)
}
}
/// An LU factorisation with partial pivoting of a dense square matrix (row-major).
#[derive(Clone, Debug)]
struct Lu {
n: usize,
a: Vec<f64>,
piv: Vec<usize>,
}
impl Lu {
fn new(n: usize, mut a: Vec<f64>) -> SplineResult<Lu> {
if a.len() != n * n {
return invalid("matrix size");
}
let scale = a.iter().fold(0.0f64, |m, x| m.max(x.abs()));
if !(scale > 0.0 && scale.is_finite()) {
return invalid("singular system");
}
let mut piv = vec![0; n];
for k in 0..n {
let (mut best, mut big) = (k, 0.0);
for r in k..n {
let x = a[r * n + k].abs();
if x > big {
(best, big) = (r, x);
}
}
if !(big > scale * 1e-13) {
return invalid("singular system");
}
piv[k] = best;
if best != k {
for c in 0..n {
a.swap(k * n + c, best * n + c);
}
}
let d = a[k * n + k];
for r in k + 1..n {
let f = a[r * n + k] / d;
a[r * n + k] = f;
if f != 0.0 {
for c in k + 1..n {
a[r * n + c] -= f * a[k * n + c];
}
}
}
}
Ok(Lu { n, a, piv })
}
fn solve<T: Linear>(&self, b: &[T]) -> Vec<T> {
let n = self.n;
let mut x = b.to_vec();
if x.len() != n {
return x;
}
for (k, &p) in self.piv.iter().enumerate() {
x.swap(k, p);
}
for r in 0..n {
let row = &self.a[r * n..r * n + r];
let acc = row.iter().zip(&x[..r]).fold(x[r], |acc, (l, xc)| acc.plus(xc.times(-l)));
x[r] = acc;
}
for r in (0..n).rev() {
let row = &self.a[r * n + r + 1..(r + 1) * n];
let acc = row.iter().zip(&x[r + 1..]).fold(x[r], |acc, (u, xc)| acc.plus(xc.times(-u)));
x[r] = acc.times(1.0 / self.a[r * n + r]);
}
x
}
}
/// The control values in `to` of the spline with control values `ctrl` in `from`, which `to`
/// contains: the same domain, a degree at least as high, and every knot of `from` among those of
/// `to` (with a polynomial `from` when the degree rises). Exact: Bézier degree elevation (NURBS
/// Book §5.5) to the target degree, then Boehm knot insertion (§5.2) of the knots `to` adds.
pub fn embed<T: Linear>(from: &Space, ctrl: &[T], to: &Space) -> SplineResult<Vec<T>> {
if ctrl.len() != from.count() {
return invalid("control values do not match the space");
}
let ([a0, b0], [a1, b1]) = (from.domain(), to.domain());
if a0 != a1 || b0 != b1 {
return invalid("the spaces' domains differ");
}
let mut p = from.degree();
let mut knots = from.knots().to_vec();
let mut pts: Vec<T> = ctrl.to_vec();
if p > to.degree() {
return invalid("the target space's degree is lower");
}
if p < to.degree() {
if from.count() != p + 1 {
return Err(SplineError::Unsupported("raising the degree of a spline with interior knots".into()));
}
while p < to.degree() {
pts = elevate_bezier(&pts);
p += 1;
}
knots = vec![a0; p + 1];
knots.extend(std::iter::repeat_n(b0, p + 1));
}
// The knots `to` adds: a merge of the two sorted vectors.
let target = to.knots();
let mut missing = Vec::new();
let mut j = 0;
for &x in target {
match knots.get(j) {
Some(&k) if k == x => j += 1,
Some(&k) if k < x => return invalid("a knot of the spline is not in the target space"),
_ => missing.push(x),
}
}
if j != knots.len() {
return invalid("a knot of the spline is not in the target space");
}
let mut h: Vec<HPoint> = pts.iter().map(|x| x.to_h()).collect();
for x in missing {
(knots, h) = insert_knot(p, &knots, &h, x)?;
}
if knots.as_slice() != target {
return invalid("knot insertion did not reach the target space");
}
Ok(h.into_iter().map(T::from_h).collect())
}
/// A Bézier piece's control values one degree higher, the same polynomial (NURBS Book eq. 5.36):
/// `Qᵢ = (i/(p+1))·Pᵢ₋₁ + (1 − i/(p+1))·Pᵢ`.
fn elevate_bezier<T: Linear>(pts: &[T]) -> Vec<T> {
let p = pts.len().saturating_sub(1);
let at = |i: usize| pts.get(i).copied().unwrap_or_else(T::zero);
(0..=p + 1)
.map(|i| {
let a = i as f64 / (p + 1) as f64;
let prev = if i == 0 { T::zero() } else { at(i - 1).times(a) };
prev.plus(at(i).times(1.0 - a))
})
.collect()
}
/// A monotone piecewise cubic Hermite function through `(xₖ, yₖ)`, both strictly increasing
/// (Fritsch & Carlson): slopes are the weighted harmonic means of the neighbouring secants
/// (Fritsch & Butland), the secant at the ends. Its slope is positive everywhere, and through two
/// points it is the straight line.
#[derive(Clone, Debug, PartialEq)]
pub struct Monotone {
x: Vec<f64>,
y: Vec<f64>,
m: Vec<f64>,
}
impl Monotone {
pub fn new(x: Vec<f64>, y: Vec<f64>) -> SplineResult<Monotone> {
let n = x.len();
if n < 2 || y.len() != n {
return invalid("a monotone cubic needs two points or more");
}
let increasing = |v: &[f64]| v.iter().all(|t| t.is_finite()) && v.windows(2).all(|w| w[0] < w[1]);
if !increasing(&x) || !increasing(&y) {
return invalid("the points of a monotone cubic must increase");
}
let h: Vec<f64> = x.windows(2).map(|w| w[1] - w[0]).collect();
let d: Vec<f64> = y.windows(2).zip(&h).map(|(w, h)| (w[1] - w[0]) / h).collect();
let mut m = Vec::with_capacity(n);
m.push(d[0]);
for k in 1..n - 1 {
let (w1, w2) = (2.0 * h[k] + h[k - 1], h[k] + 2.0 * h[k - 1]);
m.push((w1 + w2) / (w1 / d[k - 1] + w2 / d[k]));
}
m.push(d[n - 2]);
Ok(Monotone { x, y, m })
}
/// The value and the first two derivatives at `t` (clamped to the points' range).
pub fn eval(&self, t: f64) -> [f64; 3] {
let n = self.x.len();
let t = t.clamp(self.x[0], self.x[n - 1]);
let k = self.x.partition_point(|x| *x <= t).saturating_sub(1).min(n - 2);
let (x0, x1, y0, y1, m0, m1) = (self.x[k], self.x[k + 1], self.y[k], self.y[k + 1], self.m[k], self.m[k + 1]);
let h = x1 - x0;
let s = (t - x0) / h;
let (s2, s3) = (s * s, s * s * s);
let value = (2.0 * s3 - 3.0 * s2 + 1.0) * y0 + (s3 - 2.0 * s2 + s) * h * m0 + (-2.0 * s3 + 3.0 * s2) * y1 + (s3 - s2) * h * m1;
let d1 = ((6.0 * s2 - 6.0 * s) * y0 + (3.0 * s2 - 4.0 * s + 1.0) * h * m0 + (-6.0 * s2 + 6.0 * s) * y1 + (3.0 * s2 - 2.0 * s) * h * m1) / h;
let d2 = ((12.0 * s - 6.0) * y0 + (6.0 * s - 4.0) * h * m0 + (-12.0 * s + 6.0) * y1 + (6.0 * s - 2.0) * h * m1) / (h * h);
[value, d1, d2]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-10
}
/// Hermite interpolation reproduces a cubic from its values and end derivatives, and the
/// lowest-degree interpolant through two values is the straight line.
#[test]
fn interpolation_meets_values_and_derivatives() {
let f = |t: f64| 1.0 + 2.0 * t - 3.0 * t * t + 0.5 * t * t * t;
let df = |t: f64| 2.0 - 6.0 * t + 1.5 * t * t;
let d2f = |t: f64| -6.0 + 3.0 * t;
let conds = vec![Cond { t: 0.0, der: 0 }, Cond { t: 0.0, der: 1 }, Cond { t: 0.0, der: 2 }, Cond { t: 2.0, der: 0 }];
let it = Interpolant::lowest(conds, 0.0, 2.0).unwrap();
let c = it.fit(&[f(0.0), df(0.0), d2f(0.0), f(2.0)]).unwrap();
for k in 0..=10 {
let t = k as f64 * 0.2;
assert!(close(it.space().eval(&c, t, 0).unwrap(), f(t)), "{t}");
}
let line = Interpolant::lowest(vec![Cond { t: 0.0, der: 0 }, Cond { t: 1.0, der: 0 }], 0.0, 1.0).unwrap();
let c = line.fit(&[2.0, 5.0]).unwrap();
assert!(close(line.space().eval(&c, 0.25, 0).unwrap(), 2.75));
let constant = Interpolant::lowest(vec![Cond { t: 0.3, der: 0 }], 0.0, 1.0).unwrap();
let c = constant.fit(&[4.0, 0.0]).unwrap();
assert!(close(constant.space().eval(&c, 0.9, 0).unwrap(), 4.0));
}
/// Many conditions: a cubic spline with averaged knots through samples of a smooth function
/// with derivative conditions at both ends (values exact at the samples).
#[test]
fn spline_interpolation_with_end_derivatives() {
let f = |t: f64| (2.0 * t).sin();
let df = |t: f64| 2.0 * (2.0 * t).cos();
let ts: Vec<f64> = (0..=8).map(|k| k as f64 / 8.0).collect();
let mut conds: Vec<Cond> = ts.iter().map(|&t| Cond { t, der: 0 }).collect();
conds.push(Cond { t: 0.0, der: 1 });
conds.push(Cond { t: 1.0, der: 1 });
let it = Interpolant::lowest(conds, 0.0, 1.0).unwrap();
let mut data: Vec<f64> = ts.iter().map(|&t| f(t)).collect();
data.push(df(0.0));
data.push(df(1.0));
let c = it.fit(&data).unwrap();
for &t in &ts {
assert!(close(it.space().eval(&c, t, 0).unwrap(), f(t)));
}
assert!(close(it.space().eval(&c, 0.0, 1).unwrap(), df(0.0)));
assert!(close(it.space().eval(&c, 1.0, 1).unwrap(), df(1.0)));
assert!((it.space().eval(&c, 0.53, 0).unwrap() - f(0.53)).abs() < 1e-4);
}
/// Embedding is exact: a quadratic polynomial raised to a cubic with knots inserted, and a
/// cubic spline refined, evaluate the same everywhere; a space that does not contain the
/// spline is refused.
#[test]
fn embedding_keeps_the_spline() {
let from = Space::polynomial(2, 0.0, 1.0).unwrap();
let ctrl = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 2.0, 0.0), Vec3::new(3.0, -1.0, 1.0)];
let to = Space::with_breaks(3, &[0.0, 0.2, 0.5, 0.7, 1.0]).unwrap();
let out = embed(&from, &ctrl, &to).unwrap();
let spline = Space::with_breaks(3, &[0.0, 0.5, 1.0]).unwrap();
let sctrl: Vec<f64> = (0..spline.count()).map(|i| (i as f64 * 1.7).sin()).collect();
let finer = spline.refined(&[0.25, 0.5, 0.8]).unwrap();
let sout = embed(&spline, &sctrl, &finer).unwrap();
for k in 0..=20 {
let t = k as f64 / 20.0;
assert!(from.eval(&ctrl, t, 0).unwrap().dist(to.eval(&out, t, 0).unwrap()) < 1e-12);
assert!(close(spline.eval(&sctrl, t, 0).unwrap(), finer.eval(&sout, t, 0).unwrap()));
}
assert!(embed(&finer, &sout, &spline).is_err(), "a coarser space");
assert!(embed(&spline, &sctrl, &Space::with_breaks(3, &[0.0, 0.4, 1.0]).unwrap()).is_err(), "a knot missing");
assert!(embed(&spline, &sctrl, &Space::with_breaks(3, &[0.0, 0.5, 2.0]).unwrap()).is_err(), "another domain");
}
#[test]
fn monotone_cubic_is_monotone_and_straight_through_two_points() {
let m = Monotone::new(vec![0.0, 0.3, 0.5, 1.0], vec![0.0, 0.05, 0.9, 1.0]).unwrap();
let mut last = -1.0;
for k in 0..=200 {
let [y, d, _] = m.eval(k as f64 / 200.0);
assert!(y >= last && d >= 0.0, "{k}");
last = y;
}
assert!(close(m.eval(0.3)[0], 0.05) && close(m.eval(0.5)[0], 0.9));
let line = Monotone::new(vec![0.0, 1.0], vec![2.0, 4.0]).unwrap();
assert!(close(line.eval(0.25)[0], 2.5) && close(line.eval(0.7)[1], 2.0) && close(line.eval(0.7)[2], 0.0));
assert!(Monotone::new(vec![0.0, 0.0], vec![0.0, 1.0]).is_err());
assert!(Monotone::new(vec![0.0, 1.0], vec![1.0, 0.0]).is_err());
assert!(Monotone::new(vec![0.0, f64::NAN], vec![0.0, 1.0]).is_err());
}
#[test]
fn hostile_spaces_are_refused() {
assert!(Space::new(3, vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]).is_ok());
assert!(Space::new(3, vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]).is_err(), "too few knots");
assert!(Space::new(3, vec![0.0, 0.0, 0.0, 0.1, 1.0, 1.0, 1.0, 1.0]).is_err(), "not clamped");
assert!(Space::new(1, vec![0.0, 0.0, 0.5, 0.5, 1.0, 1.0]).is_err(), "a discontinuous space");
assert!(Space::new(0, vec![0.0, 1.0]).is_err());
assert!(Space::with_breaks(3, &[0.0, 0.5, 0.5, 1.0]).is_err());
assert!(Space::with_breaks(3, &[0.0, f64::NAN, 1.0]).is_err());
let s = Space::with_breaks(3, &[0.0, 1.0]).unwrap();
assert!(s.eval(&[1.0, 2.0], 0.5, 0).is_err(), "wrong control count");
assert!(s.basis(f64::NAN, 0).is_err());
// Repeated conditions do not determine a spline.
assert!(Interpolant::new(s.clone(), vec![Cond { t: 0.5, der: 0 }; 4]).is_err());
assert!(Interpolant::lowest(vec![Cond { t: 2.0, der: 0 }], 0.0, 1.0).is_err());
}
}
+27
View File
@@ -1494,6 +1494,33 @@ impl Body {
shell_groups::meshes_meet(&a, &b, 2.0 * tol)
}
/// The same contact test for several tools, reusing this unchanged body's
/// fine mesh at each required tolerance. No contact threshold is relaxed.
pub fn meets_each(&self, others: &[Body]) -> Result<Vec<bool>> {
if others.len() > 1024 {
return Err(KernelError::Invalid("too many contact tools".into()));
}
self.require_brep("contact test")?;
let size = self.size();
let mut meshes = std::collections::HashMap::new();
let mut contacts = Vec::with_capacity(others.len());
for other in others {
other.require_brep("contact test")?;
let tol = (size.max(other.size()) * 1e-4).max(1e-6);
let key = tol.to_bits();
if !meshes.contains_key(&key) {
// Bound retained meshes even when hostile inputs request many tolerances.
if meshes.len() == 16 {
meshes.clear();
}
meshes.insert(key, self.tessellate(tol)?);
}
let mesh = meshes.get(&key).ok_or_else(|| KernelError::Internal("contact mesh disappeared".into()))?;
contacts.push(shell_groups::meshes_meet(mesh, &other.tessellate(tol)?, 2.0 * tol)?);
}
Ok(contacts)
}
/// A boolean shortcut needs clearance larger than both shell mesh deflections.
pub(crate) fn separated_lumps(&self) -> Result<Vec<Body>> {
self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3))
+8 -5
View File
@@ -6,13 +6,16 @@
//! surface — its tangent plane, its curvature, the change of that curvature — as the side's order
//! asks for, with `crown` shaping the middle without touching a boundary condition.
//!
//! A patch through *three or four* boundaries is refused rather than built approximately. At a
//! corner two sides prescribe the same entries of the control net from two different surfaces, and
//! a tensor-product patch can hold both only where those two prescriptions already agree (one wall
//! carrying every side, say). Where they do not — the general case — the requested order would
//! A patch through *three or four* boundaries is refused here rather than built approximately. At
//! a corner two sides prescribe the same entries of the control net from two different surfaces,
//! and a tensor-product patch can hold both only where those two prescriptions already agree (one
//! wall carrying every side, say). Where they do not — the general case — the requested order would
//! silently drop near the corner, or the boundary curve would visibly leave its stations, and a
//! Gregory-type corner (a rational blend of the sides' own data) is needed to hold them instead
//! (issue #763). Everything this module refuses is an error, never a quietly worse surface.
//! (issue #763). A loop of one sketch's curves is the case where they agree: it lies in the
//! sketch's plane, and its flat Coons patch ([`crate::boundary_network`]) holds every order
//! exactly; the network there refuses G1/G2 on two sides that meet at a corner for the same
//! reason. Everything this module refuses is an error, never a quietly worse surface.
use solidcraft_geom::Vec3;
+744
View File
@@ -0,0 +1,744 @@
//! Boundary surfaces (Surface › Boundary): one B-spline face through curves in one or two
//! directions (`solidcraft_geom::network`: a loft between curves, a Coons patch inside four, a
//! Gordon surface through a network), made from sketch curves, 3D sketch curves or a body's
//! edges. A boundary curve that is a chain of open edges of a surface may carry a continuity
//! condition with the face beside it: G1 (tangent) or G2 (curvature).
//!
//! The reusable pieces: [`CurveSource`] turns each kind of curve into a curve the network can
//! measure, [`EdgeFaces`] gives the frame and second fundamental form of the faces along a chain
//! of edges (what a G1 or G2 condition needs), and [`boundary_surface`] builds the face.
//!
//! The two-curve bridge with G0–G3 against a sketch's plane is [`crate::boundary`]; this is the
//! general case: three or four sides, interior curves, 3D curves and edges.
use std::collections::HashSet;
use mt::{BoundedCurve, Cut, InnerSpace, ParametricCurve, ParametricSurface, SearchNearestParameter};
use solidcraft_geom::bspline::{CurveN, NurbsSurface, SplineError};
use solidcraft_geom::network::{self, CrossFace, FaceFrame, NetCurve, NetworkCurve, PointCurve, Tolerances};
use solidcraft_geom::{Plane, Seg2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, from_p3, p3};
use crate::bridge::Continuity;
use crate::plane_fit::EdgeSampler;
use crate::record::Rebuild;
use crate::{KernelError, Result, guard};
/// How far apart curves of the two directions may pass and still cross (mm).
pub const CROSS_TOLERANCE: f64 = 0.01;
/// How far the surface may stray from its curves (mm).
pub const FIT_TOLERANCE: f64 = 1e-4;
/// Most points of a 3D sketch curve a boundary surface follows (longer ones are resampled).
const MAX_WIRE_POINTS: usize = 400;
/// Most points picking the edges of one curve.
pub const MAX_EDGE_PICKS: usize = 10_000;
/// Most sketch curves one curve of a boundary surface chains.
pub const MAX_CHAIN_CURVES: usize = 1000;
/// The network's continuity for a curve's: G3 is refused (a network holds up to G2).
fn net_order(c: &BoundaryCurve) -> Result<network::Continuity> {
match c.continuity {
Continuity::G0 => Ok(network::Continuity::G0),
Continuity::G1 => Ok(network::Continuity::G1),
Continuity::G2 => Ok(network::Continuity::G2),
Continuity::G3 => Err(KernelError::Invalid(format!("not supported yet: G3 continuity on {} (a curve network holds up to G2)", c.label))),
}
}
/// Where a curve of a boundary surface comes from.
pub enum CurveSource<'a> {
/// Sketch segments on a plane, each starting where the one before it ends.
Sketch { plane: Plane, segs: Vec<Seg2> },
/// A 3D sketch curve's points: followed smoothly through them (a cubic spline).
Points(Vec<Vec3>),
/// A chain of a body's edges, each picked by a point on it.
Edges { body: &'a Body, at: Vec<Vec3> },
/// A side collapsed to a point: the apex of a three-sided patch, where the sides beside it
/// meet.
Point(Vec3),
}
/// A curve of a boundary surface: a name for messages, where it comes from, and (for edges of a
/// surface) its continuity with the face beside it.
pub struct BoundaryCurve<'a> {
pub label: String,
pub source: CurveSource<'a>,
pub continuity: Continuity,
}
/// One B-spline face through curves `u` (two or more; or one with a continuity condition, crossed
/// by `v`) and `v` (none: a loft through `u`; two each way and no more: a Coons patch; more: a
/// Gordon surface through the network). A surface body; its history has no operands (the face
/// and its edges are new). Refused, naming the curves: curves that miss each other (by more than
/// [`CROSS_TOLERANCE`]) or cross twice, curves out of order, closed curves, a continuity
/// condition on anything but an open edge of a surface, or on curves of both directions.
pub fn boundary_surface(u: &[BoundaryCurve], v: &[BoundaryCurve]) -> Result<Body> {
for c in u.iter().chain(v) {
if let CurveSource::Edges { body, .. } = &c.source {
body.require_brep("boundary surface")?;
}
}
guard("boundary surface", || {
let mut corners: Vec<Vec3> = Vec::new();
let mut to_net = |c: &BoundaryCurve| -> Result<NetworkCurve<'static>> {
let (curve, face): (Box<dyn NetCurve>, Option<EdgeFaces>) = match &c.source {
CurveSource::Sketch { plane, segs } => (Box::new(SketchChain::new(&c.label, plane, segs)?), None),
CurveSource::Points(pts) => (Box::new(Polyline::new(&c.label, pts)?), None),
CurveSource::Point(p) => {
if !p.is_finite() {
return Err(KernelError::Invalid(format!("{} is not a finite point", c.label)));
}
(Box::new(PointCurve(*p)), None)
}
CurveSource::Edges { body, at } => {
if at.is_empty() || at.len() > MAX_EDGE_PICKS || at.iter().any(|p| !p.is_finite()) {
return Err(KernelError::Invalid(format!("{}: pick 1 to {MAX_EDGE_PICKS} points on its edges", c.label)));
}
let (chain, closed) = crate::surfaces::picked_chain(body, at)?;
if closed {
return Err(KernelError::Invalid(format!("not supported yet: {} is a closed chain of edges", c.label)));
}
// The chain's inner vertices: the face's sides are split there, so that a
// join shares the body's edges one for one.
corners.extend(chain.iter().skip(1).map(|e| from_p3(e.front().point())));
let face = match c.continuity {
Continuity::G0 => None,
_ => Some(EdgeFaces::new(&c.label, body, &chain)?),
};
(Box::new(EdgeChain::new(&c.label, &chain)?), face)
}
};
if c.continuity != Continuity::G0 && face.is_none() {
return Err(KernelError::Invalid(format!("{}: G1 and G2 apply only to a body's edges", c.label)));
}
let order = net_order(c)?;
Ok(NetworkCurve { label: c.label.clone(), curve, condition: face.map(|f| (order, Box::new(f) as Box<dyn CrossFace>)) })
};
let nu: Vec<NetworkCurve> = u.iter().map(&mut to_net).collect::<Result<_>>()?;
let nv: Vec<NetworkCurve> = v.iter().map(&mut to_net).collect::<Result<_>>()?;
let surface = network::network_surface(nu, nv, Tolerances { cross: CROSS_TOLERANCE, fit: FIT_TOLERANCE }).map_err(|e| match e {
SplineError::Unsupported(m) => KernelError::Invalid(format!("not supported yet: {m}")),
SplineError::TooLarge(m) => KernelError::TooLarge(m),
SplineError::Invalid(m) => KernelError::Invalid(m),
})?;
let face = face_of(&surface, &corners)?;
let mut rec = Rebuild::default();
rec.made(&face, Vec::new());
crate::surfaces::body_of(vec![face], &[], &rec)
})
}
/// `made` (a boundary surface) sewn into `target` along the edges they share: one body, a solid
/// when it closes the gap. Refused when they share no edge.
pub fn boundary_join(target: &Body, made: &Body) -> Result<Body> {
target.require_brep("join")?;
let edges = |b: &Body| b.solid.edge_iter().map(|e| e.id()).collect::<HashSet<_>>().len();
let joined = crate::surfaces::stitch(&[target, made], CROSS_TOLERANCE)?;
if edges(&joined) == edges(target) + edges(made) {
return Err(KernelError::Invalid("the surface shares no edge with the body it joins".into()));
}
Ok(joined)
}
/// Sketch segments on a plane, run end to end by arc length.
struct SketchChain {
plane: Plane,
segs: Vec<Seg2>,
/// Cumulative length at the start of each segment, and the total.
starts: Vec<f64>,
total: f64,
}
impl SketchChain {
fn new(label: &str, plane: &Plane, segs: &[Seg2]) -> Result<SketchChain> {
if segs.is_empty() || segs.len() > MAX_CHAIN_CURVES {
return Err(KernelError::Invalid(format!("{label} has no curves, or too many")));
}
if segs.windows(2).any(|w| w[0].end().dist(w[1].start()) > 1e-6) {
return Err(KernelError::Invalid(format!("{label}: its curves don't meet end to end")));
}
let mut starts = Vec::with_capacity(segs.len());
let mut total = 0.0;
for s in segs {
starts.push(total);
total += s.length();
}
if !(total > 0.0 && total.is_finite()) {
return Err(KernelError::Invalid(format!("{label} has no length")));
}
Ok(SketchChain { plane: *plane, segs: segs.to_vec(), starts, total })
}
}
impl NetCurve for SketchChain {
fn point(&mut self, t: f64) -> Option<Vec3> {
let at = t.clamp(0.0, 1.0) * self.total;
let k = self.starts.partition_point(|s| *s <= at).saturating_sub(1);
let (seg, start) = (self.segs.get(k)?, self.starts.get(k)?);
let len = seg.length();
let local = if len > 0.0 { ((at - start) / len).clamp(0.0, 1.0) } else { 0.0 };
let p = self.plane.to_world(seg.point_at(local));
p.is_finite().then_some(p)
}
}
/// A 3D polyline followed smoothly: a cubic through its points by chord length (fewer points, a
/// lower degree; a long one resampled first).
struct Polyline {
curve: CurveN<3>,
end: f64,
}
impl Polyline {
fn new(label: &str, pts: &[Vec3]) -> Result<Polyline> {
let mut clean: Vec<Vec3> = Vec::with_capacity(pts.len());
for p in pts {
if !p.is_finite() {
return Err(KernelError::Invalid(format!("{label} has a point that is not a finite number")));
}
if clean.last().is_none_or(|q: &Vec3| q.dist(*p) > 1e-9) {
clean.push(*p);
}
}
let pts = resample(&clean, MAX_WIRE_POINTS);
if pts.len() < 2 {
return Err(KernelError::Invalid(format!("{label} has no length")));
}
let mut params = vec![0.0];
for w in pts.windows(2) {
params.push(params.last().copied().unwrap_or(0.0) + w[0].dist(w[1]));
}
let end = params.last().copied().unwrap_or(0.0);
let data: Vec<[f64; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect();
let curve = CurveN::interpolate(&params, &data, (pts.len() - 1).min(3))
.map_err(|e| KernelError::Invalid(format!("{label} can't be followed smoothly: {e}")))?;
Ok(Polyline { curve, end })
}
}
impl NetCurve for Polyline {
fn point(&mut self, t: f64) -> Option<Vec3> {
let ([x, y, z], _) = self.curve.eval(t.clamp(0.0, 1.0) * self.end)?;
Some(Vec3::new(x, y, z))
}
}
/// At most `max` points evenly along a polyline by arc length, its ends kept.
fn resample(pts: &[Vec3], max: usize) -> Vec<Vec3> {
if pts.len() <= max || max < 2 {
return pts.to_vec();
}
let mut cum = vec![0.0];
for w in pts.windows(2) {
cum.push(cum.last().copied().unwrap_or(0.0) + w[0].dist(w[1]));
}
let total = cum.last().copied().unwrap_or(0.0);
(0..max)
.filter_map(|k| {
let at = total * k as f64 / (max - 1) as f64;
let i = cum.partition_point(|c| *c <= at).saturating_sub(1).min(pts.len().saturating_sub(2));
let (c0, c1) = (cum.get(i)?, cum.get(i + 1)?);
let w = if c1 > c0 { ((at - c0) / (c1 - c0)).clamp(0.0, 1.0) } else { 0.0 };
Some(pts.get(i)?.lerp(*pts.get(i + 1)?, w))
})
.collect()
}
/// A chain of edges (each turned to run along it), by their lengths.
struct EdgeChain {
edges: Vec<EdgeSampler>,
starts: Vec<f64>,
total: f64,
}
impl EdgeChain {
fn new(label: &str, chain: &[mt::Edge]) -> Result<EdgeChain> {
let mut edges = Vec::with_capacity(chain.len());
let mut starts = Vec::with_capacity(chain.len());
let mut total = 0.0;
for e in chain {
let mut s = EdgeSampler::new(e).ok_or_else(|| KernelError::Invalid(format!("{label}: an edge has no parameter range")))?;
let mut len = 0.0;
let mut last: Option<Vec3> = None;
for k in 0..=16 {
let p = s.at(k as f64 / 16.0).ok_or_else(|| KernelError::Invalid(format!("{label}: an edge can't be evaluated")))?;
if let Some(q) = last {
len += p.dist(q);
}
last = Some(p);
}
starts.push(total);
total += len;
edges.push(s);
}
if !(total > 0.0 && total.is_finite()) {
return Err(KernelError::Invalid(format!("{label} has no length")));
}
Ok(EdgeChain { edges, starts, total })
}
}
impl NetCurve for EdgeChain {
fn point(&mut self, t: f64) -> Option<Vec3> {
let at = t.clamp(0.0, 1.0) * self.total;
let k = self.starts.partition_point(|s| *s <= at).saturating_sub(1);
let start = *self.starts.get(k)?;
let end = self.starts.get(k + 1).copied().unwrap_or(self.total);
let local = if end > start { ((at - start) / (end - start)).clamp(0.0, 1.0) } else { 0.0 };
self.edges.get_mut(k)?.at(local)
}
}
/// The faces along a chain of edges, each edge an open edge of one face of its body: their
/// frames and second fundamental forms where a boundary surface meets them (G1 and G2
/// conditions).
pub(crate) struct EdgeFaces {
sides: Vec<EdgeSide>,
}
/// One edge of the chain: points along it (to find the edge nearest a point), the surface of
/// its face, whether the face's boundary runs along the chain (`along`), and the surface
/// parameters found last (the next search starts there).
struct EdgeSide {
pts: Vec<Vec3>,
surface: mt::Surface,
along: bool,
hint: Option<(f64, f64)>,
}
impl EdgeFaces {
pub(crate) fn new(label: &str, body: &Body, chain: &[mt::Edge]) -> Result<EdgeFaces> {
let mut sides = Vec::with_capacity(chain.len());
for e in chain {
let mut faces = body.solid.face_iter().filter(|f| f.boundaries().iter().flatten().any(|x| x.id() == e.id()));
let (Some(face), None) = (faces.next(), faces.next()) else {
return Err(KernelError::Invalid(format!(
"{label}: G1 and G2 need an open edge of a surface (an edge with a face on one side only)"
)));
};
// How the face's own (absolute) boundary runs this edge.
let in_wire = face.absolute_boundaries().iter().flatten().find(|x| x.id() == e.id()).cloned();
let in_wire = in_wire.ok_or_else(|| KernelError::Failed("boundary surface: an edge's face".into()))?;
let along = in_wire.front().id() == e.front().id();
let mut sampler = EdgeSampler::new(e).ok_or_else(|| KernelError::Invalid(format!("{label}: an edge has no parameter range")))?;
let pts = (0..=16).map(|k| sampler.at(k as f64 / 16.0)).collect::<Option<Vec<_>>>();
let pts = pts.ok_or_else(|| KernelError::Invalid(format!("{label}: an edge can't be evaluated")))?;
sides.push(EdgeSide { pts, surface: face.surface(), along, hint: None });
}
Ok(EdgeFaces { sides })
}
}
impl CrossFace for EdgeFaces {
fn frame(&mut self, at: Vec3, tangent: Vec3) -> Option<FaceFrame> {
// The edge nearest the point, and the chain's direction along it there.
let near = |s: &EdgeSide| s.pts.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
let k = (0..self.sides.len()).min_by(|a, b| near(&self.sides[*a]).total_cmp(&near(&self.sides[*b])))?;
let side = self.sides.get_mut(k)?;
let seg = side.pts.windows(2).min_by(|a, b| at.dist_to_segment(a[0], a[1]).total_cmp(&at.dist_to_segment(b[0], b[1])))?;
let chain_dir = seg[1] - seg[0];
let s = &side.surface;
let (u, v) = s.search_nearest_parameter(p3(at), side.hint, 100).or_else(|| s.search_nearest_parameter(p3(at), None, 100))?;
side.hint = Some((u, v));
let (su, sv) = (s.uder(u, v), s.vder(u, v));
let n = su.cross(sv);
if !(n.magnitude2() > 0.0) {
return None;
}
let n = n.normalize();
let normal = Vec3::new(n.x, n.y, n.z);
// The face's boundary runs counter-clockwise about its surface's normal, so the face lies
// to the left of it: outward is the boundary's direction × the normal.
let wire = if side.along { chain_dir } else { -chain_dir };
let outward = wire.cross(normal).normalized()?;
// The second fundamental form, in the basis of the tangent asked about and outward.
let t = (tangent - normal * tangent.dot(normal)).normalized()?;
let b = (outward - t * outward.dot(t)).normalized()?;
let (e, f, g) = (su.dot(su), su.dot(sv), sv.dot(sv));
let det = e * g - f * f;
if !(det.abs() > 1e-24) {
return None;
}
// II(w, w) = L·α² + 2M·αβ + N·β² for w = α·S_u + β·S_v, with L, M, N the surface's
// second derivatives along its unit normal (do Carmo, §3-3): exact, rational surfaces
// included, and with nothing sampled beyond the edge, where the face's domain ends.
let nv = mt::Vector3::new(normal.x, normal.y, normal.z);
let (l, m, nn) = (s.uuder(u, v).dot(nv), s.uvder(u, v).dot(nv), s.vvder(u, v).dot(nv));
let ii = |w: Vec3| -> Option<f64> {
let wv = mt::Vector3::new(w.x, w.y, w.z);
let (p, q) = (wv.dot(su), wv.dot(sv));
let (alpha, beta) = ((p * g - q * f) / det, (q * e - p * f) / det);
let out = l * alpha * alpha + 2.0 * m * alpha * beta + nn * beta * beta;
out.is_finite().then_some(out)
};
let (tt, bb) = (ii(t)?, ii(b)?);
let tb = (ii(t + b)? - tt - bb) / 2.0;
let form = [tt, tb, bb];
form.iter().all(|x| x.is_finite()).then_some(FaceFrame { normal, outward: b, form })
}
}
/// The face of a network surface: its four sides from the surface's boundary rows, each split
/// at the `corners` that lie on it (inner vertices of edge chains). Refused when a side shrinks to
/// a point.
fn face_of(s: &NurbsSurface, corners: &[Vec3]) -> Result<mt::Face> {
let fail = |m: &str| KernelError::Failed(format!("boundary surface: {m}"));
let [nu, nv] = s.count();
let [ku, kv] = s.knots();
let rows: Vec<Vec<mt::Point3>> = (0..nu).map(|i| (0..nv).map(|j| p3(s.point(i, j).xyz())).collect()).collect();
let (uk, vk) = (mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone()));
let surface = mt::BSplineSurface::try_new((uk.clone(), vk.clone()), rows.clone()).map_err(|e| fail(&e.to_string()))?;
let row = |pts: Vec<mt::Point3>, k: &mt::KnotVec| mt::BSplineCurve::try_new(k.clone(), pts).map_err(|e| fail(&e.to_string()));
let (Some(first), Some(last)) = (rows.first(), rows.last()) else { return Err(fail("control net")) };
let bottom = row(rows.iter().filter_map(|r| r.first().copied()).collect(), &uk)?;
let top = row(rows.iter().filter_map(|r| r.last().copied()).collect(), &uk)?;
let left = row(first.clone(), &vk)?;
let right = row(last.clone(), &vk)?;
let corner = |c: &mt::BSplineCurve<mt::Point3>, end: bool| {
let (t0, t1) = c.range_tuple();
c.subs(if end { t1 } else { t0 })
};
let (c00, c10, c11, c01) = (corner(&bottom, false), corner(&bottom, true), corner(&top, true), corner(&top, false));
let size = [c00, c10, c11, c01].iter().map(|p| from_p3(*p).dist(from_p3(c00))).fold(0.0, f64::max).max(1.0);
// A side collapsed to a point (a three-sided patch) is left out of the loop: the two sides
// beside it meet at one vertex there.
let sides = [(c00, c10), (c10, c11), (c01, c11), (c00, c01)];
let collapsed: Vec<bool> = sides.iter().map(|(a, b)| from_p3(*a).dist(from_p3(*b)) <= size * 1e-9).collect();
match collapsed.iter().filter(|c| **c).count() {
0 | 1 => {}
_ => return Err(KernelError::Invalid("not supported yet: a boundary surface with more than one side shrunk to a point".into())),
}
let mut verts: Vec<mt::Vertex> = Vec::with_capacity(4);
for c in [c00, c10, c11, c01] {
let same = verts.iter().find(|v| from_p3(v.point()).dist(from_p3(c)) <= size * 1e-9).cloned();
verts.push(same.unwrap_or_else(|| mt::Vertex::new(c)));
}
let (Some(v00), Some(v10), Some(v11), Some(v01)) = (verts.first(), verts.get(1), verts.get(2), verts.get(3)) else {
return Err(fail("corners"));
};
let is = |k: usize| collapsed.get(k).copied().unwrap_or(false);
let mut wire: Vec<mt::Edge> = Vec::new();
if !is(0) {
wire.extend(side_edges(bottom, v00, v10, corners));
}
if !is(1) {
wire.extend(side_edges(right, v10, v11, corners));
}
if !is(2) {
wire.extend(side_edges(top, v01, v11, corners).into_iter().rev().map(|e| e.inverse()));
}
if !is(3) {
wire.extend(side_edges(left, v00, v01, corners).into_iter().rev().map(|e| e.inverse()));
}
// u along the bottom, v up the left: counter-clockwise in (u, v).
mt::Face::try_new(vec![wire.into()], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))
}
/// The edges of one side from `a` to `b`, split at the points of `corners` that lie on it
/// (within [`CROSS_TOLERANCE`]) strictly between its ends.
fn side_edges(mut curve: mt::BSplineCurve<mt::Point3>, a: &mt::Vertex, b: &mt::Vertex, corners: &[Vec3]) -> Vec<mt::Edge> {
let (t0, t1) = curve.range_tuple();
let margin = (t1 - t0) * 1e-6;
let mut cuts: Vec<f64> = corners
.iter()
.filter_map(|c| {
let t = curve.search_nearest_parameter(p3(*c), None, 100)?;
(t > t0 + margin && t < t1 - margin && from_p3(curve.subs(t)).dist(*c) <= CROSS_TOLERANCE).then_some(t)
})
.collect();
cuts.sort_by(f64::total_cmp);
cuts.dedup_by(|x, y| (*x - *y).abs() <= margin);
let mut edges = Vec::with_capacity(cuts.len() + 1);
let mut from = a.clone();
// Cut from the far end back, so the earlier parameters stay on the part kept.
let mut tails = Vec::with_capacity(cuts.len());
for &t in cuts.iter().rev() {
tails.push(curve.cut(t));
}
let mut pieces = vec![curve];
pieces.extend(tails.into_iter().rev());
let count = pieces.len();
for (k, piece) in pieces.into_iter().enumerate() {
let to = if k + 1 == count { b.clone() } else { mt::Vertex::new(piece.subs(piece.range_tuple().1)) };
edges.push(mt::Edge::new(&from, &to, mt::Curve::BSplineCurve(piece)));
from = to;
}
edges
}
#[cfg(test)]
mod tests {
use super::*;
use crate::patch_region;
use mt::ParametricSurface3D;
use solidcraft_geom::{Loop2, Region2, Vec2};
fn sketch(label: &str, plane: Plane, segs: Vec<Seg2>) -> BoundaryCurve<'static> {
BoundaryCurve { label: label.into(), source: CurveSource::Sketch { plane, segs }, continuity: Continuity::G0 }
}
fn line3(label: &str, a: Vec3, b: Vec3) -> BoundaryCurve<'static> {
BoundaryCurve { label: label.into(), source: CurveSource::Points(vec![a, b]), continuity: Continuity::G0 }
}
fn face_surface(b: &Body) -> mt::Surface {
b.solid.face_iter().next().unwrap().surface()
}
/// The flat sheet y ∈ [−10, 0], x ∈ [0, 10] in z = 0, its edge along y = 0 open.
fn sheet() -> Body {
let sq = Loop2::polygon(&[Vec2::new(0.0, -10.0), Vec2::new(10.0, -10.0), Vec2::new(10.0, 0.0), Vec2::new(0.0, 0.0)]);
patch_region(&Plane::XY, &Region2 { outer: sq, holes: vec![] }).unwrap()
}
/// A Coons patch of four lines bounding the hyperbolic paraboloid z = x·y/10: every point of
/// the face lies on it, and its area is the paraboloid's over the square.
#[test]
fn coons_patch_of_lines_is_a_hyperbolic_paraboloid() {
let p = |x: f64, y: f64| Vec3::new(x, y, x * y / 10.0);
let body = boundary_surface(
&[line3("u0", p(0.0, 0.0), p(10.0, 0.0)), line3("u1", p(0.0, 10.0), p(10.0, 10.0))],
&[line3("v0", p(0.0, 0.0), p(0.0, 10.0)), line3("v1", p(10.0, 0.0), p(10.0, 10.0))],
)
.unwrap();
assert!(body.is_surface() && body.face_count() == 1);
let s = face_surface(&body);
for i in 0..=10 {
for j in 0..=10 {
let q = from_p3(s.subs(i as f64 / 10.0, j as f64 / 10.0));
assert!((q.z - q.x * q.y / 10.0).abs() < 1e-9, "{q:?}");
}
}
}
/// Sketch curves: a patch inside two arcs and two lines on one plane lies in that plane.
#[test]
fn coplanar_sketch_curves_give_a_plane() {
use std::f64::consts::PI;
// Two half-discs' arcs over [0, 10] (radius 5) facing out, joined by the lines x = 0, 10.
let low = Seg2::Arc { center: Vec2::new(5.0, 0.0), radius: 5.0, start: PI, sweep: PI };
let high = Seg2::Arc { center: Vec2::new(5.0, 8.0), radius: 5.0, start: PI, sweep: -PI };
let left = Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(0.0, 8.0) };
let right = Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 8.0) };
let pl = Plane::XY.offset(3.0);
let body = boundary_surface(
&[sketch("low", pl, vec![low]), sketch("high", pl, vec![high])],
&[sketch("left", pl, vec![left]), sketch("right", pl, vec![right])],
)
.unwrap();
let s = face_surface(&body);
for i in 0..=8 {
for j in 0..=8 {
assert!((s.subs(i as f64 / 8.0, j as f64 / 8.0).z - 3.0).abs() < 1e-9);
}
}
}
/// A stadium: two half-circle arcs (radius 5) bulging out of the 10 × 8 rectangle between two
/// lines. Each arc meets its lines at 180°, so the patch is singular at its corners, and the
/// reference mesher's walk round the boundary used to run from such a corner onto the
/// surface's extrapolation past its domain (a planar spline passes through every point of its
/// plane there): the face measured 289 mm² for its 158.5. The vendored mesher's bounded
/// projection (`nearest_projection`, vendor/README.md) keeps the walk on the domain. Its area
/// is the stadium's, 80 + 25π, by the faces, the measuring mesh and the display mesh.
#[test]
fn a_patch_with_straight_corners_measures_its_own_area() {
use std::f64::consts::PI;
let low = Seg2::Arc { center: Vec2::new(5.0, 0.0), radius: 5.0, start: PI, sweep: PI };
let high = Seg2::Arc { center: Vec2::new(5.0, 8.0), radius: 5.0, start: PI, sweep: -PI };
let left = Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(0.0, 8.0) };
let right = Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 8.0) };
let pl = Plane::XY.offset(3.0);
let body = boundary_surface(
&[sketch("low", pl, vec![low]), sketch("high", pl, vec![high])],
&[sketch("left", pl, vec![left]), sketch("right", pl, vec![right])],
)
.unwrap();
let want = 80.0 + 25.0 * PI;
// Chords of the arcs at 0.01 mm sag cut off up to (2/3)·sag·length ≈ 0.21 mm².
let near = |a: f64| (a - want).abs() < 0.25;
let faces = body.faces(0.01).unwrap();
assert!(faces.len() == 1 && near(faces[0].area), "faces: {faces:?}");
let tess = body.tessellate(0.01).unwrap().measure().area;
assert!(near(tess), "measuring mesh: {tess}");
let display = body.display_mesh(0.01).unwrap().measure().area;
assert!(near(display), "display mesh: {display}");
}
/// Three sides: a quarter disc of radius 10 (two radii and the arc) is one face with three
/// edges, the collapsed side left out of its loop, and measures 25π by its faces and both
/// meshes.
#[test]
fn a_three_sided_patch_is_one_face_with_three_edges() {
use std::f64::consts::{FRAC_PI_2, PI};
let r = 10.0;
let arc = Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: r, start: 0.0, sweep: FRAC_PI_2 };
let x = Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(r, 0.0) };
let y = Seg2::Line { a: Vec2::new(0.0, r), b: Vec2::new(0.0, 0.0) };
let pl = Plane::XY;
let apex = BoundaryCurve { label: "centre".into(), source: CurveSource::Point(Vec3::ZERO), continuity: Continuity::G0 };
let body = boundary_surface(&[sketch("arc", pl, vec![arc]), apex], &[sketch("x", pl, vec![x]), sketch("y", pl, vec![y])]).unwrap();
assert!(body.is_surface() && body.face_count() == 1);
let edges: std::collections::HashSet<_> = body.solid.edge_iter().map(|e| e.id()).collect();
assert_eq!(edges.len(), 3, "the collapsed side is no edge");
let want = PI * r * r / 4.0;
let near = |a: f64| (a - want).abs() < 0.2;
let faces = body.faces(0.01).unwrap();
assert!(near(faces[0].area), "faces: {faces:?}");
let tess = body.tessellate(0.01).unwrap().measure().area;
assert!(near(tess), "measuring mesh: {tess}");
let display = body.display_mesh(0.01).unwrap().measure().area;
assert!(near(display), "display mesh: {display}");
// Two collapsed sides are no patch.
let p = |l: &str, q: Vec3| BoundaryCurve { label: l.into(), source: CurveSource::Point(q), continuity: Continuity::G0 };
assert!(
boundary_surface(&[sketch("arc", pl, vec![arc]), p("o", Vec3::ZERO)], &[p("a", Vec3::new(r, 0.0, 0.0)), sketch("y", pl, vec![y])])
.is_err()
);
assert!(boundary_surface(&[sketch("arc", pl, vec![arc]), p("nan", Vec3::new(f64::NAN, 0.0, 0.0))], &[]).is_err());
}
/// The angle between the two faces' normals across the edge at `p`.
fn normal_angle(a: &mt::Surface, b: &mt::Surface, p: Vec3) -> f64 {
let n = |s: &mt::Surface| {
let (u, v) = s.search_nearest_parameter(p3(p), None, 100).unwrap();
let n = s.normal(u, v);
Vec3::new(n.x, n.y, n.z)
};
n(a).dot(n(b)).abs().clamp(0.0, 1.0).acos().to_degrees()
}
/// G1 against a flat sheet's open edge: a loft from the edge to a line above, and a patch
/// whose sides are arcs leaving the sheet tangent to it. The normals across the shared edge
/// agree within 0.1°; the patch joins the sheet into one surface body.
#[test]
fn g1_meets_the_sheet_tangent() {
let base = sheet();
let edge =
|g| BoundaryCurve { label: "edge".into(), source: CurveSource::Edges { body: &base, at: vec![Vec3::new(5.0, 0.0, 0.0)] }, continuity: g };
let far = || line3("far", Vec3::new(0.0, 10.0, 5.0), Vec3::new(10.0, 10.0, 5.0));
let loft = boundary_surface(&[edge(Continuity::G1), far()], &[]).unwrap();
let plain = boundary_surface(&[edge(Continuity::G0), far()], &[]).unwrap();
let (sb, sl, sp) = (face_surface(&base), face_surface(&loft), face_surface(&plain));
for k in 0..=10 {
let p = Vec3::new(k as f64, 0.0, 0.0);
let a = normal_angle(&sb, &sl, p);
assert!(a < 0.1, "G1 at x = {k}: {a}°");
}
assert!(normal_angle(&sb, &sp, Vec3::new(5.0, 0.0, 0.0)) > 10.0, "G0 has a crease");
// Arcs in x = 0 and x = 10 from the edge, tangent to the sheet, up to the far line.
let arc_plane = |x: f64| Plane::new(Vec3::new(x, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
let arc = Seg2::Arc { center: Vec2::new(0.0, 12.5), radius: 12.5, start: -std::f64::consts::FRAC_PI_2, sweep: 0.927_295_218_001_612_2 };
let patch = boundary_surface(
&[edge(Continuity::G1), far()],
&[sketch("left", arc_plane(0.0), vec![arc]), sketch("right", arc_plane(10.0), vec![arc])],
)
.unwrap();
let spa = face_surface(&patch);
for k in 0..=10 {
let a = normal_angle(&sb, &spa, Vec3::new(k as f64, 0.0, 0.0));
assert!(a < 0.1, "G1 patch at x = {k}: {a}°");
}
let joined = boundary_join(&base, &patch).unwrap();
assert_eq!(joined.face_count(), 2);
assert!(
boundary_join(&base, &boundary_surface(&[far(), line3("other", Vec3::new(0.0, 20.0, 5.0), Vec3::new(10.0, 20.0, 5.0))], &[]).unwrap())
.is_err()
);
}
/// Normal curvature of `s` at the point nearest `p` in the direction `w` (projected into the
/// tangent plane), signed against the unit normal `n`.
fn normal_curvature(s: &mt::Surface, p: Vec3, w: Vec3, n: Vec3) -> f64 {
let (u, v) = s.search_nearest_parameter(p3(p), None, 100).unwrap();
let v3 = |x: mt::Vector3| Vec3::new(x.x, x.y, x.z);
let (su, sv) = (v3(s.uder(u, v)), v3(s.vder(u, v)));
let (suu, suv, svv) = (v3(s.uuder(u, v)), v3(s.uvder(u, v)), v3(s.vvder(u, v)));
let (e, f, g) = (su.dot(su), su.dot(sv), sv.dot(sv));
let (l, m, nn) = (suu.dot(n), suv.dot(n), svv.dot(n));
let (p1, q1) = (w.dot(su), w.dot(sv));
let det = e * g - f * f;
let (a, b) = ((p1 * g - q1 * f) / det, (q1 * e - p1 * f) / det);
(l * a * a + 2.0 * m * a * b + nn * b * b) / (e * a * a + 2.0 * f * a * b + g * b * b)
}
/// G2 against a real cylinder's open edge (R = 10, the face below the edge): across the edge
/// the surface's normal curvature is the cylinder's, −1/R about its outward normal, within
/// 0.1 % — also when the far curve is sheared along the edge, so that the surface's own
/// cross direction leans. With G1 alone it is not.
#[test]
fn g2_matches_a_cylinder_across_its_edge() {
let r = 10.0;
let pl = Plane::new(Vec3::ZERO, Vec3::Y, Vec3::Z).unwrap();
let arc = Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: r, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::FRAC_PI_2 };
let cyl = crate::extrude_sheet(&pl, &[vec![arc]], 0.0, 10.0).unwrap();
let sc = face_surface(&cyl);
let made = |g, shear: f64| {
let edge =
BoundaryCurve { label: "rim".into(), source: CurveSource::Edges { body: &cyl, at: vec![Vec3::new(5.0, r, 0.0)] }, continuity: g };
let top = line3("top", Vec3::new(shear, 3.0, 12.0), Vec3::new(10.0 + shear, 3.0, 12.0));
face_surface(&boundary_surface(&[edge, top], &[]).unwrap())
};
for shear in [0.0, 5.0] {
let sm = made(Continuity::G2, shear);
for k in 1..=9 {
let p = Vec3::new(k as f64, r, 0.0);
let kc = normal_curvature(&sc, p, Vec3::Z, Vec3::Y);
assert!((kc + 1.0 / r).abs() < 1e-9, "the cylinder's own curvature: {kc}");
let km = normal_curvature(&sm, p, Vec3::Z, Vec3::Y);
assert!((km - kc).abs() < 1e-3 / r, "shear {shear}, x = {k}: {km} across the edge where the cylinder has {kc}");
// Along the edge both are straight.
assert!(normal_curvature(&sm, p, Vec3::X, Vec3::Y).abs() < 1e-6);
assert!(normal_angle(&sc, &sm, p) < 0.1);
}
}
let g1 = made(Continuity::G1, 0.0);
let k1 = normal_curvature(&g1, Vec3::new(5.0, r, 0.0), Vec3::Z, Vec3::Y);
assert!((k1 + 1.0 / r).abs() > 0.1 / r, "G1 alone does not carry the curvature: {k1}");
}
/// G3 is more than a network holds: refused, never a panic.
#[test]
fn g3_is_not_supported() {
let base = sheet();
let edge = BoundaryCurve {
label: "edge".into(),
source: CurveSource::Edges { body: &base, at: vec![Vec3::new(5.0, 0.0, 0.0)] },
continuity: Continuity::G3,
};
let e = boundary_surface(&[edge, line3("far", Vec3::new(0.0, 10.0, 5.0), Vec3::new(10.0, 10.0, 5.0))], &[]).unwrap_err();
assert!(e.to_string().contains("not supported yet"), "{e}");
}
/// Refusals: G1 on a sketch curve, on an edge between two faces, curves that miss.
#[test]
fn bad_boundaries_are_refused() {
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let edge = BoundaryCurve {
label: "edge".into(),
source: CurveSource::Edges { body: &cube, at: vec![Vec3::new(5.0, 0.0, 0.0)] },
continuity: Continuity::G1,
};
let far = line3("far", Vec3::new(0.0, 10.0, 15.0), Vec3::new(10.0, 10.0, 15.0));
let e = boundary_surface(&[edge, far], &[]).unwrap_err();
assert!(e.to_string().contains("open edge"), "{e}");
let mut tangent = line3("t", Vec3::ZERO, Vec3::X);
tangent.continuity = Continuity::G1;
assert!(boundary_surface(&[tangent, line3("b", Vec3::Y, Vec3::new(1.0, 1.0, 0.0))], &[]).is_err());
let miss = boundary_surface(
&[line3("a", Vec3::ZERO, Vec3::X * 10.0), line3("b", Vec3::Y * 10.0, Vec3::new(10.0, 10.0, 0.0))],
&[line3("c", Vec3::ZERO, Vec3::Y * 10.0), line3("d", Vec3::new(10.0, 0.0, 2.0), Vec3::new(10.0, 10.0, 2.0))],
)
.unwrap_err();
assert!(miss.to_string().contains("don't meet"), "{miss}");
let pts = BoundaryCurve { label: "p".into(), source: CurveSource::Points(vec![Vec3::ZERO; 3]), continuity: Continuity::G0 };
assert!(boundary_surface(&[pts, line3("b", Vec3::Y, Vec3::new(1.0, 1.0, 0.0))], &[]).is_err());
let nan = BoundaryCurve {
label: "n".into(),
source: CurveSource::Points(vec![Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]),
continuity: Continuity::G0,
};
assert!(boundary_surface(&[nan, line3("b", Vec3::Y, Vec3::new(1.0, 1.0, 0.0))], &[]).is_err());
}
}
+237 -78
View File
@@ -417,6 +417,18 @@ fn revolved(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec
out.push(sphere_solid(plane.to_world(c), rad, [Vec3::X, Vec3::Y, Vec3::Z], &arcs)?);
continue;
}
// A full turn of a profile that comes back to the axis along a curve (a half slot about
// its centreline, a dome at the far end): truck's cone gives the faces
// swept from it the wrong region (their pole is at the end of their curve), and cuts with
// such a body failed (#1853). It is built as part turns are, a third of a turn at a time.
if angle.abs() >= std::f64::consts::TAU - 1e-9
&& r.holes.is_empty()
&& pole_at_end(&r.outer, axis_origin, d)
&& let Some(b) = revolve_on_axis(plane, &given.outer, axis_origin, d, angle)?
{
out.push(b);
continue;
}
// A full revolution of a profile with one edge on the axis: sweep the rest of the loop
// as a cone-like shell (no zero-area faces, which booleans can't handle).
if angle.abs() >= std::f64::consts::TAU - 1e-9
@@ -436,7 +448,7 @@ fn revolved(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec
let angle = if toward < 0.0 { -angle } else { angle };
// A profile with an edge on the axis: no zero-area face where that edge turns on itself.
if r.holes.is_empty()
&& let Some(b) = revolve_partial_on_axis(plane, &given.outer, axis_origin, d, angle)?
&& let Some(b) = revolve_on_axis(plane, &given.outer, axis_origin, d, angle)?
{
out.push(b);
continue;
@@ -1285,11 +1297,47 @@ fn half_disc(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Option<(Vec2, f64)> {
(same && (total - std::f64::consts::PI).abs() < 1e-6).then_some((c, r))
}
/// A partial revolve of a loop with one edge on the axis: the other edges sweep their faces,
/// the two caps (the profile at the start and at the end) share the axis edge. `None` when the
/// loop isn't like that.
fn revolve_partial_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2, angle: f64) -> Result<Option<Body>> {
use mt::{BoundedCurve, Invertible, ParametricCurve, ParametricSurface3D, SearchNearestParameter, Transformed};
/// Whether the loop has one edge on the axis and comes back to it along a curve (the segment
/// before the axis edge is not a line). A line there (a drill point) sweeps a cone, which
/// truck's cone builds right.
fn pole_at_end(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> bool {
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
let n = lp.segs.len();
let axis_segs: Vec<usize> =
(0..n).filter(|i| lp.segs.get(*i).is_some_and(|s| matches!(s, Seg2::Line { a, b } if on_axis(*a) && on_axis(*b)))).collect();
let [k] = axis_segs[..] else { return false };
lp.segs.get((k + n - 1) % n).is_some_and(|s| !on_axis(s.start()) && !matches!(s, Seg2::Line { .. }))
}
/// The edge from `a` to `b` along the profile segment `sg` (in `plane`), for a revolve's sweep.
/// One that ends on the axis (`ends_on_axis`) is built from there and turned round, so that the
/// surface swept from its curve has its pole at the curve's start. truck's revolved surface
/// reads a point on its axis at the start of its curve with the angle of the point before it,
/// but one at the end with an angle of 2π when it has no hint, past a part turn's span: the face
/// came out as another region (#1853).
fn swept_edge(plane: &Plane, sg: &Seg2, a: &mt::Vertex, b: &mt::Vertex, ends_on_axis: bool) -> mt::Edge {
if ends_on_axis {
return swept_edge(plane, &sg.reversed(), b, a, false).inverse();
}
match *sg {
Seg2::Line { .. } => builder::line(a, b),
Seg2::Arc { .. } => builder::circle_arc(a, b, p3(plane.to_world(sg.mid()))),
Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => {
crate::freeform::edge(plane, sg, a, b).unwrap_or_else(|| builder::line(a, b))
}
}
}
/// Where [`revolve_on_axis`]'s full turns start, from the profile: an angle no plane a part is
/// commonly cut along lies at (as full circles start, see `reseam`).
const FULL_TURN_START: f64 = 0.4142;
/// A revolve of a loop with one edge on the axis: the other edges sweep their faces, a third of a
/// turn at most each. A partial turn is closed by two caps (the profile at the start and at the
/// end), which share the axis edge; a full turn ends where it started. `None` when the loop
/// isn't like that.
fn revolve_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2, angle: f64) -> Result<Option<Body>> {
use mt::{BoundedCurve, ParametricCurve, ParametricSurface3D, SearchNearestParameter, Transformed};
let lp = &numbered.lp;
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
let n = lp.segs.len();
@@ -1301,102 +1349,171 @@ fn revolve_partial_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin:
if chain.is_empty() || chain.iter().skip(1).any(|s| on_axis(s.start())) || chain.iter().any(|s| on_axis(s.mid())) {
return Ok(None);
}
let full = angle.abs() >= std::f64::consts::TAU - 1e-9;
// Faces of a third of a turn at most, as truck sweeps a full turn: a boolean could not
// intersect a half turn's faces and the half circles bounding them (a half slot turned half a
// turn into a plate, #1853), and a sphere's face needs room for its poles (below).
let pieces = (angle.abs() / (std::f64::consts::TAU / 3.0) - 1e-9).ceil().max(1.0) as usize;
let step = angle / pieces as f64;
// Built with the axis through the world origin, then moved into place.
let shift = plane.to_world(axis_origin);
let local = Plane { origin: plane.origin - shift, ..*plane };
let plane = &local;
let axis = plane.dir_to_world(d);
let rot = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle));
let half = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle * 0.5));
let turned = |by: f64| mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(by));
let turn = |m: &mt::Matrix4, p: Vec3| from_p3(mt::EuclideanSpace::from_vec((m * p3(p).to_homogeneous()).truncate()));
guard("revolve", || {
let fail = |m: &str| KernelError::Failed(format!("revolve: {m}"));
let pts: Vec<Vec3> = chain.iter().map(|s| plane.to_world(s.start())).chain(chain.last().map(|s| plane.to_world(s.end()))).collect();
let last = pts.len() - 1;
let pv: Vec<mt::Vertex> = pts.iter().map(|p| builder::vertex(p3(*p))).collect();
// The ends stay where they are (on the axis).
let qv: Vec<mt::Vertex> = pts
.iter()
.enumerate()
.map(|(i, p)| {
if i == 0 || i == last { pv.get(i).cloned().unwrap_or_else(|| builder::vertex(p3(*p))) } else { builder::vertex(p3(turn(&rot, *p))) }
})
.collect();
let mut e: Vec<mt::Edge> = Vec::new();
let mut er: Vec<mt::Edge> = Vec::new();
for (i, sg) in chain.iter().enumerate() {
let (Some(a), Some(b), Some(qa), Some(qb)) = (pv.get(i), pv.get(i + 1), qv.get(i), qv.get(i + 1)) else { return Err(fail("chain")) };
let edge = match *sg {
Seg2::Line { .. } => builder::line(a, b),
Seg2::Arc { .. } => builder::circle_arc(a, b, p3(plane.to_world(sg.mid()))),
Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => {
crate::freeform::edge(plane, sg, a, b).unwrap_or_else(|| builder::line(a, b))
}
};
let curve = edge.oriented_curve().transformed(rot);
er.push(mt::Edge::new(qa, qb, curve));
e.push(edge);
let (Some(a), Some(b)) = (pv.get(i), pv.get(i + 1)) else { return Err(fail("chain")) };
e.push(swept_edge(plane, sg, a, b, on_axis(sg.end())));
}
// Arcs swept by the vertices off the axis.
let mut arcs: Vec<Option<mt::Edge>> = vec![None; pts.len()];
for i in 1..last {
let (Some(a), Some(b), Some(p)) = (pv.get(i), qv.get(i), pts.get(i)) else { continue };
if let Some(slot) = arcs.get_mut(i) {
*slot = Some(builder::circle_arc(a, b, p3(turn(&half, *p))));
// The profile's vertices and edges where each piece starts and ends: turned by the step,
// the ends staying where they are (on the axis). A part turn starts on the profile. A full
// turn starts at an odd angle and ends where it started: the profile lies in the plane
// it was drawn on, often a face of the body cut, and a seam lying in that face as well
// left the cut nothing it could intersect there (#1853).
let start = if full { FULL_TURN_START } else { 0.0 };
let mut levels: Vec<(Vec<mt::Vertex>, Vec<mt::Edge>)> = Vec::new();
for j in 0..=pieces {
if j == 0 && !full {
levels.push((pv.clone(), e.clone()));
continue;
}
if let (true, Some(first)) = (j == pieces && full, levels.first()) {
levels.push(first.clone());
continue;
}
let rot = turned(start + step * j as f64);
let qv: Vec<mt::Vertex> = pts
.iter()
.enumerate()
.map(|(i, p)| {
if i == 0 || i == last {
pv.get(i).cloned().unwrap_or_else(|| builder::vertex(p3(*p)))
} else {
builder::vertex(p3(turn(&rot, *p)))
}
})
.collect();
let mut er: Vec<mt::Edge> = Vec::new();
for (i, edge) in e.iter().enumerate() {
let (Some(qa), Some(qb)) = (qv.get(i), qv.get(i + 1)) else { return Err(fail("chain")) };
let curve = edge.curve().transformed(rot);
er.push(if edge.orientation() { mt::Edge::new(qa, qb, curve) } else { mt::Edge::new(qb, qa, curve).inverse() });
}
levels.push((qv, er));
}
// Side faces: each edge's swept surface, oriented to agree with its boundary, from the
// profile segment the edge was built from.
let mut faces: Vec<(mt::Face, FaceProv)> = Vec::new();
for (i, sg) in chain.iter().enumerate() {
let (Some(ei), Some(eri)) = (e.get(i), er.get(i)) else { return Err(fail("edges")) };
let mut ws: Vec<mt::Edge> = vec![ei.clone()];
if let Some(Some(c)) = arcs.get(i + 1) {
ws.push(c.clone());
for (j, piece) in levels.windows(2).enumerate() {
let [(pv, e), (qv, er)] = piece else { return Err(fail("pieces")) };
let half = turned(start + step * (j as f64 + 0.5));
// Arcs swept by the vertices off the axis.
let mut arcs: Vec<Option<mt::Edge>> = vec![None; pts.len()];
for i in 1..last {
let (Some(a), Some(b), Some(p)) = (pv.get(i), qv.get(i), pts.get(i)) else { continue };
if let Some(slot) = arcs.get_mut(i) {
*slot = Some(builder::circle_arc(a, b, p3(turn(&half, *p))));
}
}
ws.push(eri.inverse());
if let Some(Some(c)) = arcs.get(i) {
ws.push(c.inverse());
}
let wire: mt::Wire = ws.into();
let radial = matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9));
let f = if radial {
builder::try_attach_plane(&[wire]).map_err(|er| fail(&er.to_string()))?
} else {
let template = builder::rsweep(ei, mt::Point3::new(0.0, 0.0, 0.0), v3(axis), mt::Rad(angle));
let mut surface = template.face_iter().next().map(|f| f.oriented_surface()).ok_or_else(|| fail("swept face"))?;
// The loop's turning (Newell) against the surface normal halfway round.
let mut nw = Vec3::ZERO;
let poly: Vec<Vec3> = wire
.edge_iter()
.flat_map(|ed| {
let c = ed.oriented_curve();
let (t0, t1) = c.range_tuple();
(0..8).map(move |j| from_p3(c.subs(t0 + (t1 - t0) * j as f64 / 8.0))).collect::<Vec<_>>()
})
.collect();
for (j, p) in poly.iter().enumerate() {
if let Some(q) = poly.get((j + 1) % poly.len()) {
nw += p.cross(*q);
for (i, sg) in chain.iter().enumerate() {
let (Some(ei), Some(eri)) = (e.get(i), er.get(i)) else { return Err(fail("edges")) };
let mut ws: Vec<mt::Edge> = vec![ei.clone()];
if let Some(Some(c)) = arcs.get(i + 1) {
ws.push(c.clone());
}
ws.push(eri.inverse());
if let Some(Some(c)) = arcs.get(i) {
ws.push(c.inverse());
}
let wire: mt::Wire = ws.into();
let radial = matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9));
let f = if radial {
builder::try_attach_plane(&[wire]).map_err(|er| fail(&er.to_string()))?
} else {
// The surface turns from the piece's start through |step|, so its angle runs
// from 0 across the face. Inverting it turns its axis round, which puts the
// face at 2π - |step| to 2π: its start then lies on the angle's seam, where
// points (and a pole without a hint) read as 0, the far side, and the face
// meshed and intersected as the wrong region (#1853). The face is turned
// round instead.
let (dir, by) = if step < 0.0 { (-axis, -step) } else { (axis, step) };
let surface = match *sg {
// An arc about a centre on the axis that reaches it sweeps part of a sphere
// with its pole there, and a boolean can't follow an intersection into a
// pole: a cut by the plane through the axis (the face the profile was drawn
// on) failed (#1853). The face lies on the sphere turned about the axis
// square to the revolve's and to the face's middle, as `sphere_solid`'s
// patches do: its poles are a quarter turn from that middle, past the
// face's third of a turn, and its seam runs round the back.
Seg2::Arc { center, radius, .. } if on_axis(center) && (on_axis(sg.start()) || on_axis(sg.end())) => {
let c = plane.to_world(center);
let out = turn(&half, plane.to_world(sg.mid())) - c;
let out = (out - axis * out.dot(axis)).normalized().ok_or_else(|| fail("sphere"))?;
let pole = axis.cross(out);
let meridian = builder::circle_arc(
&builder::vertex(p3(c + pole * radius)),
&builder::vertex(p3(c - pole * radius)),
p3(c - out * radius),
);
mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(
meridian.oriented_curve(),
p3(c),
v3(pole),
)))
}
_ => {
let template = builder::rsweep(ei, mt::Point3::new(0.0, 0.0, 0.0), v3(dir), mt::Rad(by));
template.face_iter().next().map(|f| f.surface()).ok_or_else(|| fail("swept face"))?
}
};
// The loop's turning (Newell) against the surface normal halfway round.
let mut nw = Vec3::ZERO;
let poly: Vec<Vec3> = wire
.edge_iter()
.flat_map(|ed| {
let c = ed.oriented_curve();
let (t0, t1) = c.range_tuple();
(0..8).map(move |j| from_p3(c.subs(t0 + (t1 - t0) * j as f64 / 8.0))).collect::<Vec<_>>()
})
.collect();
for (j, p) in poly.iter().enumerate() {
if let Some(q) = poly.get((j + 1) % poly.len()) {
nw += p.cross(*q);
}
}
}
let probe = turn(&half, plane.to_world(sg.mid()));
let sn = surface.search_nearest_parameter(p3(probe), None, 100).map(|(u, v)| surface.normal(u, v));
if sn.is_some_and(|m| nw.dot(Vec3::new(m.x, m.y, m.z)) < 0.0) {
surface = surface.inverse();
}
mt::Face::try_new(vec![wire], surface).map_err(|er| fail(&er.to_string()))?
};
faces.push((f, record::profile(numbered.numbers((k + 1 + i) % n))));
let probe = turn(&half, plane.to_world(sg.mid()));
let sn = surface.search_nearest_parameter(p3(probe), None, 100).map(|(u, v)| surface.normal(u, v));
if sn.is_some_and(|m| nw.dot(Vec3::new(m.x, m.y, m.z)) < 0.0) {
mt::Face::try_new(vec![wire.inverse()], surface).map_err(|er| fail(&er.to_string()))?.inverse()
} else {
mt::Face::try_new(vec![wire], surface).map_err(|er| fail(&er.to_string()))?
}
};
faces.push((f, record::profile(numbered.numbers((k + 1 + i) % n))));
}
}
let (faces, mut history): (Vec<mt::Face>, Vec<FaceProv>) = faces.into_iter().unzip();
let close = |solid: Solid, history: Vec<FaceProv>| {
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
record::attach(Body::new(solid)?, &[], Some(history), Default::default()).map(Some)
};
if full {
return close(Solid::try_new(vec![faces.into()]).map_err(|er| fail(&er.to_string()))?, history);
}
// Caps: the profile at the start and at the end, sharing the axis edge.
let (Some(a0), Some(an)) = (pv.first(), pv.get(last)) else { return Err(fail("ends")) };
let ((Some(a0), Some(an)), Some((_, er))) = ((pv.first(), pv.get(last)), levels.last()) else { return Err(fail("ends")) };
let axis_edge = builder::line(an, a0);
let start: mt::Wire = e.iter().rev().map(|x| x.inverse()).chain(std::iter::once(axis_edge.inverse())).collect::<Vec<_>>().into();
let end: mt::Wire = er.iter().cloned().chain(std::iter::once(axis_edge.clone())).collect::<Vec<_>>().into();
let cs = builder::try_attach_plane(&[start]).map_err(|er| fail(&er.to_string()))?;
let ce = builder::try_attach_plane(&[end]).map_err(|er| fail(&er.to_string()))?;
let (faces, mut history): (Vec<mt::Face>, Vec<FaceProv>) = faces.into_iter().unzip();
history.extend([record::cap(false), record::cap(true)]);
let mut last_err = String::new();
for flip in 0..4 {
@@ -1404,10 +1521,7 @@ fn revolve_partial_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin:
all.push(if flip & 1 == 0 { cs.clone() } else { cs.inverse() });
all.push(if flip & 2 == 0 { ce.clone() } else { ce.inverse() });
match Solid::try_new(vec![all.into()]) {
Ok(solid) => {
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
return record::attach(Body::new(solid)?, &[], Some(history), Default::default()).map(Some);
}
Ok(solid) => return close(solid, history),
Err(er) => last_err = er.to_string(),
}
}
@@ -1562,6 +1676,15 @@ pub(crate) fn cap_and_close(sides: Vec<Side>, w0: &[mt::Wire], w1: &[mt::Wire],
Err(KernelError::Failed(format!("closing the shell: {last}")))
}
/// A sweep whose walls already close, including separate shells around profile holes.
pub(crate) fn close_sweep(sides: Vec<Side>) -> Result<Body> {
let sources: std::collections::HashMap<_, _> = sides.iter().map(|(f, p)| (f.id(), p.clone())).collect();
let shell: mt::Shell = sides.into_iter().map(|(f, _)| f).collect();
let solid = Solid::try_new(shell.connected_components()).map_err(|e| KernelError::Failed(format!("closing the sweep: {e}")))?;
let history = solid.face_iter().map(|f| sources.get(&f.id()).cloned().flatten()).collect();
record::attach(Body::new(solid)?, &[], history, Default::default())
}
/// Ruled loft through two or more planar sections (outer loops only). The sections and the
/// same sections in reverse order give the same body.
pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
@@ -1881,6 +2004,25 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
if corner {
return crate::sweep_path::sweep_path(plane, region, &path_polyline(path)?);
}
let points = path_polyline(path)?;
if crate::sweep_path::closed_path(&points) {
// Sketch circles have two half arcs. Sweep the whole turn in one operation so truck
// shares the closing edges itself. Other closed paths use stations with shared ends.
if let Some((center, axis, angle)) = full_circle(path) {
return guard("closed sweep", || {
let profile = region_wires(plane, &oriented(region)?)?;
let mut sides = Vec::new();
for p in &profile {
let shell: mt::Shell = builder::rsweep(&p.wire, p3(center), v3(axis), mt::Rad(angle));
let numbers = seeds(std::slice::from_ref(p));
let prov = record::columns(shell.face_iter(), &numbers, None, record::Column::ToEnd, &[]);
sides.extend(shell.face_iter().enumerate().map(|(i, f)| (f.clone(), prov.as_ref().and_then(|p| p.get(i)).cloned().flatten())));
}
close_sweep(sides)
});
}
return crate::sweep_path::sweep_path(plane, region, &points);
}
// The outer loop and each hole, swept along the path; the ends are capped with holes.
let profile = region_wires(plane, &oriented(region)?)?;
let starts = wires_of(&profile);
@@ -1927,6 +2069,23 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
})
}
/// A connected, single turn about one centre and axis, possibly split into sketch arcs.
fn full_circle(path: &[PathSeg]) -> Option<(Vec3, Vec3, f64)> {
let PathSeg::Arc { a: first, center, axis, .. } = *path.first()? else { return None };
let axis = axis.normalized()?;
let mut end = first;
let mut total = 0.0;
for seg in path {
let PathSeg::Arc { a, center: c, axis: k, angle } = *seg else { return None };
if a.dist(end) > 1e-7 || c.dist(center) > 1e-7 || k.normalized()?.dist(axis) > 1e-9 || !angle.is_finite() {
return None;
}
total += angle;
end = rotate_about(a, c, axis, angle);
}
((total.abs() - std::f64::consts::TAU).abs() < 1e-9).then_some((center, axis, total))
}
/// Cox–de Boor basis values N_{j,3}(u) for all j.
fn cubic_basis(knots: &[f64], n_ctrl: usize, u: f64) -> Vec<f64> {
let p = 3;
+43 -1
View File
@@ -1193,10 +1193,18 @@ pub(crate) fn imprint_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool)
/// faces): every such pair of faces goes and the shells are stitched along their edges. `None`
/// when no such pair of faces exists.
pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
glue_impl(a, b, false)
}
/// Glue every certified start cap, retaining each tool's original contact tolerance.
pub(crate) fn glue_all_caps(a: &Body, b: &Body) -> Option<Result<Body>> {
glue_impl(a, b, true)
}
fn glue_impl(a: &Body, b: &Body, all_caps: bool) -> Option<Result<Body>> {
use mt::{BoundedCurve, ParametricCurve};
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
let key = |p: Vec3| ((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64);
let mid = |e: &mt::Edge| {
let c = e.curve();
let (t0, t1) = c.range_tuple();
@@ -1205,14 +1213,40 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
// Matching faces: same plane, opposite normals, the same edges. Every pair is glued: a
// pair left behind would stay inside the result as a zero-thickness slit (issue #616).
let (pa, pb) = (planes(a), planes(b));
let mut tool_sizes = HashMap::new();
if all_caps {
for shell in b.solid.boundaries() {
let mut bounds = solidcraft_geom::Aabb3::EMPTY;
for vertex in shell.vertex_iter() {
bounds.add(from_p3(vertex.point()));
}
let size = bounds.diagonal();
if !size.is_finite() {
return None;
}
for face in shell.face_iter() {
tool_sizes.insert(face.id(), size);
}
}
}
let fa_list: Vec<&mt::Face> = a.solid.face_iter().collect();
let fb_list: Vec<&mt::Face> = b.solid.face_iter().collect();
// B edge → the matching A edge; B vertex → the matching A vertex.
let mut map: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
let mut vmap: HashMap<mt::VertexID, mt::Vertex> = HashMap::new();
let (mut glued_a, mut glued_b): (Vec<usize>, Vec<usize>) = (Vec::new(), Vec::new());
if all_caps && pa.len().saturating_mul(pb.len()) > 2_000_000 {
return None;
}
for (ia, x) in &pa {
for (ib, y) in &pb {
if all_caps
&& !matches!(b.face_sources().and_then(|sources| sources.get(*ib)), Some(Some(crate::FaceSource::ExtrudeCap { high: false, .. })))
{
continue;
}
let tol = if all_caps { (a.size().max(*tool_sizes.get(&fb_list.get(*ib)?.id())?) * 1e-7).max(1e-9) * 10.0 } else { tol };
let key = |p: Vec3| ((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64);
if glued_a.contains(ia) || glued_b.contains(ib) || x.n.dot(y.n) > -1.0 + 1e-9 || (x.d + y.d).abs() > tol {
continue;
}
@@ -1264,6 +1298,14 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
glued_b.push(*ib);
}
}
if all_caps
&& b.face_sources()?
.iter()
.enumerate()
.any(|(index, source)| matches!(source, Some(crate::FaceSource::ExtrudeCap { high: false, .. })) && !glued_b.contains(&index))
{
return None;
}
if glued_a.is_empty() {
return None;
}
+93
View File
@@ -0,0 +1,93 @@
//! Issue #2005: a closed sweep must join its end back to its start, without coincident caps.
//! The capped tool used to stall or fail when cutting the torus it lay inside.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use crate::{Body, BoolOp, PathSeg, SweepControl, boolean, measure, sweep_with};
fn square(half: f64) -> Region2 {
Region2 {
outer: Loop2::polygon(&[Vec2::new(-half, -half), Vec2::new(half, -half), Vec2::new(half, half), Vec2::new(-half, half)]),
holes: vec![],
}
}
fn profile_plane() -> Plane {
Plane { origin: Vec3::new(30.0, 0.0, 0.0), x: Vec3::X, y: Vec3::Z }
}
fn circle_path() -> [PathSeg; 2] {
// A sketch circle reaches the kernel as two half arcs.
[
PathSeg::Arc { a: Vec3::new(30.0, 0.0, 0.0), center: Vec3::ZERO, axis: Vec3::Z, angle: PI },
PathSeg::Arc { a: Vec3::new(-30.0, 0.0, 0.0), center: Vec3::ZERO, axis: Vec3::Z, angle: PI },
]
}
fn tube(body: &Body, volume: f64, genus: usize) {
let m = measure(body).unwrap();
assert!((m.volume - volume).abs() < volume * 0.01, "{m:?}, expected {volume}");
// A closed orientable tube has Euler characteristic 2 - 2g. Coincident caps gave 2.
assert_eq!(m.vertices + m.faces + 2 * genus, m.edges + 2, "{m:?}");
assert!(body.solid.boundaries().iter().all(|s| s.extract_boundaries().is_empty()));
let h = body.history().expect("sweep provenance");
assert!(h.faces.iter().all(|f| f.origins.iter().all(|o| matches!(o, crate::history::FaceOrigin::Profile { .. }))));
}
#[test]
fn closed_circle_sweeps_have_no_caps() {
for twist in [0.0, FRAC_PI_2, PI, TAU, -PI] {
let ctl = SweepControl { twist, ..SweepControl::default() };
let b = sweep_with(&profile_plane(), &square(4.0), &circle_path(), &ctl).unwrap();
tube(&b, 64.0 * TAU * 30.0, 1);
}
}
#[test]
fn one_full_arc_closes_like_a_sketch_circle() {
for angle in [TAU, -TAU] {
let path = [PathSeg::Arc { a: Vec3::new(30.0, 0.0, 0.0), center: Vec3::ZERO, axis: Vec3::Z, angle }];
let b = sweep_with(&profile_plane(), &square(4.0), &path, &SweepControl::default()).unwrap();
tube(&b, 64.0 * TAU * 30.0, 1);
}
}
#[test]
fn closed_polyline_sweep_shares_its_starting_section() {
let path = crate::path_polyline(&circle_path()).unwrap();
let b = crate::sweep_path(&profile_plane(), &square(4.0), &path).unwrap();
tube(&b, 64.0 * TAU * 30.0, 1);
}
#[test]
fn closed_sweep_refuses_an_end_section_that_does_not_match() {
for ctl in [SweepControl { twist: PI / 4.0, ..SweepControl::default() }, SweepControl { scale: 2.0, ..SweepControl::default() }] {
let err = sweep_with(&profile_plane(), &square(4.0), &circle_path(), &ctl).unwrap_err();
assert!(err.to_string().contains("closed path"), "{err}");
}
}
#[test]
fn closed_holed_sweep_joins_every_loop() {
let region = Region2 { holes: vec![square(2.0).outer], ..square(4.0) };
let ctl = SweepControl { twist: PI, ..SweepControl::default() };
let b = sweep_with(&profile_plane(), &region, &circle_path(), &ctl).unwrap();
// Two disjoint toroidal shells, one bounding the cavity.
let m = measure(&b).unwrap();
assert!((m.volume - 48.0 * TAU * 30.0).abs() < m.volume * 0.02, "{m:?}");
assert_eq!(m.vertices + m.faces, m.edges, "{m:?}");
}
#[test]
fn half_turn_sweep_cuts_a_torus() {
let base = crate::torus(Vec3::ZERO, 30.0, 8.0).unwrap();
let tool = sweep_with(&profile_plane(), &square(4.0), &circle_path(), &SweepControl { twist: PI, ..SweepControl::default() }).unwrap();
let result = boolean(&base, &tool, BoolOp::Cut).unwrap().expect("cut leaves the torus wall");
let expected = (PI * 64.0 - 64.0) * TAU * 30.0;
let m = measure(&result).unwrap();
assert!((m.volume - expected).abs() < expected * 0.015, "{m:?}, expected {expected}");
}
+10 -1
View File
@@ -25,6 +25,7 @@ mod boolean_guard;
mod boolean_record;
mod bore_guard;
mod boundary;
mod boundary_network;
mod bridge;
mod build;
mod clearance;
@@ -59,6 +60,8 @@ mod issue_1080_tests;
#[cfg(test)]
mod issue_1510_tests;
#[cfg(test)]
mod issue_2005_tests;
#[cfg(test)]
mod issue_324_tests;
#[cfg(test)]
mod issue_616_tests;
@@ -119,6 +122,8 @@ mod surface_extend;
mod surface_kind;
mod surface_offset;
mod surface_ruled;
#[cfg(test)]
mod surface_second_derivative_tests;
mod surface_untrim;
mod surfaces;
mod sweep_path;
@@ -143,6 +148,7 @@ pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fill
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
pub use boundary::{Boundary, boundary_patch};
pub use boundary_network::{BoundaryCurve, CurveSource, MAX_CHAIN_CURVES, MAX_EDGE_PICKS, boundary_join, boundary_surface};
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid};
pub use build::{
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, path_polyline, revolve,
@@ -173,7 +179,10 @@ pub use mesh_convert::convert_prismatic_mesh;
pub use meshbody::{MAX_TRIANGLES, mesh_body};
pub use native::{decode_native_body, encode_native_body, native_preview_mesh};
pub use offset::with_same_surface;
pub use ops::{BoolOp, boolean, can_join_touching_faces, half_space, join_apart, split_by_plane, transform, transform_matrix, union_apart};
pub use ops::{
BoolOp, boolean, can_join_all_touching_faces, can_join_touching_faces, compound_disjoint_extrusions, half_space, is_planar, join_apart,
split_by_plane, transform, transform_matrix, union_apart,
};
pub use polybool::{faceted_boolean, planar_boolean};
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use prism::{PLANAR_FIT_TOL, extrude_planar_regions, planar_regions_boolean};
+271 -9
View File
@@ -23,12 +23,167 @@ pub enum BoolOp {
Intersect,
}
/// Exact planar Booleans need no sampled enlargement of their tool footprint.
pub fn is_planar(body: &Body) -> bool {
if body.is_mesh() || body.face_count() > 16_384 {
return false;
}
guard("planar body", || Ok(body.solid.face_iter().all(|face| matches!(face.oriented_surface(), mt::Surface::Plane(_))))).unwrap_or(false)
}
/// Batch separated extrusion tools without repeatedly unioning an ever-growing target.
/// Construction caps provide conservative bounds, including the full circle of each arc;
/// unsupported or touching tools retain the caller's sequential Boolean path.
pub fn compound_disjoint_extrusions(tools: &[Body]) -> Result<Option<Body>> {
if tools.len() < 2 || tools.len() > 1024 {
return Ok(None);
}
guard("compound extrusion tools", || {
let mut bounds = Vec::with_capacity(tools.len());
let mut corners = Vec::with_capacity(tools.len());
let mut axes = Vec::with_capacity(tools.len());
let mut remaining = 16_384usize;
for tool in tools {
let Some(sources) = tool.face_sources() else { return Ok(None) };
if sources.len() != tool.face_count() {
return Ok(None);
}
let mut bb = solidcraft_geom::Aabb3::EMPTY;
let mut points = Some(Vec::new());
let mut directions = Vec::new();
let mut caps = 0;
for source in sources {
let Some(left) = remaining.checked_sub(1) else { return Ok(None) };
remaining = left;
match source {
Some(crate::FaceSource::ExtrudeCap { region, plane, high }) => {
caps += 1;
// The bottom support is outward-facing, but its region retains
// the original sketch coordinates (see build::extrude).
let plane = if *high { *plane } else { solidcraft_geom::Plane { y: -plane.y, ..*plane } };
for segment in &region.outer.segs {
let Some(left) = remaining.checked_sub(1) else { return Ok(None) };
remaining = left;
match *segment {
solidcraft_geom::Seg2::Line { a, b } => {
let (a, b) = (plane.to_world(a), plane.to_world(b));
bb.add(a);
bb.add(b);
if let Some(points) = points.as_mut() {
points.extend([a, b]);
}
if let Some(axis) = (b - a).cross(plane.normal()).normalized() {
directions.push(axis);
}
}
solidcraft_geom::Seg2::Arc { center, radius, .. } => {
points = None;
for x in [-radius, radius] {
for y in [-radius, radius] {
bb.add(plane.to_world(center + solidcraft_geom::Vec2::new(x, y)));
}
}
}
_ => return Ok(None),
}
}
}
Some(crate::FaceSource::ExtrudeSide { .. }) => {}
_ => return Ok(None),
}
}
if caps != 2 || bb.is_empty() || !bb.min.is_finite() || !bb.max.is_finite() {
return Ok(None);
}
bounds.push(bb);
corners.push(points);
axes.push(directions);
}
let mut remaining_separation_work = 16_000_000usize;
for (i, a) in bounds.iter().enumerate() {
for (j, b) in bounds.iter().enumerate().skip(i + 1) {
let gap = (a.diagonal().max(b.diagonal()) * 1e-6).max(1e-7);
let separated = a.max.x + gap < b.min.x
|| b.max.x + gap < a.min.x
|| a.max.y + gap < b.min.y
|| b.max.y + gap < a.min.y
|| a.max.z + gap < b.min.z
|| b.max.z + gap < a.min.z;
if separated {
continue;
}
let (Some(Some(x)), Some(Some(y))) = (corners.get(i), corners.get(j)) else { return Ok(None) };
let Some(origin) = x.first() else { return Ok(None) };
// Every straight extrusion lies inside the hull of its cap corners.
// A separating cap-edge axis therefore proves clearance even when
// rotated tools' world-coordinate boxes overlap. Arcs retain the box test.
let range = |points: &[Vec3], axis: Vec3| {
points.iter().map(|p| (*p - *origin).dot(axis)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v)))
};
let rounding = origin.x.abs().max(origin.y.abs()).max(origin.z.abs()) * f64::EPSILON * 128.0;
let separated = axes.get(i).into_iter().flatten().chain(axes.get(j).into_iter().flatten()).any(|axis| {
let Some(left) = remaining_separation_work.checked_sub(x.len().saturating_add(y.len())) else { return false };
remaining_separation_work = left;
let (a, b) = (range(x, *axis), range(y, *axis));
a.1 + gap + rounding < b.0 || b.1 + gap + rounding < a.0
});
if !separated {
return Ok(None);
}
}
}
let sources: std::collections::HashMap<_, _> = tools
.iter()
.flat_map(|tool| {
tool.solid
.face_iter()
.zip(tool.face_sources().unwrap_or_default())
.map(|(face, source)| (face.id(), (face.orientation(), source.clone())))
})
.collect();
let operands: Vec<_> = tools.iter().collect();
let shells = tools.iter().flat_map(|tool| tool.solid.boundaries().clone()).collect();
let solid = Solid::try_new(shells).map_err(|error| KernelError::Failed(format!("extrusion compound: {error}")))?;
let faces = tools
.iter()
.enumerate()
.flat_map(|(operand, tool)| (0..tool.face_count()).map(move |face| record::from_face(operand, face, crate::history::Change::Kept)))
.collect();
let mut edges = HashMap::new();
for (operand, tool) in tools.iter().enumerate() {
edges.extend(record::edge_origins(&tool.solid, operand));
}
record::build(solid, &record::counts(&operands), Some(faces), Some(&edges)).map(|mut body| {
body.regularization_protected = tools.iter().any(|tool| tool.regularization_protected);
// Only unchanged face identities carry these transient tool witnesses.
// No whole-compound extrusion/producer certificate is created.
let mapped = body
.solid
.face_iter()
.map(|face| {
sources.get(&face.id()).filter(|(orientation, _)| *orientation == face.orientation()).and_then(|(_, source)| source.clone())
})
.collect();
Some(body.with_face_sources(Some(std::sync::Arc::new(mapped))))
})
})
}
/// Whether a union can sew two matching planar faces without intersecting them.
/// Callers can retain the exact touching tool instead of extending it into material.
pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool {
a.solid.boundaries().len() == 1 && b.solid.boundaries().len() == 1 && crate::coplanar::glue(a, b).is_some_and(|r| r.is_ok())
}
/// Check all separated extrusion starts in one construction, without rebuilding
/// the unchanged target once for each face. Partial seams keep the ordinary path.
pub fn can_join_all_touching_faces(a: &Body, b: &Body) -> bool {
if a.solid.boundaries().len() != 1 || !(2..=1024).contains(&b.solid.boundaries().len()) || a.face_count().max(b.face_count()) > 16_384 {
return false;
}
guard("check extrusion seams", || Ok(crate::coplanar::glue_all_caps(a, b).is_some_and(|result| result.is_ok()))).unwrap_or(false)
}
/// Shifts applied to both operands on retries. The boolean classifies some faces by casting a
/// ray whose direction is derived from point coordinates; moving both solids (and the result
/// back) leaves the geometry unchanged but changes those rays.
@@ -225,6 +380,110 @@ fn trivial(
mod shortcut_tests {
use super::*;
#[test]
fn seam_roundoff_is_not_a_thin_meeting_but_real_thin_solids_are() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
// This circle's seams lie on the X axis: its cylinder's vertex box is flat in Y.
let region = Region2 { outer: Loop2::circle(Vec2::ZERO, 8.0), holes: vec![] };
let periodic = crate::extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().pop().unwrap();
let block = crate::box_solid(Vec3::new(-40.0, -40.0, -10.0), Vec3::new(40.0, 40.0, 10.0)).unwrap();
assert!(meeting_size(&periodic, &block).is_infinite());
let thin = crate::box_solid(Vec3::ZERO, Vec3::new(80.0, 80.0, 1e-5)).unwrap();
assert!((meeting_size(&thin, &thin) - 1e-5).abs() < 1e-12);
}
#[test]
fn rotated_extrusion_bounds_use_exact_cap_separation_and_keep_sources() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let rectangle = |angle: f64, offset: f64| {
let (c, s) = (angle.cos(), angle.sin());
Region2 {
outer: Loop2::polygon(
&[(-2., -0.5), (2., -0.5), (2., 0.5), (-2., 0.5)].map(|(x, y)| Vec2::new(x * c - (y + offset) * s, x * s + (y + offset) * c)),
),
holes: vec![],
}
};
for offset in [1.2, 1., 0.8] {
let tools = crate::extrude(&Plane::XY, &[rectangle(0.7, 0.), rectangle(0.7, offset)], 0., 2.).unwrap();
let compound = compound_disjoint_extrusions(&tools).unwrap();
assert_eq!(compound.is_some(), offset > 1.);
if let Some(compound) = compound {
let sources = compound.face_sources().unwrap();
assert_eq!(sources.len(), compound.face_count());
assert_eq!(sources.iter().filter(|source| matches!(source, Some(crate::FaceSource::ExtrudeCap { high: false, .. }))).count(), 2);
assert!(compound.validity().is_empty());
}
}
}
#[test]
fn batched_contact_test_matches_individual_tolerances() {
let target = crate::cylinder(Vec3::ZERO, Vec3::Z, 5., 3.).unwrap();
let tools = [
crate::box_solid(Vec3::new(-1., -1., 3.), Vec3::new(1., 1., 5.)).unwrap(),
crate::box_solid(Vec3::new(-1., -1., 3.002), Vec3::new(1., 1., 5.)).unwrap(),
crate::sphere(Vec3::new(20., 0., 1.), 1.).unwrap(),
];
let expected = tools.iter().map(|tool| target.meets(tool).unwrap()).collect::<Vec<_>>();
assert_eq!(target.meets_each(&tools).unwrap(), expected);
assert!(target.meets_each(&[]).unwrap().is_empty());
}
#[test]
fn compound_span_does_not_relax_full_face_seam_tolerance() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let mut target = crate::box_solid(Vec3::new(0., 0., -3.), Vec3::new(100., 2., -2.)).unwrap();
for x in [0., 98.] {
let boss = crate::box_solid(Vec3::new(x, 0., -2.), Vec3::new(x + 2., 2., 0.)).unwrap();
target = boolean(&target, &boss, BoolOp::Union).unwrap().unwrap();
}
let regions: Vec<_> = [0., 98.]
.into_iter()
.map(|x| Region2 {
outer: Loop2::polygon(&[Vec2::new(x, 0.), Vec2::new(x + 2., 0.), Vec2::new(x + 2., 2.), Vec2::new(x, 2.)]),
holes: vec![],
})
.collect();
let tools = crate::extrude(&Plane::XY, &regions, 0.000_105, 200.).unwrap();
assert!(tools.iter().all(|tool| !can_join_touching_faces(&target, tool)));
let compound = compound_disjoint_extrusions(&tools).unwrap().unwrap();
assert!(!can_join_all_touching_faces(&target, &compound));
}
#[test]
fn extrusion_compound_requires_separated_construction_bounds() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let rectangle = |x: f64| Region2 {
outer: Loop2::polygon(&[Vec2::new(x, 0.0), Vec2::new(x + 2.0, 0.0), Vec2::new(x + 2.0, 2.0), Vec2::new(x, 2.0)]),
holes: vec![],
};
let plane = Plane::from_normal(Vec3::new(100.0, -20.0, 8.0), Vec3::new(1.0, 2.0, 3.0)).unwrap();
let tools = crate::extrude(&plane, &[rectangle(0.0), rectangle(20.0)], -1.0, 5.0).unwrap();
let compound = compound_disjoint_extrusions(&tools).unwrap().unwrap();
assert_eq!(compound.shell_count(), 2);
assert_eq!(compound.history().unwrap().operands, 2);
assert!((crate::measure(&compound).unwrap().volume - 48.0).abs() < 1e-8);
assert!(compound.validity().is_empty());
for x in [1.0, 2.0, 2.000_000_1] {
let touching = crate::extrude(&solidcraft_geom::Plane::XY, &[rectangle(0.0), rectangle(x)], 0.0, 5.0).unwrap();
assert!(compound_disjoint_extrusions(&touching).unwrap().is_none());
}
let unsupported = [crate::sphere(Vec3::ZERO, 1.0).unwrap(), crate::sphere(Vec3::new(10.0, 0.0, 0.0), 1.0).unwrap()];
assert!(compound_disjoint_extrusions(&unsupported).unwrap().is_none());
}
#[test]
fn extrusion_compound_includes_arc_extrema_between_seam_vertices() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let circle = |x| Region2 { outer: Loop2::circle(Vec2::new(x, 0.0), 2.0), holes: vec![] };
let separated = crate::extrude(&Plane::XY, &[circle(0.0), circle(8.0)], 0.0, 5.0).unwrap();
assert!(compound_disjoint_extrusions(&separated).unwrap().is_some());
let overlapping = crate::extrude(&Plane::XY, &[circle(0.0), circle(3.0)], 0.0, 5.0).unwrap();
assert!(compound_disjoint_extrusions(&overlapping).unwrap().is_none());
}
#[test]
fn curved_near_contact_never_uses_a_shell_only_boolean_candidate() {
let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
@@ -513,7 +772,11 @@ fn meeting_size(a: &Body, b: &Body) -> f64 {
};
let (ba, bb) = (bbox(a), bbox(b));
let s = solidcraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }.size();
if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
// A closed sweep's section seams need not span its geometry. Roundoff in a flat
// vertex box is not a thin meeting region: refining for it
// makes millions of triangles and skips the bounded containment shortcut (#2005).
let roundoff = a.size().max(b.size()) * 1e-12;
if s.x > roundoff && s.y > roundoff && s.z > roundoff { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
}
/// Smallest side of the box where the bounds of two meshes overlap (infinite when they don't).
@@ -739,14 +1002,13 @@ fn boolean_whole_with_refinement(a: &Body, b: &Body, op: BoolOp, refine: bool) -
}
}
// Bodies touching along one whole face (a part joined with its mirror image).
if op == BoolOp::Union
&& a.solid.boundaries().len() == 1
&& b.solid.boundaries().len() == 1
&& let Some(Ok(body)) = crate::coplanar::glue(a, b)
{
let (ok, _) = check(&body);
if ok {
return Ok(Some(body));
if op == BoolOp::Union && a.solid.boundaries().len() == 1 {
let glued = if b.solid.boundaries().len() == 1 { crate::coplanar::glue(a, b) } else { crate::coplanar::glue_all_caps(a, b) };
if let Some(Ok(body)) = glued {
let (ok, _) = check(&body);
if ok {
return Ok(Some(body));
}
}
}
// Bores on a common cylinder (a pad's bore around the hole it is joined over, a disc filling
+115 -21
View File
@@ -181,8 +181,8 @@ pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, en
remaining_pairs = remaining_pairs
.checked_sub(1)
.ok_or_else(|| KernelError::Invalid("planar extrusion separation exceeds its work budget".into()))?;
let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(t.closest_param(*p)).dist(*p) <= 1e-7)
|| [t.start(), t.end()].iter().any(|p| s.point_at(s.closest_param(*p)).dist(*p) <= 1e-7);
let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(closest_param(t, *p)).dist(*p) <= 1e-7)
|| [t.start(), t.end()].iter().any(|p| s.point_at(closest_param(s, *p)).dist(*p) <= 1e-7);
if endpoints_touch || !crossings(s, t, 1e-7).is_empty() {
return Err(KernelError::Invalid("planar material pieces touch at a boundary".into()));
}
@@ -765,7 +765,7 @@ fn heights_of(b: &Body, plane: &Plane) -> (f64, f64) {
/// Does `s` run along `t` from end to end? Two lines, `s`'s ends on `t`; two arcs of one
/// circle, `s`'s ends and middle on `t`.
fn runs_along(s: &Seg2, t: &Seg2, tol: f64) -> bool {
let on = |p: Vec2| t.point_at(t.closest_param(p)).dist(p) < tol;
let on = |p: Vec2| t.point_at(closest_param(t, p)).dist(p) < tol;
match (*s, *t) {
(Seg2::Line { .. }, Seg2::Line { .. }) => on(s.start()) && on(s.end()),
(Seg2::Arc { center: c, radius: r, .. }, Seg2::Arc { center: d, radius: q, .. }) => {
@@ -840,7 +840,7 @@ fn crossings(s: &Seg2, t: &Seg2, tol: f64) -> Vec<(f64, f64)> {
}
let mut out = Vec::new();
for p in pts {
let (u, v) = (s.closest_param(p), t.closest_param(p));
let (u, v) = (closest_param(s, p), closest_param(t, p));
if s.point_at(u).dist(p) < tol && t.point_at(v).dist(p) < tol {
out.push((u, v));
}
@@ -871,7 +871,7 @@ fn inside_loop(lp: &Loop2, p: Vec2) -> bool {
}
}
// 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) {
if lp.segs.iter().any(|s| s.point_at(closest_param(s, p)).dist(p) < scale * 1e-9) {
return inside_loop_ray(lp, p);
}
let grazes = |q: Vec2| {
@@ -991,12 +991,43 @@ fn inside(regions: &[Region2], p: Vec2) -> bool {
regions.iter().any(|r| inside_loop(&r.outer, p) && !r.holes.iter().any(|h| inside_loop(h, p)))
}
/// Call only after proving the point is at least `tol` from the other boundary.
/// This sub-tolerance interior probe cannot cross that boundary, and avoids casting
/// exactly through fitted arc/line endpoints whose heights differ by roundoff.
fn inside_clear(regions: &[Region2], point: Vec2, tol: f64) -> bool {
inside(regions, point + Vec2::new(tol * 0.173, tol * 0.317))
}
/// Analytic projections avoid iterative searches at every planar boundary probe.
/// Other curve types keep the shared solver introduced on main.
fn closest_param(segment: &Seg2, point: Vec2) -> f64 {
match *segment {
Seg2::Line { a, b } => {
let direction = b - a;
let length2 = direction.dot(direction);
if length2 > 1e-300 && length2.is_finite() { ((point - a).dot(direction) / length2).clamp(0.0, 1.0) } else { 0.0 }
}
Seg2::Arc { center, radius, start, sweep } if radius > 0.0 && sweep.abs() > 1e-12 && sweep.abs() <= std::f64::consts::TAU + 1e-9 => {
let offset = point - center;
let delta = ((offset.y.atan2(offset.x) - start) * sweep.signum()).rem_euclid(std::f64::consts::TAU);
if delta <= sweep.abs() {
(delta / sweep.abs()).clamp(0.0, 1.0)
} else if segment.start().dist(point) <= segment.end().dist(point) {
0.0
} else {
1.0
}
}
_ => segment.closest_param(point),
}
}
fn dist_to(regions: &[Region2], p: Vec2) -> f64 {
regions
.iter()
.flat_map(|r| std::iter::once(&r.outer).chain(&r.holes))
.flat_map(|l| l.segs.iter())
.map(|s| s.point_at(s.closest_param(p)).dist(p))
.map(|s| s.point_at(closest_param(s, p)).dist(p))
.fold(f64::INFINITY, f64::min)
}
@@ -1043,7 +1074,7 @@ fn box_holds(b: Option<SegBox>, p: Vec2) -> bool {
/// Distance from `p` to the nearest of `segs` whose box holds it: infinite when none does (no
/// segment is within the tolerance of `p` then).
fn near_dist(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2) -> f64 {
segs.iter().zip(boxes).filter(|(_, b)| box_holds(**b, p)).map(|(s, _)| s.point_at(s.closest_param(p)).dist(p)).fold(f64::INFINITY, f64::min)
segs.iter().zip(boxes).filter(|(_, b)| box_holds(**b, p)).map(|(s, _)| s.point_at(closest_param(s, p)).dist(p)).fold(f64::INFINITY, f64::min)
}
/// Direction of the first of `segs` through `p` (within `tol`), as it runs.
@@ -1052,7 +1083,7 @@ fn near_dir(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2, tol: f64) -> Optio
.iter()
.zip(boxes)
.filter(|(_, b)| box_holds(**b, p))
.map(|(s, _)| (s, s.closest_param(p)))
.map(|(s, _)| (s, closest_param(s, p)))
.find(|(s, t)| s.point_at(*t).dist(p) < tol)?;
s.derivative(t).normalized()
}
@@ -1193,7 +1224,7 @@ fn trimmed(src: &Seg2, t0: f64, t1: f64, a: Vec2, b: Vec2) -> Seg2 {
if hi < 1.0 - 1e-12 {
// Splitting a conic renormalises its weights, which moves its parameters: the second
// split is found on the piece.
let at = s.closest_param(src.point_at(hi));
let at = closest_param(&s, src.point_at(hi));
if at < 1.0 - 1e-12 {
s = s.split_at(at).0;
}
@@ -1304,7 +1335,7 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec<Seg
} else if p.dist(src.end()) < near {
1.0
} else {
src.closest_param(p)
closest_param(src, p)
}
};
let (t0, t1) = (at(seg.start()), at(last.end()));
@@ -1340,12 +1371,12 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec<Seg
(Piece::Exact { src: f, t1, .. }, Piece::Exact { src: g, t0, .. }) if f != g && (inner(t1) || inner(t0)) => {
src(f).zip(src(g)).and_then(|(x, y)| meet(x, t1, y, t0, near).map(|(a, b)| (Some(a), Some(b), x.point_at(a))))
}
(Piece::Exact { src: f, t1, .. }, Piece::Plain(s, _)) if inner(t1) => {
src(f).zip(carrier(&s)).and_then(|(x, c)| meet(x, t1, &c, c.closest_param(joint), near).map(|(a, _)| (Some(a), None, x.point_at(a))))
}
(Piece::Plain(s, _), Piece::Exact { src: f, t0, .. }) if inner(t0) => {
src(f).zip(carrier(&s)).and_then(|(x, c)| meet(x, t0, &c, c.closest_param(joint), near).map(|(a, _)| (None, Some(a), x.point_at(a))))
}
(Piece::Exact { src: f, t1, .. }, Piece::Plain(s, _)) if inner(t1) => src(f)
.zip(carrier(&s))
.and_then(|(x, c)| meet(x, t1, &c, closest_param(&c, joint), near).map(|(a, _)| (Some(a), None, x.point_at(a)))),
(Piece::Plain(s, _), Piece::Exact { src: f, t0, .. }) if inner(t0) => src(f)
.zip(carrier(&s))
.and_then(|(x, c)| meet(x, t0, &c, closest_param(&c, joint), near).map(|(a, _)| (None, Some(a), x.point_at(a)))),
_ => None,
};
// No crossing to refine (or none found): both end where the curve does.
@@ -1449,7 +1480,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
continue;
}
for p in [t.start(), t.end()] {
let u = s.closest_param(p);
let u = closest_param(s, p);
if s.point_at(u).dist(p) < tol {
events += 1;
if events > MAX_PLANAR_EVENTS {
@@ -1466,7 +1497,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
continue;
}
for p in [s.start(), s.end()] {
let v = t.closest_param(p);
let v = closest_param(t, p);
if t.point_at(v).dist(p) < tol {
events += 1;
if events > MAX_PLANAR_EVENTS {
@@ -1522,7 +1553,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
BoolOp::Cut => !same,
}
} else {
let ins = inside(&b, m);
let ins = inside_clear(&b, m, tol);
match op {
BoolOp::Union | BoolOp::Cut => !ins,
BoolOp::Intersect => ins,
@@ -1548,7 +1579,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
if near_dist(&sa, &box_a, m) < tol {
continue;
}
let ins = inside(&a, m);
let ins = inside_clear(&a, m, tol);
if kept.len() >= MAX_PLANAR_SEGMENTS {
return None;
}
@@ -1712,7 +1743,7 @@ fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
if p.dist(seg.start()) < tol || p.dist(seg.end()) < tol {
return None;
}
let t = seg.closest_param(*p);
let t = closest_param(seg, *p);
(t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t)
})
.collect();
@@ -1942,6 +1973,13 @@ fn sew_connected_sections(
ends.push(u);
}
}
// Welding above keeps its original encounter order. Side splitting needs
// only the distinct stations, whose resulting parameters are sorted anyway.
// Deduplicate in place, with bounded sorting work and no extra point list.
if pts.len() <= MAX_PLANAR_SEGMENTS * 8 {
pts.sort_unstable_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
pts.dedup_by_key(|p| (p.x.to_bits(), p.y.to_bits()));
}
// A piece of a free-form curve whose ends weld into one point (a font's curve cut a hair from
// its end) is no edge and has no wall: the pieces on either side meet there. (Lines and arcs
// are left as they were: such a piece of one fails the sewing, as before.)
@@ -2518,6 +2556,62 @@ pub(crate) fn prism_union_between_caps(a: &Body, b: &Body, op: BoolOp) -> Option
#[cfg(test)]
mod planar_tests {
use super::*;
#[test]
fn shared_arc_line_endpoint_roundoff_does_not_open_a_union() {
let a = Region2 { outer: Loop2::polygon(&[Vec2::new(-2., 0.), Vec2::new(2., 0.), Vec2::new(2., 2.), Vec2::new(-2., 2.)]), holes: vec![] };
let line = |a: (f64, f64), b: (f64, f64)| Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) };
for epsilon in [-4e-14, 4e-14] {
let b = Region2 {
outer: Loop2 {
segs: vec![
line((1.15, epsilon), (-1.15, -epsilon)),
Seg2::Arc { center: Vec2::new(-1.35, epsilon), radius: 0.2, start: 0., sweep: -std::f64::consts::FRAC_PI_2 },
line((-1.35, -0.2), (-2.4, -0.2)),
line((-2.4, -0.2), (-2.4, -1.)),
line((-2.4, -1.), (2.4, -1.)),
line((2.4, -1.), (2.4, -0.2)),
line((2.4, -0.2), (1.35, -0.2)),
Seg2::Arc {
center: Vec2::new(1.35, -epsilon),
radius: 0.2,
start: -std::f64::consts::FRAC_PI_2,
sweep: -std::f64::consts::FRAC_PI_2,
},
],
},
holes: vec![],
};
let result = planar_regions_boolean(std::slice::from_ref(&a), std::slice::from_ref(&b), BoolOp::Union, 1e-5).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].outer.is_closed(1e-4));
assert!((result[0].area() - a.area() - b.area()).abs() < 1e-6);
}
}
#[test]
fn analytic_projection_keeps_clamped_lines_and_wrapped_arcs() {
let line = Seg2::Line { a: Vec2::new(2., 3.), b: Vec2::new(5., 7.) };
for (point, expected) in [(Vec2::new(-1., -1.), 0.), (Vec2::new(3.5, 5.), 0.5), (Vec2::new(8., 11.), 1.)] {
assert!((closest_param(&line, point) - expected).abs() < 1e-12);
}
for start in [-7., -0.5, 3., 7.] {
for sweep in [-std::f64::consts::TAU, -5., -0.2, 0.2, 5., std::f64::consts::TAU] {
let arc = Seg2::Arc { center: Vec2::new(2., -3.), radius: 4., start, sweep };
for index in 0..128 {
let angle = index as f64 * std::f64::consts::TAU / 128.;
let point = Vec2::new(2., -3.) + Vec2::new(angle.cos(), angle.sin()) * 6.;
let nearest = arc.point_at(closest_param(&arc, point));
// Includes both signs, sweeps over pi, seam crossings and endpoint clamps.
for sample in 0..=128 {
assert!(nearest.dist(point) <= arc.point_at(sample as f64 / 128.).dist(point) + 1e-10);
}
}
for t in [0., 0.25, 0.5, 0.75, 1.] {
let point = arc.point_at(t);
assert!(arc.point_at(closest_param(&arc, point)).dist(point) < 1e-10);
}
}
}
}
fn rectangle(a: Vec2, b: Vec2) -> Region2 {
Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] }
}
+72 -2
View File
@@ -4,7 +4,7 @@
use std::collections::HashMap;
use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve};
use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve, ParametricSurface};
use truck_modeling as mt;
use super::Ctx;
@@ -50,6 +50,62 @@ fn rid(p: Option<&Param>, what: &str) -> R<u64> {
p.and_then(Param::as_ref_id).ok_or_else(|| format!("{what}: expected a reference"))
}
/// A contractible outer loop whose winding opposes the surface's parameter normal.
/// Samples follow the wire through periodic seams; a loop that winds around a surface is
/// not a disk in parameter space and must retain the file's sense.
fn reversed_outer(surface: &mt::Surface, wire: &mt::Wire, tol: f64) -> bool {
if let mt::Surface::RevolutedCurve(proc) = surface {
let rc = proc.entity();
let curve = rc.entity_curve();
let on_axis = |p: mt::Point3| (p - rc.origin()).cross(rc.axis()).magnitude() <= tol;
// A compact support has two legitimate sides of a closed trim (a sphere's
// hemispheres, for example); its winding must choose the side, not repair it.
if (on_axis(curve.front()) && on_axis(curve.back())) || curve.front().distance(curve.back()) <= tol {
return false;
}
}
let periods = truck_meshalgo::tessellation::surface_periods(surface);
let mut first = None::<[f64; 2]>;
let mut previous = None::<[f64; 2]>;
let (mut area, mut magnitude) = (0.0, 0.0);
for edge in wire.edge_iter() {
let curve = edge.oriented_curve();
let (a, b) = curve.range_tuple();
for k in 0..8 {
let point = curve.subs(a + (b - a) * f64::from(k) / 8.0);
let hint = previous.map(|p| (p[0], p[1]));
let valid = |&(u, v): &(f64, f64)| u.is_finite() && v.is_finite() && surface.subs(u, v).distance(point) <= tol;
let Some((u, v)) = truck_meshalgo::tessellation::project_trim_point(surface, point, hint, tol).filter(valid) else {
return false;
};
// A pole has no unique parameter in its collapsed direction. Its arbitrary
// search result cannot determine the winding of an outer loop.
let (du, dv) = (surface.uder(u, v), surface.vder(u, v));
if du.cross(dv).magnitude2() <= 1e-12 * (du.magnitude2() + dv.magnitude2()).powi(2) {
return false;
}
let mut p = [u, v];
let origin = *first.get_or_insert(p);
if let Some(prev) = previous {
for (d, x) in p.iter_mut().enumerate() {
if let Some(period) = periods.get(d).copied().flatten().filter(|p| *p > 0.0) {
*x -= ((*x - prev[d]) / period).round() * period;
}
}
let cross = (prev[0] - origin[0]) * (p[1] - origin[1]) - (p[0] - origin[0]) * (prev[1] - origin[1]);
area += cross;
magnitude += cross.abs();
}
previous = Some(p);
}
}
let (Some(first), Some(last)) = (first, previous) else { return false };
if periods.iter().enumerate().any(|(d, p)| p.is_some_and(|p| (first[d] - last[d]).abs() > p * 0.5)) {
return false;
}
area < -16.0 * f64::EPSILON * magnitude
}
impl Builder {
fn vertex(&mut self, cx: &Ctx, id: u64) -> R<mt::Vertex> {
if let Some(v) = self.vertices.get(&id) {
@@ -205,9 +261,13 @@ impl Builder {
return Err(format!("a face with more than {MAX_BOUNDS} boundary loops"));
}
let mut wires = Vec::new();
let mut outer = None;
let mut edges = 0usize;
for b in bounds {
if let Some(w) = self.bound(cx, b)? {
if entity(cx, b)?.name() == "FACE_OUTER_BOUND" {
outer = Some(wires.len());
}
edges = edges.saturating_add(w.len());
if edges > MAX_LOOP {
return Err(format!("a face with more than {MAX_LOOP} boundary edges"));
@@ -243,7 +303,7 @@ impl Builder {
(0..=8).map(move |i| c.subs(t0 + (t1 - t0) * i as f64 / 8.0)).collect::<Vec<_>>()
})
.collect();
let surface = if e.name() == "FACE" {
let mut surface = if e.name() == "FACE" {
crate::plane_fit::plane_through(&extent).map(mt::Surface::Plane).ok_or("polygonal face without a plane")?
} else {
let sid = rid(p.get(2), "face geometry")?;
@@ -251,6 +311,16 @@ impl Builder {
guard("step surface", || geom::surface(cx, sid, &extent, &seam, sense, 0).map_err(crate::KernelError::Failed))
.map_err(|e| e.to_string())?
};
// An explicit outer bound encloses the face. Some exporters give its surface the
// opposite sense; treating that loop as a hole meshes the rest of the supporting
// cylinder instead. Match the surface to the outer loop without reversing
// shared topological edges. Winding loops (bands and pole seams) are left alone.
if e.name() != "FACE" && seam.is_empty() && outer.and_then(|i| wires.get(i)).is_some_and(|w| reversed_outer(&surface, w, cx.tol)) {
let sid = rid(p.get(2), "face geometry")?;
let sense = p.get(3).and_then(Param::as_bool).unwrap_or(true);
surface = guard("step surface orientation", || geom::surface(cx, sid, &extent, &seam, !sense, 0).map_err(crate::KernelError::Failed))
.map_err(|e| e.to_string())?;
}
// Loops on a surface of revolution start away from the axis: a pole (cone apex, sphere
// pole) has no definite rotation parameter, and the mesher takes the first point's
// parameter as the anchor for the rest of the loop.
+117
View File
@@ -1483,3 +1483,120 @@ fn periodic_knot_tube_faces_import_and_mesh() {
assert!(rel(m.area, area) < 0.02, "area {} (expected {area})", m.area);
assert!(t0.elapsed().as_secs_f64() < 60.0, "took {:?}", t0.elapsed());
}
/// An original analytic STEP patch: a 7 mm high, 0.002 radian strip on a 3 m
/// cylinder. Its explicit outer bound encloses the strip even when the surface's
/// supplied sense disagrees with the loop (#1974).
fn shallow_cylinder_patch(sense: bool, outer: bool) -> String {
shallow_cylinder_patch_at_radius(sense, outer, 3000.0)
}
fn shallow_cylinder_patch_at_radius(sense: bool, outer: bool, r: f64) -> String {
let (angle, h) = (6.0 / r, 7.0);
let (x, y) = (10.0 + r * (angle.cos() - 1.0), r * angle.sin());
let origin = 10.0 - r;
let bound = if outer { "FACE_OUTER_BOUND" } else { "FACE_BOUND" };
let sense = if sense { "T" } else { "F" };
format!(
"{HEAD}
#1=CARTESIAN_POINT('',({origin},0.,0.));
#2=DIRECTION('',(0.,0.,1.));
#3=DIRECTION('',(1.,0.,0.));
#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);
#5=CYLINDRICAL_SURFACE('',#4,{r});
#6=CARTESIAN_POINT('',(10.,0.,0.));
#7=CARTESIAN_POINT('',({x},{y},0.));
#8=CARTESIAN_POINT('',({x},{y},{h}));
#9=CARTESIAN_POINT('',(10.,0.,{h}));
#10=VERTEX_POINT('',#6);
#11=VERTEX_POINT('',#7);
#12=VERTEX_POINT('',#8);
#13=VERTEX_POINT('',#9);
#14=CIRCLE('',#4,{r});
#15=CARTESIAN_POINT('',({origin},0.,{h}));
#16=AXIS2_PLACEMENT_3D('',#15,#2,#3);
#17=CIRCLE('',#16,{r});
#18=VECTOR('',#2,1.);
#19=LINE('',#7,#18);
#20=LINE('',#6,#18);
#21=EDGE_CURVE('',#10,#11,#14,.T.);
#22=EDGE_CURVE('',#11,#12,#19,.T.);
#23=EDGE_CURVE('',#12,#13,#17,.F.);
#24=EDGE_CURVE('',#13,#10,#20,.F.);
#25=ORIENTED_EDGE('',*,*,#21,.T.);
#26=ORIENTED_EDGE('',*,*,#22,.T.);
#27=ORIENTED_EDGE('',*,*,#23,.T.);
#28=ORIENTED_EDGE('',*,*,#24,.T.);
#29=EDGE_LOOP('',(#25,#26,#27,#28));
#30={bound}('',#29,.T.);
#31=ADVANCED_FACE('',(#30),#5,.{sense}.);
#32=OPEN_SHELL('',(#31));
#33=SHELL_BASED_SURFACE_MODEL('Strip',(#32));
#34=SHAPE_REPRESENTATION('',(#33),$);
{TAIL}"
)
}
#[test]
fn explicit_outer_bound_keeps_shallow_cylinder_patch_trimmed() {
for (sense, radius) in [(true, 3000.), (false, 3000.), (true, 188_980.), (false, 188_980.)] {
let imp = step_import(&shallow_cylinder_patch_at_radius(sense, true, radius)).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
let b = &imp.bodies[0];
assert_eq!(b.file_faces, 1);
assert!(!b.closed);
for mesh in [
b.body.tessellate(0.01).unwrap(),
b.body.reference_display_mesh_angular(0.01, crate::DISPLAY_ANGLE).unwrap(),
b.body.display_mesh_angular(0.01, crate::DISPLAY_ANGLE).unwrap(),
] {
assert!(!mesh.triangles.is_empty());
assert!(mesh.bounds().diagonal() < 10.0, "sense={sense}: {:?}", mesh.bounds());
assert!((mesh.measure().area - 42.0).abs() < 0.01, "sense={sense}: area {}", mesh.measure().area);
assert!(mesh.normals.iter().all(|n| n.x > 0.99), "the outer loop determines the face normal");
}
}
}
#[test]
fn implicit_bound_can_still_describe_the_complement_of_a_patch() {
let imp = step_import(&shallow_cylinder_patch(false, false)).unwrap();
let mesh = imp.bodies[0].body.tessellate(0.1).unwrap();
assert!(mesh.bounds().diagonal() > 5000.0, "without an explicit outer bound the loop is a hole");
}
#[test]
fn explicit_outer_bound_crossing_a_closed_nurbs_seam_stays_trimmed() {
// The same analytic strip, with its cylinder expressed as a rational B-spline.
// Put the parameter seam halfway through the strip, independently of its edges.
let mut points = String::new();
let mut rows = Vec::new();
let mut weights = Vec::new();
let w = std::f64::consts::FRAC_1_SQRT_2;
let circle = [(1., 0., 1.), (1., 1., w), (0., 1., 1.), (-1., 1., w), (-1., 0., 1.), (-1., -1., w), (0., -1., 1.), (1., -1., w), (1., 0., 1.)];
let (sin, cos) = 0.001_f64.sin_cos();
for (i, (x, y, weight)) in circle.into_iter().enumerate() {
let id = 1000 + i * 2;
let (x, y) = (10.0 - 3000.0 + 3000.0 * (x * cos - y * sin), 3000.0 * (x * sin + y * cos));
points += &format!("#{id}=CARTESIAN_POINT('',({x:.16},{y:.16},0.));\n#{}=CARTESIAN_POINT('',({x:.16},{y:.16},7.));\n", id + 1);
rows.push(format!("(#{id},#{})", id + 1));
weights.push(format!("({weight:.16},{weight:.16})"));
}
let surface = format!(
"#5=(BOUNDED_SURFACE() B_SPLINE_SURFACE(2,1,({}),.UNSPECIFIED.,.T.,.F.,.F.) B_SPLINE_SURFACE_WITH_KNOTS((3,2,2,2,3),(2,2),(0.,1.,2.,3.,4.),(0.,1.),.UNSPECIFIED.) GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_SURFACE(({})) REPRESENTATION_ITEM('') SURFACE());",
rows.join(","),
weights.join(",")
);
for sense in [true, false] {
let text = shallow_cylinder_patch(sense, true);
let old = "#5=CYLINDRICAL_SURFACE('',#4,3000);";
assert!(text.contains(old));
let text = text.replacen(old, &surface, 1).replace(TAIL, &format!("{points}{TAIL}"));
let imp = step_import(&text).unwrap();
let body = &imp.bodies[0].body;
let mesh = body.reference_display_mesh_angular(0.01, crate::DISPLAY_ANGLE).unwrap();
assert!(!mesh.triangles.is_empty(), "{:?}", imp.warnings);
assert!(mesh.bounds().diagonal() < 10.0, "sense={sense}: {:?}", mesh.bounds());
assert!((mesh.measure().area - 42.0).abs() < 0.01, "sense={sense}: area {}", mesh.measure().area);
}
}
@@ -0,0 +1,96 @@
//! The vendored truck-geometry's second derivatives of B-spline and NURBS surfaces (`uuder`,
//! `vvder`) against finite differences of the surface itself, for degrees 2 to 5. Upstream's
//! were short of the factor p − 1, so every curvature read from a cubic surface came out half its
//! value (#763: a G2 boundary surface measured half the cylinder's curvature it does carry).
//! These pin the vendored fix, so a refresh of the crate can't quietly bring the error back.
use solidcraft_geom::Vec3;
use truck_modeling as mt;
use truck_modeling::ParametricSurface;
use crate::body::from_p3;
/// A degree-`p` × degree-`q` surface on a 5 × 6 net with an interior knot each way, bent in z.
fn surface(p: usize, q: usize) -> mt::BSplineSurface<mt::Point3> {
let knots = |d: usize, n: usize| {
let inner: Vec<f64> = (1..n - d).map(|k| k as f64 / (n - d) as f64).collect();
let mut k = vec![0.0; d + 1];
k.extend(inner);
k.extend(vec![1.0; d + 1]);
mt::KnotVec::from(k)
};
let (nu, nv) = (p + 3, q + 3);
let rows: Vec<Vec<mt::Point3>> = (0..nu)
.map(|i| {
(0..nv)
.map(|j| {
let (x, y) = (i as f64 * 2.0, j as f64 * 1.5);
mt::Point3::new(x + 0.3 * y, y, 0.4 * x * x - 0.2 * y * y + 0.1 * x * y)
})
.collect()
})
.collect();
mt::BSplineSurface::new((knots(p, nu), knots(q, nv)), rows)
}
/// Second differences of `f` along the parameter line, central inside and one-sided at the ends.
fn second(f: impl Fn(f64) -> Vec3, t: f64) -> Vec3 {
let h = 1e-4;
if t < 2.0 * h {
(f(t) * 2.0 - f(t + h) * 5.0 + f(t + 2.0 * h) * 4.0 - f(t + 3.0 * h)) / (h * h)
} else if t > 1.0 - 2.0 * h {
(f(t) * 2.0 - f(t - h) * 5.0 + f(t - 2.0 * h) * 4.0 - f(t - 3.0 * h)) / (h * h)
} else {
(f(t - h) - f(t) * 2.0 + f(t + h)) / (h * h)
}
}
fn v3(x: mt::Vector3) -> Vec3 {
Vec3::new(x.x, x.y, x.z)
}
#[test]
fn bspline_surface_second_derivatives_match_finite_differences() {
for p in 2..=5 {
for q in 2..=5 {
let s = surface(p, q);
for (u, v) in [(0.0, 0.0), (0.37, 0.0), (0.5, 0.61), (1.0, 0.2), (0.13, 1.0)] {
let uu = second(|t| from_p3(s.subs(t, v)), u);
let vv = second(|t| from_p3(s.subs(u, t)), v);
let scale = uu.len().max(vv.len()).max(1.0);
assert!(v3(s.uuder(u, v)).dist(uu) < 1e-4 * scale, "degree ({p}, {q}) uuder at ({u}, {v}): {:?} vs {uu:?}", s.uuder(u, v));
assert!(v3(s.vvder(u, v)).dist(vv) < 1e-4 * scale, "degree ({p}, {q}) vvder at ({u}, {v}): {:?} vs {vv:?}", s.vvder(u, v));
}
}
}
}
/// A rational surface's second derivatives come from its homogeneous B-spline's: they are right
/// too.
#[test]
fn nurbs_surface_second_derivatives_match_finite_differences() {
let b = surface(3, 3);
let (ku, kv) = b.knot_vecs().clone();
let rows: Vec<Vec<mt::Vector4>> = b
.control_points()
.iter()
.enumerate()
.map(|(i, row)| {
row.iter()
.enumerate()
.map(|(j, p)| {
let w = 1.0 + 0.3 * ((i + 2 * j) % 3) as f64;
mt::Vector4::new(p.x * w, p.y * w, p.z * w, w)
})
.collect()
})
.collect();
let s = mt::NurbsSurface::new(mt::BSplineSurface::new((ku, kv), rows));
for (u, v) in [(0.0, 0.0), (0.37, 0.0), (0.5, 0.61), (1.0, 0.2)] {
let uu = second(|t| from_p3(s.subs(t, v)), u);
let vv = second(|t| from_p3(s.subs(u, t)), v);
let scale = uu.len().max(vv.len()).max(1.0);
assert!(v3(s.uuder(u, v)).dist(uu) < 1e-4 * scale, "uuder at ({u}, {v}): {:?} vs {uu:?}", s.uuder(u, v));
assert!(v3(s.vvder(u, v)).dist(vv) < 1e-4 * scale, "vvder at ({u}, {v}): {:?} vs {vv:?}", s.vvder(u, v));
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ fn surface_of(shell: mt::Shell) -> Body {
/// A body from faces: a solid when they close, else a surface; with the history of the
/// operation that made the faces from `operands` as `rec` records.
fn body_of(faces: Vec<mt::Face>, operands: &[&mt::Solid], rec: &Rebuild) -> Result<Body> {
pub(crate) fn body_of(faces: Vec<mt::Face>, operands: &[&mt::Solid], rec: &Rebuild) -> Result<Body> {
let shell = mt::Shell::from(faces);
if shell.shell_condition() == mt::ShellCondition::Closed {
crate::record::rebuilt(mt::Solid::new_unchecked(vec![shell]), operands, rec)
+98 -6
View File
@@ -10,7 +10,7 @@ use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, p3};
use crate::build::{cap_and_close, interpolate_cubic, uniform_wire};
use crate::build::{cap_and_close, close_sweep, interpolate_cubic, smooth_loft_faces, uniform_wire};
use crate::numbered::{NumberedLoop, NumberedRegion};
use crate::record::{self, Side};
use crate::{KernelError, Result, guard};
@@ -171,6 +171,43 @@ fn clean(path: &[Vec3]) -> Result<Vec<Vec3>> {
Ok(out)
}
pub(crate) fn closed_path(pts: &[Vec3]) -> bool {
pts.len() > 3 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.dist(*b) <= 1e-7)
}
/// The cyclic correspondence of a loop's last section to its first. Matching whole curves
/// (rather than vertices alone) prevents sewing an incompatible twist or scale shut.
fn closing_offset(plane: &Plane, profile: &NumberedLoop, st: &[Station]) -> Result<usize> {
let fail =
|| KernelError::Invalid("a sweep along a closed path must return to its starting profile; adjust the twist, end scale or rails".into());
let (Some(first), Some(last)) = (st.first(), st.last()) else { return Err(fail()) };
let n = profile.lp.segs.len();
if n == 0 || n > 4096 {
return Err(fail());
}
let at = |s: &Station, q: Vec2| s.at + s.apply(plane.to_world(q) - first.at);
let tol = (profile.lp.signed_area().abs().sqrt() * 1e-7).clamp(1e-8, 1e-5);
// Filter offsets by the first corner before comparing all the curve samples.
let start = profile.lp.segs.first().map(Seg2::start).ok_or_else(fail)?;
(0..n)
.filter(|k| profile.lp.segs.get(*k).is_some_and(|s| at(last, start).dist(at(first, s.start())) <= tol))
.find(|k| {
profile.lp.segs.iter().enumerate().all(|(j, a)| {
profile.lp.segs.get((j + k) % n).is_some_and(|b| {
(0..SEG_SAMPLES).all(|i| {
let t = i as f64 / (SEG_SAMPLES - 1) as f64;
at(last, a.point_at(t)).dist(at(first, b.point_at(t))) <= tol
})
})
})
})
.ok_or_else(fail)
}
fn rotated_wire(wire: &mt::Wire, offset: usize) -> mt::Wire {
wire.edge_iter().skip(offset).chain(wire.edge_iter().take(offset)).cloned().collect()
}
fn turn(a: Vec3, b: Vec3) -> f64 {
match (a.normalized(), b.normalized()) {
(Some(a), Some(b)) => a.dot(b).clamp(-1.0, 1.0).acos(),
@@ -506,7 +543,12 @@ pub fn sweep_path_with(plane: &Plane, region: &Region2, path: &[Vec3], ctl: &Swe
let v = swept_volume(plane, region.area().abs(), &st, &tangents);
(st, v)
};
if let Ok(b) = smooth_walls(plane, &profile.outer, &st, expected) {
if closed_path(&pts) {
let offset = closing_offset(plane, &profile.outer, &st)?;
if let Ok(b) = smooth_closed_walls(plane, &profile.outer, &st, offset, expected) {
return Ok(b);
}
} else if let Ok(b) = smooth_walls(plane, &profile.outer, &st, expected) {
return Ok(b);
}
}
@@ -515,10 +557,11 @@ pub fn sweep_path_with(plane: &Plane, region: &Region2, path: &[Vec3], ctl: &Swe
fn central_tangents(p: &[Vec3]) -> Result<Vec<Vec3>> {
let n = p.len();
let closed = closed_path(p);
(0..n)
.map(|i| {
let a = p.get(i.saturating_sub(1)).copied().unwrap_or_default();
let b = p.get((i + 1).min(n - 1)).copied().unwrap_or_default();
let a = p.get(if closed && (i == 0 || i + 1 == n) { n - 2 } else { i.saturating_sub(1) }).copied().unwrap_or_default();
let b = p.get(if closed && (i == 0 || i + 1 == n) { 1 } else { (i + 1).min(n - 1) }).copied().unwrap_or_default();
(b - a).normalized().ok_or_else(|| KernelError::Invalid("the sweep path has no direction".into()))
})
.collect()
@@ -535,12 +578,25 @@ fn ruled_stations(plane: &Plane, pts: &[Vec3], ctl: &SweepControl) -> Result<(Ve
let pts = densify(pts, ctl);
let pts: Vec<Vec3> = if pts.len() > MAX_RULED_STATIONS { resample(&pts, MAX_RULED_STATIONS) } else { pts };
let n = pts.len();
let closed = closed_path(&pts);
let seg_dir = |i: usize| -> Option<Vec3> { (*pts.get(i + 1)? - *pts.get(i)?).normalized() };
let mut tangents = Vec::with_capacity(n);
let mut mitre = Vec::with_capacity(n);
for i in 0..n {
let d_in = if i > 0 { seg_dir(i - 1) } else { None };
let d_out = if i + 1 < n { seg_dir(i) } else { None };
let d_in = if i > 0 {
seg_dir(i - 1)
} else if closed {
seg_dir(n - 2)
} else {
None
};
let d_out = if i + 1 < n {
seg_dir(i)
} else if closed {
seg_dir(0)
} else {
None
};
let (t, m) = match (d_in, d_out) {
(Some(a), Some(b)) => {
let t = (a + b).normalized().ok_or_else(|| KernelError::Invalid("the sweep path turns back on itself".into()))?;
@@ -706,6 +762,15 @@ fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3], ctl: &SweepContr
let pl = station_plane(plane, s0, s);
wires.push(loops.iter().map(|l| uniform_wire(&pl, &l.lp)).collect::<Result<Vec<_>>>()?);
}
let closed = closed_path(&pts);
if closed {
let offsets = loops.iter().map(|l| closing_offset(plane, l, &st)).collect::<Result<Vec<_>>>()?;
let first = wires.first().ok_or_else(|| KernelError::Invalid("sweep path".into()))?;
let end: Vec<mt::Wire> = first.iter().zip(offsets).map(|(w, k)| rotated_wire(w, k)).collect();
if let Some(last) = wires.last_mut() {
*last = end;
}
}
guard("path sweep", || {
// Every station's wires have the loops' segments, edge for edge.
let mut faces: Vec<Side> = Vec::new();
@@ -716,11 +781,38 @@ fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3], ctl: &SweepContr
faces.extend(record::ruled(&sides, a, b, &l.from, &l.from));
}
}
if closed {
return close_sweep(faces);
}
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("path sweep".into())) };
cap_and_close(faces, first, last, false)
})
}
/// Two smooth runs share their middle section and the closing section. Splitting the run
/// avoids edges from a vertex back to itself when the profile returns without a permutation.
fn smooth_closed_walls(plane: &Plane, profile: &NumberedLoop, st: &[Station], offset: usize, expected: f64) -> Result<Body> {
let fail = || KernelError::Failed("closed path sweep: the walls do not close a solid".into());
if profile.lp.segs.len() > 64 || st.len() < 7 {
return Err(fail());
}
guard("closed path sweep", || {
let first = st.first().ok_or_else(fail)?;
let mut wires = st.iter().map(|s| uniform_wire(&station_plane(plane, first.at, s), &profile.lp)).collect::<Result<Vec<_>>>()?;
let end = rotated_wire(wires.first().ok_or_else(fail)?, offset);
if let Some(last) = wires.last_mut() {
*last = end;
}
let middle = (wires.len() - 1) / 2;
let numbers = vec![profile.from.clone(); wires.len()];
let mut sides = smooth_loft_faces(wires.get(..=middle).ok_or_else(fail)?, numbers.get(..=middle).ok_or_else(fail)?, true)?;
sides.extend(smooth_loft_faces(wires.get(middle..).ok_or_else(fail)?, numbers.get(middle..).ok_or_else(fail)?, true)?);
let b = close_sweep(sides)?;
let tol = (profile.lp.signed_area().abs().sqrt() * 0.05).max(1e-3);
if b.tessellate(tol).is_ok_and(|m| (m.measure().volume - expected).abs() <= 0.15 * expected.abs()) { Ok(b) } else { Err(fail()) }
})
}
/// One B-spline wall per profile segment through the stations, capped; checked by volume.
fn smooth_walls(plane: &Plane, profile: &NumberedLoop, st: &[Station], expected: f64) -> Result<Body> {
let lp = &profile.lp;
+86
View File
@@ -0,0 +1,86 @@
//! Revolves of profiles with an edge on the axis that come back to it along a curve or a line
//! (#1853): a half slot about its centreline, a quarter disc and a bullet whose arc ends on the
//! axis, a drill point whose tip does. Their volumes are analytic, and a half slot revolved about
//! a centreline lying in a face of a plate, as a cut, takes what it should.
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use solidcraft_kernel::{BoolOp, boolean, box_solid, measure, revolve};
use std::f64::consts::{FRAC_PI_2, PI, TAU};
const R: f64 = 3.0;
fn line(a: (f64, f64), b: (f64, f64)) -> Seg2 {
Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) }
}
/// A quarter of a circle of radius `R` about `c`, counter-clockwise from `start`.
fn quarter(c: (f64, f64), start: f64) -> Seg2 {
Seg2::Arc { center: Vec2::new(c.0, c.1), radius: R, start, sweep: FRAC_PI_2 }
}
fn region(segs: Vec<Seg2>) -> Region2 {
Region2 { outer: Loop2 { segs }, holes: vec![] }
}
/// Half a slot (radius `R`, centres 14 apart) cut along its centreline on the x axis.
fn half_slot() -> Region2 {
region(vec![line((0.0, 0.0), (20.0, 0.0)), quarter((17.0, 0.0), 0.0), line((17.0, R), (3.0, R)), quarter((3.0, 0.0), FRAC_PI_2)])
}
#[test]
fn revolves_that_end_on_the_axis_along_a_curve_have_their_volume() {
let sphere = 4.0 / 3.0 * PI * R.powi(3);
let cylinder = PI * R * R * 14.0;
let shapes = [
("half slot", half_slot(), cylinder + sphere),
("quarter disc", region(vec![line((-R, 0.0), (0.0, 0.0)), line((0.0, 0.0), (0.0, R)), quarter((0.0, 0.0), FRAC_PI_2)]), sphere / 2.0),
(
"bullet",
region(vec![line((0.0, 0.0), (17.0, 0.0)), line((17.0, 0.0), (17.0, R)), line((17.0, R), (3.0, R)), quarter((3.0, 0.0), FRAC_PI_2)]),
cylinder + sphere / 2.0,
),
// A drill point: a line coming back to the axis (a full turn keeps truck's cone).
(
"drill point",
region(vec![line((0.0, 0.0), (20.0, 0.0)), line((20.0, 0.0), (20.0, R)), line((20.0, R), (3.0, R)), line((3.0, R), (0.0, 0.0))]),
PI * R * R * 17.0 + PI * R * R * 3.0 / 3.0,
),
];
// The plane through the origin, and one moved and turned away from it.
let planes = [Plane::XY, Plane { origin: Vec3::new(5.0, 7.0, 10.0), x: Vec3::Y, y: Vec3::Z }];
for (name, r, full) in &shapes {
for plane in &planes {
for turn in [FRAC_PI_2, PI, -PI, TAU] {
let body = revolve(plane, std::slice::from_ref(r), Vec2::ZERO, Vec2::new(1.0, 0.0), turn).unwrap().pop().unwrap();
assert!(body.validity().is_empty(), "{name}, {turn}: {:?}", body.validity());
let want = full * turn.abs() / TAU;
let got = measure(&body).unwrap().volume;
assert!((got - want).abs() < 1e-3 * want, "{name} turned {turn} on {:?}: {got} instead of {want}", plane.origin);
}
}
}
}
#[test]
fn a_half_slot_revolved_about_a_centreline_in_a_face_cuts_it() {
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 20.0, 10.0)).unwrap();
let whole = measure(&plate).unwrap().volume;
// The half slot from x = 10 to 30 with its centreline on y = 10 of the top face; the plate
// holds the half of the capsule below it.
let inside = (PI * R * R * 14.0 + 4.0 / 3.0 * PI * R.powi(3)) / 2.0;
let r = half_slot();
// The sketch plane facing out of the plate and into it (its y running the other way).
for flip in [1.0, -1.0] {
let plane = Plane { origin: Vec3::new(10.0, 10.0, 10.0), x: Vec3::X, y: Vec3::Y * flip };
for turn in [PI, -PI, TAU] {
let tool = revolve(&plane, std::slice::from_ref(&r), Vec2::ZERO, Vec2::new(1.0, 0.0), turn).unwrap().pop().unwrap();
// A half turn into the plate, or a full one, cuts a groove; a half turn out of it misses.
let into = turn.abs() > PI || tool.tessellate(0.01).unwrap().bounds().min.z < 9.0;
let cut = boolean(&plate, &tool, BoolOp::Cut).unwrap().unwrap();
assert!(cut.validity().is_empty(), "flip {flip}, {turn}: {:?}", cut.validity());
let removed = whole - measure(&cut).unwrap().volume;
let want = if into { inside } else { 0.0 };
assert!((removed - want).abs() < 1e-3 * inside, "flip {flip}, turned {turn}: removed {removed} instead of {want}");
}
}
}
@@ -0,0 +1,92 @@
//! Original analytic surfaces exercising trim projection (#1974), without an external CAD fixture.
use truck_meshalgo::prelude::*;
use truck_modeling::{self as mt, BoundedCurve, InnerSpace};
fn mesh(surface: mt::Surface, curves: Vec<mt::Curve>) -> PolygonMesh {
let vertices: Vec<_> = curves.iter().map(|c| mt::Vertex::new(c.front())).collect();
let count = vertices.len();
let edges: Vec<_> = curves.into_iter().enumerate().map(|(i, c)| mt::Edge::new_unchecked(&vertices[i], &vertices[(i + 1) % count], c)).collect();
let face = mt::Face::new_unchecked(vec![edges.into()], surface);
let shell = mt::Shell::from(vec![face]).robust_triangulation(0.001);
let polygon = shell.face_iter().next().unwrap().surface().unwrap();
assert!(polygon.faces().triangle_iter().next().is_some(), "the trimmed face must remain visible");
polygon
}
#[test]
fn approximate_boundary_does_not_escape_a_bounded_spline() {
// S(u,v) = (50u + 10v, .001v, 10u² + 4v), 0 <= u,v <= 1.
// Near u=1 its tangents are almost parallel. A slightly approximate top edge
// sends an undamped search hinted from the preceding point to u>1.
let p = mt::Point3::new;
let surface = mt::BSplineSurface::new(
(mt::KnotVec::bezier_knot(2), mt::KnotVec::bezier_knot(1)),
vec![vec![p(0., 0., 0.), p(10., 0.001, 4.)], vec![p(25., 0., 0.), p(35., 0.001, 4.)], vec![p(50., 0., 10.), p(60., 0.001, 14.)]],
);
let curves = vec![
mt::BSplineCurve::new(mt::KnotVec::bezier_knot(2), vec![p(60., 0.001, 14.), p(35., 0., 4.), p(10., 0.001, 4.)]).into(),
mt::Line(p(10., 0.001, 4.), p(0., 0., 0.)).into(),
mt::BSplineCurve::new(mt::KnotVec::bezier_knot(2), vec![p(0., 0., 0.), p(25., 0., 0.), p(50., 0., 10.)]).into(),
mt::Line(p(50., 0., 10.), p(60., 0.001, 14.)).into(),
];
let mesh = mesh(mt::Surface::BSplineSurface(surface), curves);
for uv in mesh.uv_coords() {
assert!((-1e-6..=1. + 1e-6).contains(&uv.x) && (-1e-6..=1. + 1e-6).contains(&uv.y), "parameter {uv:?}");
}
assert!(mesh.positions().iter().all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()));
}
#[test]
fn a_trim_crossing_a_closed_nurbs_seam_is_a_small_cylinder_patch() {
let (radius, angle, bottom, top) = (10.0_f64, 0.1_f64, 2.0, 5.0);
let w = std::f64::consts::FRAC_1_SQRT_2;
let circle = [(1., 0., 1.), (1., 1., w), (0., 1., 1.), (-1., 1., w), (-1., 0., 1.), (-1., -1., w), (0., -1., 1.), (1., -1., w), (1., 0., 1.)];
let ctrl = circle
.iter()
.map(|&(x, y, w)| vec![mt::Vector4::new(radius * x * w, radius * y * w, 0., w), mt::Vector4::new(radius * x * w, radius * y * w, 10. * w, w)])
.collect();
let surface = mt::NurbsSurface::new(mt::BSplineSurface::new(
(mt::KnotVec::from(vec![0., 0., 0., 1., 1., 2., 2., 3., 3., 4., 4., 4.]), mt::KnotVec::bezier_knot(1)),
ctrl,
));
let at = |theta: f64, z| mt::Point3::new(radius * theta.cos(), radius * theta.sin(), z);
let arc = |z, reverse| {
let w = angle.cos();
let mut ctrl = vec![at(-angle, z).to_homogeneous(), mt::Vector4::new(radius, 0., z * w, w), at(angle, z).to_homogeneous()];
if reverse {
ctrl.reverse();
}
mt::NurbsCurve::new(mt::BSplineCurve::new(mt::KnotVec::bezier_knot(2), ctrl)).into()
};
let curves = vec![
arc(bottom, false),
mt::Line(at(angle, bottom), at(angle, top)).into(),
arc(top, true),
mt::Line(at(-angle, top), at(-angle, bottom)).into(),
];
let mesh = mesh(mt::Surface::NurbsSurface(surface), curves);
for p in mesh.positions() {
assert!(p.x >= radius * angle.cos() - 1e-6 && p.x <= radius + 1e-6, "{p:?}");
assert!(p.y.abs() <= radius * angle.sin() + 1e-6 && (bottom - 1e-6..=top + 1e-6).contains(&p.z), "{p:?}");
}
let area: f64 = mesh
.faces()
.triangle_iter()
.map(|t| {
let [a, b, c] = [0, 1, 2].map(|i| mesh.positions()[t[i].pos]);
(b - a).cross(c - a).magnitude() * 0.5
})
.sum();
assert!((area - radius * 2.0 * angle * (top - bottom)).abs() < 0.01, "area {area}");
}
#[test]
fn an_affine_planes_unit_chart_does_not_clip_a_trim() {
let p = mt::Point3::new;
let points = [p(10., 20., 0.), p(13., 20., 0.), p(13., 22., 0.), p(10., 22., 0.)];
let curves = (0..4).map(|i| mt::Line(points[i], points[(i + 1) % 4]).into()).collect();
let mesh = mesh(mt::Plane::new(p(0., 0., 0.), p(1., 0., 0.), p(0., 1., 0.)).into(), curves);
for p in mesh.positions() {
assert!((10. - 1e-6..=13. + 1e-6).contains(&p.x) && (20. - 1e-6..=22. + 1e-6).contains(&p.y), "{p:?}");
}
}
+216
View File
@@ -0,0 +1,216 @@
//! Loose ends of a sketch's profile curves, for the sketch to show where a profile can fail.
//!
//! Profiles are read from the geometry: ends that lie together close a loop whether or not a
//! constraint ties them (see `profiles`). Two kinds of end are worth showing:
//! - an **open end** touches no other profile curve, so no profile runs through it;
//! - an **untied end** touches another profile curve only by position: the loop closes now, but
//! nothing keeps it closed when a dimension changes or a point is dragged.
//!
//! An end is tied when it is shared with another curve's end, joined to one by coincident
//! constraints, or held on a curve (point on curve, midpoint).
use solidcraft_geom::{Seg2, Vec2};
use crate::model::{ConstraintKind, CurveKind, Sketch};
use crate::union_find::{find, union};
/// How close an end has to be to another curve to touch it: the distance at which profile
/// finding joins and splits curves.
pub const END_TOL: f64 = 1e-5;
/// Ends looked at, at most: a larger sketch reports nothing rather than taking long.
const MAX_ENDS: usize = 100_000;
/// What an untied end touches.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EndTouch {
/// Another curve's end (a point index).
Point(usize),
/// Another curve, between its ends (a curve index).
Curve(usize),
}
/// An end that touches another curve without being tied to it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct UntiedEnd {
/// The end's point index.
pub point: usize,
/// The curve it is an end of.
pub curve: usize,
pub touches: EndTouch,
}
/// The open and untied ends of a sketch's drawn profile curves (point indices).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EndReport {
pub open: Vec<usize>,
pub untied: Vec<UntiedEnd>,
}
impl EndReport {
pub fn is_empty(&self) -> bool {
self.open.is_empty() && self.untied.is_empty()
}
}
/// Curves profiles are made of (the ones `find_profiles` reads).
fn profile_curve(sk: &Sketch, ci: usize) -> bool {
sk.curves.get(ci).is_some_and(|c| !(c.construction || c.centerline || c.infinite))
}
/// A box around a segment (conservative for curved ones).
fn seg_box(s: &Seg2) -> (Vec2, Vec2) {
let hull = |pts: &[Vec2]| {
pts.iter().fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), p| {
(Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)))
})
};
match *s {
Seg2::Line { a, b } => hull(&[a, b]),
Seg2::Arc { center, radius, .. } => {
let r = Vec2::new(radius.abs(), radius.abs());
(center - r, center + r)
}
Seg2::Cubic { p0, p1, p2, p3 } => hull(&[p0, p1, p2, p3]),
Seg2::Conic { a, apex, b, .. } => hull(&[a, apex, b]),
Seg2::Bezier { n, p } => hull(p.get(..=usize::from(n)).unwrap_or(p.as_slice())),
}
}
/// The open and untied ends of the drawn profile curves (lines, arcs and open free-form curves
/// that are not construction, projected or text). Positions are the sketch's current ones.
pub fn loose_ends(sk: &Sketch) -> EndReport {
let n = sk.points.len();
// Coincident constraints join points into one place.
let mut group: Vec<usize> = (0..n).collect();
for c in &sk.constraints {
if let ConstraintKind::Coincident { p, q } = c.kind {
union(&mut group, p, q);
}
}
// Per place: how many profile curve ends meet there, and whether a point there is held
// on a profile curve.
let mut ends_at = vec![0usize; n];
let mut held = vec![false; n];
let mut ends: Vec<(usize, usize)> = Vec::new();
for (ci, c) in sk.curves.iter().enumerate() {
if !profile_curve(sk, ci) {
continue;
}
let Some((a, b)) = c.kind.ends() else { continue };
for p in [a, b] {
let g = find(&mut group, p);
if let Some(k) = ends_at.get_mut(g) {
*k += 1;
}
}
// Lines shorter than profile finding keeps are left out of profiles too.
let degenerate = matches!(c.kind, CurveKind::Line { .. }) && sk.point(a).zip(sk.point(b)).is_some_and(|(pa, pb)| pa.dist(pb) <= END_TOL);
if c.link.is_none() && !degenerate {
ends.push((a, ci));
if b != a {
ends.push((b, ci));
}
}
}
for c in &sk.constraints {
let on = match c.kind {
ConstraintKind::PointOnCurve { p, c } | ConstraintKind::Midpoint { p, l: c } if profile_curve(sk, c) => Some(p),
_ => None,
};
if let Some(p) = on {
let g = find(&mut group, p);
if let Some(h) = held.get_mut(g) {
*h = true;
}
}
}
if ends.len() > MAX_ENDS {
return EndReport::default();
}
// Ends tied to nothing: the ones to look at by position.
let mut free: Vec<(usize, usize, Vec2)> = Vec::new();
for &(p, ci) in &ends {
let g = find(&mut group, p);
if ends_at.get(g).copied().unwrap_or(0) >= 2 || held.get(g).copied().unwrap_or(false) {
continue;
}
if let Some(at) = sk.point(p).filter(|v| v.x.is_finite() && v.y.is_finite()) {
free.push((p, ci, at));
}
}
if free.is_empty() {
return EndReport::default();
}
free.sort_by(|a, b| a.2.x.total_cmp(&b.2.x));
// What each free end touches: another curve's end first, else another curve's span.
let mut touch: Vec<Option<EndTouch>> = vec![None; free.len()];
for (ci, c) in sk.curves.iter().enumerate() {
if !profile_curve(sk, ci) {
continue;
}
let curve_ends = c.kind.ends();
for seg in sk.segs(ci) {
let (lo, hi) = seg_box(&seg);
let first = free.partition_point(|f| f.2.x < lo.x - END_TOL);
for (k, &(p, own, at)) in free.iter().enumerate().skip(first) {
if at.x > hi.x + END_TOL {
break;
}
if at.y < lo.y - END_TOL || at.y > hi.y + END_TOL || matches!(touch.get(k), Some(Some(EndTouch::Point(_)))) {
continue;
}
// Another end of a curve (its own other end too: a loop drawn as one curve).
let at_end = curve_ends.and_then(|(a, b)| [a, b].into_iter().find(|&q| q != p && sk.point(q).is_some_and(|v| v.dist(at) <= END_TOL)));
let found = match at_end {
Some(q) => Some(EndTouch::Point(q)),
None if ci != own && seg.point_at(seg.closest_param(at)).dist(at) <= END_TOL => Some(EndTouch::Curve(ci)),
None => None,
};
if let (Some(found), Some(slot)) = (found, touch.get_mut(k))
&& (slot.is_none() || matches!(found, EndTouch::Point(_)))
{
*slot = Some(found);
}
}
}
}
// Free ends that touch each other are one place, however many meet there: each is tied to
// the place's first end, which is tied to what it touches outside the place (if anything).
let index: std::collections::HashMap<usize, usize> = free.iter().enumerate().map(|(k, f)| (f.0, k)).collect();
let mut place: Vec<usize> = (0..free.len()).collect();
for (k, t) in touch.iter().enumerate() {
if let Some(EndTouch::Point(q)) = t
&& let Some(&j) = index.get(q)
{
union(&mut place, k, j);
}
}
let mut first: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
for (k, f) in free.iter().enumerate() {
let root = find(&mut place, k);
let e = first.entry(root).or_insert(f.0);
*e = (*e).min(f.0);
}
let mut report = EndReport::default();
for (k, &(p, curve, _)) in free.iter().enumerate() {
let Some(t) = touch.get(k).copied().flatten() else {
report.open.push(p);
continue;
};
let root = find(&mut place, k);
let anchor = first.get(&root).copied().unwrap_or(p);
let touches = match t {
_ if anchor != p => EndTouch::Point(anchor),
// The place's first end: tied to what it touches outside the place.
EndTouch::Point(q) if index.contains_key(&q) => continue,
t => t,
};
report.untied.push(UntiedEnd { point: p, curve, touches });
}
report.open.sort_unstable();
report.open.dedup();
report.untied.sort_by_key(|u| u.point);
report.untied.dedup_by_key(|u| u.point);
report
}
+126
View File
@@ -0,0 +1,126 @@
//! Open and untied ends of profile curves.
use solidcraft_geom::Vec2;
use crate::{ConstraintKind, EndTouch, Sketch, find_profiles, loose_ends};
fn v(x: f64, y: f64) -> Vec2 {
Vec2::new(x, y)
}
/// A closed polyline whose corners are each one shared point.
fn shared_square(sk: &mut Sketch) -> Vec<usize> {
let pts: Vec<usize> = [v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
(0..4).map(|i| sk.add_line_pts(pts[i], pts[(i + 1) % 4], None).unwrap()).collect()
}
/// A square drawn as four separate lines: every corner is two points at one place.
fn loose_square(sk: &mut Sketch) -> Vec<usize> {
let c = [v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)];
(0..4).map(|i| sk.add_line(c[i], c[(i + 1) % 4], None, None, None).unwrap()).collect()
}
#[test]
fn shared_corners_are_tied() {
let mut sk = Sketch::new();
shared_square(&mut sk);
assert!(loose_ends(&sk).is_empty(), "{:?}", loose_ends(&sk));
}
#[test]
fn corners_together_by_position_only_are_untied() {
let mut sk = Sketch::new();
loose_square(&mut sk);
// The square closes (profiles are read from positions)...
assert_eq!(find_profiles(&sk).len(), 1);
// ...but nothing keeps it closed: four corners, each reported once.
let r = loose_ends(&sk);
assert!(r.open.is_empty(), "{r:?}");
assert_eq!(r.untied.len(), 4, "{r:?}");
assert!(r.untied.iter().all(|u| matches!(u.touches, EndTouch::Point(_))), "{r:?}");
}
#[test]
fn coincident_constraints_tie_corners() {
let mut sk = Sketch::new();
let lines = loose_square(&mut sk);
for i in 0..4 {
let end = sk.curves[lines[i]].kind.ends().unwrap().1;
let start = sk.curves[lines[(i + 1) % 4]].kind.ends().unwrap().0;
sk.add_constraint(ConstraintKind::Coincident { p: end, q: start }, None).unwrap();
}
assert!(loose_ends(&sk).is_empty(), "{:?}", loose_ends(&sk));
}
#[test]
fn a_gap_is_two_open_ends() {
let mut sk = Sketch::new();
let a = sk.add_point(v(0.0, 0.0), None).unwrap();
let b = sk.add_point(v(10.0, 0.0), None).unwrap();
let c = sk.add_point(v(10.0, 10.0), None).unwrap();
sk.add_line_pts(a, b, None).unwrap();
sk.add_line_pts(b, c, None).unwrap();
// The closing line stops 0.01 mm short of the start.
let d = sk.add_point(v(0.0, 0.01), None).unwrap();
sk.add_line_pts(c, d, None).unwrap();
assert!(find_profiles(&sk).is_empty());
let r = loose_ends(&sk);
assert_eq!(r.open, vec![a, d], "{r:?}");
assert!(r.untied.is_empty(), "{r:?}");
}
#[test]
fn an_end_on_another_curve_is_untied_until_held() {
let mut sk = Sketch::new();
shared_square(&mut sk);
// A divider from the bottom side's middle to the top side's middle, by position only.
let l = sk.add_line(v(5.0, 0.0), v(5.0, 10.0), None, None, None).unwrap();
assert_eq!(find_profiles(&sk).len(), 2);
let r = loose_ends(&sk);
assert_eq!(r.untied.len(), 2, "{r:?}");
assert!(r.untied.iter().all(|u| u.curve == l && matches!(u.touches, EndTouch::Curve(_))), "{r:?}");
// Held on the sides, they are tied.
for u in r.untied {
if let EndTouch::Curve(c) = u.touches {
sk.add_constraint(ConstraintKind::PointOnCurve { p: u.point, c }, None).unwrap();
}
}
assert!(loose_ends(&sk).is_empty(), "{:?}", loose_ends(&sk));
}
#[test]
fn construction_lines_and_circles_have_no_loose_ends() {
let mut sk = Sketch::new();
let l = sk.add_line(v(0.0, 0.0), v(10.0, 0.0), None, None, None).unwrap();
sk.curves[l].construction = true;
sk.add_circle(v(20.0, 0.0), 3.0, None, None).unwrap();
assert!(loose_ends(&sk).is_empty(), "{:?}", loose_ends(&sk));
}
#[test]
fn a_lone_line_has_two_open_ends() {
let mut sk = Sketch::new();
let l = sk.add_line(v(0.0, 0.0), v(10.0, 0.0), None, None, None).unwrap();
let (a, b) = sk.curves[l].kind.ends().unwrap();
assert_eq!(loose_ends(&sk).open, vec![a.min(b), a.max(b)]);
}
#[test]
fn three_ends_meeting_by_position_are_one_place_tied_at_once() {
let mut sk = Sketch::new();
for to in [v(0.0, 0.0), v(20.0, 0.0), v(10.0, 10.0)] {
sk.add_line(to, v(10.0, 0.0), None, None, None).unwrap();
}
let r = loose_ends(&sk);
// Two of the three need tying, both to the third.
assert_eq!(r.untied.len(), 2, "{r:?}");
let anchor = match r.untied[0].touches {
EndTouch::Point(q) => q,
EndTouch::Curve(_) => usize::MAX,
};
assert!(r.untied.iter().all(|u| u.touches == EndTouch::Point(anchor) && u.point != anchor), "{r:?}");
for u in &r.untied {
sk.add_constraint(ConstraintKind::Coincident { p: u.point, q: anchor }, None).unwrap();
}
assert!(loose_ends(&sk).untied.is_empty(), "{:?}", loose_ends(&sk));
}
+61
View File
@@ -0,0 +1,61 @@
//! A solve with geometry held moves the rest as little as it can.
use solidcraft_geom::Vec2;
use crate::{Analysis, ConstraintKind, Hold, Sketch, solve_with};
fn v(x: f64, y: f64) -> Vec2 {
Vec2::new(x, y)
}
/// A rectangle with a divider whose ends are held on the bottom and top sides (point on curve).
/// Dragging the top-left corner tilts the top side: the divider's top end slides along with it,
/// the far corner stays.
#[test]
fn a_point_held_on_a_line_slides_instead_of_swinging_the_line() {
let mut sk = Sketch::new();
let c: Vec<usize> = [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
let sides: Vec<usize> = (0..4).map(|i| sk.add_line_pts(c[i], c[(i + 1) % 4], None).unwrap()).collect();
let bottom = sk.add_point(v(20.0, 0.0), None).unwrap();
let top = sk.add_point(v(20.0, 30.0), None).unwrap();
sk.add_line_pts(bottom, top, None).unwrap();
sk.add_constraint(ConstraintKind::PointOnCurve { p: bottom, c: sides[0] }, None).unwrap();
sk.add_constraint(ConstraintKind::PointOnCurve { p: top, c: sides[2] }, None).unwrap();
let before: Vec<Vec2> = sk.points.iter().map(|p| p.pos).collect();
sk.points[c[3]].pos = v(1.0, 31.0);
let rep = solve_with(&mut sk, &Hold { points: vec![c[3]], radii: vec![] }, Some(&before), Analysis::StatusOnly, None);
assert!(rep.ok(), "{rep:?}");
let moved = |i: usize| sk.points[i].pos.dist(before[i]);
// The top side through (1, 31) and (40, 30) passes x = 20 at y ≈ 30.51: the divider's end
// needs about half a millimetre. The far corner needs nothing.
assert!(moved(top) < 0.6, "divider end moved {}", moved(top));
assert!(moved(c[2]) < 0.3, "far corner moved {}", moved(c[2]));
assert!(moved(c[1]) < 1e-6 && moved(bottom) < 1e-6, "the bottom has no reason to move");
}
/// The same rectangle, dragging the top-right corner down: the top side turns about its other
/// corner, which stays, and the divider's end slides down with it.
#[test]
fn the_far_corner_stays_when_the_side_a_point_is_held_on_turns() {
let mut sk = Sketch::new();
let c: Vec<usize> = [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
let sides: Vec<usize> = (0..4).map(|i| sk.add_line_pts(c[i], c[(i + 1) % 4], None).unwrap()).collect();
let bottom = sk.add_point(v(20.0, 0.0), None).unwrap();
let top = sk.add_point(v(20.0, 30.0), None).unwrap();
sk.add_line_pts(bottom, top, None).unwrap();
sk.add_constraint(ConstraintKind::PointOnCurve { p: bottom, c: sides[0] }, None).unwrap();
sk.add_constraint(ConstraintKind::PointOnCurve { p: top, c: sides[2] }, None).unwrap();
// Down in steps, as a live drag goes: each solve starts from the last.
for k in 1..=10 {
let before: Vec<Vec2> = sk.points.iter().map(|p| p.pos).collect();
sk.points[c[2]].pos = v(40.0, 30.0 - k as f64);
let rep = solve_with(&mut sk, &Hold { points: vec![c[2]], radii: vec![] }, Some(&before), Analysis::StatusOnly, None);
assert!(rep.ok(), "{rep:?}");
}
let corner = sk.points[c[3]].pos;
assert!(corner.dist(v(0.0, 30.0)) < 0.1, "the far corner went to {corner:?}");
// On the turned side, near where it was.
let (end, a, b) = (sk.points[top].pos, corner, sk.points[c[2]].pos);
let off_side = ((b - a).cross(end - a) / a.dist(b)).abs();
assert!(off_side < 1e-6 && (end.x - 20.0).abs() < 1.0, "the divider's end is at {end:?}");
}
+6
View File
@@ -20,6 +20,7 @@ mod construct;
mod curves;
mod decompose;
mod dims;
mod ends;
mod fonts;
mod freeform_offset;
pub mod linalg;
@@ -44,6 +45,7 @@ pub use curves::{
};
pub use decompose::Budget;
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, dim_layout_reaching, dim_reach, encode_text};
pub use ends::{END_TOL, EndReport, EndTouch, UntiedEnd, loose_ends};
pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, find_style_in, font_data, font_data_styled, installed_families, set_font_file_check};
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, TextExprs, sketch_axis_geom};
@@ -74,8 +76,12 @@ mod construct_tests;
#[cfg(test)]
mod continuation_tests;
#[cfg(test)]
mod ends_tests;
#[cfg(test)]
mod font_tests;
#[cfg(test)]
mod least_motion_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod thin_tests;
+1 -1
View File
@@ -123,7 +123,7 @@ pub(super) fn dependent_blocks(
/// dimension (it constrains nothing) or an unknown index.
pub fn constraint_residual(sk: &Sketch, ci: usize) -> Option<f64> {
let c = sk.constraints.get(ci).filter(|c| !c.driven)?;
let fixed = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
let fixed = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
let on_curve = points_on_curves(sk);
let b = match super::touch_point(sk, &c.kind, &on_curve) {
Some(p) => Block::TangentAt(ci, &c.kind, p),
+33 -11
View File
@@ -36,6 +36,9 @@ const MAX_COMPONENT_VARS: usize = 4_000;
const TOL: f64 = 1e-9;
/// A solution is accepted when every residual is below this (mm).
const ACCEPT: f64 = 1e-6;
/// How much less it costs a solve to move a point held on a curve than any other point: such a
/// point is placed relative to the curve, so it follows the curve rather than the curve it.
const SLIDE_WEIGHT: f64 = 0.01;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -107,6 +110,9 @@ struct Layout {
n: usize,
/// The point coordinates' variables: `0..points`.
points: usize,
/// Per variable: a coordinate of a point held on a curve (point on curve, midpoint), which
/// a solve slides along it before moving the curve (see [`SLIDE_WEIGHT`]).
slide: Vec<bool>,
}
impl Layout {
@@ -145,7 +151,7 @@ pub(crate) fn held_points(sk: &Sketch, hold: &Hold) -> Vec<bool> {
fn layout(sk: &Sketch, hold: &Hold) -> Layout {
let mut n = 0;
let pvar = held_points(sk, hold)
let pvar: Vec<Option<usize>> = held_points(sk, hold)
.iter()
.map(|f| {
if *f {
@@ -169,7 +175,19 @@ fn layout(sk: &Sketch, hold: &Hold) -> Layout {
_ => None,
})
.collect();
Layout { pvar, rvar, n, points }
let mut slide = vec![false; n];
for c in &sk.constraints {
if let ConstraintKind::PointOnCurve { p, .. } | ConstraintKind::Midpoint { p, .. } = c.kind
&& let Some(Some(v)) = pvar.get(p)
{
for k in [*v, *v + 1] {
if let Some(s) = slide.get_mut(k) {
*s = true;
}
}
}
}
Layout { pvar, rvar, n, points, slide }
}
/// Read-only view of the sketch with variable values substituted.
@@ -618,7 +636,7 @@ fn measure(s: &State, k: &ConstraintKind) -> Option<f64> {
/// What a dimension measures on the sketch as it stands (mm, radians for angles); `None` for a
/// constraint that is not a dimension.
pub fn measure_dimension(sk: &Sketch, k: &ConstraintKind) -> Option<f64> {
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
measure(&State { sk, lay: &lay, x: &[] }, k).filter(|v| v.is_finite())
}
@@ -954,13 +972,17 @@ fn lm_capped(
}
}
let mut improved = false;
// Damping floor: variables the residuals barely see must not take huge steps.
let floor = (0..n).map(|i| a.get(i, i)).fold(0.0_f64, f64::max) * 1e-4;
// Damping by distance moved, not by JᵀJ's diagonal (Marquardt): of the steps that fit the
// residuals equally well it takes the shortest, so what the constraints leave free moves
// as little as it can. (Scaled by the diagonal, a variable the residuals see weakly is
// cheap to move: a point held on a line made the line's far end swing round instead of
// the point sliding along it.) A point held on a curve is cheaper still, so it slides
// rather than the curve's ends moving.
let weight: Vec<f64> = vars.iter().map(|&v| if lay.slide.get(v).copied().unwrap_or(false) { SLIDE_WEIGHT } else { 1.0 }).collect();
for _ in 0..30 {
let mut m = a.clone();
for i in 0..n {
let d = a.get(i, i);
m.add(i, i, lambda * (d.max(floor) + 1e-9) + 1e-12);
for (i, w) in weight.iter().enumerate() {
m.add(i, i, lambda * w + 1e-12);
}
let rhs: Vec<f64> = g.iter().map(|v| -v).collect();
let Some(step) = lin_solve(m, rhs) else {
@@ -2201,7 +2223,7 @@ fn solve_inner(
None => true,
};
// Driven dimensions measure the solved geometry.
let fixed_lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
let fixed_lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
let measured: Vec<Option<f64>> =
sk.constraints.iter().map(|c| if c.driven { measure(&State { sk, lay: &fixed_lay, x: &[] }, &c.kind) } else { None }).collect();
for (c, m) in sk.constraints.iter_mut().zip(measured) {
@@ -2265,7 +2287,7 @@ mod continuation_unit_tests {
let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap();
let b = sk.add_point(Vec2::new(40.0, 0.0), None).unwrap();
let c = sk.add_point(Vec2::new(20.0, 1e-6), None).unwrap();
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
let turn = Orient::Turn([a, b, c]);
let state =
|sk: &Sketch| (orientation(&State { sk, lay: &lay, x: &[] }, turn), reference_orientation(&State { sk, lay: &lay, x: &[] }, turn));
@@ -2285,7 +2307,7 @@ mod continuation_unit_tests {
let q = sk.add_point(Vec2::new(10.0, 5.0), None).unwrap();
let r = sk.add_point(Vec2::new(0.0, 10.0), None).unwrap();
let l = sk.add_line(Vec2::new(-5.0, -5.0), Vec2::new(5.0, -5.0), None, None, None).unwrap();
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
let sign = |sk: &Sketch, o: Orient| orientation(&State { sk, lay: &lay, x: &[] }, o);
let checks = [Orient::SideX(p, q, 10.0), Orient::SideY(p, q, 5.0), Orient::OfLine(q, l, 10.0), Orient::Turn([p, q, r])];
let before: Vec<_> = checks.iter().map(|o| sign(&sk, *o)).collect();
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "solidcraft-3dx-client"
description = "SolidCraft: a client of the 3Dconnexion driver on macOS (3DxWare), loaded at run time"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/macos.rs can allow
# it: loading a framework and calling through its function pointers has no safe API. Keep the rest
# in sync with [workspace.lints].
[lints.rust]
unsafe_code = "deny"
[lints.clippy]
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
neg_cmp_op_on_partial_ord = "allow"
+11
View File
@@ -0,0 +1,11 @@
//! Prints what the 3Dconnexion driver reports: `cargo run -p solidcraft-3dx-client --example dump`.
//! The driver sends nothing to a program that is not in an app bundle, so only `DeviceAdded` shows here;
//! use SolidCraft's own `.app` to see the cap move.
fn main() {
if let Err(error) = solidcraft_3dx_client::start("SolidCraft dump", |event| println!("{event:?}")) {
eprintln!("{error}");
return;
}
std::thread::sleep(std::time::Duration::from_secs(60));
}
+81
View File
@@ -0,0 +1,81 @@
//! A client of the 3Dconnexion driver (3DxWare) on macOS.
//!
//! The driver's helper app opens the device exclusively while it runs, so reading the HID device
//! directly fails then. The driver offers its own client API instead, in
//! `3DconnexionClient.framework`: an app registers with the driver and gets the cap's six axes
//! (after the user's 3DxWare settings) and the buttons as messages on a run loop.
//!
//! The framework is loaded with `dlopen` when [`start`] is called, so nothing links against it and
//! the app runs without the driver installed ([`Error::NotInstalled`]). The framework is never
//! bundled or redistributed; its declarations here are our own. On other platforms [`start`]
//! returns [`Error::Unsupported`].
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::fmt;
#[cfg(target_os = "macos")]
mod macos;
/// What the driver reports.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Event {
/// A 3D mouse was plugged in or the driver connected to one.
DeviceAdded,
/// The 3D mouse was unplugged or the driver went away.
DeviceRemoved,
/// The cap moved: push left/right, forward/back, lift/press, then tilt forward/back,
/// tilt left/right and twist, each about -350..350 as the HID device reports them.
Axes { translation: [i16; 3], rotation: [i16; 3] },
/// The buttons that are down, one bit each.
Buttons(u32),
}
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
/// Not macOS.
Unsupported,
/// The driver's framework is not installed (or not loadable).
NotInstalled,
/// The framework lacks a function this client needs (another driver version).
MissingSymbol(&'static str),
/// The driver refused the handlers (its `OSErr`).
Handlers(i16),
/// The driver refused the client registration.
Register,
/// [`start`] was called before.
AlreadyStarted,
/// The client's thread could not be started.
Thread(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Unsupported => write!(f, "the 3Dconnexion driver client only exists on macOS"),
Error::NotInstalled => write!(f, "the 3Dconnexion driver is not installed"),
Error::MissingSymbol(name) => write!(f, "the 3Dconnexion driver lacks {name}"),
Error::Handlers(code) => write!(f, "the 3Dconnexion driver refused the handlers (error {code})"),
Error::Register => write!(f, "the 3Dconnexion driver refused the client"),
Error::AlreadyStarted => write!(f, "the 3Dconnexion client is already started"),
Error::Thread(reason) => write!(f, "the 3Dconnexion client thread did not start: {reason}"),
}
}
}
impl std::error::Error for Error {}
/// Connects to the driver and calls `sink` (on the client's own thread) for everything it reports.
/// `name` is how the driver lists this app. Only one client per process; the connection lasts
/// until the process ends.
pub fn start(name: &str, sink: impl Fn(Event) + Send + Sync + 'static) -> Result<(), Error> {
#[cfg(target_os = "macos")]
{
macos::start(name, Box::new(sink))
}
#[cfg(not(target_os = "macos"))]
{
let _ = (name, sink);
Err(Error::Unsupported)
}
}
+220
View File
@@ -0,0 +1,220 @@
//! The driver's client API, called through function pointers from `dlopen`.
//!
//! No safe API exists for this, hence the `unsafe_code` allowance for the whole module. The
//! unsafe parts are small: the C declarations, the lookups and the two callbacks, which read the
//! message the driver hands them.
#![allow(unsafe_code)]
use std::ffi::{CString, c_char, c_int, c_void};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU16, Ordering};
use crate::{Error, Event};
const FRAMEWORK: &str = "/Library/Frameworks/3DconnexionClient.framework/3DconnexionClient";
const RTLD_NOW: c_int = 2;
/// `'****'`: any application signature.
const WILDCARD_SIGNATURE: u32 = 0x2A2A_2A2A;
/// The driver gives this client the cap, instead of sharing it with other clients.
const MODE_TAKE_OVER: u16 = 1;
/// Every axis and every button.
const MASK_ALL: u32 = 0x3FFF;
/// `'3dSR'`: the message that carries a device state.
const MSG_DEVICE_STATE: u32 = 0x3364_5352;
const CMD_BUTTONS: u16 = 2;
const CMD_AXES: u16 = 3;
/// The device state the driver passes, read field by field from these byte offsets (the struct is
/// packed to two bytes) so that no reference to driver memory is ever formed.
const STATE_BYTES: usize = 48;
const OFFSET_CLIENT: usize = 2;
const OFFSET_COMMAND: usize = 4;
const OFFSET_AXES: usize = 30;
const OFFSET_BUTTONS: usize = 44;
unsafe extern "C" {
fn dlopen(path: *const c_char, mode: c_int) -> *mut c_void;
fn dlsym(handle: *mut c_void, name: *const c_char) -> *mut c_void;
}
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
static kCFRunLoopDefaultMode: *const c_void;
fn CFRunLoopRunInMode(mode: *const c_void, seconds: f64, return_after_source_handled: u8) -> i32;
}
type MessageHandler = extern "C" fn(u32, u32, *mut c_void);
type DeviceHandler = extern "C" fn(u32);
type InstallHandlers = unsafe extern "C" fn(Option<MessageHandler>, Option<DeviceHandler>, Option<DeviceHandler>) -> i16;
type RegisterClient = unsafe extern "C" fn(u32, *const u8, u16, u32) -> u16;
type SetMask = unsafe extern "C" fn(u16, u32);
type Sink = Box<dyn Fn(Event) + Send + Sync>;
static SINK: OnceLock<Sink> = OnceLock::new();
/// The id the driver gave this client: it broadcasts every message to every client.
static CLIENT: AtomicU16 = AtomicU16::new(0);
fn symbol(handle: *mut c_void, name: &'static str) -> Result<*mut c_void, Error> {
let c_name = CString::new(name).map_err(|_| Error::MissingSymbol(name))?;
// SAFETY: `handle` came from a successful dlopen and `c_name` is a NUL-terminated string.
let found = unsafe { dlsym(handle, c_name.as_ptr()) };
if found.is_null() { Err(Error::MissingSymbol(name)) } else { Ok(found) }
}
fn emit(event: Event) {
if let Some(sink) = SINK.get() {
// A panic must not unwind into the driver's C code.
let _ = catch_unwind(AssertUnwindSafe(|| sink(event)));
}
}
extern "C" fn on_added(_connection: u32) {
emit(Event::DeviceAdded);
}
extern "C" fn on_removed(_connection: u32) {
emit(Event::DeviceRemoved);
}
extern "C" fn on_message(_connection: u32, kind: u32, argument: *mut c_void) {
if kind != MSG_DEVICE_STATE || argument.is_null() {
return;
}
// SAFETY: for this message type the driver passes a device state, which is `STATE_BYTES` long
// and valid for the duration of the call.
let state = unsafe { std::slice::from_raw_parts(argument.cast::<u8>().cast_const(), STATE_BYTES) };
if let Some(event) = decode(state, CLIENT.load(Ordering::Relaxed)) {
emit(event);
}
}
fn u16_at(state: &[u8], offset: usize) -> Option<u16> {
Some(u16::from_ne_bytes(state.get(offset..offset + 2)?.try_into().ok()?))
}
fn i16_at(state: &[u8], offset: usize) -> Option<i16> {
Some(i16::from_ne_bytes(state.get(offset..offset + 2)?.try_into().ok()?))
}
/// The event a device state stands for, if it is meant for `client`.
fn decode(state: &[u8], client: u16) -> Option<Event> {
if u16_at(state, OFFSET_CLIENT)? != client {
return None;
}
match u16_at(state, OFFSET_COMMAND)? {
CMD_AXES => {
let axis = |i: usize| i16_at(state, OFFSET_AXES + 2 * i);
Some(Event::Axes { translation: [axis(0)?, axis(1)?, axis(2)?], rotation: [axis(3)?, axis(4)?, axis(5)?] })
}
CMD_BUTTONS => Some(Event::Buttons(u32::from_ne_bytes(state.get(OFFSET_BUTTONS..OFFSET_BUTTONS + 4)?.try_into().ok()?))),
_ => None,
}
}
/// Loads the framework, installs the handlers and registers the client. Runs on the client thread,
/// whose run loop the driver then sends its messages to.
fn connect(name: &str) -> Result<(), Error> {
let path = CString::new(FRAMEWORK).map_err(|_| Error::NotInstalled)?;
// SAFETY: `path` is a NUL-terminated string.
let handle = unsafe { dlopen(path.as_ptr(), RTLD_NOW) };
if handle.is_null() {
return Err(Error::NotInstalled);
}
let install = symbol(handle, "InstallConnexionHandlers")?;
let register = symbol(handle, "RegisterConnexionClient")?;
// SAFETY: the framework declares these functions with exactly these signatures.
let (install, register) =
unsafe { (std::mem::transmute::<*mut c_void, InstallHandlers>(install), std::mem::transmute::<*mut c_void, RegisterClient>(register)) };
// SAFETY: the handlers are `extern "C"` functions that live for the whole process.
let code = unsafe { install(Some(on_message), Some(on_added), Some(on_removed)) };
if code != 0 {
return Err(Error::Handlers(code));
}
// The name is a Pascal string: a length byte, then the characters.
let mut pascal = vec![0_u8];
pascal.extend(name.bytes().take(255));
pascal[0] = u8::try_from(pascal.len() - 1).unwrap_or(0);
pascal.push(0);
// SAFETY: `pascal` outlives the call, which copies it.
let id = unsafe { register(WILDCARD_SIGNATURE, pascal.as_ptr(), MODE_TAKE_OVER, MASK_ALL) };
if id == 0 {
return Err(Error::Register);
}
CLIENT.store(id, Ordering::Relaxed);
// Newer drivers want the masks again once the client has an id. Both are optional.
for (name, mask) in [("SetConnexionClientMask", MASK_ALL), ("SetConnexionClientButtonMask", u32::MAX)] {
if let Ok(set) = symbol(handle, name) {
// SAFETY: the framework declares these functions with this signature.
let set = unsafe { std::mem::transmute::<*mut c_void, SetMask>(set) };
// SAFETY: `id` is the id the driver gave this client.
unsafe { set(id, mask) };
}
}
Ok(())
}
pub fn start(name: &str, sink: Sink) -> Result<(), Error> {
SINK.set(sink).map_err(|_| Error::AlreadyStarted)?;
let name = name.to_owned();
let (ready, started) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("3dx-client".into())
.spawn(move || {
let result = connect(&name);
let connected = result.is_ok();
let _ = ready.send(result);
if !connected {
return;
}
// The driver's messages arrive on this thread's run loop. Waking up now and then keeps
// the loop alive in case it had nothing to wait for yet.
loop {
// SAFETY: the mode is CoreFoundation's own constant.
unsafe { CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0, 0) };
}
})
.map_err(|error| Error::Thread(error.to_string()))?;
started.recv().map_err(|error| Error::Thread(error.to_string()))?
}
#[cfg(test)]
mod tests {
use super::*;
/// A device state as the driver sent it for a light tilt forward (captured from a SpaceMouse
/// Wireless): command 3 (axes) with the fourth axis, tilt forward/back, at -3.
fn tilt_state(client: u16) -> [u8; STATE_BYTES] {
let mut state = [0_u8; STATE_BYTES];
state[OFFSET_CLIENT..OFFSET_CLIENT + 2].copy_from_slice(&client.to_ne_bytes());
state[OFFSET_COMMAND..OFFSET_COMMAND + 2].copy_from_slice(&CMD_AXES.to_ne_bytes());
state[OFFSET_AXES + 6..OFFSET_AXES + 8].copy_from_slice(&(-3_i16).to_ne_bytes());
state
}
#[test]
fn an_axes_state_for_this_client_becomes_axes() {
assert_eq!(decode(&tilt_state(4106), 4106), Some(Event::Axes { translation: [0; 3], rotation: [-3, 0, 0] }));
}
#[test]
fn a_state_for_another_client_is_ignored() {
assert_eq!(decode(&tilt_state(4106), 4107), None);
}
#[test]
fn a_button_state_carries_the_mask() {
let mut state = tilt_state(7);
state[OFFSET_COMMAND..OFFSET_COMMAND + 2].copy_from_slice(&CMD_BUTTONS.to_ne_bytes());
state[OFFSET_BUTTONS..OFFSET_BUTTONS + 4].copy_from_slice(&5_u32.to_ne_bytes());
assert_eq!(decode(&state, 7), Some(Event::Buttons(5)));
}
#[test]
fn a_short_message_is_ignored() {
assert_eq!(decode(&[0; 10], 0), None);
}
}
+127
View File
@@ -0,0 +1,127 @@
//! The Boundary Surface dialog (#763): a loop of sketch curves becomes the loop patch, edges and
//! curves across become a network with its continuity and operation, curves that meet end to end
//! are one chain, and Edit Feature brings a network back as it was.
use crate::dialogs::Kind;
use crate::dialogs_surface::Surf;
use crate::scenario::Harness;
use serde_json::{Map, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::{FeatureKind, Operation, kernel::Continuity};
use solidcraft_engine::geom::Vec3;
fn ok(h: &mut Harness) {
h.frames(2);
h.step(&json!({"widget":"OK"})).unwrap();
// An edit is applied off the UI thread: give it the frames it takes.
for _ in 0..200 {
if h.app.dialog.is_none() {
break;
}
h.frames(1);
}
assert!(h.app.dialog.is_none(), "{:?} {:?} {:?}", h.app.preview.error, h.app.dialog.as_ref().map(|d| (&d.error, &d.inputs)), h.app.session.log);
}
#[test]
fn a_loop_of_sketch_curves_is_the_loop_patch() {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY","name":"Loop"})).unwrap();
for (id, a, b) in [("s1", [0, 0], [10, 0]), ("s2", [10, 0], [10, 10]), ("s3", [10, 10], [0, 10]), ("s4", [0, 10], [0, 0])] {
h.app.run("sketch.line", json!({"points":[a, b],"ids":[id]})).unwrap();
}
h.app.run("sketch.finish", json!({})).unwrap();
h.step(&json!({"call":"ui.start","params":{"command":"surface.boundary"}})).unwrap();
h.frames(3);
assert!(h.widget("Cross curves").is_some() && h.widget("Continuity").is_some() && h.widget("Operation").is_some());
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = ["s1", "s2", "s3", "s4"].iter().map(|id| Sel::SketchCurve { id: (*id).into() }).collect();
ok(&mut h);
let f = h.app.session.doc.features.last().unwrap();
let FeatureKind::SurfaceBoundary { curves, orders, .. } = &f.kind else { panic!("{:?}", f.kind) };
assert_eq!(curves, &["s1", "s2", "s3", "s4"]);
assert!(orders.iter().all(|o| *o == Continuity::G1), "{orders:?}");
assert_eq!(h.app.session.world_state().bodies.len(), 1);
}
/// A quarter cylinder sheet (its top edge open at y = 10, z = 0) and a 3D line above it.
fn cylinder_and_line(h: &mut Harness) -> String {
h.app.run("sketch.create", json!({"plane":"YZ","name":"Arc"})).unwrap();
let c = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
let r = h.app.run("sketch.arc.three_point", json!({"start":[0,-10],"end":[10,0],"through":[c,-c]})).unwrap();
let arc = r["created"]["curves"][0].as_str().unwrap().to_string();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("surface.extrude", json!({"sketch":"Arc","curves":[arc],"distance":10})).unwrap();
h.app.run("sketch.create", json!({"plane":"XY","name":"Top"})).unwrap();
h.app.run("sketch.options", json!({"sketch_3d": true})).unwrap();
let r = h.app.run("sketch.line3d", json!({"points":[[0,3,12],[10,3,12]]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
r["curves"][0].as_str().unwrap().to_string()
}
#[test]
fn edges_and_curves_make_a_network_and_come_back_in_an_edit() {
let mut h = Harness::new();
let top = cylinder_and_line(&mut h);
let rim = crate::dialogs::edge_sel(&h.app.session, Vec3::new(5.0, 10.0, 0.0)).unwrap();
let Sel::Edge { body, .. } = &rim else { panic!("{rim:?}") };
let body = body.clone();
h.step(&json!({"call":"ui.start","params":{"command":"surface.boundary"}})).unwrap();
h.frames(3);
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = vec![rim.clone(), Sel::SketchCurve { id: top.clone() }];
let Kind::Surface(Surf::Boundary { continuity, join }) = &mut d.kind else { panic!("{:?}", d.kind) };
*continuity = 2;
*join = true;
ok(&mut h);
let name = h.app.session.doc.features.last().unwrap().name.clone();
let network = |h: &Harness| match &h.app.session.doc.find_feature(&name).unwrap().kind {
FeatureKind::SurfaceNetwork { u, v, operation } => (u.clone(), v.clone(), *operation),
k => panic!("{k:?}"),
};
let (u, v, operation) = network(&h);
assert_eq!((u.len(), v.len(), operation), (2, 0, Operation::Join));
assert_eq!(u[0].body.as_ref().map(|b| b.to_string()), Some(body.clone()));
assert_eq!(u[0].continuity, Continuity::G2);
assert_eq!(u[1].curves, vec![top.clone()]);
// Joined into the sheet: one body, two faces.
let st = h.app.session.world_state();
assert_eq!(st.bodies.len(), 1);
assert_eq!(st.body(&body).map(|b| b.body.face_count()), Some(2));
drop(st);
h.step(&json!({"call":"ui.editFeature","params":{"feature":name}})).unwrap();
h.frames(8);
let d = h.app.dialog.as_ref().unwrap();
assert!(matches!(d.kind, Kind::Surface(Surf::Boundary { continuity: 2, join: true })), "{:?}", d.kind);
assert_eq!(d.inputs[0].items.len(), 2, "{:?}", d.inputs[0].items);
ok(&mut h);
let (u2, v2, op2) = network(&h);
assert_eq!((u2.len(), v2.len(), op2), (2, 0, Operation::Join));
assert_eq!((u2[0].continuity, &u2[1].curves), (Continuity::G2, &vec![top]));
}
/// Picks that meet end to end in one sketch are one curve of the network; a pick elsewhere is
/// another.
#[test]
fn curves_that_meet_are_one_chain() {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY","name":"Net"})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],[5,0]],"ids":["a1"]})).unwrap();
h.app.run("sketch.line", json!({"points":[[5,0],[10,0]],"ids":["a2"]})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,8],[10,8]],"ids":["b"]})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],[0,8]],"ids":["c"]})).unwrap();
h.app.run("sketch.line", json!({"points":[[10,0],[10,8]],"ids":["d"]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
let sketch = h.app.session.doc.find_feature("Net").unwrap().id;
let k = Surf::Boundary { continuity: 0, join: false };
let (_, mut inputs) = crate::dialogs_surface::start(&h.app, "surface.boundary").unwrap();
inputs[0].items = ["a2", "b", "a1"].iter().map(|id| Sel::SketchCurve { id: (*id).into() }).collect();
inputs[1].items = ["c", "d"].iter().map(|id| Sel::SketchCurve { id: (*id).into() }).collect();
let cmds = crate::dialogs_surface::commands(&h.app, &k, &inputs, &Map::new()).unwrap();
let (id, p) = &cmds[0];
assert_eq!(id, "surface.boundary");
assert_eq!(p["u"], json!([{"sketch": sketch, "curves": ["a2", "a1"]}, {"sketch": sketch, "curves": ["b"]}]), "{p}");
assert_eq!(p["v"], json!([{"sketch": sketch, "curves": ["c"]}, {"sketch": sketch, "curves": ["d"]}]), "{p}");
// And it builds: the chain a1–a2 is one boundary of the patch.
h.app.run("surface.boundary", p.clone()).unwrap();
assert_eq!(h.app.session.world_state().bodies.len(), 1);
}
+45 -5
View File
@@ -151,6 +151,8 @@ pub enum Kind {
thickness: String,
depth: String,
flip: bool,
/// Grow along the sketch normal rather than across the plane.
normal: bool,
},
/// Raise or sink profiles of a sketch on a face.
Emboss {
@@ -491,7 +493,7 @@ impl Dialog {
Dialog::new(Kind::Mirror, vec![SelInput::new("Objects", BODIES, true), SelInput::new("Mirror Plane", PLANES | PLANAR_FACES, false)])
}
"solid.rib" | "solid.web" => Dialog::new(
Kind::Rib { web: id == "solid.web", thickness: "2 mm".into(), depth: String::new(), flip: false },
Kind::Rib { web: id == "solid.web", thickness: "2 mm".into(), depth: String::new(), flip: false, normal: false },
vec![SelInput::new("Curves", CURVES, true)],
),
"solid.emboss" => Dialog::new(
@@ -2701,7 +2703,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.end_row();
}
Kind::Split | Kind::ReplaceFace | Kind::Remove | Kind::Midplane => {}
Kind::Rib { thickness, depth, flip, .. } => {
Kind::Rib { thickness, depth, flip, normal, .. } => {
row_label(ui, "Direction");
let mut i = usize::from(*normal);
combo(ui, "rib_direction", &["In plane", "Normal to sketch"], &mut i);
*normal = i == 1;
ui.end_row();
row_label(ui, "Thickness");
enter |= field(ui, thickness);
ui.end_row();
@@ -3753,7 +3760,36 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
if !sketch.is_null() {
out.push(("solid.extrude".to_string(), with(json!({"sketch": sketch, "profiles": idx}))));
}
// Each planar body face extrudes on its own.
// Coplanar faces of one body share one sketch and Boolean. Mixed selections
// retain their original order and explicit face-body scope.
let state = s.world_state();
let mut group: Option<(String, solidcraft_engine::geom::Plane, Vec<Value>)> = None;
let mut coplanar = true;
for pick in sels(d, 0) {
let Sel::Face { body, index, point } = pick else { continue };
let Some(source) = state.body(body) else { return Err("selected body disappeared".into()) };
let Some(plane) = source.body.planar_face(*index) else {
coplanar = false;
break;
};
let tolerance = (source.body.size() * 1e-9).max(1e-8);
if let Some((name, base, points)) = &mut group {
if name != body || base.normal().dot(plane.normal()) <= 1.0 - 1e-9 || base.height(plane.origin).abs() > tolerance {
coplanar = false;
break;
}
points.push(pt(*point));
} else {
group = Some((body.clone(), plane, vec![pt(*point)]));
}
}
if coplanar
&& let Some((body, _, points)) = group
&& points.len() > 1
{
out.push(("solid.extrude".to_string(), with(json!({"faces": points, "face_body": body}))));
return Ok(out);
}
for pick in sels(d, 0) {
if let Sel::Face { body, point, .. } = pick {
out.push(("solid.extrude".to_string(), with(json!({"face": pt(*point), "face_body": body}))));
@@ -3977,11 +4013,14 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
};
("construct.plane.at_angle", json!({"base": base, "axis": axis, "angle": angle}))
}
Kind::Rib { web, thickness, depth, flip } => {
Kind::Rib { web, thickness, depth, flip, normal } => {
need(0, "open sketch curves")?;
let curves = curve_ids(d, 0);
let sketch = curves_sketch(app, &curves, None).ok_or("the curves must be in one sketch")?;
let mut p = json!({"sketch": sketch, "curves": curves, "thickness": thickness, "flip": flip});
if *normal {
p["direction"] = json!("normal");
}
if !depth.trim().is_empty() {
p["depth"] = json!(depth);
}
@@ -5204,13 +5243,14 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::Rib { curves, thickness, depth, flip, web, .. } => {
FeatureKind::Rib { curves, thickness, depth, flip, web, normal, .. } => {
let mut d = start(if *web { "solid.web" } else { "solid.rib" })?;
d.kind = Kind::Rib {
web: *web,
thickness: thickness.to_string(),
depth: depth.as_deref().map(str::to_string).unwrap_or_default(),
flip: *flip,
normal: *normal,
};
if let Some(inp) = d.inputs.first_mut() {
inp.items = curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
+199 -1
View File
@@ -1,5 +1,6 @@
//! Dialogs for the surface tools and face splitting (#19): Surface Extrude, Revolve, Sweep and
//! Loft, Offset and Ruled Surface, Patch, Stitch, Thicken, Extend, Trim, Untrim and Split Face. Each has a live preview and Edit Feature.
//! Loft, Offset and Ruled Surface, Boundary Surface, Patch, Stitch, Thicken, Extend, Trim, Untrim
//! and Split Face. Each has a live preview and Edit Feature.
use serde_json::{Map, Value, json};
use solidcraft_engine::Sel;
@@ -27,6 +28,13 @@ pub enum Surf {
},
Sweep(crate::dialogs_sweep::SweepOpts),
Loft,
/// `continuity`: an index into [`PATCH_CONTINUITY`] (for a loop of sketch curves, how much of
/// the sketch's plane each side repeats; for picked edges, how the surface meets the faces
/// beside them); `join`: sew the surface into the body of its edges.
Boundary {
continuity: usize,
join: bool,
},
/// `continuity`: an index into [`PATCH_CONTINUITY`] (how a patch on edges meets the faces
/// beside its loop).
Patch {
@@ -74,6 +82,7 @@ impl Surf {
Surf::Revolve { .. } => "SURFACE REVOLVE",
Surf::Sweep(_) => "SURFACE SWEEP",
Surf::Loft => "SURFACE LOFT",
Surf::Boundary { .. } => "BOUNDARY SURFACE",
Surf::Patch { .. } => "PATCH",
Surf::Unstitch => "UNSTITCH",
Surf::ReverseNormal => "REVERSE NORMAL",
@@ -104,6 +113,9 @@ impl Surf {
/// How a patch on edges meets the faces beside its loop (labels, and the `continuity` parameter).
const PATCH_CONTINUITY: [(&str, &str); 3] = [("Position (G0)", "g0"), ("Tangent (G1)", "g1"), ("Curvature (G2)", "g2")];
/// What a boundary surface becomes: a body of its own, or part of the body of its edges.
const BOUNDARY_OPS: [&str; 2] = ["New Body", "Join"];
/// Which holes Untrim takes away: every one, the picked ones, or none (the outer boundary only).
const UNTRIM_HOLES: [&str; 3] = ["All", "Picked", "None"];
@@ -128,6 +140,7 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
]
}
Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)],
Surf::Boundary { .. } => vec![SelInput::new("Curves", CURVES | EDGES, true), SelInput::new("Cross curves", CURVES | EDGES, true)],
Surf::Patch { .. } => vec![SelInput::new("Boundary", PROFILES | EDGES, true), SelInput::new("Pass through", VERTICES | FACES, true)],
Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)],
Surf::ReverseNormal => vec![SelInput::new("Surface, or faces to turn over", BODIES | FACES, true)],
@@ -155,6 +168,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
"surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None },
"surface.sweep" => Surf::Sweep(crate::dialogs_sweep::SweepOpts::default()),
"surface.loft" => Surf::Loft,
"surface.boundary" => Surf::Boundary { continuity: 1, join: false },
"surface.patch" => Surf::Patch { continuity: 0 },
"surface.unstitch" => Surf::Unstitch,
"surface.reverse_normal" => Surf::ReverseNormal,
@@ -264,6 +278,16 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
combo(ui, "pa_cont", &PATCH_CONTINUITY.map(|(label, _)| label), continuity);
ui.end_row();
}
Surf::Boundary { continuity, join } => {
row_label(ui, "Continuity");
combo(ui, "bo_cont", &PATCH_CONTINUITY.map(|(label, _)| label), continuity);
ui.end_row();
row_label(ui, "Operation");
let mut op = usize::from(*join);
combo(ui, "bo_op", &BOUNDARY_OPS, &mut op);
*join = op == 1;
ui.end_row();
}
Surf::Sweep(opts) => enter |= crate::dialogs_sweep::rows(ui, opts, "ss"),
Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Loft => {}
}
@@ -342,6 +366,98 @@ fn tool(app: &SolveApp, sel: Option<&Sel>) -> Result<(&'static str, Value), Stri
}
}
/// One curve of a boundary surface as the dialog picks it: sketch curves of one sketch, or edges
/// of one body, that meet end to end.
#[derive(Clone, Debug, PartialEq)]
enum Chain {
Sketch { sketch: u64, ids: Vec<String> },
Edges { body: String, points: Vec<Vec3> },
}
/// The picks grouped into curves: picks of one sketch (or one body) whose ends meet run on into
/// one chain, in the order they were first picked; anything else is a curve of its own.
fn chains(app: &SolveApp, picks: &[Sel]) -> Result<Vec<Chain>, String> {
let st = app.session.world_state();
// Each pick: what it belongs to (a sketch or a body), its ends, and how it joins a chain.
let mut parts: Vec<(Chain, [Vec3; 2])> = Vec::with_capacity(picks.len());
for x in picks {
match x {
Sel::SketchCurve { id } => {
let sketch = crate::dialogs::curves_sketch(app, std::slice::from_ref(id), None).ok_or("a picked curve is no longer in a sketch")?;
let ss = st.sketch(sketch).ok_or("a picked curve's sketch has not been solved")?;
let ends = if let Some(ci) = ss.sketch.curve_index(id) {
let segs = ss.sketch.segs(ci);
match (segs.first(), segs.last()) {
(Some(a), Some(b)) => [ss.plane.to_world(a.start()), ss.plane.to_world(b.end())],
_ => return Err(format!("curve `{id}` has no geometry")),
}
} else {
let w = ss.sketch.wire_index(id).and_then(|wi| ss.sketch.wires.get(wi)).ok_or("a picked curve is no longer in a sketch")?;
match (w.pts.first(), w.pts.last()) {
(Some(a), Some(b)) => [*a, *b],
_ => return Err(format!("curve `{id}` has no geometry")),
}
};
parts.push((Chain::Sketch { sketch, ids: vec![id.clone()] }, ends));
}
Sel::Edge { body, index, point } => {
let b = st.body(body).ok_or("a picked edge's body is gone")?;
let m = b.mesh();
let e = m.edges.get(*index).ok_or("a picked edge is gone")?;
let (Some(a), Some(z)) = (e.first(), e.last()) else { return Err("a picked edge has no points".into()) };
parts.push((Chain::Edges { body: body.clone(), points: vec![*point] }, [*a, *z]));
}
_ => return Err("pick sketch curves or edges".into()),
}
}
let same_source = |a: &Chain, b: &Chain| match (a, b) {
(Chain::Sketch { sketch: x, .. }, Chain::Sketch { sketch: y, .. }) => x == y,
(Chain::Edges { body: x, .. }, Chain::Edges { body: y, .. }) => x == y,
_ => false,
};
let meet = |a: &[Vec3; 2], b: &[Vec3; 2]| a.iter().any(|p| b.iter().any(|q| p.dist(*q) <= 1e-6 * (1.0 + p.len())));
// Union the picks that meet, keeping the first pick of each chain in front.
let mut group: Vec<usize> = (0..parts.len()).collect();
fn root(group: &[usize], mut i: usize) -> usize {
while let Some(&g) = group.get(i) {
if g == i {
break;
}
i = g;
}
i
}
for i in 0..parts.len() {
for j in 0..i {
let (Some((ci, ei)), Some((cj, ej))) = (parts.get(i), parts.get(j)) else { continue };
if same_source(ci, cj) && meet(ei, ej) {
let (ri, rj) = (root(&group, i), root(&group, j));
if let Some(g) = group.get_mut(ri.max(rj)) {
*g = ri.min(rj);
}
}
}
}
let mut out: Vec<(usize, Chain)> = Vec::new();
for (i, (part, _)) in parts.into_iter().enumerate() {
let r = root(&group, i);
match out.iter_mut().find(|(k, _)| *k == r) {
Some((_, Chain::Sketch { ids, .. })) => {
if let Chain::Sketch { ids: more, .. } = part {
ids.extend(more);
}
}
Some((_, Chain::Edges { points, .. })) => {
if let Chain::Edges { points: more, .. } = part {
points.extend(more);
}
}
None => out.push((r, part)),
}
}
Ok(out.into_iter().map(|(_, c)| c).collect())
}
/// The command a surface dialog makes.
pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
let need = |i: usize, what: &str| if items(inputs, i).is_empty() { Err(format!("select {what} first")) } else { Ok(()) };
@@ -407,6 +523,48 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
.collect::<Result<_, String>>()?;
("surface.loft", json!({ "sections": sections }))
}
Surf::Boundary { continuity, join } => {
need(0, "the curves (sketch curves or edges)")?;
let word = PATCH_CONTINUITY.get(*continuity).map_or("g0", |(_, w)| *w);
let across = items(inputs, 1);
let picks = items(inputs, 0);
let sketch_ids: Vec<String> =
picks.iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
// Two to four sketch curves of one sketch and nothing across: the loop patch.
let looped = across.is_empty() && !*join && sketch_ids.len() == picks.len() && (2..=4).contains(&picks.len());
match looped.then(|| crate::dialogs::curves_sketch(app, &sketch_ids, None)).flatten() {
Some(sk) => {
let mut p = json!({"sketch": sk, "curves": sketch_ids});
// Orders that differ from side to side (kept from the feature) stand as they are.
if !extra.contains_key("continuity") {
p["continuity"] = json!(word);
}
("surface.boundary", p)
}
None => {
let curve = |c: &Chain| match c {
Chain::Sketch { sketch, ids } => json!({"sketch": sketch, "curves": ids}),
Chain::Edges { body, points } => {
let mut v = json!({"body": body, "edges": points.iter().map(|p| pt(*p)).collect::<Vec<_>>()});
if *continuity > 0 {
v["continuity"] = json!(word);
}
v
}
};
let u: Vec<Value> = chains(app, picks)?.iter().map(curve).collect();
let v: Vec<Value> = chains(app, across)?.iter().map(curve).collect();
let mut p = json!({"u": u});
if !v.is_empty() {
p["v"] = json!(v);
}
if *join {
p["operation"] = json!("join");
}
("surface.boundary", p)
}
}
}
Surf::Patch { continuity } => {
need(0, "a boundary (sketch profiles or a loop of edges)")?;
let profiles: Vec<(u64, usize)> =
@@ -629,6 +787,46 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
],
)
}
FeatureKind::SurfaceBoundary { curves, orders, crown, samples, .. } => {
let first = orders.first().copied().unwrap_or(solidcraft_engine::doc::kernel::Continuity::G1);
let index = PATCH_CONTINUITY.iter().position(|(_, w)| *w == first.as_str());
// What the dialog does not show is kept as it was: a crown, the stations, and orders
// that differ from side to side or that the list does not offer (G3).
let mut extra = Map::new();
if *crown != 0.0 {
extra.insert("crown".into(), json!(crown));
}
if *samples != solidcraft_engine::doc::BOUNDARY_SAMPLES {
extra.insert("samples".into(), json!(samples));
}
if index.is_none() || orders.iter().any(|o| *o != first) {
extra.insert("continuity".into(), json!(orders.iter().map(|o| o.as_str()).collect::<Vec<_>>()));
}
let k = Surf::Boundary { continuity: index.unwrap_or(1), join: false };
let mut ins = inputs(&k);
if let Some(inp) = ins.first_mut() {
inp.items = curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
}
return Some((Kind::Surface(k), ins, extra));
}
FeatureKind::SurfaceNetwork { u, v, operation } => {
// A network with a point for a side has nothing to pick it with here.
if u.iter().chain(v).any(|c| c.point.is_some()) {
return None;
}
let sels = |list: &[solidcraft_engine::doc::NetworkCurve]| -> Vec<Sel> {
list.iter()
.flat_map(|c| {
let mut out: Vec<Sel> = c.curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
out.extend(c.edges.iter().filter_map(edge));
out
})
.collect()
};
let g = u.iter().chain(v).find(|c| c.body.is_some()).map_or(solidcraft_engine::doc::kernel::Continuity::G0, |c| c.continuity);
let index = PATCH_CONTINUITY.iter().position(|(_, w)| *w == g.as_str()).unwrap_or(0);
(Surf::Boundary { continuity: index, join: *operation == solidcraft_engine::doc::Operation::Join }, vec![sels(u), sels(v)])
}
FeatureKind::SurfaceLoft { sections, .. } => {
(Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch.0, sec.profiles.as_ref(), &sec.curves)).collect()])
}
+64 -5
View File
@@ -49,6 +49,7 @@ struct Edit {
/// Type-first creation: the value took the keyboard as soon as the picks determined the
/// dimension, and its text follows the pointer until a click pins it or Enter places it.
live: Option<Live>,
selection: Option<egui::text::CCursorRange>,
}
#[derive(Clone, Copy, Debug)]
@@ -132,7 +133,9 @@ pub fn begin_dimension(app: &mut SolveApp, entities: Vec<String>, at: Vec2) -> R
app.set_status("The sketch already determines that dimension, 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), live: None }));
EDIT.with(|edit| {
*edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft), live: None, selection: None })
});
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
Ok(())
}
@@ -147,7 +150,7 @@ pub fn begin_live(app: &mut SolveApp, entities: Vec<String>, pointer: Option<Pos
return Ok(false);
}
let live = Some(Live { origin: pointer, moved: false, typed: false });
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft), live }));
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft), live, selection: None }));
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
Ok(true)
}
@@ -351,7 +354,9 @@ pub fn double_click(app: &mut SolveApp, pos: Pos2) -> bool {
return true;
};
let expr = app.session.doc.param(&param).map(|p| p.expr.clone()).unwrap_or_default();
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param, expr, at: Some(d.text.center()), fresh: FRESH, wait: 0, draft: None, live: None }));
EDIT.with(|e| {
*e.borrow_mut() = Some(Edit { param, expr, at: Some(d.text.center()), fresh: FRESH, wait: 0, draft: None, live: None, selection: None })
});
true
}
@@ -577,7 +582,52 @@ fn pos2_mid_top(r: Rect) -> Pos2 {
/// its value is selected, so typing replaces it).
pub fn edit_param(app: &SolveApp, param: &str) {
let expr = app.session.doc.param(param).map(|p| p.expr.clone()).unwrap_or_default();
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param: param.to_string(), expr, at: None, fresh: FRESH, wait: 10, draft: None, live: None }));
EDIT.with(|e| {
*e.borrow_mut() = Some(Edit { param: param.to_string(), expr, at: None, fresh: FRESH, wait: 10, draft: None, live: None, selection: None })
});
}
/// Insert a reference at the expression's selection, using character offsets (not bytes).
fn insert_reference(ctx: &egui::Context, edit: &mut Edit, name: &str) {
let id = egui::Id::new("sc_dim_edit");
let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default();
let len = edit.expr.chars().count();
let (start, end) = edit
.selection
.or_else(|| state.cursor.char_range())
.map(|r| {
let a = r.primary.index.0.min(len);
let b = r.secondary.index.0.min(len);
(a.min(b), a.max(b))
})
.unwrap_or((len, len));
let before: String = edit.expr.chars().take(start).collect();
let after: String = edit.expr.chars().skip(end).collect();
edit.expr = format!("{before}{name}{after}");
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(start + name.chars().count()))));
edit.selection = state.cursor.char_range();
state.store(ctx, id);
ctx.memory_mut(|m| m.request_focus(id));
edit.fresh = FRESH - 1;
if let Some(live) = edit.live.as_mut() {
live.typed = true;
}
}
/// Consume dimension clicks while editing; only a double-click inserts a reference.
pub fn pick_reference(ctx: &egui::Context, pos: Pos2, double: bool) -> bool {
EDIT.with(|editor| {
let mut editor = editor.borrow_mut();
let Some(edit) = editor.as_mut() else { return false };
let Some(id) = hit(pos) else { return false };
let param = DRAWN.with(|d| d.borrow().iter().find(|d| d.id == id).and_then(|d| d.param.clone()));
if double && let Some(param) = param.filter(|p| p != &edit.param) {
insert_reference(ctx, edit, &param);
}
// Self references and driven dimensions cannot supply a reference.
edit.fresh = FRESH - 1;
true
})
}
fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
@@ -612,8 +662,16 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
crate::frame::area(egui::Id::new("sc_dim_edit_area")).fixed_pos(anchor).order(egui::Order::Foreground).show(ctx, |ui| {
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| {
ui.set_max_width(popup_width);
let r = ui.add(egui::TextEdit::singleline(&mut e.expr).id(id).desired_width(popup_width.min(90.0)));
ui.label(egui::RichText::new(format!("Dimension: {}", e.param)).strong())
.on_hover_text("Double-click another driving dimension to insert its name");
let r = ui.add(egui::TextEdit::singleline(&mut e.expr).id(id).desired_width(popup_width));
if r.has_focus() {
e.selection = egui::TextEdit::load_state(ctx, id).and_then(|s| s.cursor.char_range());
}
let completed = crate::params_dialog::complete(ui, &r, &mut e.expr);
if completed {
e.selection = egui::TextEdit::load_state(ctx, id).and_then(|s| s.cursor.char_range());
}
if let Some(live) = e.live.as_mut() {
live.typed |= r.changed() || completed;
}
@@ -625,6 +683,7 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
let n = e.expr.chars().count();
let mut s = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default();
s.cursor.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
e.selection = s.cursor.char_range();
s.store(ui.ctx(), id);
}
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
@@ -32,6 +32,57 @@ fn type_value(h: &mut Harness, text: &str) {
h.step(&json!({"text":text})).unwrap();
}
}
#[test]
fn dimension_editor_names_and_reference_picks_drive_new_and_existing_dimensions() {
let mut h = line([20., 0.]);
let source =
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":20,"text_at":[10,8]})).unwrap()["param"].as_str().unwrap().to_string();
h.app.run("sketch.line", json!({"points":[[0,-10],[12,-10]],"infer":false})).unwrap();
h.settle();
place(&mut h, &[[6., -10.]], [6., -16.]);
let target = h.app.session.doc.next_d_name();
assert!(h.widget(&format!("Dimension: {target}")).is_some(), "new dimension shows its actual parameter name");
// Let the preceding placement clicks expire before starting a distinct double-click.
h.frames(40);
h.step(&json!({"click":{"dimension":source},"double":true})).unwrap();
assert!(crate::dim_view::editing(), "reference click keeps the draft open");
h.step(&json!({"text":" / 2"})).unwrap();
h.step(&json!({"key":"Enter"})).unwrap();
assert!(!crate::dim_view::editing(), "{}", h.call("ui.inspect", json!({})));
assert_eq!(h.app.session.doc.param(&target).unwrap().expr, format!("{source} / 2"));
h.app.tool = None;
h.settle();
crate::dim_view::edit_param(&h.app, &target);
h.frames(5);
assert!(h.widget(&format!("Dimension: {target}")).is_some());
type_value(&mut h, "2 * ");
h.frames(40);
h.step(&json!({"click":{"dimension":source},"double":true})).unwrap();
h.step(&json!({"key":"Enter"})).unwrap();
assert_eq!(h.app.session.doc.param(&target).unwrap().expr, format!("2 * {source}"));
h.app.run("parameters.change", json!({"name":source,"expression":"15 mm"})).unwrap();
assert!((h.app.session.doc.eval(&target, solidcraft_engine::doc::expr::Kind::Length).unwrap() - 30.).abs() < 1e-6);
crate::dim_view::cancel();
}
#[test]
fn single_clicking_another_dimension_does_not_change_the_expression() {
let mut h = line([20., 0.]);
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":20,"text_at":[10,8]})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,-10],[12,-10]],"infer":false})).unwrap();
h.app.run("sketch.dimension", json!({"entities":["l2"],"value":12,"text_at":[6,-16]})).unwrap();
h.settle();
crate::dim_view::edit_param(&h.app, "d2");
h.frames(5);
type_value(&mut h, "8");
h.step(&json!({"click":{"dimension":"d1"}})).unwrap();
assert!(crate::dim_view::editing());
h.step(&json!({"key":"Enter"})).unwrap();
assert_eq!(h.app.session.doc.param("d2").unwrap().expr, "8");
crate::dim_view::cancel();
}
#[test]
fn dimension_parameters_use_typing_and_ctrl_space_completion() {
for (prefix, expected) in [("wid", "width"), ("", "width"), ("d", "d1")] {
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more