Compare commits

..

342 Commits

Author SHA1 Message Date
jyviko 017a2a6073 Known-failing tests: the fillet into an earlier round passes on solidcore
solidcraft-engine::tests::robustness::fillet_into_an_earlier_round passes
on solidcore (its line gave the C++ kernel's v1.1.0-alpha volume), so it
comes off the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 18:55:44 -05:00
jyviko d55fedd563 smlib export test: a rounded cube's corners checked on solidcore's spheres
a_rounded_cubes_ball_patches_go_round_with_their_spheres asserted the C++
kernel's ball-patch corner faces (flat kind 6, their definitions bit for
bit); solidcore builds the rounded cube's corners without ball patches (0
of 8; ball patches through the record are solidcore#2357). It becomes
a_rounded_cubes_corners_go_round_on_their_spheres, against the closed
form: one corner sphere of radius 2 about each corner pulled in by 2
along each axis, the record valid, and the volume
b^3 + 6 b^2 r + 3 pi r^2 b + 4/3 pi r^3 (b = a - 2r) as built and through
the record. A ball-patch corner on the same sphere passes it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 18:53:07 -05:00
jyviko d49a7eac09 Known-failing tests: the convex chamfer that would grow the part passes
solidcraft-kernel::blend_wrong_way_tests::a_chamfer_that_would_grow_the_part_on_a_convex_edge_is_refused
passes on solidcore (#11360's CI), so its line comes off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 18:51:37 -05:00
jyviko b2fd08e85a Profiler: no smlib category left in its tests and help
The profile test's externally timed span prints as "external call", as
it is now recorded; diagnostics.profile's help no longer lists the C
API's smlib category, and the STEP import's profile test wants the
solidcore category alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 18:51:26 -05:00
jyviko f70f55eff2 Kernel ladder: the golden recorded on solidcore
The golden was recorded on C++ SMLib v1.1.0-alpha (a18bbe5); the ladder
now runs on solidcore, the only kernel, and the kernel rig's ladder gate
with it. Re-recorded with `cargo xtask kernel-cases --ladder --record`
(170/170 pass; tiers re-sorted by time). Every case whose status or
counts change, before (C++) and after (solidcore):

- 07-fillets/012-variable-into-rounded-corner: fail -> pass; faces 9 -> 8,
  edges 20 -> 18, vertices 13 -> 12; volume 47770.9459 -> 47755.1186 mm3
  (the variable round no longer gains the End at the smooth vertex,
  smlib #124)
- 07-fillets/021-corner-three-sequential-r5-r3-r2-partial-chain: edges
  25 -> 24, vertices 16 -> 15; volume 47591.4820 -> 47589.7705 mm3 (no
  End cap edge)
- 06-threads/018-external-m10-both-hands: edges 597 -> 486, vertices
  398 -> 324 (faces 204, volume 1804.924 as before). solidcore gives the
  same counts on four runs, so its `unstable` mark (which named the C++
  union's dangling edge pointers) is removed.

Volume only (counts unchanged): 07-fillets/013-corner-setback
47325.9351 -> 47325.5317 mm3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 18:33:30 -05:00
jyviko c2bd7fa88e Merge main (ea772a0): #11351 squashed, which this branch already holds, and #11255 (Agent chat: a compatible Node for discovered adapters)
Main's tree is #11351's head (084695416, merged here earlier) plus #11255
and its regenerated catalogs, so main is recorded as merged and only
those changes are applied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 18:21:44 -05:00
jyviko f1ee045dee smlib: rustfmt after the C++ kernel's removal
Blank lines the deleted tests left, and lines that fit on one now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 18:09:38 -05:00
jyviko 19db2d7c02 Docs and comments: solidcore is the kernel
The profiler's kernel category is `solidcore` (the C API's `smlib` spans
went with the C++ kernel): the docs, the schema's description and the
tests that named it say so. The attribution row, deny.toml and the
workspace manifest describe solidcore as the kernel behind crates/smlib,
not a second backend; docs/design-container.md says a cache an earlier
build wrote on `smlib` is evaluated again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 18:08:03 -05:00
jyviko 7d7203f9ed Rig and CI: one kernel, solidcore
- CI: the "Native Linux tests (C++ SMLib)" job, Clippy's run with
  --features smlib and every SMLib fetch step go; the kernel rig and the
  fillet rig build the CLI without the feature, and the ladder gate runs
  without --kernel smlib.
- xtask kernel-cases has no --kernel any more (solidcore|smlib|both, and
  the side by side with the C++ kernel); passed, it is refused as --route
  is. A run keeps results.json and dumps/ in its output folder and writes
  docs/kernel-cases.md, the kernel's report.
- The per-area lists: each area's KNOWN_FAILING.solidcore becomes its
  KNOWN_FAILING, replacing the C++ kernel's list; the workflows, the
  README, the plan's self-test and xtask's help say so.
- tests/KNOWN_FAILING_TESTS.smlib, xtask's SMLIB_LIST and
  scripts/fetch-smlib.sh are deleted; PATHS-ALL drops the script.
- The CLI's kernel-cases names its kernel solidcore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 18:07:53 -05:00
jyviko aa87a149f8 smlib crate: the C++ SMLib kernel and its FFI removed
crates/smlib now wraps solidcore alone; the crate keeps its name (the
rename is a later move).
- Gone: the C++ FFI (src/ffi.rs, src/ffi/), build.rs and its `cc`
  build dependency, SMLIB_REV, SMLIB_PREBUILT, the `native` feature, the
  backend routing (src/route.rs: Backend, the choice, SOLIDCRAFT_KERNEL),
  init(), the C API's log and profiler lines, the flat-array handle
  (flat::Flat, Brep::export) and the history records' decoder. Brep is
  solidcore's body; Error loses NotBuilt and Version; set_dump_dir can no
  longer fail. blend_fit/Fit go: only the C++ kernel ever named a fit.
- The `smlib` feature is gone from the kernel, the engine and the apps
  (cli, web, desktop); the kernel's smlib-gated tests and the board fixture
  example run on solidcore, the side-by-side boolean example (a comparison
  with the C++ kernel) is deleted.
- Tests that compared with the C++ kernel are deleted (the parity and
  closed-form halves run on C++, cpp_round_trip, arrays_match_the_cpp_kernel,
  validate_refuses_what_the_cpp_kernel_refuses, the cross-kernel file test,
  the SSI corpus run on C++). Their solidcore-only assertions stay in the
  same files as checks against solidcore: the blend and boolean history
  changes, the offset drafts (one Unsupported, all building), the L sheet's
  thickened volume, the 871 rounds and the moved mitred top. The tests that
  were built only with the C++ kernel and already ran on solidcore (ops,
  sweeps, check, measure, import, export) are ungated.
- tests/KNOWN_FAILING_TESTS drops the six lines of the deleted comparisons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 18:01:31 -05:00
jyviko fa1b4a9b9d Kernel choice removed: solidcore is the only kernel
The C++ SMLib kernel goes (owner, 2026-11-02), so nothing chooses a kernel
any more:
- diagnostics.kernel and its schema types (KernelParams, KernelReply,
  KernelChoice) are gone, with their tests;
- the Geometry kernel preference and its section of the Design page are
  gone, with assets/locales/pending/geometry-kernel.json. A saved
  preference file that still names a kernel loads without error and the
  key is ignored, like any removed setting
  (saved_preferences_with_a_removed_setting_still_load);
- the model cache records "solidcore" as the kernel that computed it, so a
  cache an earlier build wrote on the C++ kernel is still a miss;
- the CLI test of a cache saved on one kernel and opened on the other is
  gone (the io test covers the miss).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 17:56:35 -05:00
jyviko ea772a0438 Merge develop into main: SolidCraft on the SMLib kernel (v1.1.0-alpha) (#11351)
* UI: rim and wall picking tests for a circle of arcs that is one wall

A circle drawn as two half arcs now extrudes to one full-turn wall with its
seam edge, and each rim is one closed edge (tangency-labs/smlib#67). The tests
keep their intent on that body: a rim clicked once is one item, measured and
highlighted whole and picked from either side; a wall picked either side of
its seam is the same surface, lit whole, and toggles off from the other side;
every face is its own patch.

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

* Fillets: a later round at a corner where earlier rounds meet blends them again with it

The owner's ruling of 2026-10-30: a later fillet on an edge at a vertex where earlier
rounds already meet in a mitre makes the corner rounding all of them at once makes, one
smooth patch, the mitre gone. A body made by constant-radius rounds keeps what they were
made on and which edges they rounded at which radius; a later round reaching such a corner
blends the earlier rounds' edges (from the earliest of those meeting there on) with the new
edges in one SMLib blend of the body before them. The result's history is put over the body
the later round was given: an earlier round's face is that face trimmed anew (its name
kept), the corner patch and the new rounds are the later fillet's. A picked mitre seam has
no round of its own: it is refused unless a new edge of its radius meets it.

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

* Tests: a later r3 round at an r2 mitre, through the Fillet feature, makes the all-at-once corner

Two box edges rounded r2 in one fillet, then the upright edge at their mitre r3 in a second
fillet (tangent chain on, so the seam comes with it): 10 faces, 7928.743041 mm³ as
fillet_radii of the box gives, the r2 rounds' names kept, the r3 round and the corner patch
the second fillet's.

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

* Kernel: a body's record of rounds goes with its history, and can be read back

The rounds that made a body are over the body its history is over: a new history (an
operation carrying a step's result back to its own operands) leaves the record out, so a
later round never reads one history against another body. Body::rounds gives the record and
Body::with_rounds_of takes one back, checked (edges of the body before them, a fillet's
radii, the body's history over that body), for a saved model to keep it.

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

* Model cache: a body made by rounds keeps them, so a later round blends them the same after opening

A design gave another body when opened from its model cache: the rounds a body was made of
lived only in memory, so a later round at their mitre was built sequentially (or refused, as
too large) after opening and blended with them when evaluated anew. The saved model now
records, per kernel body made by rounds, the body they were made on (a place in the bodies,
stored once though no state holds it) and the edges with their radii; reading it attaches
them back down each chain, and a thinned model keeps those bodies with the bodies made from
them. Model cache format 7.

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

* Tests: a corner patch of rounds 2/3/4 takes no more than the strips leave in its block

three_rounds_at_a_corner bounded the patch's extra cut at 3 mm³ beyond the strips, a figure
with no derivation; SMLib's all-at-once body at radii 2, 3, 4 takes 3.099 (V 7878.579574
against strips 7881.678). That body is the strips' outside the block its patch lies in: its
rounds are the strips' cylinders ending at a's section y = rc, b's x = 20 − rc and c's rails
where they meet a's and b's (the patch's four corners, now asserted), so the patch takes at
most what the strips leave in that block (11.86 at 2/2/3, 32.69 at 2/3/4). The lower bound is
that block; the upper one, at most the strips' volume, stays.

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

* Tests: the io crate's hand-built saved models list their rounds, as format 7 records do

Model cache format 7 (f3cf0c4f3) records a `rounds` list in every saved model, and reading one
without it is refused ("saved model: rounds"), as is a model without any of its other lists:
the states, the preview and the thinned model all came back empty. The io tests' saved_model
and timeline_model wrote format-6 records, so six tests (the round trip, the repeated part
stored once, and the four too-big-for-the-file cases) read nothing. Their bodies are none made
by rounds: the list is empty, and every expectation stays as it was.

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

* SMLib: pin eaa998a (tangency-labs/smlib#68, #69, #70)

Batch 4: drafts and loops, mitre corners, rim chamfers, rulings, tight box, stitch, point
classification, eased corners. Batch 5: a face's tight box divides its cells at the surface's
knots (thread boxes exact); a cylinder, cone, sphere or torus is extended along its own circles.
Batch 6: a revolution's extension closes its turn; a dropped point's foot is the converged one.
The C API is ABI 5 (smlib_blend_dropping and smlib_brep_classify_point are new; nothing existing
changed), so the binding expects 5.

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

* Tests: a strip's tangent and curvature run-offs are the distance long across the surface

SMLib measures an extension's distance across the surface (smlib_extend_sheet corrects
CreateExtendedSurface until the parameter line through the side's middle is that long), so each
run-off of the cylinder strip, swept 20 along z, adds 5 x 20: 10 pi/2 20 + 100 = 414.159 for
both. The parabola-length formula (418.18) was truck's.

Ruling d2 (2026-10-30).

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

* kernel: one smooth angle, and a round too small to change the part is refused

The blend's tangent angle is the shaded view's half degree (smooth_edges::ANGLE), one value
for smooth. A constant round lies r (sec(d/2) - 1) inside the crease it replaces (about
r d^2/8); below the edge's tolerance it changes nothing and is refused before SMLib (which
answered SM_ERR_INVALID_INPUT), naming the smallest radius that rounds,
tolerance / (sec(d/2) - 1). d is the least turn along the edge
(tangent_edge::least_angle_along, at tangent_all_along's stations).

Ruling d3 (2026-10-30). The block's tolerance is 2e-5 mm, not 0.1 um: at 1 deg the smallest
radius is 0.526 mm, so R2 and R20 reach SMLib, which still calls an edge within 2 deg smooth
(SmEdge::GetBlendEdgeuse); that test is ignored until SMLib uses 0.5 deg.

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

* Fillet rig: the knife-edge chamfers name SMLib's cause, a cusp's ball radius of 0

smlib_chamfer turns the distance into a ball radius d sqrt((1 - c)/(1 + c)), c the faces'
binormals' dot (capi chamfer_radius); at #851's cusp the binormals agree, the radius is 0 and
the call is SM_ERR_INVALID_INPUT before the fillet executive runs (gdb: chamfer_edges sees
radius 0; the dump's why is empty). SolidCraft hands every chamfer to SMLib, so the
ball-contact chord (the ball of radius d: 3.822 wide at d = 2, 159.2365 removed) is SMLib's to
build.

Ruling d4 (2026-10-30).

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

* kernel tests: a corner round wider than its faces overflows and takes the corner's other edges

R21 on a 20 mm cube's edge builds (6107.529370, closed form, to 2e-12). With R5 on the
corner's other edges the R21 takes the faces they lie on, so the body is R21's alone; SMLib
fails that blend (status 1001, fillet error 4, Unable to Trim Rail Curve) and the test is
ignored until it builds. It replaces the refusal test that expected TooLarge.

Ruling d8 (2026-10-30).

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

* Tests: a 1 degree crease rounds, and the knife edge's chamfers build, on SMLib smlib-batch-4-rulings

With SMLib's smooth limit at half a degree (422e6f1) the 1 degree crease rounds R2 and R20
(the test is no longer ignored), and with a cusp's chamfer the ball of radius d (d231547) the
The 0.5 one stays, for its own cause (the chamfer face left apart from the body). Two corner
cases the same SMLib (replay/1.0.1 and those commits) builds come off too.

Needs SMLIB_REV at that SMLib once it is pushed.

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

* kernel, doc: an overflowing round goes first; an edge it removes is gone, and the fillet says so

SMLib (smlib-batch-4-rulings 0e83cca, SMLIB_ABI_VERSION 5) rounds the edges whose rounds overflow
their faces first and then what is left of the others; smlib_blend_dropping says which picks'
edges the overflow removed whole. The smlib crate binds it (Brep::blend_dropping), the kernel
returns those picks (fillet_removing), and a fillet notes them as its warning: "the edge at
(0, 0, 10) was removed by the larger round, so it is not rounded" (a 50 x 50 x 20 block, R21 on
its bottom front edge with the front left upright: the block is R21's alone, in closed form).

Ruling d8: [21, 5, 5] on a 20 cube builds as R21 alone (6107.529370); the test is no longer
ignored. R25 with R2 at a corner (blend_corner) builds the same way, R25's alone in closed form,
where it was refused. The rig's 1080 notched block outline at R8 builds now and comes off the
known-failing list.

Needs SMLIB_REV at that SMLib once it is pushed.

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

* Fillet rig: the knife edge's 0.5 chamfer builds

SMLib's trim took a fillet meeting its face at more than 78 degrees for one with no side
(smlib-batch-4-rulings 971e567); the chord of the knife edge's 0.5 chamfer meets the flat face at
81 degrees. The case comes off the known-failing list.

Needs SMLIB_REV at that SMLib once it is pushed.

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

* Tests: a 15° tapered text builds, every letter grown, exact where its outline gives it

Owner ruling d6: the letter t of "tres long" was drafted inward by SMLib (its
loop's normal, summed from the cross products of its chords, pointed down;
fixed in SMLib). With the fix every letter grows, and the join builds at 3°
and 15° by the Round rule and by the default rule (tangent for spline
outlines), where before 15° was expected to be refused.

Exact where the outline derives it: a letter's section at height z is its
outline offset by e = z tan a, by the Round rule of area A + P e + C e² while
the offset trims nothing (Steiner; C half each loop's turning less
tan(t/2) - t/2 at each reflex corner), so the letter is
A h + P k h²/2 + C k² h³/3. Each extruded alone, the t at 3° and s, l and o
at 3° and 15° are checked to 1e-6 (SMLib measures them within 1.3e-9; its
mass properties integrate to 1e-8). The concave joins of r, n, g and e are
tighter than e already at 3° (their swallowtails trimmed: r 1.1e-3 under the
formula), the t's by 15° (9.9e-5 over), and on the block the letters grow
into each other, so the whole word is checked for what holds exactly: one
closed body with the block, and half-way up every point of each straight
letter's outline inside the solid (the t drafted inward failed that).

With SMLib's fix (smlib-batch-4-text-loops 82133b2).

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

* Kernel rig: a sloped basin's mitred rim rounds are checked tangent along their contact lines

1080-fillet-sloped-basin-1-mm-rim-r0-5-4-edges asked G1 at every blend seam. Its front wall
leans 19 over 20 and the others 10 over 20, so at each rim corner the two mitred rounds meet the
sloped corner edge at different heights (0.155625 and 0.276393 below the top): the deeper round
runs on past the corner edge and is cut by the plane of the other's wall, a crease by
construction, the gap below the shallower round filled on that plane. The check now measures
the rounds' contact seams, on the top and on each wall, where the rounds are tangent. The
volume is unchanged.

With SMLib smlib-batch-4-mitre (1c15364): the case passes (V 144530.427946 against
144530.427909, 15 faces, sound); before it, only the four corner creases failed the check.

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

* smlib: Brep::classify_point says exactly where a point lies against a body

Binds the fork's smlib_brep_classify_point (SmBrep::Point3DClassify at the body's own zone
tolerance): inside, outside, or on the boundary within that tolerance of a face, an edge or a
vertex. A box and a cylinder just off its curved wall in measure's tests.

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

* kernel: Body::classify_point decides inside, outside or on the boundary exactly

SMLib's point classification at the body's own tolerance, on the exact faces and edges, in
place of a mesh at a chord of 2e-3 of the body's size (about 0.4 mm on a 200 mm part). A point
within the tolerance of a face, edge or vertex is OnBoundary, never decided by a ray. A mesh
body's triangles are its exact shape: on them within the record's tolerance, else by ray
crossings. The kernel's own probes move to it; Body::contains goes once its last callers have.

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

* doc: a hole decides whether it exits at its drill point's shoulder exactly

Material past the depth needs a body with the point inside, air before it needs every body to
have it outside; a point within a body's tolerance of the exit face is neither.

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

* engine tests: probes ask for the exact point class

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

* ui, cli tests: probes ask for the exact point class

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

* kernel: Body::contains, the mesh test, is gone

Every caller asks Body::classify_point now.

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

* kernel tests: the rim chamfers running off their rims build, on SMLib 737c392

chamfer_wider_than_sloped_basin_rim_builds and a_rim_loop_chamfer_running_off_its_rim_builds
waited for SMLib to build the cliff overflow (CloseRemovalRegion refused it,
SM_FILERR_NO_ROOM). The pinned SMLib builds it (eddf0ede4, bba9914b6): both pass on aarch64
Linux and are no longer ignored.

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

* Fillet rig: the cases SMLib 737c392 builds come off the known-failing list

The gate on aarch64 Linux reports 16 fillet cases FIXED with the pinned SMLib: the sloped and
equal-slope basin rim chamfers and rounds past their rims (1080-*, 5835-*), the pocket rim
chamfer past the floor, and the three pocket rim rounds now mitred at the pocket's corners
(every-pocket-*). No other area's gate reports a case fixed.

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

* kernel: the re-blend's removed picks formatted as rustfmt lays them out

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

* kernel: blend_smlib laid out by the 2024 edition's rustfmt

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

* Skills: the tests skill's unit-test example stays under the 3600-character limit

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

* Fillet: the radius-too-large choice in plain nouns

"If too large: Stop and say so / Largest that fits / Narrow where needed"
becomes "Face limits: Exact radius / Largest radius / Narrowed radius", in
every language of the catalog, with the error and the tip that name them.

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

* kernel: a round that would cut through its wall into a hollow is refused

The owner's rule of 2026-10-30: a round running past a rim of its faces
into open air builds; one whose section would remove material through to
a hollow behind its edge (a slit into the hollow, a cut through the part)
is refused as too large, so the fillet names the largest radius that fits.

Decided exactly in the section of a straight convex edge between planar
faces: the round's centre is at depth r below both planes, and it opens
where its arc uncovers a boundary point strictly inside the wedge with no
material beyond it toward the centre (the body's exact point
classification). Looked at: straight edges parallel to the round's (a
hollow's corner), cylinders about parallel axes (a rounded corner), and a
picked concave parallel edge's own round, which fills the corner.

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

* Fillet rig: 1668's R7 past the inside corner is refused at 4 + 2 sqrt2

The round's centre (32 - r, 42 - r) leaves the hollow's corner (30, 40)
outside its circle, opening the wall, where sqrt2 (r - 2) = r: the case
expects the refusal naming 6.828. 2852's R30, refused naming 25.24, now
passes too: both come off the known-failing list.

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

* xtask: known-failing grades a nextest junit report against tests/KNOWN_FAILING_TESTS

`cargo xtask known-failing --junit PATH` reads nextest's junit (a file, or every .xml under a
directory, so all of a run's junit artifacts at once) and fails on a test that failed and the
list does not name, and on a listed test that passed (FIXED: take its line off, as the kernel
rig's gate does). A listed test that did not run is neither, since a pull request tests only the
packages it touches. The list has the rig's KNOWN_FAILING format (`<test> <cause>`, sorted, the
rig's own parser), with `<test>` nextest's `<binary id>::<test name>`; `cargo xtask lists`
checks it is sorted. The list starts empty.

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

* CI: the test jobs grade their junit against tests/KNOWN_FAILING_TESTS

Native Linux tests and the GPU path test shards no longer fail on any failed test: nextest's
"tests failed" exit (100) goes to `cargo xtask known-failing --junit`, which fails on a failure
the list does not name and on a listed test that passes. Any other nextest exit (a build error,
a bad filter) still fails the step at once. The tests skill says to list a failing test with its
cause, in place of ignoring it.

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

* Tests: the known-failing list takes develop's 10 failing tests

Develop 38fd4a4f6 (CI run 41157648345, after the SMLib eaa998a pin) fails 10 cargo tests; each
is listed with the cause its failure message gives. Seven are SMLib refusals or results off
their derived volumes (fillet, chamfer and shell), the cli action matrix and naming corpus
among them; the two corner_g2 tests are the fitted G2 corner patch bulging past the box, which
the explicit G2 corner fixes.

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

* Reference tests: the eight cases that fail on SMLib eaa998a name their cause as known failures

Develop 38fd4a4f6's Reference tests fail 34, 35, 77, 78 and 81 (STEP export finds fillet edges
4.6e-5 to 6.1e-5 off their surfaces against 2e-5), 37 (the apex blend 0.193 off), 80 (the
taper-to-fit area) and 83 (thicken status 1001). Each expected.json names its cause in
known_failure, which the oracle already reads: a known failure that passes fails the job.

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

* Sweep path: a curve of several pieces is turned round whole

The path chain oriented its first piece, not its first curve: a first curve
of several exact pieces (a fit spline, an ellipse, a projected free-form
edge) was never turned round, so a path whose next curve meets it at its
start was refused as "not connected end to end". The chain now orients
curve by curve: the first runs toward the second where they meet at one
end only (else from the end nearest the profile), each next one starts
where the previous ended, with one joint tolerance for both questions.

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

* SMLib: pin 2fc375f (tangency-labs/smlib#71, #72, #73)

A sheet's side is extended exactly the distance asked, or the call says it cannot (#71);
NVIDIA's -Werror build passes again (#72); a body splits by a sheet of several faces as it
is, smlib_split_body_by_sheet (#73). The C API is ABI 6 (the new entry), so the binding
expects 6.

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

* kernel: Extend grows a surface of several faces face by face

A surface body of more than one face (a fit-point spline's spans extruded, #3830) was refused.
Each face with a picked edge is now extended alone by SMLib's extension of its surface, and the
faces are sewn back together, each the face it was. A side that lengthens onto a face meeting it
tangent takes that face along, past its edge continuing the picked one; across a crease the
neighbour grows only when its edge is picked too, otherwise the extension is refused. Faces whose
lengthened sides no longer meet are refused. Up to a target keeps the pieces every grown face
lies in.

3830's extensions now build; its split still fails (the tool point picks one span, and a face
tool cuts with that face's surface alone), so it stays on the list with that reason.

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

* kernel: Split Body by a face of a smooth sheet cuts with the whole sheet

A face tool cut with that face's surface alone, so a face of a surface body made of spans joined
tangent (a fit-point spline extruded, #3830) cut nothing where its span ends inside the body.
Split Body by a face of a surface body now takes the faces joined to it tangentially (the exact
check Extend uses, within the one angle for smooth) and slices with them as they are, through
SMLib's slice by a sheet (smlib_split_body_by_sheet, tangency-labs/smlib 7a01f1b), its sides those
of the picked face. A sheet that only nicks the body cuts nothing, as before; a lone face, or a
face of a solid, still cuts with its surface extended.

3830 comes off the booleans list. Its answer counted the two halves of the cup and not the surface
that cut it, which stays as a body of its own (4879 removes its surface for that reason): three
bodies, the volume unchanged.

Needs SMLib 7a01f1b pinned (SMLIB_REV, prebuilt; its SMLIB_ABI_VERSION 6, which crates/smlib now expects) to link.

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

* Tests: a cylinder strip's run-offs are 100 pi + 100 to 1e-6, SMLib's exact area

The tangent and curvature run-offs of the quarter cylinder r 10 strip, 5 across the surface,
are its section swept 20 along z: exactly 10 pi/2 20 + 5 x 20 = 414.159265. measure is SMLib's
integration on the exact surface (1e-8), not a mesh, so the run-off check is 1e-6 relative and
the 'measured on meshes' notes go. Red until SMLib's extension is solved to its distance
(smlib-extend-converges 590d8a1): eaa998a's curvature run-off is 4.986 long, area 413.882.

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

* kernel: an extension SMLib cannot make exact says so, naming the length asked

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

* Sketch: a control spline can carry its own knots and weights

A sketch spline over control points was always the clamped uniform B-spline
over them, so a free-form edge whose knots are not evenly spaced (or whose
weights are not 1) could only come into a sketch as one curve per Bezier
span. A control spline now may save `nurbs`: its clamped knots and its
weights. Its points, polyline, exact pieces (one a knot span; conics for a
rational quadratic), the solver's distance and curvature, links, parallel
re-projection and inspect.sketch all read them; a loaded design's are
checked. One pending format file (spline-knots).

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

* Sketch projection: a free-form edge comes in as the one exact spline it is

A projected spline edge of several Bezier spans came into the sketch as one
curve per span (#1508), so a G2 round's bottom edge was 16 curves and a path
naming the corner named one span of it (#3604). The kernel now reads such an
edge (a spline, or an elliptical arc of several spans) as the one clamped
B-spline it is over the edge's interval, its knots and weights its own, and
the sketch takes it as one control spline whose ends sit on the edge's
vertices. Planar regions still read edges span by span. The 3604 case builds
with its original path and leaves KNOWN_FAILING.

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

* Fillet rig: blends past an outer rim of their faces build, on SMLib smlib-blends-past-rims

The owner's ruling: a round or chamfer that runs past an outer edge of its face into open air
builds, tangent where it has room and cut by the face beyond where it runs out. SMLib's branch
smlib-blends-past-rims builds them by the rig's analytic answers. Off the known-failing list:
the disc's rim past its 2 mm wall (190), the hole's top rim R12 past the plate's bottom, the
chamfer past the cylinder's bottom (2893), each closing on the wall continued as itself, and
926's chamfer ending on the boss's wall, which now builds to its answer too.

The sloped basins' R4 rim rounds (1080, mitred rounds built together where one overflows) build
to their analytic volumes (to 6e-9), and stay listed with that reason: their continuity check
(g1 at every blend seam) takes in the mitres' creases, G0 by the mitre ruling.
2893-chamfer-two-distances-elliptical-rim is graded as a solid (the owner: it builds) and stays
listed, waiting on the ruling on unequal chamfers along a rim whose angle varies.
5158-fillet-round-just-past-a-10mm-face stays listed with its cause found: the round passes
5e-6 under the box's top back edge, inside the body's tolerance.

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

* Naming: test that a round of a round's end has no seam with that round's faces

The r1 round of a block's top edge, chained onto the end curve of the r2
round of its arc at a cylinder (rig fillets/every-block-cylinder-crease-
top-edge-after-arc-round-r1), ends where it runs into the cylinder. That
edge is no seam of the r1 round: the cylinder is a face of the r2 round,
not of the r1. blend_seams takes it for one today (the r2 round's name
F3:blend:F1:+z|F2:side holds a | of its own), and the rig reads it as a
contact seam at 60 degrees.

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

* Naming: a blend's seams split its edge's name at the | between its two faces

A blend face's name holds the edge it replaced, <face>|<face>, and a side
of that edge can be a blend face whose own name holds a |. Whether a face
is a contact of the blend was read as the edge's name starting or ending
with the face's name next to a |, so the r2 round's own face F2:side read
as a contact of the r1 round F4:blend:F1:+y|F3:blend:F1:+z|F2:side. The
edge's name is now split into its two faces, a blend side read whole as
the two faces of its own edge.

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

* Fillet rig: five refused blends build, off the known-failing list

926 (the chamfer ending on the boss's wall), the oblique cut's r0.1
round, the every-block r1 round (its seam with the cylinder is no
contact seam, after the blend_seams fix), the up-down squares' concave
chamfers and the shelled box's lip chamfer build with SMLib branch
smlib-blend-refusals (f8650b8 and later). 1207 r0.5 passes SMLib's check
on its rails now and stays listed for the next cause: its end rib on the
boss, which starts where the round and the boss are tangent.

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

* Kernel rig: the trough and the pocket whose floors fall to a drain build

SMLib now offsets a vertex where four faces meet (two walls and two facets at the floor's
corners, four facets at the drain) to two vertices joined by a new edge (tangency-labs/smlib
branch smlib-shells). Both volumes match their closed forms. The pocket's lining, hanging from
the top's rim round the pocket, stands apart from the slab's outer walls with the cavity rising
between them to the open top: the case says two shells in the one body.

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

* Kernel rig: the plate with a hole through its arc side shells open at its far end

SMLib's merge now intersects the offsets of an edge's faces where they could not be mated along
it (the hole's offset poking out through the half-round side's; tangency-labs/solidcore#1795):
one closed valid solid with the one face open, as the owner ruled.

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

* Kernel rig: the ring cut frame and the up-down join shell

SMLib's inset now keeps only the regions that are cavity, not the one the offsets close where
they overlap (between the two faces of #545's 0.5 floor, round the up-down join's two empty
octants). The frame's core under the recess is hollowed to a sealed void, so the case says two
shells and its closed form, 21044 mm³.

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

* Kernel rig: #616's lid shells open at a rib's side, with the 0.15 lip

SMLib keeps the skirt top's offset where its loops wind the other way under the opening, the
cavity's floor (the owner's ruling of 2026-10-31: build it; a lip thinner than the wall is no
break-through).

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

* Known failing tests: the action matrix's shells and chamfers as they fail with SMLib batch 7

The ring cut frame's and the up-down join's shells build now, and so do the up-down join's and
modern-plastic's chamfers (SMLib smlib-blend-refusals). #616's lid in the corpus still fails
SMLib's loop check, the shelled box shelled again at an inner wall is unchanged, and the
oblique cut of a cylinder's fillet still fails SmSurfOfRevolution's Test #7: 3 actions of 7.

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

* SMLib: pin 26cc451 (batch 7: rounds past a rim, blend refusals that build, shells)

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

* Known failing tests: the shell after a cut whose end touches an arc side builds on SMLib batch 7

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

* Surface fair test: the energy falls strictly, the face moves, and the edges are read after the fair

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

* Sketch scaling test: the first dimension sets l1 to 40 and scales both joined rectangles about the origin

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

* Frames tests: a pick on a moved component or an instance removes the exact round and bore volumes

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

* Issue 1080 tests: the rib's rounds and the mitred corner chamfers and rounds have their exact volumes

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

* Engine tests: lip_after_boss checks each lip's and groove's exact band, hole_as_deep_as_the_plate each hole's exact bore

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

* Robustness tests: lip_after_boss and hole_as_deep_as_the_plate live once, in the engine's tests.rs with exact volumes

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

* Engine tests: loft_cut goes; loft_spacing's hollow elbow covers its section counts and more

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

* Kernel loft test: the square-to-circle loft has the oracle's exact ruled volume, without printing

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

* Tessellation lens test: the lens's triangles fill exactly the rhombus through its arcs' midpoints

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

* Pattern test: six chamfered holes take exactly five more holes' bore and chamfer rings

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

* Kernel pocket floor fillet test: the corner rounds and the floor loop round add their exact volumes, by Pappus at the corners

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

* Shell regression: the saddle-corner shell opened on the top box's side has its exact volume

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

* SMLib history test: every operand's order is the identity on its record's faces and edges

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

* Boolean history tests: the SMLib module's box-minus-sphere, box-minus-cylinder and box-union-box copies go; the top-level ones check more

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

* Modify history tests: shell, offset and thicken are one test each through the public API, with the volumes, refusals and history the SMLib copies checked

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

* Chamfer history test: the chamfer after each box cut is exactly one face on the painted block too

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

* Grid test: a pole side's face is the five triangles fanned from its one grid node, none degenerate

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

* Kernel tests: the twelve ignored print-only debug tests and surveys go; coplanar_curved_booleans checks the flush-capped cylinder cuts and unions they printed

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

* Kernel: the #883 board fixture generator is an example, not an ignored test

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

* STEP import test: a seam sphere whose meridian stops short of the pole imports as the whole 2 mm ball

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

* Material tests: the nine copies in material_properties go; the persistence file checks the library copy's card and formats 20 and 21 in one loop

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

* Sample test: one loop checks every sample builds cleanly and without a panic; the robustness copy goes

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

* Known failing: the non-antipodal seam sphere import, refused for a loop of no (u, v) area

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

* Tests: rustfmt the board fixture example and the coplanar asserts

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

* Kernel rig: the two 3871 cone cases were byte-for-byte the 258 cases; the 258 cases carry their sources

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

* Kernel rig: a volume or area can be graded as a hand-derived range where analysis only bounds it

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

* Kernel rig: fillets keeps one case per regime; 17 replications of a case in the same regime go

The sloped-basin chamfers and rounds at 0.5 mm rims repeat the 1 mm rim's regimes; two of the
three knife-edge chamfer tips, the r1 and box r3 curvature corners, the r1 tangent corner, the
1 mm pocket-rim and hole-rim chamfers short of the floor and the r1 together-round each repeat a kept case's regime
and grades. #854 moves to 851-chamfer-arc-top-edge-9-5, and the cases that cited a dropped one
cite the kept one or state its formula.

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

* Kernel rig: five cases outside fillets repeat a kept case's regime and go

A square cut is the slanted plane's ordinary crossing; the cam's half turn is the quarter turn's
contact at a symmetric angle; the r15 slab stands inside the same coincidence tolerance as the r10;
the 90 degree unfold and the 40 degree flange repeat the 72 degree and 20 degree cases' formulas.

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

* Kernel rig: 3243 rim and tooth join graded on its volume (rim plus the trimmed tooth less their 0.01 mm overlap)

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

* Kernel rig: 3782 emboss on a cylinder graded on its volume, area, faces and box (the wrapped 4 x 5 badge raised 1 mm)

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

* Kernel rig: 3604 G2 swept-tool cut graded on a volume range (filleted box minus the swept corner section)

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

* Kernel rig: 4505 coil joined through a cylinder wall graded on its volume, area and box (cylinder plus the wire's screw-swept segment outside r 76)

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

* Kernel rig: 4879 window cut through a thickened spline revolve graded on a volume range and its faces

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

* Kernel rig: 4623 join of crossing pleated loft skins graded on its volume and box (both skins less their overlap)

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

* Kernel rig: 831 tapered glyph extrude graded on a volume range and box (the font's S drafted in by 10 deg)

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

* Kernel rig: 2357 ring of 90 cylinder segments graded on its volume and box (the segments less their 90 joint overlaps)

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

* Kernel rig: 4542 fluted wall joined on a revolved base graded on its volume and box (the base by Pappus plus the fit-spline ring)

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

* Kernel rig: 1985 and 2243 STEP-export enclosures graded on their volume and box (the outline less the cavity, plus gusset and standoffs)

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

* Kernel rig: both 4092 loft, ribs and pad unions graded on their volume and box

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

* Kernel rig: 1525 graded on its volume (the convex body's exact 2 mm inward offset), faces and box

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

* Kernel rig: 1494 graded on its volume (box and cylinder less the 1 mm-offset cavity, sharp to rounded concave edges) and box

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

* Kernel rig: 4492 graded on its box and a volume range (the face's 19 mm sweep added, within the block's sides)

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

* Kernel rig: 745 graded on its volume (the end moved 1 mm plus the spline boss's exact area), faces and box

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

* Kernel rig: 4567 graded on its volume (the revolved spline less its revolved 2 mm offset), faces and box

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

* Kernel rig: the up-down squares shell graded on its volume (the columns less the 0.346 mm-offset cavity, sharp to rounded concave edges) and box

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

* Kernel rig: 545 graded on its volume (the frame less its 0.5 mm-offset cavity, sharp to rounded concave edges), its sealed pillar void and box

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

* Kernel rig: the shell-again case graded on its volume (the shelled box less the 0.5 mm-offset cavity in its walls) and box

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

* Kernel rig: the arc-sided plate shell graded on its volume (sharp to rounded at the hole's bottom), faces and box

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

* Kernel rig: 616 graded on its volume (the lid less its 0.5 mm-offset cavity, sharp to rounded concave edges) and box

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

* Kernel rig: 391 graded on its volume (the shelled housing, sharp to rounded at the flange, less the letters' 0.6 mm deboss) and box

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

* Kernel rig: shell/3576 was the ladder's drafted box with rounds shelled, recipe for recipe; the ladder case grades it and carries #3576 and #3573

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

* Kernel rig: the two 851 chamfers of 2 graded on their volume (the cone's sweep, and its union with the front edge's prism)

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

* Kernel rig: the chamfered corner of the lipped shell graded on its volume (the chamfer's triangle through the wall)

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

* Kernel rig: the concave round into the peg's cylinder graded on a volume range (the torus it must add, the band it can't leave)

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

* Kernel rig: the two folded 72 degree trays graded on their volume (flat area x thickness) and the walls' reach

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

* Kernel rig: the flanged rhombus graded on its volume (flat area x thickness, all four reliefs cut) and its walls' ends

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

* Kernel rig: the base with two adjacent flanges and a hole graded on its volume (base, bends and panels folded)

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

* Kernel rig: the flange beside the ledge graded on a volume range (the flange's two readings of its length)

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

* Kernel rig: the two G0 window patches graded on the box the part they close keeps

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

* Kernel rig: the hub's six swept grooves graded on its volume (the revolved hub less each tube's part inside it)

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

* Kernel rig: the cut mirrored loft graded on the box its sections pin

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

* Kernel rig: the three modelled-thread joins and the fine M48 graded on their volume (the basic profile's groove, Pappus)

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

* Kernel rig: the oblique-cut round graded by its seams (tangent, not curvature-continuous); the flanged rhombus says what it tests (its reliefs stay apart at 60 mm)

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

* Kernel rig: 3841 is 168's rim touching its coin along a circle, and 4092's reduced recipe is its report less a flange clear of the failing join; the kept cases carry their sources

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

* Kernel rig: the oblique-cut round keeps its old grade until the new blend algorithm lands

Its seams grade needs the continuity check to skip the body the round is not on. Fillet cases
wait for the new algorithm.

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

* Kernel rig: 1985's enclosure is 78 x 77 (74 and 73 inside, 2 mm walls); its volume 38063.827 by hand

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

* Kernel rig: the three spline walls' face counts wait for the one-wall decision; their volumes grade them

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

* STEP import: a face bounded by seams alone runs its loop the other way, never turns over

A loop that uses each of its edges once each way (a whole torus within its tube and ring seams)
is the same set of uses run backwards, so it says nothing of the face's side. One written
clockwise in (u, v) turned the face over, against its same_sense: the torus came in inside out
with a warning. Such a loop is now reversed and the face keeps its side.

Test: seam_only_torus_imports_as_a_whole_ring, both orders of the two seams, volume 2π²Rr².

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

* STEP import: a face bounded by a slit alone is the whole of its closed surface

A loop that runs out along a chain of edges and straight back (a meridian from a sphere's pole
stopping short of the other, out and back) encloses no (u, v) area, yet on a closed surface it
bounds all of it less a cut of none. The reader only read such a face when the slit already ran
pole to pole (and moved the sphere's frame onto it only then), so the 2 mm ball whose meridian
stops at 2.5 rad was refused for its empty loop.

The slit is now made the face's seams, so its loop bounds the surface's whole domain: on a
sphere, a revolution whose profile ends on its axis, or a spline closed round u that collapses
at both ends, the slit's meridian is run on to both poles, out and back (a sphere's frame put
on the slit first, whichever two points it joins); on a torus, the circle the slit lies along is
closed and the other circle through its start is the second seam. The seams on a sphere are its
own meridians (angle 0 on the equator), so their pcurves are exact lines on its form.

Tests: non_antipodal_seam_sphere_imports_as_a_whole_ball passes (its known-failing line goes);
seam_only_torus_imports_as_a_whole_ring adds a torus bounded by a slit along its tube.

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

* Rig: the test-peg's concave arc and front top edge rounded together stay within the body's box

A new fillet case, report-fillet-concave-arc-and-front-top-edge-together-solver-residues:
the reported part's bar (with the sketch solver's residues) whose sides run tangent into the
cylinder, its concave arc and front top edge rounded r2 together. The plane of the front face
bounds the whole body, so every blend lies within the unblended body's exact box (SMLib's tight
box); graded to 4e-5, the corner patch's own tolerance. On SMLib 2fc375f the corner patch
stands 0.0374 out of the front face (box y min 0.0205): listed in KNOWN_FAILING.

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

* Rig: the test-peg corner at r5 and r7

Two more fillet cases, the reported part's concave arc and front top edge rounded together at
r5 and r7, with the same exact-box containment check (graded to 4e-5, the corner patch's
tolerance). On SMLib 2fc375f the corner patch stands 0.128 (r5) and 0.230 (r7) out of the
bar's front face (box y min -0.0699 and -0.1723): listed in KNOWN_FAILING.

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

* Rig: blends that end where their face runs tangent into another

Fifteen fillet cases on the test-peg's shape with clean numbers: a bar 40 x 16 x 10 whose far
end is a half disc R8, a disc R8 standing 10 on its top, joined, so the front face runs tangent
into the cylinder along x = 40 and the front top edge (convex) meets the concave arc of the top
tangentially there. Each blend ends on a face tangent to the one it rolls on.

- Rounds: both edges together at r2 and r5; one after the other in either order at r2, and at
  r1.5 and r3; the front edge alone as a curvature round, whose end on the cylinder is a
  semicubical cusp. The containment invariant (the unblended body's exact box, to 4e-5) and
  G1 seams, or the analytic volume where the blend has a closed section.
- Chamfers: the front edge alone and the concave arc alone (analytic volumes: the section along
  x up to the wall, the cone's spandrel ring clipped by the sides), both together, and one after
  the other (1 then 2).
- STEP: the peg written to STEP and read back, its front edge rounded (analytic) and both edges
  rounded together.
- A cone: the peg drafted 5 deg, whose front face runs tangent into a cone along a generator,
  its front edge alone and both edges together.

The quadratures are tanh-sinh, converged to 1e-13; SMLib's own volumes of the passing analytic
cases agree to 4e-9. A sphere is left out: a plane meets a sphere tangentially at a point, not
along a seam, so it is not this ending.

On SMLib 2fc375f eight fail, listed in KNOWN_FAILING: every chamfer that ends tangent (status
1001, SmFilletNx1Corner), the two rounds together (the corner patch 0.0374 at r2 and 0.128 at r5
out of the front face, and the rounds meeting with normals 180 degrees apart where their
contact lines on the top cross), the same on the STEP-read peg (status 1001) and on the drafted
peg (its corner refused as unsupported).

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

* Rig: fillet corners graded curvature-continuous, the default to be

Six fillet cases that pick their edges with no type, so they take the fillet's default, which the
owner has ruled is to be curvature-continuous (G2) at every corner, with circular rounds kept as
an option. Each grades every seam of the rounds and of the corner patch G2, on one valid body:

- an oblique convex corner (a block drafted 15 deg);
- a four-edge and a five-edge apex corner (pyramids lofted to a point);
- a pocket's concave corner;
- a mixed corner (a step: one concave edge, two convex);
- a corner beside a cylinder (the top corner of a half cylinder).

All six fail on SMLib 2fc375f, listed in KNOWN_FAILING: today's default is the circular round,
whose seams jump in curvature by 1/r. On the five-edge apex the corner patch also meets two
rounds at 8.45 degrees (G0).

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

* Rig: the test-peg's front chamfer builds

With the SMLib fork's smlib-tangent-ends (a rail's end on its side edge is the rail/edge
crossing), report-peg-front-chamfer-d2 passes. Its line leaves KNOWN_FAILING, for the pin that
carries that fix.

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

* Rig: the curvature-continuous corner cases pick the curvature fillet

The six corner cases added in 3a854104a took the fillet's default type, on the premise that the
default would become curvature-continuous. The default stays the circular round, so each case
now asks for the curvature fillet itself (type curvature: curvature-continuous sections and
corner patches) and keeps its grading: every seam of the rounds and of the corner patch G2, one
valid body. They are renamed from cov-fillet-g2-default-* to cov-fillet-g2-*.

The oblique three-edge corner (a block drafted 15 deg) and the pocket corner pass. Four fail, as
KNOWN_FAILING now says:

- the corner beside a cylinder: the curvature fillet builds its sections only between planar
  faces;
- the four- and five-edge apex corners: refused because the default setback is shorter than
  where the rounds' contact lines meet (3.90 and 3.51 mm from the apex);
- the mixed corner: SMLib sets back a corner only where its edges are all convex or all
  concave.

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

* Rig: the test-peg's concave chamfer, alone and after the front chamfer, builds

With SMLib's tangent end as an event of the fillet's trace (blend-end-event 6d73e4f), the
concave chamfer's rail on the cylinder ends where it crosses the seam along which the front
face runs tangent into the cylinder, continued past the vertex, and its end rib on the front
face is built from there. report-peg-concave-chamfer-d2 and
report-peg-front-chamfer-d1-then-concave-chamfer-d2 pass and leave KNOWN_FAILING. The two
chamfers together still fail, now at the mixed-convexity corner where they meet: its line says
so. Rig against SMLib 0cc48bd: no area regresses (963/993 pass, 961 on a87c502), ladder 168/168,
kernel lib only its listed failures.

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

* Kernel rig: constant rounds of the exact classes, and a tee's round

A plane cutting a cylinder or a cone at an angle, a sphere or a cone coaxial with the other face, a
cone and a cylinder touching one ball, two pipes touching at a point: their rounds' centre lines
are rational, so the rounds are rational surfaces of degrees fixed by the construction ((4, 2),
(2, 2) revolved, (6, 2), (12, 2)). The tee's centre line is not rational; its round is graded on
its volume. Volumes from the divergence theorem over the exact pieces, at rel 1e-6. The six that
SMLib 0cc48bd traces are listed in KNOWN_FAILING with what they come out as.

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

* Kernel rig: the exact-class rounds pass on SMLib's classified exact rounds

The rounds now come as the rational surfaces their classes give, closed ones as two faces split
at their seams, the touching pipes' as four: one splines entry a face, each of the class's degrees.
Their KNOWN_FAILING lines go.

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

* SMLib: pin 7ac037e (the new blend's tangent-end event, zero-speed G2 ends, and its exact-class rounds)

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

* Kernel rig: the fillets known-failing list is the 22 cases that fail on SMLib 7ac037e

Twelve listed cases build on the new blend's end event and exact classes, or were already
built on develop: they come off the list.

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

* Kernel rig: #316's bottom rounds as large as the wall, or larger, shell with a step wall at the opening, not refused

Where the round meets the open bottom the cavity's wall steps down along the bottom's normal, so the
round meets it at 90 degrees and the opening is exactly the picked face. The cases are renamed and
graded on the exact volumes, and come off the shell known-failing list.

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

* Merge fixes: every crate and test target compiles on the merged tree, but for crates/geom's hlr

world_state, view::bounds, home_camera and scene return Result on develop; new
main code takes that with ? or session_world. New fixtures carry the spline's
nurbs. ForeignCopy::of returns Result. occurrence_placement_with walks the path
with its moves, since the placement index holds pending_moves only.

The hlr port is separate and still to come: this commit does not compile as it
stands in crates/geom/src/hlr.

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

* Drawings hidden lines: read develop's B-rep records

Edges come from the record's edge list, cut to their interval (C3::of_edge), and
faces from its face list; a cylinder, cone, sphere or torus chart takes its bounds
from where its NURBS form lies on its label (S3::of_face). A rigidly placed
revolution has main's revolution chart; other placements stay refused. Moving a body
to its centre goes through Brep::moved. Loops are read against the natural normal of
the face's parameters where a chart wraps.

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

* Hidden-line fixtures: build develop's records from the fixtures' data

Fixtures keep their bounded curves and surfaces and their own edge numbers; a private
builder makes the canonical record, and the local prepare puts the edges back in the
fixture's numbering. Assertions are unchanged.

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

* Hidden lines: tests of the bounded edges, face charts and placed revolutions

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

* drawing-hlr doc: what is drawn and refused on develop's records

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

* hlr tests: a placed revolution is refused when its placement is not rigid

The new model has one record for main's Revolution, which was drawn, and its
PlacedRevolution, which was refused. An identity placement is now the former, so
this test's fixture takes a non-rigid one. The refusal it asserts is unchanged.

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

* Hidden lines: a traced intersection is drawn only where its spline lies on both surfaces

A leader off either surface is refused like a curve with no spline to follow. Tests: a traced rim
drawn on the circle and plane within tolerance, the same leader 1 mm off refused, and kernel bodies
with arcs through the production prepare against closed forms.

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

* Hidden lines: kernel bodies through the production prepare against closed forms

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

* Tests from main: the world state is a Result here

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

* A blend's pick measures close edges on their curves, not their polylines

A polyline can be a chord off its edge, more than two edges are apart near a
tangent point. A pick exactly on a ball's rim took the straight edge the rim is
tangent to, and solidcore built a chamfer of that edge with a clean check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
(cherry picked from commit c786db6bf4)

* Merge fixes from the first SMLib test pass

Model cache key covers the crates develop's kernel adds under doc (profile, smlib, step).
A piece taking a name also takes the feature that made it: SMLib lists a cut's pieces in another
order, and the swap left the name on a piece of the wrong feature.
A replaced analysis keeps its status slot. A command with no world picks does not place the model
first, so assembly.mass_properties answers for a flattening placement. FeatureCommon loses the
touching key the merge added; only the contracts that read it take it.
Two of main's tests listed as failing on SMLib, with why.

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

* Merge fixes from the ui test pass and the gates

Main's thread.direction keys go with develop's thread hand, which replaced them. Vietnamese for the
messages develop added while main made vi a complete catalog. CGA and 1-bit themes set develop's
comb and section hatch colours. A ribbon button with a persistent effect is pressed only while its
tool runs, so persistent_ribbon_pressed alone decides how its effect is shown; other analyses stay
pressed while on. Two of main's ui tests listed as failing on SMLib, with why.
Gates: fixed thread tables become slices, one collapsed if, rustfmt.

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

* A silhouette takes its facing from the surface's normals

A flat triangle beside the outline can tip the other way from the surface it approximates, and its
edges joined the outline. On SMLib's sphere that made 192 segments instead of the equator's 64, 100
curves and 95 profiles from the tight fit of #7989. Each corner's normal is used where it is close
to the triangle's own; a mesh without such normals (none, zero, not numbers, or averaged across
creases) keeps the triangle's normal, as before.

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

* Clippy and rustfmt on the merged tree

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

* Stepwise open checks every feature under the session's mode

OpenSteps::start evaluated under the default check mode, and finish kept those results under the session's mode: an each_step or off session opened in steps recomputed nothing. start now takes the mode (the app passes its session's), and a mode that changed before finish evaluates the design again under the new one.

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

* Clippy without SMLib: the planar-sheet edge reader is built only with it

Its one caller is in the smlib-only sheets module, so without the feature it was dead code and
clippy -D warnings refused the kernel.

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

* SMLib: pin d01d7b0 (the new blend's one Gⁿ section generator: the chord, circle and curvature sections, and the variable rounds as the union of their balls)

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

* Kernel rig: the section orders, chord, circle and curvature section, on planes and against a cylinder

Eight fillet cases for the blend's cross-section by its order of contact with the faces, each
graded on its analytic volume at rel 1e-7 and its exact continuity (the order, and not the next):

- a box's top back edge chamfered 3 (G0), rounded r3 (G1) and curvature-rounded 3 (G2): the
  areas cut off the corner s^2/2, (1 - pi/4) s^2 and 113/756 s^2, the last by the default
  thumbweight's definition;
- a pipe R10 dipping into a plate, its concave edge chamfered 2 (the chord between the contacts of
  the ball SMLib rolls for that setback, radius 1), rounded r2 and curvature-rounded 2, each
  section's added area by Green's theorem along the plate, the section and the pipe's arc; the
  order-2 section is built from the faces' feet of the point at distance d from both, which on
  the pipe are not linear in d;
- a boss root curvature-rounded 2, the square corner's section revolved (Pappus);
- a variable round r1 to r5 along a box edge (rel 1e-5): the balls' envelope is a circular cone,
  and the volume is the cone's sections integrated along the edge.

Three fail today and are listed: the two curvature rounds whose faces are not a square corner of
planes (SMLib's quintic blend curve, not the order-2 section: rel 6.5e-7 and 1.2e-5) and the
variable round (great circles, not the envelope's: rel 4.8e-5). The other five pass on SMLib
7ac037e.

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

* Kernel rig: the curvature fillets' expected bodies from the order-2 section at thumbweight 0

SMLIB_BLEND_CURVATURE's section is now the order-2 G^n section at thumbweight 0. Between two
planes square to each other it cuts A = 0.154192655384275 s^2 off the corner, its first moment
about either face 0.0291753295725360 s^3 (Green's theorem on the section): the curvature cases
built on that corner (862, 2708, every-box-edge, every-hole-top-rim, every-l-bracket,
every-block-cylinder-crease, the three-edges-at-bar-corner reports, 3604's fillet) take their
volumes from it by their own methods, the peg's front blend 23.3237146876 (its section along x
up to the wall). Between planes the section's t^3 term vanishes (their feet are linear in the
distance): the box edge case is exactly G3. The boss root is a certified fit, G3 to the plate and
G2 to the wall at Continuity Analysis's tolerances: at least G2. The boss root and the pipe on
the plate pass on SMLib's section and leave the known-failing list.

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

* Curvature fillets: the section's default thumbweight is equal area, so the round's volumes are the circle's

On the round's rails the order-2 section takes off what the ball's circle does: (1 - pi/4) s^2 at a
square corner, first moment 0.0474559669443 s^3 (w = -0.5252123889). The kernel and engine tests
and the curvature rig cases take their volumes from it by their own methods; the pipe on the plate
from its own wedge (70.52878 degrees, w = -0.8544103493). The G2 corner patch's seam test is listed
until the corner step.

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

* Kernel rig: four G2 corners wait for the corner step: their patch meets the new section's rounds G1 at one face seam

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

* Kernel rig: the turn at a corner of two rounds, Mitre (the default) and Swept, at a pocket's floor and a boss's base, by exact volumes

A round's spandrel A = (1 - pi/4) r^2 and its first moment about its wall M = r^3 (5/6 - pi/4):
Mitre adds L A -+ 2 M per edge (cut at, or run on to, the corners' bisector planes), Swept turns
the section about a convex corner's edge, (pi/2) M a corner, and refuses a concave corner rounded
less than the ball. The pocket's tangent chain round its R3 corners is a torus each. Graded to
1e-7, where Mitre and Swept differ by 6.6e-6. The Swept cases are listed until solid.fillet
takes a turn.

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

* Kernel rig: a junction family: oblique wedges 30 to 150 degrees rounded and chamfered (exact), bosses upright and tilted, pipe tees and a Y junction

Four are listed with their causes: the equal-pipe tee (its exact conic-pair rounds meet at the
ellipses' crossings in a corner the old corner code cannot join), the tilted bosses (1001, before the
blend rework too) and the Y junction (refused for room at its triple points).

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

* Variable rounds: graded by the union of their balls (their envelope), not the balls' great circles

The smlib crate's laws and ladder 005, 006, 007 and 012 take the volumes the balls' envelope
removes; the law with a corner is its own test, listed as failing until the corner step.

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

* Kernel tests: variable rounds graded by the union of their balls, the removed volume to 1e-4 of itself

removed_by_balls integrates, in each plane square to a right-angled edge, the corner the union
of the balls leaves (by rays from the edge, the least entry over the balls), cut to the body's
section. The law with a corner through a middle point is its own test, listed as failing.

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

* Kernel tests: a spline radius whose balls fold over one another at its peak is refused

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

* Kernel rig: the variable round on its cone passes (the round is the balls' envelope)

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

* Tests: three rounds of different radii at a corner listed: the corner's transition kinks with the balls' touch circles

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

* Ladder: the variable rounds graded to 1e-6 by the union of their balls; 012 by its law going on past the edge

At the default 1e-3 the great circles' volumes (4e-6 off) passed too. SMLib builds each within
1.6e-7. The golden takes the new volumes and areas; counts unchanged. The G2 corner test listed
as failing passes on SMLib's step 3 and leaves the list.

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

* Reference tests: the variable rounds 77, 78 and 81 pass on SMLib d01d7b0; their known-failure marks come off

The new section builds their edges on the surfaces, so STEP export no longer finds them 4.6e-5
to 6.1e-5 off. develop's Reference tests (60393a718) report all three as passing while marked.

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

* STEP import: a ball's halves whose seams are off each other's quarter lines each form within one turn

A sphere split into two halves along a circle about its axis, each half covering a pole with no
seam, gets a seam per half from the first vertex of its loop to the pole. The circles between the
halves group their frames so they turn together, and when the two seams are not a whole number of
quarters apart no one turn fits both: the group kept its frames, a half's form ran past one turn,
SMLib's trim (SMLib and solidcore alike) across the form's end crossed the face, and the mesh
folded over the ball (a rotor's ball showed torn; its half had 196 times its area in triangles).

When no one turn fits the group, each spilling face is now turned on its own to start on a quarter
line. On the rotor file this also clears three other faces that were filled leniently and a
0.076 mm edge gap; regression test in step_in/tests.rs with the area and volume of each half.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
(cherry picked from commit b3188e005d427043b918267449c2336a1777e54f)

* SMLib: pin 5e8c9c1 (the new blend's corners: Ball and End/Join; exact fold and room checks with fit sizes; overflow in place; the round as its definition)

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

* Kernel tests: a law's dip shows its corner ball's sphere, a peak's two rounds cross (the reasons had them swapped)

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

* Reference tests: the variable rounds 77, 78, 80 and 81 expect the union of their balls

A variable round is the envelope of the balls rolled along the edge, which takes less than a
quarter round in each section. The expectations are now that union, the law continued past the
edge's ends along its end slopes, integrated by Gauss-Legendre panels in x and t; 2, 4 and 8
panels agree to 1e-9 of the body's values, and 77's removed volume matches a brute-force union of
balls to 2.5e-10. The nearest-ball search now scans the balls the ray meets around ball x
(|x - s| < r(s) sqrt(sin 2t)) on their own, which near the faces and where 78's radius closes to 0
are narrower than the global scan's step. Regenerating keeps each case's known_failure. 79, a
constant radius, is unchanged.

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

* Known failing: the law corners' kernel and crate tests and rig 1207 pass on SMLib 48ea616

SMLib builds a variable round's law corners as its balls' union (the corner
ball's zone at a dip, the sides' crossing at a peak): the kernel's corner
through a middle point (39.5410) and the crate's 4 -> 2 -> 4 law (39.5421) pass.
Rig 1207 (the bar's top edge rounded after the boss's arc, r0.5) builds and
grades on the same SMLib; it failed on c820346.

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

* Kernel rig: every fillet and chamfer case rounds its tangent chain

The owner's ruling of 2026-11-01: the rig and the ladder round with tangent_chain true, how a
user's default pick behaves. The 38 fillets-area recipes and the ladder's 07-fillets/016 and 018
that passed false now pass true. The shell recipes keep theirs: a shell's tangent_chain is its
open-face chain, a different feature.

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

* Kernel rig: the partial-selection cases kept as -partial-chain copies, tangent chain off

Where a case's point is one edge of a tangent chain rounded alone, a copy keeps tangent_chain
false as its own case (SMLib rounds exactly the picked edges, ending at the smooth vertex); their
expectations stay as they were, to be re-derived:
- fillets/1207-fillet-box-end-edge-into-unblended-round-partial-chain;
- fillets/every-fillet-after-fillet-curvature-front-then-side-r3-partial-chain;
- fillets/1207-fillet-bar-top-edge-after-boss-arc-round-partial-chain, renamed from
  ...-no-tangent-chain: with the chain on it is exactly 1207-fillet-bar-top-edge-after-boss-arc-round,
  which the rig already has;
- ladder/07-fillets/020 and 021, the r5/r3/r7 and r5/r3/r2 corners (016 and 018) with the r3 on
  its edge alone: with the chain on, the r3 runs on along the r5 round's end arc and down the
  vertical edge the third round was to take.

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

* Kernel rig: the concave arc between the block's tangent sides kept with tangent chain off

fillets/every-block-cylinder-tangent-concave-arc-r2-partial-chain: the pick takes one piece of the
concave half circle, which SMLib splits; with the chain off its other pieces are left out, and the
blend is refused at the piece's end (the base gate's outcome). With the chain on the case rounds
the whole half circle. The copy keeps its expectation, to be re-derived.

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

* Kernel rig: two chains that now build graded by their exact volumes

With the tangent chain on, two cases round more than the edge they pick, and their old
expectations were the single edge's:
- 1207-fillet-box-end-edge-into-unblended-round: the r1 chain is the end face's top edge, the r3
  round's end arc and the back upright; 34 A + (pi/2) A (3 - c) by Pappus on the arc:
  V = 7953.139216 (it built 7953.139216, the old 7957.505 passed only inside 1e-3), 10 faces.
- 851-fillet-top-front-then-front-left-upright: the upright's chain runs up the first round's
  end arc on x = 0 and along the left top edge, so the corner becomes the R2 ball's eighth:
  V0 less the first round's spandrel to the wall (35.344727) less 27 (4 - pi) + 2 pi/3:
  V = 9530.777979 (built 9530.777987), 10 faces.

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

* Kernel rig: a curvature chain round a round's end refused for its size; the concave half circle builds

- every-fillet-after-fillet-curvature-front-then-side-r3: the side edge's chain runs round the
  first curvature round's end curve, whose radius of curvature has a weighted mean of 3 (its
  setback) and is unbounded at the ends, so somewhere below 3: the size-3 blend's contact line
  folds back on itself. Refused, "larger than the faces beside its edges allow", as ladder
  07-fillets/016's r7 on the r5 arc. (It was solid or refused for running on tangentially.)
- every-block-cylinder-tangent-concave-arc-r2-no-tangent-chain: the chain takes every piece of
  the half circle and ends at the convex edges it runs on into; it builds the analytic 19091.9620,
  so the outcome is solid (the refusal alternative was the chain-off one's).

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

* Known failing: two 851 chains whose corner piece is a ball of tube-centre radius 0

851-fillet-arc-then-top-front and 851-fillet-top-front-then-upright: with the tangent chain on,
each R2 chain runs along the end curve of an R2 round on a curved face (the arc round's end on the
front face; the first round's end on the wall), where the rolling ball sits on that round's own
centre line, so the piece is the ball about (39.2538, -19, 8): the three-edge rolling-ball corner.
SMLib fails with status 1001; the same piece on a plane end arc builds (front-left upright).
Expectations unchanged.

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

* Ladder: 016 and 018 refused, their r3 chain takes the vertical edge; 020 and 021 recorded

With the tangent chain on, 016's and 018's r3 runs on along the r5 round's end arc and down the
vertical edge, so the third pick (60, 40, 8) is 3 from both of that round's contact lines and
names no edge: refused, asking for a point along one edge. The corners they were made for are
020 and 021 (the r3 alone), whose golden entries are 016's and 018's (021 measured on f12a101).

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

* Kernel tests: three rounds at a box corner end on one rational patch on the ball's sphere, not an analytic sphere

SMLib's Ball corner is always the rational patch exactly on the sphere (owner ruling, blend
design §23), so the corner counts as an 'other' face beside the three cylinders and six planes.
The removed volume still checks the corner against r³ − πr³/6.

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

* Kernel rig: the curvature round on a pipe on a plate is listed: a seam grades G3 where exactly G2 is wanted

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

* Kernel tests: the box-and-fillet wire fixture records the round's cylinder frame as SMLib now keeps it

The round's cylinder keeps the frame it was made on when it is turned (SMLib's SwapUV no longer
re-derives it): its origin sits at x = −20 on the same axis line, (x, 2, 8) along x, radius 2.
Same cylinder; one number in the record moves.

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

* Known failing: two kernel tests' reasons as they fail on SMLib 5e8c9c1 (a partial selection running into an unblended tangent edge)

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

* Kernel rig: two curvature rounds get 600 s, past the 180 s default, while G2 certification is slow (tangency-labs/smlib#96)

On the CI runners the boss root and the hole rim, both size-2/size-1 curvature rounds, build
correctly but take more than 180 s: the order-2 section's span certificate and the round's
definition are evaluated station by station. The per-case limit is the one the thread cases use.

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

* Lid ring sketch test: find the ring under the pick, not by main's kernel's name

On SMLib the lip join's exact history names the lid underside's ring a piece
of the lid's own start (F14:start#[...]), as the naming rule says for a face
that came from the feature's input; main's kernel named it after the case's
top it rests on (F2:top). The test looked the ring up by that name and so
stopped before the attachment it guards. It now takes the underside piece
under the pick point, which the cap certificate attaches on either kernel.

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

* Viewport culling test: each box's triangles from its mesh, not 12

SMLib meshes a 10 mm box in 108 triangles, so the hard-coded 12 a box stopped
the test before it reached the culling. The expected counts are now each
body's span in the scene.

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

* Timeline chip test: a fillet that really reduces

The R20 fillet with fit reduce on one top edge of a 10 mm box builds on SMLib
without a reduced-radius warning, so the fixture had no warned feature. It
now rounds the four vertical edges at R6, reduced to the full round (5), as
the copy-warning test beside it does, and checks the warning says so.

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

* Oracle: 34-chamfer-then-fillet and 35-fillet-curved-curved pass on SMLib 5e8c9c1 (their STEP edges are on their surfaces)

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

* Known-failing: the shell of a fully rounded box opened at one face fails on SMLib 5e8c9c1 (tangency-labs/smlib#102)

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

* Known-failing: the action matrix's oblique cylinder cut fillet passes on SMLib 5e8c9c1; two shells still fail

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

* Kernel rig: the boss root's curvature round gets the rig's 1800 s cap: on the CI runners, 8 cases at a time, its G2 certification passed 600 s (tangency-labs/smlib#96)

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

* Kernel rig: a case's continuity can ask for an order per seam (seams: [{order, edges, exact}])

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

* Kernel rig: the pipe on a plate's curvature round meets the plate G2 and the pipe G3, 40307.8856312 (option B's order-2 jets, tangency-labs/solidcraft#10845)

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

* Edge names: a seam names its one face once; the curve-id test reads every edge kernel-neutrally

A seam's two sides are the same face, and edge names list each face an edge
bounds once, so SMLib's extruded circle names its side's seam F2:side:c1, with
no separator. The name grammar now says one face also means a seam. The #5858
test no longer counts one '|' an edge: every part of every edge name is a
whole face name of the edge, one per face it bounds, two for the rims and one
only for the side's seam (checked to run bottom to top on the circle).

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

* rustfmt on the merged tree

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

* Known failing on SMLib: two of main's new tests assume truck's box mesh and fillet torus

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

* SMLib: pin f916a8d (a round on a definition face is its definition; the G1 setback fill certified convex, setbacks too short refused naming the least; the G2 certificate halves a span no level can certify at once)

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

* Fillet: a setback too short for its corner is refused naming the least that builds (smlib_blend_fit_size)

SMLib's setback corner takes a G1 fill only once certified convex, and refuses a setback too
short for that as SMLIB_ERR_NO_ROOM with SMLIB_FIT_SETBACK_AT_LEAST, the smallest setback that
builds. The kernel reads it (solidcraft-smlib binds smlib_blend_fit_size) and names it; the
Fillet dialog says it in plain words. Other room refusals keep their words. The r5 box corner
set back 5.5 is refused naming 5.962, which builds.

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

* Kernel tests: three rounds of different radii at a corner meet in SMLib's setback corner

Each round runs whole to its section square to its edge at its setback (the owner's rule, at
least 1.5 r, scaled to the least at which the G1 fill is certified convex), and the hole is the
setback split: one patch per round and the triangle in three quadrilaterals, 15 faces, G1.
Outside the box the sections span the body is the three strips', so its volume is theirs within
that box's. A later r3 round at the mitred corner makes the body all three at once make
(7928.142563938, 15 faces), as the engine and the model-cache tests have it.

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

* Kernel tests: SMLib's own corner between curvature rounds is curvature continuous, as the G2 patch is

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

* Kernel rig: the boss root's and the hole rim's curvature rounds take the default timeout again (37 s and 34 s on the box under the rig's parallel load, tangency-labs/smlib#96)

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

* Agents: model decisions use exact geometry; a mesh is for display, export, previews and mesh bodies

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

* xtask: mesh-decisions, model decisions use exact geometry; today's 176 mesh decision sites are listed and the list only shrinks

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

* Sheet metal: cuts and holes pass over sheets and bends by their exact boxes, never a mesh's

A primitive cut skips the sheets kernel::apart (exact bounds grown by the record tolerance)
finds clear of its tool; the bend check leaves the clear case to kernel::boolean, which answers
it from the same exact boxes with no intersection work. The mesh-box helpers are gone, and their
four lines leave xtask/mesh-decisions.txt (172 sites left).

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

* Agents: AGENTS.md loses a stray merge-conflict marker line at its end

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

* Locales: Vietnamese for the setback-too-short refusal, now that vi is a complete catalog

Main made vi a complete catalog; develop's new message (ui.fillet_error_setback_short) had no vi
entry, so every_complete_catalog_carries_every_english_message failed on the merged tree.

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

* Kernel rig: exact volumes for 1213's bent-tube round and 4782's bar round, rel 1e-6

1213: the loft is a cubic skin through the five sections (ellipses between
them), not a torus; its volume 131601.597487935 by quadrature less the exact
rolling-ball removal 233.662461919 gives 131367.935026 (was a Pappus estimate
on a torus, 131360.148, at rel 5e-4).

4782: the section's area in closed form, 25 pi/3 - 5 sqrt(75) + 25, so
V = 630.2934873 (was a grid estimate, 630.293125).

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

* Kernel rig: the boss root's and the hole rim's curvature rounds take 600 s again: under CI's parallel load they pass the 180 s default (develop 820b7b6)

They take 37 s and 34 s alone on the box, but CI runs 8 cases at once on slower runners. Their
G2 certificate cost is the open item (tangency-labs/smlib#106, moved to solidcore#2344).

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

* After the merge: clippy's auto-deref, and the mesh-decisions list for the merged tree

Develop's list was taken on develop alone. Main's code (tap-marking reach, cut and hole box
checks, join pieces, print faces, placed measuring, selection items, imported mesh payloads) and
the branch's own exact_edges and named_vertex use meshes too; three listed functions were renamed
by main (face_outline and plate_recesses into FaceSlice, paste into build).

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

* After the merge: main's new tests on the SMLib kernel

- Cut To the far side of a curved wall (#8634): the tool stops past the first crossing of the
  face ahead of the profile; SMLib's whole bore is one face that a line crosses twice, so the
  single-crossing rule found none and the cut took nothing.
- Clearing view analyses goes through refresh_display, as main's #10687 made the others, so an
  open feature editor keeps OK and Cancel after Zebra.
- vi, a complete catalog on main, gets the setback-too-short message.
- fillets/KNOWN_FAILING: 4782's cause is now its continuity check (SMLib f916a8d builds the
  expected volume).
- Known failing, as main's tests assume truck: the spline wall's spans as one mesh patch, a
  hole touching an edge at a point leaving one body, and a copy of a copy's triangles matching
  a fresh mesh.

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

* Twisted sweep: circles about a straight path are exact cylinders (solidcore#2184)

A twist about a straight path maps a profile circle centred on the path's
line onto itself, so its swept wall is exactly the cylinder. The skin
through the stations stayed within 3.9e-9 of it (6.6e-9 on the outer wall),
and a boolean with another body on that cylinder failed with status 1001
(solidcore#2184; the kernel's handling of a B-spline face lying on a
cylinder stays open there).

sweep_controlled now builds those walls exactly when the path is lines
along the profile's normal and there is no end scale or rails: a profile
whose every loop is such a circle is swept untwisted, and such holes in
another outer loop are cut as exact cylinders from the skinned sweep of
the rest. Other loops, and an axis-centred arc inside a loop of other
curves, are skinned as before; a failed cut falls back to skinning all.

Regression test in smlib_sweeps_tests.rs: #2184's tubes joined at 10, 16,
18, 20 and 30 degrees give one closed body of 11.5625 pi with cylinder
walls; a twisted hexagon with the bore keeps a cylinder bore.

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

* After the merge: a hole that misses is found by exact boxes; a face is a one-face patch; main's mesh-box helpers gone

- `hole_misses` (main's #10701, through brett's branch) decided from the targets' mesh boxes. It now uses their exact boxes (`Body::exact_bounds`). An unknown box still leaves the hole not known to miss.
- With #10303's two shortcuts and `hole_misses` off the mesh, `body_box`, `mesh_box` and `padded_box` have no caller left, and are deleted.
- `project`'s intersection link passes its one face to `meeting_edges` as a one-element patch. This branch picks one face, not #9009's mesh patch.
- xtask/mesh-decisions.txt drops the six sites that no longer use a mesh here: `apply_traced_with`, `cut_end_past_face`, `beyond_cylinder_reach`, `hole_misses`, `body_box`/`mesh_box`/`padded_box`.

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

* After the merge: split_move's Result reaches its callers; to_local's duplicated move check goes

`frames::split_move` returns a Result on this branch: an unplaceable model is an error, not a silent no-split.
- Move/Copy of a component (main's #9914) propagates it, as its other calls do.
- `to_local` had the solid.move check twice after the merge (brett's Option one and ours). Ours stays, after the no-picks/not-placed return.

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

* After the merge: main's two new tests take world_state's Result, as the rest of their files do

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

* Native B-rep: a ball patch is its own surface kind, read from and written to SMLib's flat kind 6

SMLib's flat arrays now carry the corner of three equal rounds as `SURFACE_BALL_PATCH` (kind 6): its net as a NURBS face's block, with its sphere's centre and radius as its definition (ids 36 and 166). The record reads it as `ball_patch {center, radius}`, a label like the analytic kinds, with the net as its `nurbs`. A plain sphere with a form won't do: the net is not a piece of SMLib's sphere NURBS, so SMLib's `IsNurbSurfaceSphere` would refuse it.

Written back, a ball patch is kind 6 with the same definition, which SMLib takes once its net is certified on that sphere. Elsewhere:
- Offsets classify it as its sphere, so its offsets stay exact and collapse at the centre.
- Face kinds count it as a sphere: fillet_three_edges_at_a_corner's corner is now `sphere`, no longer `other`.
- STEP, which has no ball-patch entity, gets the net as the face's B-spline surface, pcurves included.
- Iso curves and SSI of the label are refused; the face's form is what evaluates.

Test: a_rounded_cubes_ball_patches_go_round_with_their_spheres. The cube's eight corners are ball patches about the corners pulled in by r. Points of each patch lie on its sphere to 1e-12. The patches are kind 6 again after import, with the same definitions bit for bit, and the closed-form volume holds.

Format: pending/ball-patch-faces.rs. Docs: docs/native-brep.md and the rig README's `surface_types` (`ball_patch`).

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

* SMLib: pin v1.1.0-alpha (a18bbe5): the generic blend's step-4 corners, the Ball's sphere patch through the flat arrays, all-target CI

Since f916a8d:
- #120: every target again, plus the warnings fix;
- #121: the Ball's patch as flat kind 6 with its sphere, read by the previous commit;
- #122: tests gated on counts;
- #123: the Mixed corner;
- #124: the End at a smooth vertex;
- #125: four or more rounds, and open setbacks;
- #126: the equal-area thumbweight guard;
- #127: the trunk merge.

The SMLIB_PREBUILT checksums are from the release prebuilt-a18bbe5…, written by `scripts/fetch-smlib.sh --pin`.

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

* smlib export tests: rustfmt's line for the rounded cube's edge midpoints

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

* Kernel rig: the fillet and ladder results on SMLib v1.1.0-alpha, the new failures recorded with their reasons

Fixed (off the known-failing list): the Mixed corner's peg cases and the concave arc with the front top edge (smlib #123), and the 40° boss junction (smlib #124). Eight cases in all.

New failures, recorded as they are, each with its reason. They passed on f916a8d.
- **Six cases grade a blend seam G0 where G1 (or G2) is wanted,** at the end of the round's contact seam, after smlib #124's End at a smooth vertex. Two of them also shift in volume.
- **Four cases come back unsupported** after smlib #125, which leaves setbacks to the Setback's corners only.
- **Ladder 07/012** makes 9 faces, 20 edges and 13 vertices, where its golden had 8, 18 and 12. Its golden entry is the new result, with the reason added.

Whether each change is by design or a defect is being established. A defect is fixed in solidcore, where the End and the Setback are being brought over (#2364, #2365), not in the frozen SMLib.

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

* Kernel ladder: 07/021 and 06/018 recorded as they are on SMLib v1.1.0-alpha, each with its reason

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

* Kernel ladder: a golden case marked unstable is gated on its status, and 06/018 is marked

On SMLib v1.1.0-alpha, 06/018 makes 597 or 594 edges from the same input:
the union's post-process keeps pointers to edges it has deleted. A golden
entry's `unstable` reason makes the gate compare that case's status only
and name it in its output; recording keeps the reason, and only an edit of
the golden removes it. v1.1.0-alpha.1 fixes the cause and removes the mark.

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

* After the merge with main: its new code on develop's exact bounds, types and thread fields

- The sheet cut's cull (#10301) and the tapped wall's (#10227) use each
  body's exact box (`exact_box`), not its display mesh.
- model.threads replies with develop's thread fields: form, hand (right,
  left or both) and starts.
- surface.boundary's schema checks a network curve's continuity as the
  handler does, and words a list entry's error as it does.
- Main's new tests take develop's Hole fields and world_state's Result.
- mesh-decisions: contact_sweep's triangle check is now `depth` (main
  split `hits`).

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

* Known failing: the 14 cargo tests SMLib v1.1.0-alpha fails, with what each sees; two cases that pass now

Develop's CI after the a18bbe5 pin (run 41522001173) fails 14 tests that
passed on f916a8d, and passes two listed ones. Each new line says what the
test sees on v1.1.0-alpha; v1.1.0-alpha.1 fixes the setback hole's cause.
shell_tangent_chain_that_opens_every_face_explains_how_to_keep_walls and
oracle 37-pyramid-apex-blend pass and leave their lists.

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

* Cargo.lock: develop's profile, smlib and step crates at main's 0.30.1

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

* Kernel rig: main's six new sketch-only commands are exempt from a kernel case

sketch.construction_line (and its horizontal and vertical forms) and
sketch.spline.insert_point, remove_point and reverse change only the 2D
sketch.

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

* Cuts leave the bodies their tools can't reach, by exact boxes (#10225 on exact geometry)

- The per-target cut loop skips a body whose exact box lies clear of
  every tool's, as main's version did with mesh boxes. The sheet culls
  (#10226, #10301) and this one now leave the far sheets and bodies alone:
  a_cut_leaves_bodies_it_cannot_reach_as_they_were passes and leaves the
  known-failing list.
- a_cut_still_runs_where_its_tool_touches_grazes_or_meets_a_curved_face:
  a tool 0.5 µm clear of the block is now left out (exact boxes; 0.5 µm is
  far past the bodies' tolerances); touching and 0.1 mm in still cut.
- Known failing, with reasons: main's edge lookup by a stored point
  (#11286, on the display mesh) is not taken; SMLib refuses the
  wrong-way chamfer for its tangent chain first.
- EXEMPT: main's eight new sketch-only commands.

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

* Kernel rig: main's new view, rendering, appearance and settings commands are exempt from a case

62 of the commands main added change no geometry: the view and render
panes, rendering lights and results, appearance tools, settings profiles,
point clouds (drawn as points, never a body), the selection, two sketch
edits, component links and History bar visibility, edit.repeat,
timeline.play, inspect.compare_picture (a picture against the outline),
and component.paste_from_design, which pastes the app's cross-window
clipboard that no command fills. Each joins EXEMPT's first section under
its family's reason.

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

* Kernel rig: construct.plane.axis_point gets construction cases

The plane through a box edge and its far corner, graded against its
closed-form normal (0,-2,3)/sqrt(13) through the origin, and the refusal
of a point on the axis.

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

* Kernel rig: assembly cases for mass properties, align, find limits and moving items between parts

- assembly.mass_properties: two boxes (2 g/cm3) in a rigid group: mass
  0.006 kg, centre (18.333,5,5), inertia diag(0.1, 1.0333, 1.0333) kg mm2
  by the parallel-axis theorem.
- occurrence.align: a block's end face onto a base's top face: a quarter
  turn about y, centre (70,20,20), box (65,15,10)-(75,25,30).
- joint.find_limits: a slider between two stops: min -20, max 10 mm.
- component.move_features, browser.reparent and
  component.move_sketches.to_parts: items moved into a turned and moved
  part keep their world place (volumes, centres and boxes by hand).

The mass case assigns a material it creates, so material.create and
material.assign leave EXEMPT. PATHS names the new command modules.

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

* Kernel rig: export and print face cases in the import area

- file.export_step, export_stl, export_3mf, export_obj: a 30x20x10 box
  written and read back (the meshes converted to a solid): V 6000,
  A 2200, 6 faces, box (5,-10,0)-(35,10,10).
- file.export_urdf: a hinged arm on a grounded base: 3 links, 2 joints,
  and the base's mesh, written in metres, reads back as the
  1 x 0.5 x 0.2 box (V 0.1, A 1.6).
- body.print_face: a cylinder's flat end is accepted, its side refused.

PATHS adds the URDF writer and the print face modules.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: brett19 <brett19@gmail.com>
2026-11-02 22:53:32 +00:00
jyviko 4757402675 Merge #11351 at 084695416: exact-box culls, the rig cases and exemptions for main's new commands
# Conflicts:
#	tests/KNOWN_FAILING_TESTS
2026-11-02 17:38:17 -05:00
jyviko 7ad1d3e125 diagnostics.kernel takes a typed contract, as main's other diagnostics commands do
Its choice (solidcore or smlib) and its reply ({choice, environment}) get
schemas; a bad choice is refused with the handler's message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 16:58:17 -05:00
jyviko 3483d1af01 Merge #11351 at e6a4b0a9a: main's sketch-only commands exempt from a kernel case 2026-11-02 16:50:37 -05:00
jyviko 25175d68ca Merge #8526 (brett19/solidcore e30cfd2ac): solidcore as the default kernel, C++ SMLib behind --features smlib
Three conflicts:
- crates/smlib flat.rs and export.rs: #8526's export copies the C++
  arrays by id range; the ranges now run to the ball-patch arrays
  (FACE_DEFINITION_REAL_COUNT, FACE_DEFINITION_REALS), with
  WIRE_EDGE_NURBS_FORM (35) and FACE_DEFINITION_REAL_COUNT (36) both kept.
- tests/KNOWN_FAILING_TESTS: solidcore's lines and develop's, each test
  once; tests/KNOWN_FAILING_TESTS.smlib takes develop's SMLib a18bbe5 lines
  too. The first full run trims both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 16:42:23 -05:00
github-actions[bot] 841b355df1 Regenerate generated files and fold pending files [skip ci] 2026-11-02 21:42:14 +00:00
Colin Faulkingham 28b7ae007d Agent chat: select a compatible Node for discovered adapters (#2207) (#11255)
* claim: #2207

* fix(agent chat): recover adapters launched with an old Node
2026-11-02 19:40:29 +00:00
brett19 e30cfd2acf Tests and the rig for a default build without the C++ kernel
- step_in's a_solid_smlib_refuses_with_its_faces_is_refused_not_meshed assumed
  a build without SMLib has no kernel; on solidcore the cube imports, so it
  now runs only under SOLIDCRAFT_KERNEL=smlib and says it skipped otherwise.
- rounds_on_the_871_part and mitred_rounds_follow_a_moved_top compare with
  the C++ kernel (listed in tests/KNOWN_FAILING_TESTS) when it is built; their
  solidcore half runs as *_on_solidcore on a build without it, so each list
  entry is graded only where its comparison runs.
- kernel-cases without --cli builds the CLI once (with --features smlib when
  a run asks for the C++ kernel) before the cases, so the first cases don't
  spend their timeout compiling it.
- rustfmt for diagnostics.kernel's message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU
2026-11-02 12:30:22 -07:00
brett19 29a148abd4 CI: build without the C++ SMLib kernel except where a job needs it
Every job that only builds or tests the default (solidcore) build drops the
SMLib fetch: quick compile, tests, build-tests, GPU path, doc tests, oracle,
Windows, wasm, RISC-V, release, pages, docker, generated, external corpus and
the fillet rig. The kernel rig keeps --features smlib (its ladder runs on
--kernel smlib). Clippy also lints the C++ path, and a new job, Native Linux
tests (C++ SMLib), runs solidcraft-smlib, solidcraft-kernel and the CLI's
model_cache with --features smlib, where the parity tests are built, graded
against tests/KNOWN_FAILING_TESTS; `ci` requires it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU
2026-11-02 10:56:02 -07:00
brett19 5a93e6c4d2 Make the C++ SMLib kernel opt-in at build time (feature smlib, off by default)
solidcore, the pure-Rust kernel, is always built and stays the default. The
`smlib` feature (apps, engine, kernel; solidcraft-smlib's `native`) now has
to be asked for to compile and link SMLib: the app, the CLI and the web app
drop it from `default`, and solidcraft-gpu's dev-dependency no longer forces
it on. Without it no C++ toolchain, fetch-smlib.sh, prebuilt or build.rs work
is needed.

On a build without it, asking for the C++ kernel (SOLIDCRAFT_KERNEL=smlib,
diagnostics.kernel, the rig) is refused with Error::NotBuilt, which now says
"this build doesn't include the C++ SMLib kernel; build with --features
smlib", and SOLIDCRAFT_KERNEL=smlib logs that once. `cargo xtask
kernel-cases` builds the CLI with --features smlib only when --kernel smlib
or both asks for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU
2026-11-02 10:56:02 -07:00
brett19 d93a8bfd17 Merge ws/sync-5 (smlib at e1d532d90) into solidcore (sync 5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU
2026-11-02 06:48:46 -07:00
brett19 b8fa0d8aab Known failing on solidcore after the f916a8d sync; blend_fit in the entry-point guard
SMLib f916a8d's setback corner (smlib#104: three rounds of different radii, the curvature
rounds' G2 corner, too-short setbacks refused naming the least) is solidcore#2310, not ported
yet: five tests that now want it fail on solidcore. Main's three tests that assume truck fail
here as on SMLib. blend_fit asks no kernel on solidcore (it names no size there yet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-02 04:37:03 -07:00
brett19 d7ef9771bc Merge fork/smlib be78372b2 (twisted sweep circles as exact cylinders, solidcore#2184) into solidcore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-02 04:23:22 -07:00
brett19 8af1fdf5ac Known failing on SMLib (the .smlib list): as the smlib branch lists them at f916a8d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-02 03:04:25 -07:00
brett19 c043e0b873 Merge ws/sync-4 (smlib with develop f916a8d and main 0.30) into solidcore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz

# Conflicts:
#	crates/kernel/src/blend_smlib.rs
#	tests/KNOWN_FAILING_TESTS
2026-11-02 03:03:21 -07:00
brett19 5840756620 Known failing on solidcore after the smlib merge: SMLib 5e8c9c1's new blend (solidcore#2273) and main's new tests
Rig (fillets/KNOWN_FAILING.solidcore): the two 851 rows develop now rounds with their tangent
chain fail 1001 as on SMLib; the pipe on a plate's curvature round is graded to 5e8c9c1's
section, which solidcore has yet to port. Tests (KNOWN_FAILING_TESTS): main's box STL and
boss-torus tests, as on SMLib; the box-corner test and four C++ comparisons that expect
5e8c9c1's Ball corners and rounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 22:02:53 -07:00
brett19 e1ca2fd883 Merge smlib into solidcore
smlib b7e83cd7c: origin/develop (SMLib pin 5e8c9c1) and origin/main.

Conflicts, both sides kept:
- Cargo.toml: solidcore's pinned solidcore dependency beside main's solidcraft-testkit.
- tests/KNOWN_FAILING_TESTS: solidcore's default-kernel list kept (refreshed from this
  merge's run in a following commit); smlib's list for SMLib 5e8c9c1 becomes
  tests/KNOWN_FAILING_TESTS.smlib.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 20:45:19 -07:00
brett19 dd00ff989f Native wire fixture: a box edge round's cylinder keeps its own frame
With swap_uv_keeps_analytic_definition (#2281) on, the fillet's cylinder in
box_and_fillet_have_stable_wire_records keeps the frame it was made with:
its origin moves along the axis from x = 0 to x = -20 (the edge runs x 0 to
40), with the same axis, radius 2 and trims; the last bits of the axis and
radius move with it. The same surface; turning that flag alone off gives
the old record back. Re-blessed with NATIVE_BREP_BLESS=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 20:39:18 -07:00
brett19 4c1eed9603 Tabbed plate: the rim and one corner arc round together (solidcore#2223)
solidcore#2223 (closed by #2302, under fillet_section_end_trim_at_creases):
the R25 rim and the x > 0 R2 corner arc rounded together at r 0.5 on the
plate 2 high take off 8.117810114 (the rim's 7.958674274526857 and the
corner arc's Pappus 0.15913583972191622) within 1e-7, 12 faces, valid. A
new test beside the tabbed-plate test, which shares its outline.

The tabbed-plate test gets past that pair and now stops at the two R2
corner arcs picked together: 1001 in trim_at_creases (cliff_overflow with
the crease trims off), though each builds alone (solidcore#2314). Its
known-failing line says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 19:33:33 -07:00
brett19 92618ac4c5 Turn on solidcore's new fixes at 2dbd88b6e
The eight UpstreamFixes flags new since adf26ddbc, all on; HELD_BACK stays
empty: none is broken by the flag rule.

Toggled on the rig on solidcore (all eight off against on, then each alone
off on the 43 cases whose volume moved): 982/1014 pass either way, no case
changes status, the largest volume change is 1.35e-8 relative.
- fillet_overflow_trim_at_creases (#2279): 19 cases, up to 1.35e-8. The
  sloped basin rounds move toward their closed forms (r4 1.24e-8 -> 2.1e-10
  off, r4 2 edges 6.1e-9 -> 4.3e-10); 4055's bar chamfer over a joined pin
  moves from 2e-11 to 1.35e-8 off its closed form, inside its tolerance.
- face_shrink_bound_on_knot (#2281): 9 cases, at most 5.7e-13.
- swap_uv_keeps_analytic_definition (#2281): 11 cases (tapered booleans,
  shells, an oblique cone rim), at most 3e-14.
- face_trim_curves_kept_where_exact (#2281): 10 cases, last bits only.
- offset_updates_result_tolerances (#2297),
  fillet_section_end_trim_at_creases (#2302, solidcore#2223),
  boolean_coincident_strip_drops_crossing (#2285),
  cone_iso_circle_at_axial_height (#2289): no rig case moves.
No boolean, shell or offset case moves by more than 5.7e-13. The tangent-peg
cases of solidcore#2256 keep their statuses, volumes and checks bit for bit.
Rig time 1433 s off, 1344 s on; 3368-two-faceted-prisms-cut-then-joined
timed out once in the flags-off rig and passes alone with them off in 152 s
at the same volume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 19:14:46 -07:00
brett19 5ec92b0ba2 Move the solidcore pin to 2dbd88b6e
solidcore main at 2dbd88b6e adds the slit-edge check (#2276, unconditional),
NLib curve derivatives at order 5 (#2280, unconditional), generic blend step
4a (#2281: face_trim_curves_kept_where_exact, swap_uv_keeps_analytic_definition,
face_shrink_bound_on_knot), step 6's overflow trimmed at creases (#2279:
fillet_overflow_trim_at_creases), offset and shell updating their result's
tolerances (#2297: offset_updates_result_tolerances), the section-ended
round's crease trim (#2302, solidcore#2223: fillet_section_end_trim_at_creases),
the coincident-strip crossing drop (#2285: boolean_coincident_strip_drops_crossing)
and the cone iso circle at an axial height (#2289: cone_iso_circle_at_axial_height),
each new flag off until the next commit. SolidCraft never runs the tolerance
update after offset or shell itself, so there is no call to remove. No API
change reaches SolidCraft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 18:44:09 -07:00
brett19 81c02315c5 Merge smlib 876203621 into solidcore: the STEP torn-ball fix and four review test fixes
The four tests fixed on smlib (lid ring sketch, curve-id seam, timeline chip, viewport culling)
leave both known-failing lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 18:38:04 -07:00
brett19 d52cf6b14f Known failures at solidcore adf26ddbc: curvature and variable rounds built
The rig on solidcore: 982/1014 pass (968 at pin 13). Fourteen cases
come off the lists (the curvature rounds, the variable cone, 3604's G2
swept tool, the curvature fillet after a fillet). Six remain with new
causes: the G2 pocket corner and the bar corner's three edges (two) G1
at one seam and the cylinder-side G2 corner not closed, as on SMLib;
the oblique three-edge corner refused for its mixed wedges; the mixed
corner refused as on SMLib.

Tests (nextest, ci profile, PR filter): 34 come off
KNOWN_FAILING_TESTS (curvature and variable rounds). three_rounds_at_a_corner
is listed (its corner's variable transition at 0.144°, as on SMLib
d01d7b0); chord_and_variable_fillets now fails on its waist law's
corner (solidcore#430).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 18:32:59 -07:00
brett19 f9fc681670 solidcore: a curvature fillet is the equal-area Gⁿ section; mixed wedges refused
Blend::Curvature now builds the capi's section, as fork 6148773's capi
does: SM_FSG_BLEND_CURVE of order 2 at the equal-area thumbweight,
passed by solidcore's name GN_THUMBWEIGHT_EQUAL_AREA (solidcore#2274
moves its value from -2 to 2), with no blend-curve-at-thirds setting.
The sync-2 refusal (e9ac0e606) comes off.

One call resolves the equal-area thumbweight once, from the first
blend's wedge, and every edge takes it, in the fork as in solidcore: an
edge of another wedge then takes off the wrong area, depending on the
edges' order (17% short, 21% over;
solidcore#2250 issuecomment-6563419897). A curvature fillet over edges
whose faces meet at different angles (at each edge's midpoint, within
1e-6 rad) is refused as Error::Unported citing that comment; the call
is not split per edge.

Against closed forms on the rig, solidcore within 1e-9 of SMLib
d01d7b0: 862 7845.48670 (7845.48668), 2708 11791.330160 (11791.330159),
every-box-edge-r3 and section box-edge-r3 23922.743338, boss-root-r2
26056.152951, pipe-on-plate-r2 40302.590788 (completes, #2264),
hole-top-rim-r1 15207.56176, crease-r2 13963.15756, l-bracket 7538.62833,
peg front 9383.62724, all within 3e-9 relative. The G2 corner cases
pocket-corner and the bar corner's three edges (two variants) build and
fail continuity G1 at one seam, as on SMLib; the oblique three-edge
corner is refused for its mixed wedges. The kernel's and engine's
curvature tests now pass.

a_curvature_round_takes_its_wedges_equal_area_and_mixed_wedges_are_refused
holds each wedge's r² (cot(α/2) - (π - α)/2) to 1e-6 on a chamfered box
(135° and 90°), both orders refused, two 90° edges built together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 18:32:46 -07:00
brett19 3b4adeb73b solidcore: a variable round is built again, as the union of its balls
With #2262 and fillet_gn_section_generator on, solidcore builds a
variable round as the union of its balls, so fillet_variable's
refusal from sync-2 (9f64cce54, solidcore#2250) comes off. Laws with a
corner stay refused (solidcore#430); a law whose balls fold is refused
by solidcore (NoRoomReason::BallsFold) and arrives as Error::NoRoom.

Against closed forms, solidcore and SMLib d01d7b0 agreeing to 1e-11:
- 1 to 3 along 30 mm removes 27.858907 against the balls' union's
  27.857434 (great circles 27.898239); a law of one radius 2 removes
  25.753574 against (1 - π/4) 4 · 30 = 25.752220: both 5.3e-5 over,
  the solver's surface fit, as on the C++ kernel.
- the rig's 1 to 5 cone: 11956.21991 against 11956.22243 (2.1e-7).
- kernel tests: a_linear_radius_runs_from_the_start_end (18.5378),
  variable_radius_on_a_box_edge, down_to_zero (22.5949),
  taper_follows_the_room (the wedge 5245.68 against the balls'
  union) pass.

The refused test becomes a_variable_round_is_the_union_of_its_balls
(the two closed forms, either direction, a folding law refused, a pick
miss named first). the_wedge_round_keeps_its_volume held 5223.0386,
measured before the fork's balls' union; it now takes its closed form
from removed_by_balls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 18:32:30 -07:00
brett19 d33b6eb306 Turn on fillet_gn_section_generator
solidcore's one new UpstreamFixes flag since c9a06dc7c (#2260, #2261,
#2262; WR-685, solidcore#2250): the curvature section is the Gⁿ
generator's, at the equal-area thumbweight by default, and a variable
round is the union of its balls, a law whose balls fold refused as
NoRoomReason::BallsFold.

Toggled on the rig on solidcore (flag off against on, everything else as
pinned): only curvature and variable rounds move. With it off the
curvature rounds fail or come out wrong (the hole rim's r1 curvature
round 63.94 mm³ against 15207.56, the boss root's 420.9 against
26056.15) and the variable cone case takes the great circles
(11955.6466); with it on all match their closed forms (next two
commits). Every other case keeps its status and its volume bit for bit.
Rig time 1064 s off, 1248 s on, the difference the curvature rounds that
now build (the hole rim 89 s and boss root 92 s, as on SMLib d01d7b0's
102 s and 105 s). One unit test, three_rounds_at_a_corner, moves: the
corner's variable transition between rounds of 2, 3, 4 meets its
neighbours at 0.144° (the test allows 0.05°), as on SMLib d01d7b0, its
volume and patch corners as before; listed, as for SMLib. Not broken by
the flag rule: HELD_BACK stays empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 18:32:28 -07:00
brett19 c8351719ef Move the solidcore pin to adf26ddbc
solidcore main at adf26ddbc adds the Gⁿ section's equal-area thumbweight
and eased setback (#2261, under fillet_gn_section_generator), the
SmBSplineCurve AssertValid and HasInternalPole statuses (#2272, under
query_statuses_pass_on, already on) and a plan addendum, so this pin
carries #2260, #2261 and #2262 together. No API change reaches SolidCraft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 18:32:28 -07:00
brett19 e30e983c3d Move the solidcore pin to 4d3ce6264
solidcore main at 4d3ce6264 adds the Gⁿ section generator (#2260) and
variable rounds as the union of their balls (#2262), both behind the new
fillet_gn_section_generator flag; the ssi_tangent_curve_tracer crease
round fix (#2101, solidcore#2089); cooperative cancellation (#2263,
solidcore#2136); and difftest and plan PRs. No API change reaches
SolidCraft: the crate builds as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 16:15:04 -07:00
brett19 de9a654dbd Turn on solidcore's new fixes at c9a06dc7c
Two new UpstreamFixes flags, neither shown broken. On the rig on
solidcore every case keeps its status and no case reaches C++. Six
volumes move, all because of fillet_surface_plane_curve_on_plane_equation
(each goes back when that flag alone is turned off), by at most 3.8e-8
relative. Where the case has a closed form, the shift moves it closer:
the 851/1113 arc chamfers go from 1.7e-4..3.4e-4 off to 1.4e-5..2.2e-5
off (#1113's full wall height from 3.3e-4 to 1.9e-5), and the pipes
touching at a point go from 3.7e-4 to 3.1e-4 off.
fillet_extended_rail_end_at_side_edge_crossing changes no case on the
rig. The rig's total time stays the same (974 s with the flags off,
977 s with them on). HELD_BACK stays empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 14:37:02 -07:00
brett19 c6fa7e5634 Move the solidcore pin to c9a06dc7c
It builds without change in SolidCraft: solidcore's API changes since
9fb13f145 add two UpstreamFixes flags, the surface/plane curve traced
on the plane's equation (WR-682) and the extended rail end at the side
edge crossing (WR-681), plus STEP pcurve reading (a new BuildOptions
field, off by default) and the surface/plane curve as a free function,
none of which SolidCraft calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 14:21:42 -07:00
brett19 bd4dc081d2 Known failures on solidcore after the merge: the refused curvature and variable rounds, develop's new cases
Rebuilt from the trapped nextest run (11497 ran, 51 known failing, no regression) and the rig on
solidcore (968/1014). The curvature fillets (solidcore#2250, #2260) and variable-radius fillets
(solidcore#2250, #2262) are refused, so their tests and rig cases are listed as such, the
rig's as [unsupported]; a law with a corner stays refused as before (solidcore#430). develop's
new junction and turn cases fail on solidcore as on SMLib d01d7b0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 13:58:40 -07:00
brett19 9f64cce54d solidcore: a variable-radius fillet is refused until solidcore builds the union of its balls
SMLib d01d7b0 builds a variable round as the union of its balls (smlib#78 6b346f6, 5db8feb,
fb5c060): each ball touches the round along the circle shifted -r r'/|c'| from its great
circle. solidcore's variable round still takes the great circles, a body every check passes
but not that round (a linear 3 to 1 on a 20 mm edge removes 18.5998 against 18.5378).
fillet_variable on solidcore now gives Error::Unported citing solidcore#2250 (port in progress
as #2262, on #2260) after the capi's own checks, a pick that misses and a law with a corner
(solidcore#430) still answered first. The refusal comes off when the pin carries #2262 with
fillet_gn_section_generator on. Constant-radius rounds are untouched.

The C++ closed form of the linear 1 to 3 law is the balls' union's, 27.857434 (integrated
independently; the method gives develop's cone case 43.77766 against its 43.777573); the
curvature case's solidcore twins are no longer ignored, so they are listed while refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 13:03:47 -07:00
brett19 e9ac0e606e solidcore: a curvature fillet is refused until solidcore has the equal-area Gⁿ section
SMLib d01d7b0's capi builds the curvature section as the Gⁿ generator's order 2 at the
equal-area thumbweight (fork 6148773), taking off (1 - π/4) r² at a square corner. Our capi
port still built solidcore's blend-curve quintic (113/756 r²): a body every check passes,
off the closed form (862's box edge 7892.38 against 7845.49). solidcore has no Gⁿ generator
yet (solidcore#2250, port in progress as #2260), so Blend::Curvature now gives
Error::Unported, "not supported yet" with the issue, rather than another section. The
refusal comes off when the pin carries #2260 and 6148773 with fillet_gn_section_generator on.

The C++ closed-form case takes the equal-area volume; its solidcore twins are ignored until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 11:47:31 -07:00
brett19 ff894bfb67 Merge smlib 4afadeed5 (develop 6ba069d06, main 176c22aca) into solidcore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz

# Conflicts:
#	crates/smlib/src/ops_tests.rs
#	tests/KNOWN_FAILING_TESTS
2026-11-01 11:16:50 -07:00
brett19 c494a18f98 Rig: the reimported peg's rounds fail as the peg's do
fillets/report-peg-step-front-and-concave-rounds-together-r2 no longer
fails with 1001 on solidcore: since solidcore#2198 (cb9e544ab, WR-641) an
extended rail end whose section cannot be solved is skipped, and the round
builds as fillets/report-peg-front-and-concave-rounds-together-r2, the same
peg not written to STEP, already does: a seam G0 at (32, 2, 10) and the
tight box 6.5e-3 below the front face y = 0, where the exact box is
(0, 0, 0) .. (48, 16, 20). The case stays listed, with that reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 10:36:47 -07:00
brett19 09b6460057 Turn on solidcore's new fixes at 9fb13f145
Nineteen new UpstreamFixes flags, none shown broken: on the rig on
solidcore every case keeps its status, and 31 volumes move, all by
cylinder_plane_section_trims_exact (each restored by turning it alone
off), by at most 6e-8 relative. Where the case has a closed form the
shift is towards it: the circle swept along a polyline (1875 pi) goes
from 3.5e-4 to 8e-8 off, the three shell 316 cases from up to 1.6e-4
to under 2e-6, and the pipes of 2820 and 2862 from 3e-5 to 7e-8. The
two sloped-basin r4 rounds, which fail on continuity either way, move
by 5e-5 and 1.1e-4 (under 1e-9 relative). The rig's total time is as
before (888 s to 893 s). HELD_BACK stays empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 09:19:05 -07:00
brett19 02de15f904 Move the solidcore pin to 9fb13f145
It builds without change in SolidCraft: solidcore's API changes since
c931c9641 add nineteen UpstreamFixes flags and ValidateAndUpdateTolerances'
update mode, which SolidCraft does not call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 08:51:45 -07:00
brett19 0aff357051 Merge fix: solidcore reads planar sheets without SMLib, so their edge reader is always built
smlib's 4627402e4 built prism_curve::projected only with the smlib feature, where its one caller
lived; on solidcore that caller (prism.rs sheets) is built without it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 07:33:59 -07:00
brett19 486bdf7e66 Merge fork/smlib into solidcore
smlib 4627402e4 brings origin/main be425c610.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 07:14:59 -07:00
brett19 b57c24f7f7 Known-failing tests: the tabbed plate cites solidcore#2223
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 05:57:32 -07:00
brett19 f2ce343a0b Known failures after the new fixes: four tests and a rig case pass
They pass now and are un-listed: the convex-edge chamfer refused as too
large (solidcore#2175), the thickened spherical cap (#2174), the
thickened B-spline patch (#2111), the extrude reading back the region
clicked (#2177), and the rig's round of cylinders touching at a point
(#2110). solidcore now passes the six self-crossing intersection cases
it missed against the C++ kernel (ssi_split_curves_at_singular_points:
off, they miss again), so the corpus test asks for no miss at all.

Listed: the moved-top offset test's C++ comparison, as solidcore's
offset edges keep their tolerances and the pinned C++ kernel's don't
(both stay 1.2e-8 to 3.1e-7 off the closed form). The tabbed plate test
gets past the corner arc alone (#2176) and fails later, on the rim and
a corner arc rounded together, each of which builds alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 04:56:15 -07:00
brett19 a1b4119c38 Turn on geometric_g2_continuity: 2992's slowdown is gone
Run alone, crashes/2992-join-disk-and-36-twisted-loft-blades takes 35.6 s
with it on and 27.7 s off at c931c9641 (it was 121 s against 31 s), with
the same result (volume 7e-10 apart). The whole rig on solidcore with it
on keeps every status and moves one other volume by 1e-12. HELD_BACK is
empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 03:52:31 -07:00
brett19 e1b76bdfc6 Turn on solidcore's new fixes at c931c9641
Thirty-three new UpstreamFixes flags, none shown broken: on the rig on
solidcore no case loses its status, no volume moves by more than 6e-9
relative, and the slower cases of a loaded run time as before when run
alone. One case, the round of cylinders touching at a point, now passes
(ssi_split_curves_at_singular_points, solidcore#2110). Among them are the
fixes for solidcore#2174 (offset_open_edge_second_trim_point), #2176
(fillet_round_ends_at_tangent_continuation) and #2177
(close_loop_relates_only_coincident_edges).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 03:52:16 -07:00
brett19 84bca59696 Move the solidcore pin to c931c9641
OrientSheetSurfaces now takes the edit's notification environment. The
split by a sheet runs it through the kernel's callbacks with the sheet's
own attributes, as SMLib's objects notify their attributes on a UV swap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 03:21:18 -07:00
brett19 6de10a7fbd Model cache test: say why it reaches the C++ kernel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 01:46:24 -07:00
brett19 fb851d9c25 Known-failing tests: main's four tests that fail on solidcore as on C++
Rebuilt from CI's PR selection (nextest, ci profile) on 4a98a1f8a, x86_64: 20 failed of 11424. Every line already listed still fails; action_matrix_sample stays listed (CI's filter excludes it); the i18n failure is fixed in 9c2388ef8. The four tests main added fail with the same messages as on C++, where smlib's list has them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 01:33:14 -07:00
brett19 9c2388ef8c Geometry kernel strings in Vietnamese, now a complete catalog
main made Vietnamese a complete catalog; the five geometry-kernel messages solidcore added had no Vietnamese, so every_complete_catalog_carries_every_english_message failed after the merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 01:25:18 -07:00
brett19 4a98a1f8a4 Model cache: a saved model is its kernel's
The cache key did not name the kernel, so a design saved on solidcore opened under SOLIDCRAFT_KERNEL=smlib showed solidcore's bodies, and the other way round. Case fillets/report-fillet-concave-arc-and-front-top-edge-together-r7-solver-residues: saved on solidcore and opened on C++ gave volume 9446.261 (C++ evaluates 9453.338); saved on C++ and opened on solidcore gave 9453.338 (solidcore evaluates 9446.261). The manifest now records the kernel and a cache of another kernel, or of none, is a miss: the design is evaluated on the kernel in force.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
2026-11-01 00:42:31 -07:00
brett19 8f78846314 Merge remote-tracking branch 'fork/smlib' into ws/solidcore-on-smlib 2026-11-01 00:37:52 -07:00
brett19 462738b6aa Known failing after the smlib merge: the r5 test-peg corner on solidcore, main's C++ test lines
The r5 case's corner patch stands 1.56e-3 out of the front face on solidcore (it passed before the merge); the C++ list takes the four lines main's tests added on smlib.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 23:59:59 -07:00
brett19 d076a69e75 Merge smlib (main + develop) into solidcore
The hole and placement sections stay on main's Tools page and network locations on its General page; the geometry kernel section stays on the Design page. prepare_preferences refuses a kernel choice before main's checks. tests/KNOWN_FAILING_TESTS keeps solidcore's lines until rebuilt from a run on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 23:26:14 -07:00
brett19 15c5cd5d5f Known-failing tests: cite the solidcore issues now filed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 23:08:47 -07:00
brett19 afb55d806b One kernel flag: solidcore, the default, or smlib; routed is gone
The process runs on one kernel, so `Choice` and the routing table (`Op`, `Overrides`,
SOLIDCRAFT_KERNEL_ROUTES) go, and every call asks `route::backend()`. SOLIDCRAFT_KERNEL takes smlib or
solidcore as before.

- The preference offers solidcore and SMLib. A saved `routed` loads as solidcore without an error;
  asked for now, by ui.prefs or diagnostics.kernel, it is refused. Another unknown saved value stays
  as saved and runs on the default, as it did.
- diagnostics.kernel answers {choice, environment}. `smlib` on a build without the C++ kernel, wasm
  included, is refused as not available, never answered by the other kernel; a saved `smlib` there
  says so on the status line and the preference page.
- The conversion between kernels stays: a body computed before the choice changed still needs it, and
  so do the parity tests.

The notes' translations in 6 languages and the new message's are machine-written, not reviewed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 21:48:18 -07:00
brett19 cd94f78f31 Rig: --route is gone; a run has one kernel
The per-family runs (`solidcore[boolean,measure]`) needed the process to mix kernels, which the
kernel choice is about to stop doing. --route is refused with a message, so a script that still
passes it does not run as something else. The report's labels went with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 21:48:04 -07:00
brett19 3908128a85 Known-failing tests: refreshed from the default run at solidcore de85a3e00
Seven lines passed at this pin and go (the pleats, the corner rounds wider than their faces, the
B-spline patch offset); the action matrix is down to the 3 actions that also fail on C++, and the
naming corpus to oracle/83, also as on C++. The chamfer line says what happens now that the pick
takes the rim. Three lines name an issue still to be filed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 21:38:37 -07:00
brett19 c786db6bf4 A blend's pick measures close edges on their curves, not their polylines
A polyline can be a chord off its edge, more than two edges are apart near a
tangent point. A pick exactly on a ball's rim took the straight edge the rim is
tangent to, and solidcore built a chamfer of that edge with a clean check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 17:46:08 -07:00
brett19 b04e363e73 Rig: #316's renamed shell cases pass on solidcore, off its known-failing list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 16:51:05 -07:00
brett19 5dffd4f585 Merge origin/develop 05f5e8601 (#316's bottom rounds, kernel-case data only)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 16:50:50 -07:00
brett19 6221b7f4bf Rig: solidcore known-failing lists at pin de85a3e00
From the rig on this branch, 949 of 974 and one timeout: threads/3368-two-faceted-prisms-cut-then-joined
is not listed, it takes 160 s alone against the 180 s limit and passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 16:50:50 -07:00
brett19 c7f877a5e1 Merge solidcore 9efdc6763 (every family on solidcore by default) into ws/pin-10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 16:07:19 -07:00
brett19 9efdc6763a smlib builds its tests clean without the C++ kernel
The solidcore half of ssi_corpus runs without `native` (it needs no C++), and on_cpp
is unused there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 16:04:54 -07:00
brett19 75229f2b99 Offset tests: a sphere moved to its centre leaves nothing again (solidcore#2092 fixed by #2096)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 15:57:25 -07:00
brett19 d0c95f0dcf Chamfer: second offsets through SetFilletParameters (solidcore#2095)
The joined-squares chamfer now reaches the vertex-fan split: 6010 as its closed form, and the
peg chamfers pass again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 15:57:25 -07:00
brett19 0b48548af0 Turn on solidcore's new fixes and take the rational-span flag off HELD_BACK
New on: fillet_exact_round_classes, curve_check_zero_speed_tangent_end,
cpoly_remove_poly_face_check_member, check_seam_crossing_unclosed, ascii_doubles_round_trip.
bspline_trim_keeps_rational_end_spans: sheet-metal/3078 passes its check with it on since
solidcore#2097, volume unchanged (31362.04580633055). geometric_g2_continuity stays held:
the fix for solidcore#2091 (#2108) has not merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 15:57:25 -07:00
brett19 f6bc30121a Dump folder and crash report don't touch the C++ kernel when nothing runs on it
set_dump_dir started the C++ kernel on every call, and the rig calls it per case.
Both now ask the C++ side only when a family is routed there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 15:57:08 -07:00
brett19 0f843c295a Pin solidcore at main de85a3e00 (#2080, #2081, #2086, #2095, #2096, #2097, #2102, #2103)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 15:31:30 -07:00
brett19 120523a85a Remove the basic surface intersector; the default path's guard runs on native builds
Method::Basic and Error::NotOnSolidcore are gone. The walk over every kernel
entry point now also runs built with the C++ kernel, and fails if the default
path touches a C++ body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 15:05:50 -07:00
brett19 25d376f0f5 Merge ws/pin-9 into ws/default-solidcore
Keeps the failure dumps on solidcore's brep_copy; KNOWN_FAILING_TESTS stays
solidcore's, KNOWN_FAILING_TESTS.smlib takes develop's C++ list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 14:59:38 -07:00
brett19 197cd4f436 Booleans: the copies no longer take an index
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 14:16:32 -07:00
brett19 1c90e60aa7 Working copies by solidcore's brep_copy, not our own key domains (#2047 fixed by #2055)
copy_of named each copy in its own key domain with no naming scheme of its own; brep_copy gives a Notify
copy. A split then marks the body Caller: with_kernel_callbacks names the cut's new edge curves by the
kernel's identities, which it records, and the check reads owners by the body's naming (Test #61). Rig
level: 939/974, no status or volume change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 14:08:19 -07:00
brett19 5d59d42d8e Turn on gap_samples_non_bspline_v_stride, gap_array_append_updates_longest, imprint_skips_collapsed_definition
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 13:26:19 -07:00
brett19 126a281bbc Pin solidcore at main c093bc5a3 (#2054, #2060, #2061, #2065, #2066, #2068, #2069, #2072, #2076)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 13:26:19 -07:00
brett19 df6a65bea4 Merge origin/develop aa41c0b5d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 13:25:23 -07:00
brett19 1bf9c8665c HELD_BACK: geometric_g2_continuity is a fourfold slowdown on 2992, not a hang
Run alone, 2992 ends in 121 s with the flag on, whichever way the rational-span flag is, and in 31 s off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 13:06:50 -07:00
brett19 da4db59a3d HELD_BACK: geometric_g2_continuity's reason is 2992 alone
3368's timeout was the machine's load: it takes 156 s with g2 off, as it took at pin 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 12:10:13 -07:00
brett19 5d7dca1a91 Offset tests: both kernels refuse a top moved -9; the sphere to its centre waits on solidcore
The C++ kernel at 26cc451 refuses the moved top at -9, as ours does. #2027's numbers need 4.7e-9 at
-7.5, not 1e-6. offset_splits_corner_vertices makes the sphere moved to its centre fail with 1001 rather
than an empty result: ignored with the reason until solidcore answers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 11:19:50 -07:00
brett19 38849d7b00 Hold geometric_g2_continuity: with the rational-span flag off, 2992 and 3368 don't end
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 11:19:50 -07:00
brett19 32b26888c1 Turn on offset_keep_cavity_regions and offset_four_face_vertex_split
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 11:00:12 -07:00
brett19 9df34988cd Pin solidcore at main bb6b65706 (#2049, #2051, #2055)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 11:00:12 -07:00
brett19 2cd566aae0 Hold bspline_trim_keeps_rational_end_spans: it fails 3078's check
With it on sheet-metal/3078 fails SmBSplineCurve::Test #15 Bad Repeated CPts; held off the case passes,
same volume. Holding it is the case the new rule asks for: a check failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 10:59:04 -07:00
brett19 9337aff4c4 Every solidcore fix on unless it appears broken (brett19's rule)
The rule that a flag goes on only if the fork has it is gone: differing from SMLib is no evidence
against a fix. All 32 held flags are on, fillet_refuse_crossing_rails with solidcore#2053 among them.
HELD_BACK now takes only a flag shown broken, with its case and numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 10:28:27 -07:00
brett19 2b1a63d3f9 Pin solidcore at main 2898e1197 (#2053)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 10:27:49 -07:00
brett19 d4eb66f25e Turn on offset_splits_corner_vertices (fork 2e1bb8c's behaviour)
The fork at 26cc451 offsets four faces at a vertex to two vertices joined by a new edge, as the
flag does, by another route. On, the rig's two drain floors pass, at SMLib's volumes within 4e-15,
and nothing else moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 10:18:03 -07:00
brett19 6f1e2ef343 Merge branch 'solidcore' of https://github.com/brett19/solidcraft into ws/default-solidcore 2026-10-31 10:01:21 -07:00
brett19 b4e0da3db8 KNOWN_FAILING_TESTS: cite solidcore#2048
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 10:01:21 -07:00
brett19 6a0166853f A round mitred to one that overflows goes first with it (fork 9ba2b12); flags() and warnings
overflow_first takes the mitred pairs of the stopped call (solidcore#2045's mitred_edges) and
grows the rounds that go first through every mitre, as the capi's. The flag test reads
UpstreamFixes::flags (solidcore#2024), and the check's own filter of SMLib's warning gives way to
AssertReport::is_warning (solidcore#2023).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 09:37:58 -07:00
brett19 15e3a61dab Pin 8: decide the new solidcore flags against the fork at 26cc451
On: query_statuses_pass_on, sweep_statuses_pass_on, ssi_proto_topology_statuses_pass_on,
analytic_inversion_answers_apart, fillet_statuses_pass_on, fillet_corner_per_vertex_fan,
fillet_rail_trim_at_near_knot, and fillet_corner_statuses and fillet_edge_curve_domain_statuses
(both soft like the fork since solidcore#2036). Held, the fork keeps upstream's code:
proto_edge_split_upper_keeps_the_old_max, point_on_start_point_squared_tolerance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 09:36:12 -07:00
brett19 eff6416d46 Pin solidcore at main f75e9d2e8 (#2023, #2024, #2034-#2040, #2043-#2045)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 09:34:32 -07:00
brett19 7f1bf7cde6 Merge origin/develop 88e00486b
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 09:34:28 -07:00
brett19 ed16c4bfc1 Merge develop b0d1ba345 (SMLib 26cc451) through ws/develop-b0d1ba
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 09:34:28 -07:00
brett19 0b627d6373 Restore all_solid's loop: an unused index slipped in
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 08:31:30 -07:00
brett19 ff39488aad solidcraft-tests skill stays under its length
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 08:26:19 -07:00
brett19 c5e4a42f4f Format
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 08:20:36 -07:00
brett19 2647b12e55 Hold back fillet_refuse_crossing_rails: not in the fork
The fork has no such check: SmFilletExecutive.cpp:3654 stops overlapping bands,
which stays on. Off, 5 of the 12 action-matrix failures on solidcore pass (oracle/28,
30, 36, 54, typical/filleted-shelled-box's fillet), each failing with the flag's
closest-point query's 1001; the rig does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 08:12:56 -07:00
brett19 869165d326 KNOWN_FAILING_TESTS: exact causes of the action matrix failures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 08:09:43 -07:00
brett19 fdb9134a9b Merge branch 'solidcore' of https://github.com/brett19/solidcraft into ws/default-solidcore 2026-10-31 07:42:22 -07:00
brett19 99ba9dfb1a Tests known to fail, one list per kernel; untrim carries history tags; fix two tests
KNOWN_FAILING_TESTS is now solidcore's, the default; KNOWN_FAILING_TESTS.smlib keeps
the C++ kernel's. A face copied out for untrimming now carries its tags on solidcore.
The profile test accepts either kernel's spans; ssi_corpus skips a kernel the
environment overrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 07:42:22 -07:00
brett19 b6285e5252 Overflowing rounds go first again (solidcore#1980)
OVERFLOW_GOES_FIRST on. The rig shows no wrong volume: 5158's plate keeps its
value and 1080-notched-r8 moves to within 7e-11 of the C++ kernel. The sloped basin's
R4 rounds are refused as in the C++ kernel at 2fc375f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 07:25:03 -07:00
brett19 8ad306e695 boolean2d through boolean_2d, typed edge-vanishes, typed put-back refusal
boolean2d goes through api_brep::boolean_2d, SM_ERR_OFFSET_EDGE_VANISHES through
is_offset_edge_vanishes (solidcore#2016). A mitred round that cannot be put back
(solidcore#2027) is Error::Unsupported. The tests held for solidcore#1462, #1973,
#1980 and #1981 run again. copy_of stays as it is: results of offset_faces on a
brep_copy (NamingScheme::Notify) fail check_brep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 06:57:15 -07:00
brett19 6295833c45 Held flags re-read at SMLib 26cc451: none changes
Each held flag's fork site is untouched by 2fc375f..26cc451. The capi's
mitre rule (fork 9ba2b12) has no solidcore call to read yet; noted at
OVERFLOW_GOES_FIRST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 06:50:04 -07:00
brett19 14b1d19563 Pin 7: decide the new solidcore flags against the fork
On: fillet_corner_face_curve_halves, brep_edit_statuses_pass_on,
fillet_run_on_bspline_trims, offset_tangent_gap_statuses and ssi_alignment_statuses
(both soft like the fork since solidcore#2017). The other new flags are held back,
each with the fork's source as the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 06:48:40 -07:00
brett19 c26f9760de Merge develop b0d1ba345 into solidcore
SMLib moves to 26cc451 with develop. Merged cleanly; the kernel test
changes are develop's, moved or tightened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 06:48:37 -07:00
brett19 7e92386297 Merge fork/solidcore into the develop merge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 06:48:07 -07:00
brett19 5c02791534 Pin solidcore at main c369f8801 (#1977, #1996, #2000, #2016, #2017, #2018, #2027 and more)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 06:32:44 -07:00
brett19 fbc9e16ce7 Merge develop e6abea809 into solidcore
Control splines carry knots and weights; sketch projection brings a
free-form edge in as one exact spline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 04:48:06 -07:00
brett19 130aaa38c0 Solidcore known-failing lists at pin 9dde25e97; ladder gate stays on the C++ kernel
KNOWN_FAILING.solidcore lists the 24 cases that fail on the default kernel, 23 of them also on SMLib's
list. The ladder golden is the C++ kernel's and solidcore differs on one case, so that gate opts in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:36:07 -07:00
brett19 aca0b57eea Turn on plane_recognition_origin_after_axes
The fork places a recognized plane's origin after squaring its axes (0286682, in SMLIB_REV;
SmPlane.cpp:2836-2852 at 2fc375f), and solidcore#2004 brings the same behind this flag. It closes
sweeps/4305's Bad Gap and moves sweeps/5037 to SMLib's volume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:14:49 -07:00
brett19 83487c4d93 Pin solidcore at main 9f2c980b3 (#2004, #2005, #2006, #2008)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:14:49 -07:00
brett19 ad6fd72bcf Merge solidcore 9b73dfe74 (ws/pin-5) into ws/default-solidcore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:14:28 -07:00
brett19 7979615c08 Rig gate grades the kernel given
--known-failing follows the run's kernel: KNOWN_FAILING.solidcore for the default and the CI gates,
KNOWN_FAILING for --kernel smlib. The solidcore lists come with the final pin. Also fixes the --route
test for the new default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:11:18 -07:00
brett19 4d997d16f6 smlib: SSI on solidcore, its failure dumps and call in flight; rows that are true
ssi::intersect, domain and evaluate run on solidcore (global and advanced; the basic intersector has
no public entry and says so), and ssi_corpus scores both kernels: six cases C++ passes are listed as
solidcore's misses. A failing solidcore call now leaves its B-rep inputs and what failed, and
current_call names the running call without a lock. Op::History and Op::Flat, read by nothing, are
gone from the table, so diagnostics.kernel reports only what is true.

A guard test keeps the list of entry points: with no C++ kernel each answers, but for the C++ kernel's
own arrays. route::on_cpp now fails a test that never reached C++.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:11:18 -07:00
brett19 9b73dfe749 Offsets go through solidcore's simple calls; ignored tests cite solidcore issues
`shell_brep_simple`, `offset_brep_simple` and `local_operation_simple` carry the same options and
the source's attributes, so the 120 lines of key domains and notification stores in core/offset.rs
go (gap-audit D2). The held-flags comment now says what each flag's fork code does. Ignored tests cite
solidcore#1980, #1462, #1973 and #1981 instead of local files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:01:40 -07:00
brett19 5ffc55e530 Profiler: a span per solidcore call, in a category of its own
The smlib category times the C API's calls, so on the new default the kernel's calls went unseen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 02:48:16 -07:00
brett19 f01b877570 Audit the held flags against the fork's source; tabbed plate and mitred rounds parity
Reading the fork at SMLIB_REV finds `perspective_sphere_offsets` is there (709c5be: the cull projects
each sphere's own radius from its centre), so it goes on. The new
`gap_samples_use_trimmed_spans` and `twist_simple_duw_sign` are held: upstream's defects are still in
the fork.

The tabbed plate and #1997's rounded box are compared with the C++ kernel. `sweeps::List` is visible
to the crate for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 02:37:40 -07:00
brett19 2badb54040 Geometry kernel preference: say that Routed is solidcore for every operation
The note (all seven languages), the command's description and the test: smlib keeps the C++ kernel
reachable for every family, the old behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 02:35:56 -07:00
brett19 b1e5b31b2d Rig: kernel-cases runs on solidcore unless told otherwise
--kernel smlib and both remain. --known-failing still grades the SMLib side, whose lists these are,
so it needs that said, and the CI gates opt in. The ladder takes --kernel for its one backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 02:35:43 -07:00
brett19 ee078b97c4 smlib: no unrouted C++ path on solidcore; .smb files move too
Native files run on solidcore through its public api_files (each kernel reads the other's, in both
forms). face_points routes with Split, pcurves with Convert, and an ssi::Surface no longer needs the
C API: its arguments are checked here as the C API checks them, and the C side is made only when a
family on the C++ kernel asks. intersect, domain and evaluate have no solidcore path and refuse with
Error::NotOnSolidcore. Planar region booleans and the rig CLI no longer need the smlib feature.

Tests that exercise the C++ kernel opt in per thread (route::on_cpp), so parity tests keep comparing
two kernels instead of solidcore with itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 02:35:43 -07:00
brett19 03470c3df3 Pin solidcore at main 9dde25e97 (#1988, #1997, #1985, #1940, #1974, #1975, #1976)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 02:25:14 -07:00
brett19 612ac9a367 Merge ws/pin-4: solidcore 166d2de01, variable-span and coincidence flags on, parity tests for the audit's fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 02:24:21 -07:00
brett19 e8e26db71c Tests hold what they measured: 871's refusal is NoRoom, no prints
The ring and lip test says it does not single out the crossing flag: it passes with the flag off
on this path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 02:23:09 -07:00
brett19 887a1c9f1d Route every operation family to solidcore by default
The routing table now sends each family to solidcore; the C++ SMLib kernel is an opt-in
(SOLIDCRAFT_KERNEL=smlib, the Geometry kernel preference, SOLIDCRAFT_KERNEL_ROUTES). Native .smb
files stay on SMLib here; the next commit moves them. Fallout follows in separate commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 02:20:38 -07:00
brett19 6c3cc42d74 Turn on the variable round, crossing and status flags; test #871, ring and lip, step wall
On, their fork commits in SMLIB_REV: `fillet_variable_span_follows_fillet` (4438859),
`simple_coincidence_crossing_is_point` (c2e3d9d), and from solidcore#1775 the statuses whose fork
passes do what the flag does: `boolean_temporary_transform_statuses` (e7ebc8d),
`primitive_path_curve_statuses`, `planar_path_sweep_statuses` and
`planar_path_profile_placement_statuses` (c8f6a31), `fillet_trim_status` (9b7cd48). The last two
were held as having no fork commit, which was wrong.

Held: `fillet_corner_statuses`, `fillet_edge_curve_domain_statuses`,
`offset_tangent_gap_statuses`, `ssi_alignment_statuses` (the fork carries on softly where these
refuse) and `translation_sweep_regularize` (no fork commit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 01:51:42 -07:00
brett19 e75015a9a2 Pin solidcore at main 166d2de01 (#1983, #1987, #1990, #1991, #1992, #1775, #1937)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 01:44:37 -07:00
brett19 9fb8e778ff Merge ws/pin-3: solidcore bdd614891, tight box and SSI probation flags on, measure's dead string match removed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 01:33:09 -07:00
brett19 29134cfb80 Turn on the tight box and low angle start flags, hold the rest with what was checked
`face_tight_box_searches_trim` goes on now that solidcore#1961 brings fork 326de73.
`ssi_low_angle_start_on_probation` goes on: fork 30d25bf was rebased to 1f5c0f7, in SMLIB_REV.
Three port fixes and the new `extent3d_contains_both_segment_endpoints` stay held: the defects are
still in the fork's source at 2fc375f.

`measure::check` no longer matches `check_brep`'s operation string. solidcore does not raise that
error (a Caller body without keys is checked under arena identities), so the arm was dead; the
refusal is decided on the typed `NamingScheme` alone, and a test holds it there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 01:11:45 -07:00
brett19 bdc9c8f1eb Pin solidcore at main bdd614891 (#1961)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 01:09:19 -07:00
brett19 c156ab444e Merge ws/pin-2: solidcore ef3a24ab1, review fixes, mitre gap, run-on and tight drop flags, capi area and cone draft
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:53:00 -07:00
brett19 59dfe27433 smlib: a cone about the pull drafts to the cone its generators turned make, on solidcore (capi d51a723, smlib#53)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:40:26 -07:00
brett19 0b83327edd smlib: a body's area counts its sheet faces on solidcore (capi 9767916)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:40:26 -07:00
brett19 f85398899a smlib: flags at solidcore ef3a24ab1; the L sheet's thicken test runs (#1950 fixed)
On: fillet_mitre_gap_patch (fork 2223108), fillet_run_on_past_far_end_only (7224896), good_drop_result_distances_in_order (eaa998a). Held back: geometric_g2_continuity and solidcore's new flags with no fork commit in SMLIB_REV.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:35:29 -07:00
brett19 e0fb9ca9d4 Pin solidcore at main ef3a24ab1 (#1947, #1948, #1949, #1958, #1960)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:34:48 -07:00
brett19 b13ee97b0c Merge remote-tracking branch 'fork/ws/review-1031' into ws/pin-2 2026-10-31 00:33:43 -07:00
brett19 7f6b96e65e Merge ws/next-pin: develop 20d5d8b, SMLib 2fc375f, solidcore 117a3b86d, cliff, mitre and offset-faces routing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:33:32 -07:00
brett19 3eb4d96e63 smlib: clippy in the chamfer repro test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:31:41 -07:00
brett19 a7bb27dae5 smlib: hold back the overflow-first ordering; mitre oracles and two solidcore repros
The ordering (fork 241f7ba) is ported but off (OVERFLOW_GOES_FIRST): solidcore's cliff overflow loses the attributes of target edges its subtraction replaces, so a shortened picked edge reads as removed and is silently not rounded (2 rig cases wrong). Tests: the capi's rounds_at_a_concave_face_corner_are_mitred; overflow_goes_first (ignored, gives 6107.529369565 with it on); two ignored solidcore-only repros.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:20:56 -07:00
brett19 8e93872bbb smlib: hold back solidcore's two new flags at 117a3b86d (no fork commit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:35:52 -07:00
brett19 5a23119137 Pin solidcore at main 117a3b86d (#1963 closes #1950, #1925)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:35:52 -07:00
brett19 c5503f7c3f smlib: the L sheet's thicken test names solidcore#1950, which it turned out to be
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:33:29 -07:00
brett19 fd270252f2 smlib: convert: process kernel for wire edges, unheld surfaces refused, conics make their NURB
L1: GetWireEdges uses the process kernel, not a fresh default one.
L3: a face whose surface the B-rep's list does not hold is refused, as smlib_flat.cpp:1520.
L4: a hyperbola or parabola without its NURB makes one on a copy, as GetOrCreateGwNurbPointer.
L5: every rejection of the C++ Validate is in ours, read line by line; a mutation test against
the C++ kernel holds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:32:17 -07:00
brett19 0eb73bc655 smlib: an L sheet's thicken parity test, ignored for a solidcore refusal
C++ makes the closed form, 840 mm3. thicken_planar_side_walls makes solidcore answer
Unported "SmFace handle of a surface's face". Written up apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:32:17 -07:00
brett19 7b6537e935 smlib: rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:31:24 -07:00
brett19 4ea96a750c smlib: flags at solidcore 42aab6a6d, cliff refusals by NoRoomReason, overflow-first ordering
Flags on: fillet_cliff_overflow, fillet_3x2_tangent_side_edge_roles, offset_extension_as_itself, fillet_mitre_concave_face_corners (fork commits in SMLIB_REV 2fc375f). Held back: the port's own fixes with no fork commit. Ports capi blend_call / overflow_first (fork 241f7ba) onto the hooks of solidcore #1929, with blend_dropping routed. From ws/cliff-1905: blend_failure on NoRoomReason, fillet_over_a_cliff un-ignored, a_cliff_overflow_the_fork_refuses_has_no_room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:26:37 -07:00
brett19 21d8c1ab49 smlib: route offset_faces to solidcore's OffsetFacesExecutive; named() keys are (kind+1)<<32|index
The keys no longer collide past arena index 1,000,000 (test at that index). Un-ignores offset_faces_top_out and tags_cross_a_face_offset (solidcore #1890, #1895).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:26:37 -07:00
brett19 e69cbae539 smlib: port capi 2fc375f (split by a sheet) and 0f7b209 (extend run-off solved) to solidcore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:26:37 -07:00
brett19 67b5d928c8 smlib: history parity over closed edges, seams, three operands, sheets and merges
Closed edges including moved()'s open.is_empty() branch, a curved face split across its
seam, union_all of three, merges with several origins on each side, sheet offset, a
curved sheet's thicken and a shell of a cylinder match C++. Two tests are ignored for
solidcore differences written up apart: boolean_2d carries no attributes, and a flat
sheet's thicken loses the edge tags on the sheet's side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:23:04 -07:00
brett19 dda7d6a0ec smlib: a failed closest point in history's moved counts as off the piece
smlib_history.cpp:246 counts any failure of SmApiEdgeClosestPoint as off the piece, so
the face is Modified. The port passed on an error that was no status, which failed a
boolean that had succeeded. An edge without its curve shows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:15:40 -07:00
brett19 a0ea9ba1c4 smlib: an offset edge that vanishes is Unsupported, as the capi's status says
Found comparing drafts while checking M2: the capi's status (smlib_shim.h:82) answers
SM_ERR_OFFSET_EDGE_VANISHES (1043) as SMLIB_ERR_UNSUPPORTED; the port passed on Status(1043).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:15:40 -07:00
brett19 651a9fd247 Pin solidcore at main 42aab6a6d
Compiles with the next commits (the develop merge's Split Body by a sheet is not routed yet, and the new flags are not listed yet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:15:35 -07:00
brett19 a08afa5a8b smlib: a draft that moved no face is NoEffect on solidcore too
Port the capi's moved_faces (smlib_ops.cpp:156-165, :1078), its mark on the picked
faces as solidcore#1875/#1877 lay out, since a surface has no identity apart from
its owning face. The reason it was skipped (solidcore#1462) was stale. No draft
found reaches SMLIB_ERR_NO_EFFECT on C++, so the parity test holds the same answer,
volume and counts over the nearest cases, and a unit test holds the reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:14:56 -07:00
brett19 fe98ad07d2 Merge remote-tracking branch 'origin/develop' into ws/next-pin 2026-10-30 23:13:25 -07:00
brett19 827c7ef0c6 Merge ws/develop-e18fb40: develop e18fb40, SMLib eaa998a, capi ports, two half-port flags held back
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:11:25 -07:00
brett19 ae76f0567c smlib: C++ bodies cross solidcore and come back with the C++ kernel's own arrays
C++ -> arrays -> solidcore -> arrays -> C++ over a filleted box, a holed plate,
a swept B-spline, an ellipse edge, a sealed void, sheets, wire edges with a point
shell, two lumps and a shelled rounded box. The reference is the C++ kernel's own
crossing, which itself moves trims by about 1e-7 on a shelled box; tolerances and
EXTENT are excepted, and the test says why. No port difference found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:10:05 -07:00
brett19 63d4bb5d8b smlib: hold back two fork fixes solidcore has only half of
face_tight_box_searches_trim lacks fork 326de73 and good_drop_result_distances_in_order lacks
eaa998a's tight convergence test. With them on, 15 thread cases fail on their tight box and the
peg's round is refused; both agree with SMLib eaa998a with them off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:02:51 -07:00
brett19 922f781e2e Merge ws/combined-1031: solidcore 6a2e8c68f, split-face and triage fixes, develop 456e5b7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 22:44:33 -07:00
brett19 cc3a7329c8 smlib: classify_point returns its with_body call directly (clippy let_and_return)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 22:44:05 -07:00
brett19 2b49e6c251 smlib: stitch closes a sheet sewn onto a solid's edges into it (capi e5d9bba)
Port of the capi's close_onto_shared_edges in core/merge.rs: edges still free after the manifold
stitch are glued at the same tolerance to the edges they lie on, whatever uses those
(allow_spine_edges), on a copy. Faces then between two material regions go. The copy is kept when
no edge has more than two faces. A gluing that leaves fewer material regions than it found is
refused with Error::Unsupported.

Tests in core/merge_tests.rs mirror capi/tests/sheets.cpp, on solidcore and on C++: a biquadratic
cover over a 10 mm cube sews into a closed solid of 6 faces and 1100 mm^3; a bicubic cover lying
on the top is refused; a flap off a top edge stays beside the cube, 7 faces, 1000 mm^3, 700 mm^2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 22:34:48 -07:00
brett19 d9e922a5a7 smlib: a knife edge's chamfer is the chord of the ball of radius the distance (capi e96898d)
chamfer_radius takes the distance itself at a cusp; test knife_edge_chamfers against the capi's
closed form (not yet run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 04:59:21 +01:00
brett19 fe44a27e33 smlib: blend_dropping routes to solidcore (capi 241f7ba)
The overflow-first rounds need the fork's cliff overflow, which solidcore has not ported: on
solidcore the rounds blend together and no pick is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 04:59:21 +01:00
brett19 b09c48a42a smlib: classify_point runs on solidcore (capi 34d6b2b)
SmBrep::Point3DClassify at the body's zone tolerance through solidcore's BrepClassifier; test
points_classify_as_the_capi mirrors the capi's cases, with C++ parity under native.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 04:59:21 +01:00
brett19 d6cb8f12c9 smlib: the fixes SMLIB_REV eaa998a carries are on
fillet_trim_inside_by_best_dot (fork de4dae6), fillet_half_degree_crease (2e8758b),
fillet_split_edge_keeps_near_half (ceb3272), good_drop_result_distances_in_order (0b4de8c, landed
as eaa998a), curve_loop_normal_from_area (be45e8d) and face_tight_box_searches_trim (beb6061; the
fork's 326de73 knot division is not in solidcore yet) leave HELD_BACK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 04:59:11 +01:00
brett19 cb31c0d4bf Merge origin/develop e18fb40d8 into ws/develop-e18fb40 2026-10-31 04:47:55 +01:00
brett19 068c6ac762 smlib: fillet_over_a_cliff stays ignored at solidcore 6a2e8c68f with its current refusal
The rail still leaves the face it rolls on (FilletErrorType::NoRoom), which the crate reports as
Unsupported: solidcore#960's cliff overflow (fork 8ba4667) is not complete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:56:18 +01:00
brett19 1b291f750e smlib: gate the C++-only test helpers on the native feature
tests_of (measure_tests) and Raw::canonical (history) are used only by tests that compare with the
C++ kernel, so clippy without native flagged them as dead code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:27:25 +01:00
brett19 727796cbcc smlib: turn on solidcore's new fork fixes at 6a2e8c68f
fillet_nx1_tangent_side_edge_same_convexity (WR-600, fork 7992ec7) and
fillet_removal_takes_ring_fillets (WR-605, fork 52bfef0) each port a tangency-labs/smlib commit
that is an ancestor of SMLIB_REV 68fb9162. The pin needs no API change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:27:25 +01:00
brett19 da6cc21c2c smlib: pin solidcore to 6a2e8c68f (main after #1070)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:24:08 +01:00
brett19 b9659c8e5d Merge ws/develop-456e5b7 into ws/combined-1031 2026-10-31 03:23:48 +01:00
brett19 8de0453f58 Merge ws/triage-1031 into ws/combined-1031 2026-10-31 03:23:48 +01:00
brett19 84939bca6f Merge ws/split-g2-trim into ws/combined-1031 2026-10-31 03:23:48 +01:00
brett19 10ffd88d01 smlib: untrim out of the body runs on solidcore now that CopyFaces through the merge copies (solidcore#1872)
The capi's copied.GetSize() != 1 is SMLIB_ERR_EMPTY, not a refusal; the four out-of-body untrim
cases are no longer ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:21:52 +01:00
brett19 783a4075fa smlib: Split Face on solidcore intersects a face's surface through its face's cache
cut_face intersected the face's surface with a non-plane tool (or a plane strip) by a plain
global_surface_intersect on an evaluation copy of the face's surface. That copy keeps its face
owner, and solidcore refuses a trimmed cache for a face-owned surface reached without the
B-rep ("TrimSrfCache::build_tree_with_face for a face-owned surface", Unported), so a B-spline
face cut by plane strips (surface.trim with sketch lines on a spline sheet) failed with "SMLib
cannot build this exactly". The C++ reaches the face through face->GetSurface(); the port now
solves through the B-rep's BrepFaceServices with the face's cache handle, as solidcore's own
face_planar_section_curves does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:21:51 +01:00
Brett Lawson 6f81622a3e smlib: a blend at a corner no type handles is CornerUnsupported on solidcore, as on C++
The capi's blend_failure maps SM_FILERR_CORNER_UNSUPPORTED to
SMLIB_ERR_CORNER_UNSUPPORTED. solidcore notes that code (with
fixes.fillet_vertex_error_diagnostics, which we turn on) when
CreateFilletCorners leaves a vertex without a corner, but the port's
blend_failure mapped only NoRoom, so such a blend failed with the bare
status 1001 (fillets/1390-chamfer-hole-rim-touching-bottom). The note is
kept through the later step that fails because of it, as solidcore's own
FilletSession::do_fillet keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:08:46 +01:00
Brett Lawson a626da2936 smlib: turn on solidcore's new fork fixes at 9b398723b; hold back the rest
On (each ports a tangency-labs/smlib commit that is an ancestor of SMLIB_REV 68fb9162):
revolve_splits_curves_on_axis (WR-590, e4f1377), cover_wraps_across_closed_seam (WR-599,
16bf1cb), imprint_extension_keeps_analytic_curve (WR-601, bec8a0b),
fillet_trace_from_tangent_point_toward_next (WR-603, 68fb916),
fillet_tangent_point_one_sided_step (WR-595, f8fd83e), fillet_rails_meet_at_one_end_point
(WR-594, 5fdefb9), fillet_trace_reaches_rails (WR-609/610, a9277fd, 6e77631),
delete_vertex_joins_seam_iso_curve (WR-598, 829b982), box_face_failures_propagate (WR-596,
0df62c8, 35008b1, 449c81c), ellipse_adjust_step_interval_keeps_turns (WR-211, 5c0b5ed),
fillet_cleanup_removes_input_slits (WR-602, cac11ea), fillet_rail_end_on_continued_surface
(WR-630, 3f22e97), glue_vertices_joins_every_region (WR-607, 661f79c).

Held back: temporary_radius_setup_errors (#458), ellipse_step_interval_checks (#428),
perspective_sphere_offsets (#525) and curve_loop_normal_from_area (#1776) have no fork commit;
fillet_trim_inside_by_best_dot (de4dae6), fillet_half_degree_crease (2e8758b),
fillet_split_edge_keeps_near_half (ceb3272) and good_drop_result_distances_in_order (0b4de8c)
port fork commits not in SMLIB_REV; corner_patch_face_side_twist (7fd9fb0) is unpublished.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:02:12 +01:00
Brett Lawson 368a3ca293 smlib: pin solidcore to 9b398723b (main after #1840)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 03:01:55 +01:00
Brett Lawson 0afb20287d smlib: closed_nonmanifold on solidcore takes a failed edge query as false, as the capi does
The capi's closed_nonmanifold (smlib_ops.cpp) returns false for an edge that
is null or whose GetEdgeuses fails; the port propagated the error, failing the
boolean instead of reporting it not solid. The per-edge test is now
`edge_closes`, which takes either failed query as false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 02:53:22 +01:00
Brett Lawson b4ee38083f smlib: solidcore's check counts no SmBrep test 21 warning, as the capi's does
The capi's smlib_brep_check (smlib_check.cpp, `advisory`) skips SmBrep test 21,
"Suspicious Shell Count": a warning AssertValid reports without failing. The
solidcore port counted it, so a sheet of faces offset apart across a sharp edge
(one shell per face, rightly) was refused after the stitch whenever the Check
family ran on solidcore (surfaces/cov-offset-two-faces-across-a-sharp-edge-come-apart).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 02:53:22 +01:00
Brett Lawson 6edfba0a79 Merge remote-tracking branch 'origin/develop' into ws/develop-456e5b7 2026-10-31 02:47:23 +01:00
Brett Lawson 1ad666601d smlib: the Split and Transform families run on solidcore (split, untrim, extend, stitch, merge faces, copy and transform)
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/smlib/src/core.rs
2026-10-31 01:39:15 +01:00
Brett Lawson c117401cff Merge develop 134a34f8 into solidcore 2026-10-31 01:39:06 +01:00
Brett Lawson cedd11eb4b smlib: untrim and extend on solidcore (smlib_untrim_face, smlib_extend_sheet), routed by Op::Split
A port of the fork capi's smlib_untrim.cpp (holes taken away with DeleteInnerLoop, the outer
boundary back on the surface's box with the kept holes rebuilt by MakeVertexLoop and
MakeEdgeInFace) and smlib_extend.cpp (CreateExtendedSurface corrected to the distance across the
side's middle, CopyAndAddAnalytics, a cone or cylinder continued naturally kept analytic).

An untrim of a face out of its body (in_place = 0) is not supported yet on solidcore: its
CopyFaces through the merge copies nothing (the merge copy's failure is ignored). Those cases'
solidcore tests are ignored; their C++ closed-form tests run. Closed-form, C++ parity,
hostile-argument and history tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:47:39 +01:00
Brett Lawson a42710ef98 Merge develop 134a34f8 into solidcore 2026-10-31 00:47:30 +01:00
Brett Lawson 4168920ad4 smlib: stitch and merge faces on solidcore (smlib_stitch, smlib_merge_faces), routed by Op::Split
A port of the fork capi's smlib_stitch (StitchFaces at the caller's tolerance, once, then
OrientTrimmedSurfaces; not StitchAndOrient) and smlib_merge_faces (the kept edges picked by
point, then RemoveTopologicalEdgesAndVertices and finish). Closed-form, C++ parity,
hostile-argument and history tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:47:30 +01:00
Brett Lawson cdddae8a17 smlib: Split Face and Split Body on solidcore (smlib_split_faces, smlib_split_body), routed by Op::Split
A port of the fork capi's smlib_split.cpp: faces cut where tool surfaces meet them
(CreatePlanarSectionCurves for a plane reaching over the face, GlobalSurfaceIntersect otherwise,
then MergeCurvesOnSurface with the tool), and a solid sliced (SM_BO_SLICE) by a one-face sheet
on the tool, once turned over. A tool Surface keeps its constructor's description, from which
solidcore makes its own surface as smlib_ssi.cpp makes SMLib's. Closed-form, C++ parity,
hostile-argument and history tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:47:30 +01:00
Brett Lawson 58ed3002c4 smlib: copies and transforms on solidcore (smlib_brep_copy, smlib_brep_transform), routed by Op::Transform
A port of the fork capi's smlib_brep_copy and smlib_brep_transform: SmApiBrepCopy, then
SmBrep::Transform(matrix, TRUE) on the copy (solidcore's transform_matrix). Brep::try_clone and
Brep::transformed take it when Op::Transform runs on solidcore; the default stays SMLib.
Closed-form, C++ parity, hostile-matrix and history tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:47:30 +01:00
Brett Lawson d20aee0757 smlib: solidcore's boolean_wire_edge_keeps_faceuse_shell opt-in is on (the fork's be8ac73, in SMLIB_REV)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:43:38 +01:00
Brett Lawson ec3a21778e smlib: a blend on solidcore has a history, as on the C++ kernel
solidcore#1594 carries the body's attributes through its fillets, so a solidcore blend's faces
and edges keep their tags. Brep::made_core_blend, which marked a solidcore blend's history
untracked ("not supported yet: the history of a blend on solidcore (solidcore#1594)"), goes:
solidcore's blends are made_core bodies, as its other operations' are, and Brep::history reads
them. tags_cross_a_fillet runs again, and tags_cross_a_chamfer joins it: one edge of a box
rounded or chamfered keeps the bottom and back, trims the top, front and sides and makes the
blend face, on both kernels, with the same history face by face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:35:23 +01:00
Brett Lawson c9fcf82a54 smlib: solidcore's four new fork-carried opt-ins are on; the fillet trim status waits
boolean_wire_edge_keeps_faceuse_shell (WR-585, solidcore#1753, smlib@be8ac73),
fillet_nx1_tangent_corner_edge (WR-593, #1816, smlib@1ff7353), fillet_torus_rail_on_axis (WR-592,
#1814, smlib@994f129) and loops_go_on_round_the_vertex (WR-591, #1813, smlib@9209e76) are on:
their fork commits are in SMLIB_REV 68fb9162. fillet_trim_status (WR-604, solidcore#674) has no
fork commit, so it goes on HELD_BACK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:24:30 +01:00
Brett Lawson 509209838a solidcore: move the pin to afcee131a (main, with solidcore#1594, #1674, #1595)
Merged since 15e72dff9:
- #1594 B-rep-owned attributes carried through fillets (#1462 P3): a blend's faces and edges
  keep their origins
- #1674 offset results and sweep wires named by their geometry owners (fixes #1670, #1671)
- #1595 SmBrep::Transform with a matrix
- #1753 opt-in a new edge joining a wire shell to a faceuse shell keeps the faceuse shell
  (boolean_wire_edge_keeps_faceuse_shell, WR-585, fork be8ac73)
- #1816 opt-in Nx1 corner takes a tangent edge past the tangent side edge as its corner edge
  (fillet_nx1_tangent_corner_edge, WR-593, fork 1ff7353)
- #1813 opt-in loop ordering goes on round a vertex where loops touch
  (loops_go_on_round_the_vertex, WR-591, fork 9209e76)
- #1814 opt-in torus rail point on the arc's axis (fillet_torus_rail_on_axis, WR-592, fork 994f129)
- #1784, #1785 offset round divergences and the offset fillet coincidence hang (unconditional,
  with BuildOffsetOfFaceuse's lamina delete fix)
- #674 opt-in SmFilletGeom::Trim returns its first trim error (fillet_trim_status, WR-604)
- #1821 blend.fillet_removal (SmFilletRemoval, DeleteFilletFace)
- #1786, #1797 deleting a shell, face or loop removes its records
- #1782 fillet corner insertion treats a removed patch-face record as deleted
- #1832 brep writer keeps owned surfaces under a caller's naming
- #1690 EvalNormal reads the matrix of a failed evaluation (near-flat GlobalPointSolve)
- #1702 curve AssertValid
- #475/#677, #481/#668, #527/#662 opt-in curvature, oscillation and refine-range tests
- tests and difftests: #1680, #1793, #1830, #1831, #1841, #1842, #1843, #1845;
  plan: #1849; CI: #1802

(#1803, HANG-51, was already in 15e72dff9.) main's CI test job passed on afcee131a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-31 00:24:15 +01:00
Brett Lawson 736f179414 solidcore: move the pin to e7585558 (main, with solidcore#1595: SmBrep::Transform with a 4x4 matrix)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 23:49:45 +01:00
Brett Lawson a2deba2690 Merge develop 6d8e002f into solidcore
# Conflicts:
#	crates/ui-egui/src/prefs.rs
2026-10-30 22:47:56 +01:00
Brett Lawson a2d5971be5 smlib: the quarter-cylinder sheet offset and the half-disc extrude match the C++ kernel on solidcore
offset_sheet_quarter_cylinder (both solidcore tests) and extrude_half_disc_split_arc's
solidcore_matches_the_cpp_kernel pass: solidcore#1495's linear_sweep_arc_is_cylinder (fork
12918e9, now on) sweeps the partial arc to an SmCylinder as the C++ kernel does (solidcore#1477).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 21:59:53 +01:00
Brett Lawson b0568524be smlib: solidcore's four new fork-carried opt-ins are on; the trimmed face tight box waits
boolean_new_face_closure_both_sides (WR-584, smlib@69c5198), linear_sweep_arc_is_cylinder
(#1477, smlib@12918e9), offset_closes_loop_gaps (WR-578, smlib@7ff0a94 and 34318f4) and
cylinder_plane_tangent_within_tolerance (WR-510, #1525; smlib#55, squash-merged as c2f1788 with
SmCone.cpp as in the bd20f6e the flag cites) are on: their fork commits are in SMLIB_REV 68fb9162.
face_tight_box_searches_trim (WR-588, solidcore#1811) is the fork's beb6061 from smlib#68, which
landed after 68fb9162, so it goes on HELD_BACK. The three held-back flags have no fork commit in
68fb9162 yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 21:59:53 +01:00
Brett Lawson f64857f953 solidcore: move the pin to 15e72dff9 (main, with solidcore#1752, #1495, #1796)
Merged since bba8d8798:
- #1752 opt-in two-sided closure test for a new face (WR-584, fork 69c5198)
- #1495 opt-in linear sweep of an arc square to its plane makes a cylinder (#1477, fork 12918e9)
- #1758 opt-in offset closes loop gaps between offset faces (WR-578)
- #1571 opt-in plane within a face's zone tolerance of a cylinder wall touches it along one line
  (#1525, WR-510)
- #1811 opt-in trimmed face's tight box is found inside its trim (#1799, WR-588)
- #1796 test: an offset surface exports through ApproximateSurface (#1441)
- #1751 test for stitch_solid_nests_cavities (WR-569)
- #1738 ResolveOverlappingProtoEdges sets point-class parameters on lent B-reps
- #1803 resolve_overlapping_proto_edges stops an overlap group that re-forms forever (HANG-51)
- #1820 screw sweep with exact walls and axial cylinders (#1162, #1163)
- #1706 SmOffsetSurface and SmSrfInVolume AssertValid
- #1358 the brep_p3 runner refuses the HANG-16 regime on the Rust side too
- #1791 curve_class/point_class dispatch gaps are not unported
- tests and difftests: #1719, #1824, #1373; plan: #1780; CI: #1810, #1802

main's CI test job passed on 15e72dff9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 21:44:01 +01:00
Brett Lawson 5f7a21157e smlib: the half-disc extrude's C++ comparison cites solidcore#1477
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 21:39:50 +01:00
Brett Lawson fecd4bee35 smlib: an extruded profile's arcs on one circle sweep one wall face on solidcore
Ports tangency-labs/smlib 7dee48f (capi/src/smlib_sweeps.cpp, one_wall_per_circle) to
the solidcore extrude: before a straight sweep, each run of consecutive arcs of a loop on
one circle (centres and radii within the zone tolerance, axes alike at the rim) becomes one
arc; a loop made only of such arcs is the full circle, its seam at the drawn circle's start
when the arcs share its frame, else at the middle of the longest arc. Draft, revolve and the
other sweeps are unchanged.

Tests: a circle drawn as two halves (one frame) and as a quarter plus three quarters (two
frames) extrude to 3 faces, 3 edges, 2 vertices with one cylinder, area 150π and volume
250π, on solidcore and on the C++ kernel (parity on counts and kinds); a half disc whose arc
is two quarters gives one curved wall, 4/6/4, area 75π + 100. Its kinds parity is ignored:
solidcore sweeps any partial arc (a single half arc too) to a surface of extrusion where the
C++ kernel makes a cylinder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 21:33:16 +01:00
Brett Lawson bb4ff018e5 Merge develop 6700e6ef into solidcore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 20:48:25 +01:00
Brett Lawson 9a901d1362 smlib: tag history on solidcore is one application Tag per object, with the capi's handlers
solidcore#1747 lets an application attribute class override SMLib's handlers
(AttributeHandlers, Attribute::application), so history on solidcore now carries the capi's Tag
(smlib_history.cpp at tangency-labs/smlib f8fd83e2) as it is: one attribute of id
SM_AI_USER_1 + 7 per face or edge holding its origins, boundary edges and `at`, copy behaviour.
Split sets an untagged child to the split object's tag with its `at` (the fork's 1c7accd) and
joins it into a tagged one; Merge joins the tags on the sources other than the result; Coincident
joins into the target; Copy is SMLib's default. join leaves `at` alone, or adds a Tag without one
in place of whatever attribute of that id was there.

The two deviations the per-origin Generic attributes had are gone: a copy onto an object that
already has a tag now keeps that tag only, and an untagged object a merge or coincidence lands on
gets a tag with no `at`.

New C++ parity tests: a post subtracted from the cube it stands on, and a cube subtracted from
the one beside it, copy onto tagged entities (both fail on the per-origin port); a post imprinted
against a cube's side splits the top's edge into three, each piece lying where the edge lay, so
the side is Kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 20:23:40 +01:00
Brett Lawson 8d47118ff8 smlib: solidcore's three new fork-carried opt-ins are on; the blend-curve revolve opt-in waits
local_operation_closes_walls_to_nothing (WR-566, smlib@ccbf77a), linear_sweep_arc_to_cylinder_zero
(WR-576, smlib@fd59111) and imprint_reuses_coincident_face_edge (WR-580, smlib@81cd61a) are on:
their fork commits are in SMLIB_REV f8fd83e2. fillet_blend_curve_revolved_on_arc (WR-581,
solidcore#1759) is an own design with no fork commit yet, so it goes on HELD_BACK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 20:16:50 +01:00
Brett Lawson ccfa37a9ba solidcore: move the pin to bba8d8798 (main, with solidcore#1747 application attribute handlers)
Merged since 43ed10e54:
- #1747 application-defined attribute handlers (AttributeHandlers, Attribute::application,
  application_value; fixes #1730)
- #1722 CreateUVTrimCurve's 101-point drop measures each point's surface distance
- #1763 opt-in local operation closes walls that shrink to nothing (WR-566)
- #1759 opt-in full-width blend-curve round revolved on an arc (WR-581, #919)
- #1750 opt-in imprint reuses a face's coincident edge (WR-580, #1519)
- #1488 iso curve at a knot above the degree is upstream's zero curve
- #1309 heal.gap evaluates the exact raw-knot UV trim curve
- #1708 reuse the same geometry object where upstream does (clone audit, part 2)
- #1714 a linear sweep tests its curve for an arc to the cylinder check's zero (fork fd59111)
- #1685 find_infinite_region propagates internal errors
- #1686 curve owners kept when classification edits write back
- #1777 bspline_surface_part is public (#1767); #1778 loopuse::notify_ref is public (#1766)
- docs: #1770 (planar section force_uv_classify, #1769), #1683 (curve sweep from faces)
- tests: #1443, #1713, #1275
- difftests: #1265, #1296, #1391, #1555, #1689, #1698, #1700, #1707, #1723, #1729, #1745, #1771
- site: #1804

main's CI test job passed on bba8d8798; its difftest jobs stop on an sccache-action rate limit and
main does not build with --features cpp (solidcore#1805), which SolidCraft does not use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 20:05:05 +01:00
Brett Lawson 494a08de1f smlib: history on solidcore: a face whose edge is only cut by a vertex is Kept (fork 1c7accd)
Ports tangency-labs/smlib@1c7accd's capi/src/smlib_history.cpp change into
core/history.rs `read`: a face is Kept when the pieces of each of its edges
are all on its boundary and together lie where the edge lay (between its
ends, through the points it ran through), not only while each edge is still
whole. `moved` now takes all pieces of one edge: their odd-count vertices
are the edge's ends (none for a closed edge, whose end some piece still ends
on), and each recorded point lies on one of them. The capi's Split handler
now gives an untagged split child the split edge's `at`, which the
attribute-per-origin port already did; the module note says so.

Test a_face_whose_edge_is_only_cut_by_a_vertex_is_kept: a bar imprinted
across a 40 x 30 x 20 block's top cuts the top edges of y = 0 and y = 30 by
a vertex each; both kernels give the block's other five faces Kept, none
Modified, and the same history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 19:02:53 +01:00
Brett Lawson 512cf5bd48 Merge develop f1199f89 into solidcore 2026-10-30 18:47:47 +01:00
Brett Lawson 63864b2984 smlib: solidcore's five new fork-carried opt-ins are on; the trim end-span opt-in waits
fillet_chamfer_cone_stored_as_cone (WR-572, smlib@b5743ec), offset_point_vertex_caps (WR-531,
smlib@d5dadaa), approximate_surface_bezier_starter (WR-573, smlib@eae96cb),
proto_edge_splits_at_captured_vertices (WR-579, smlib@9eec159) and stitch_solid_nests_cavities
(WR-569, smlib@351c57c) are on: their fork commits are in SMLIB_REV 985faaee.
bspline_trim_keeps_rational_end_spans (WR-575, solidcore#1711) has no fork commit, so it goes on
HELD_BACK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 18:19:03 +01:00
Brett Lawson 9c1f8904e0 solidcore: move the pin to 43ed10e54 (main, with solidcore#1650, fixing #1631)
Merged since 3569b5d73:
- #1650 one owner identity for binders and check_brep, so operations on caller-keyed copies
  check valid (fixes #1631: "the body names its geometry owners by its maker's keys")
- #1739 local_operation refreshes cached topology before returning
- #1749 opt-in proto edge splits at captured vertex definitions (WR-579, #1517)
- #1768 opt-in stitched solid nests its cavities (WR-569)
- #1609 opt-in extended offset caps a cone's apex with a sphere (WR-531)
- #1712 opt-in ApproximateSurface Bezier starter (WR-573, #1501)
- #1711 opt-in trim keeps a conic's short end span (WR-575, #1529; no fork commit yet)
- #1709 opt-in chamfer round an arc stored as a cone (WR-572, #1503)
- #1000 port of topology.brep_constructor
- #1530 release solidcore 1.0.1 (the crate's version is now 1.0.1)
- tests: #1746, #1735, #1710, #1748, #1757, #1762, #1754
- difftests: #1761, #1733, #1764, #1715, #1732, #1744, #1726, #1740, #1742, #1743, #1741,
  #1737, #1731, #1728, #1725, #1721, #1724, #1720, #1718, #1717, #1716, #1699, #1704, #1696,
  #1697
- chores and plans: #1734, #1772, #1774

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 18:17:38 +01:00
Brett Lawson a0dfdeecaf Merge develop 7c9e5ec6 into solidcore 2026-10-30 16:47:18 +01:00
Brett Lawson cd25e9e8f2 smlib: a face section on solidcore cuts planes, cylinders, cones and spheres too (solidcore#1769)
The port asks CreatePlanarSectionCurves for UV curves, so it now forces the UV classify: an
analytic face's intersector makes no UV curve, and without the flag the trim dropped the whole
section and the call answered Empty. SmBrep.h documents the flag for exactly this case. The
capi still leaves it FALSE, so the C++ kernel keeps answering Empty on analytic faces; the
parity test now holds the port to the section there and to the C++ curves on the loft.

a_cylinder_side_section_is_its_circle is un-ignored; a_box_top_section_is_the_line_across_it
checks the line (5,0,10)-(5,10,10) across a 10 mm box's top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 16:25:29 +01:00
Brett Lawson c3f5bb40b9 smlib: a draft on solidcore keeps the body's tags and face history
solidcore's local operation carries the B-rep's attributes since 1ee3a930c, which the pin
3569b5d73 holds, so history::tags_cross_a_draft runs again and matches the C++ draft. The draft's
body is no longer marked untracked: Brep::made_core_draft goes and ops::draft answers with
Brep::made_core, so a draft's history is read as every other solidcore operation's is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 15:09:53 +01:00
Brett Lawson cf2a15ace8 smlib: solidcore's four fork-carried opt-ins are on; only the trim base v opt-in waits
fillet_torus_span_from_start_section (WR-568, smlib@985faae), fillet_knife_edge_rounds (WR-570,
smlib@b7ec55b), taper_wall_closes_at_apex (WR-571, smlib@183ea65) and
revolution_round_offset_collapses (WR-567, smlib@8b2090f) are on: their fork commits are in
SMLIB_REV 985faaee. HELD_BACK keeps only precise_properties_trim_base_v, which has no fork commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 14:56:39 +01:00
Brett Lawson b60963b12c smlib: SolidCraft's tags and face history run on solidcore (port of the capi's smlib_history) 2026-10-30 14:56:23 +01:00
Brett Lawson d8a680915e Merge develop e46359ff into solidcore 2026-10-30 14:56:17 +01:00
Brett Lawson 10c7d3afd3 smlib: tags and history on solidcore: smlib_history.cpp ported onto the Brep's attribute store
core/history.rs ports smlib_history_tag and smlib_history_read onto solidcore's
B-rep-owned AttributeStore. Each origin is an attribute of its own (its id names
the origin), so SMLib's default Split/Merge/Copy/Coincident handlers, which add
an attribute unless the object has one of its id, give the union the capi's Tag
handlers make. A face's origin carries its boundary edges then, an edge's the
points it ran through, and the read classifies Kept/Modified/Split/Merged/
Generated as the capi does.

Brep remembers the kernel that made it (history is read there; the flat arrays
carry no tags) and, for a tagged body, its operand, so the body converted for
the other kernel is tagged the same way. Blends (solidcore#1594) and drafts,
which do not carry attributes yet on solidcore, give "not supported yet" for
their history instead of an untagged one.

tags_cross_a_solidcore_boolean now passes. Parity tests against the C++ kernel:
union, subtraction, intersection, side-by-side union (merged faces), imprint,
copy and shell agree; fillet (solidcore#1594), face offset (solidcore#1060) and
draft are ignored with the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 14:51:22 +01:00
Brett Lawson 76cdc727e9 Merge develop e46359ff into solidcore 2026-10-30 14:47:33 +01:00
Brett Lawson 0bed45d82c smlib: solidcore's planar thicken side walls opt-in is on; four opt-ins wait for the fork
thicken_planar_side_walls (WR-564, solidcore#1665) is on: its fork commit, smlib@d384e86, is in
SMLIB_REV. fillet_torus_span_from_start_section (smlib@985faae), fillet_knife_edge_rounds
(smlib@b7ec55b), taper_wall_closes_at_apex (smlib@183ea65) and
revolution_round_offset_collapses (smlib@8b2090f) are past SMLIB_REV, so they go on HELD_BACK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 13:55:13 +01:00
Brett Lawson a6b280467a solidcore: move the pin to 3569b5d73 (main, with solidcore#1665, #1681, #1614, #1677)
Merged since dca3135cc:
- #1665 thickening a sheet puts its flat side walls on planes, opt-in (WR-564, fixes #1599)
- #1681 a UV trim made on a classification snapshot keeps its owner (fixes #1678)
- #1687 a UV trim's owner is its edge use's notification key
- #1614 B-rep attributes through sweeps and surfaces (#1462 P6)
- #1677 SmFace::DeleteInnerLoop
- #1690 EvalNormal reads the matrix of a failed evaluation (near-flat GlobalPointSolve)
- #1692 opt-in round of revolution collapses under an inward offset (WR-567)
- #1693 opt-in torus round span from its start section (WR-568)
- #1694 opt-in knife-edge rounds (WR-570)
- #1695 opt-in draft walls that close partway up end at their apex (WR-571)
- #1691 booleans pair each face only with faces whose box is near
- #1676, #1667, #1682 tests; #1701 docs

solidcore#1650 (Caller-keyed check, #1631) is not in yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 13:39:48 +01:00
Brett Lawson 51c474efbc Merge develop 739e47b9 into solidcore 2026-10-30 12:47:46 +01:00
Brett Lawson 81088bf0c4 smlib: a sweep along a bent path is tested on solidcore (solidcore#1628 is fixed)
sweep_along_a_bent_path runs again: solidcore and C++ both give
80.763948538 (Pappus: pi (10 + 5 pi) = 80.76395), a cylinder, a torus and
two caps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 12:05:55 +01:00
Brett Lawson 5314031ba3 smlib: solidcore's tangent-face edge check and horn-torus revolve opt-ins are on
edge_check_orders_tangent_faces (solidcore#1684, fork 5533bbb) and
revolve_arc_touching_axis_is_horn_torus (solidcore#1679, fork c4e378d) are
both in SMLIB_REV 1ccd4612. precise_properties_trim_base_v (solidcore#1664,
WR-563) stays off: the fork has no commit for it, so the C++ kernel still
starts the area integrals at the domain's lowest v. The flag test now names
what is held back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 12:04:08 +01:00
Brett Lawson f4eec76b4b solidcore: move the pin to dca3135cc (main, with solidcore#1630, #1632, #1649, #1664)
Also brings solidcore#1666, #1679, #1659 and #1684.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 11:52:02 +01:00
Brett Lawson f1772028bf Merge develop 74001e53 into solidcore (via ws/develop-sync-74001)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 11:31:12 +01:00
Brett Lawson 41491734af Merge develop 74001e53 into solidcore 2026-10-30 10:47:31 +01:00
Brett Lawson 97a475d8dd smlib: solidcore's torus check and step-wall opt-ins are on: the torus check reads its V interval, a spindle torus through its apex is a self-intersection, and a local operation builds the step wall between tangent faces moved apart
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 10:46:36 +01:00
Brett Lawson 0877278d6a solidcore: move the pin to 2b81c033 (main, with solidcore#1640, #1623, #1655, #1673, #1668, #1662)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 10:26:10 +01:00
Brett Lawson 2d84f8a172 smlib: solidcore's new opt-in fixes are on: sliver iso curves degenerate, Coons corner patches tangent to their sides, a fillet's own edge kept at a corner patch, the corner ball centred where the axes meet, one solution for a seam point, 2D boolean covers of far faces, area only between solid and void, a coincident definition split across the split point, the tight box from the interval's knots, overlapping bands refused, and the SSI tangent-curve tracer
Each is in the fork at develop's SMLIB_REV 1ccd4612 (tangency-labs/smlib 8088f5e, cae8618,
1072d1b, bc6ddf9, 577f7be, 67b8a1e, d2d9586, f11d0ac, 02fc29b, c0d74ee, and ef4ab25, e9fde8f,
d8c61db, 1693c9d), so solidcore stays with SolidCraft's C++.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 09:11:35 +01:00
Brett Lawson 39adb66ad1 solidcore: move the pin to 55748ecf (main, with solidcore#1648, #1645, #1644, #1641, #1639, #1637, #1636, #1634, #1657, #1656, #1654, #1653, #1652, #1651, #1598, #1622, #1618, #1616, #1603, #1626, #1661, #1658, #1660, #1629, #1663: attributes through offsets, shells, booleans and copies (#1462 P2, P4), the tight box from the interval's knots (#1054), a merge's UV trims named after their edge use (#435), SM_API ordered-point surfaces, exact projection copies, and the fork's WR-562, WR-547, WR-546, WR-541, WR-537, WR-536, WR-535, WR-533, WR-542 and WR-75 opt-ins)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 09:00:08 +01:00
Brett Lawson 436fae56a4 smlib: drafting a cylinder's side runs on solidcore (solidcore#1606, fixed by #1611)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 07:21:46 +01:00
Brett Lawson 271f0a39ec smlib: a face's planar section runs on solidcore, as the capi's smlib_face_section (solidcore#1534)
SmBrep::CreatePlanarSectionCurves over the body with the 3D, UV and face outputs, the curve
tolerance as the approximation tolerance and bForceUVClassify left FALSE, as the capi calls it;
a face list not one per curve is unsupported, the picked face's curves go out as NURBS, none is
empty. The section of a lofted wall is the line across it, the same on both kernels. As the
capi leaves bForceUVClassify FALSE, a face on an analytic surface (a box's top, a cylinder's
side) has no UV curve and is trimmed away: both kernels report it empty, and the closed-form
circle on a cylinder is ignored on that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 07:21:46 +01:00
Brett Lawson 3346f41721 smlib: solidcore's new opt-in fixes are on: UV trim checks on the surface, loop orientation from the loop's box, cones extended as themselves, the local operation's box, joined drop pieces, exact edges on analytic faces after a shell, and the two SSI trace fixes
Each is in the fork at develop's SMLIB_REV 1ccd4612 (tangency-labs/smlib b4f8c57, 54ca3e0,
b283ea2, a1ee273, e36feb1, cd85d34, 0dbe681, 8d95600), so solidcore stays with SolidCraft's C++.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 07:21:46 +01:00
Brett Lawson 9ceddd9d56 solidcore: move the pin to 937a6319 (main, with solidcore#1534, #1537, #1544, #1617, #1608, #1582, #1591, #1625, #1586, #1619, #1620, #1611, #1647, #1646, #1643, #1642, #1638, #1635, #1633: planar section curves, SM_API intersections, cut, HCR silhouettes on solids, UV trim checks on the surface and tiny loops oriented, projection, trimming, tessellated boundaries and knot removal, the fillet refusal that leaves the body unchanged, Cone::lean_cylinder as a cone, and the fork's WR-260, WR-550, WR-545, WR-543, WR-80, WR-534 and WR-532 opt-ins)
CreateParallelProjectionCurves now takes the curve itself (any of the SmBSplineCurve classes),
so smlib_project_curves passes its line, arc or B-spline as the capi's GetRootCurve() does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 07:10:25 +01:00
Brett Lawson 1feba5af98 smlib: the bent-path sweep's ignore cites solidcore#1628
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 06:16:04 +01:00
Brett Lawson dd19f817f7 Merge develop ec4eebaa into solidcore (via ws/develop-sync-1030b)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 06:15:10 +01:00
Brett Lawson 6a3558a966 smlib: a sweep along lines and arcs goes piece by piece on solidcore, as the capi does
SMLib 90aabff's smlib_sweep_placed sweeps a path of lines and arcs that turns with the path
(not closed, each piece leaving in the direction the one before arrives in) one segment at a
time, exactly: a line by the linear sweep, an arc by the rotational sweep, the profile
carried from each to the next, every piece's faces copied into one B-rep and stitched, the
caps the profile at both ends. The port (from solidcore#1581's head) sent that path to the
curve sweep, whose fitted walls differ from the C++ kernel's cylinder and torus. `segmented`,
`carried` and `piecewise` (with `Rigid` and `Segment`) are ported, ahead of the curve sweep.

On solidcore the second piece's copy fails: copy_faces without the merge into a B-rep that
already has faces finds no notification record for what it makes (solidcore#TBD, repro in
the bug file), so sweep_along_a_bent_path is ignored on solidcore until that is fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 04:59:06 +01:00
Brett Lawson 57dd2e7117 Merge develop ec4eebaa into solidcore 2026-10-30 04:47:38 +01:00
Brett Lawson 97d79e3b88 smlib: the solidcore tag test names what still holds it back
solidcore#1568 (#1462 P1) carries a B-rep's attributes through a boolean, but the tags
`Brep::tagged` puts on the C++ body never reach solidcore: the crossing is the capi's flat
arrays, which carry no attributes (smlib.h, smlib_flat.cpp), and the history is read back
on the C++ body after the same crossing. tags_cross_a_solidcore_boolean stays ignored, its
reason now that crossing rather than solidcore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 04:44:41 +01:00
Brett Lawson 2ad118666f smlib: curvature-continuous rounds and corners left open on solidcore (solidcore#1438, #1439)
Wires solidcore#1597's per-session fillet options into the smlib crate's port of the capi's
round_edges and blend_call:

- solidcore#1438: a curvature-continuous blend runs the blend-curve section
  (SM_FSG_BLEND_CURVE, continuity 2) with `set_blend_curve_at_thirds` on before the edges
  are prepared, as round_edges sets SetBlendCurveAtThirds. The new case
  fillet_curvature_one_edge takes abc - 113/756 r^2 L (the quintic with its inner control
  points at thirds), to 1e-9, on both kernels.
- solidcore#1439: `smlib_blend_setback_open` sets `set_mark_corner_patches` and reads
  `corner_patch_faces` before the session drops; after the stitch each set-back corner's
  patch is the live faces (p1::faces) marked with its index, and any mark left on the body
  is removed (RemoveAttributeFromTopology). The capi's open_corners is ported: each patch's
  rim chained from the faces beside it, the patches deleted with their edges and vertices
  (DeleteFace(f, TRUE)), and the holes listed per corner point in the result's edge and
  face order. The C++ kernel's own test case (60 x 40 x 20 box, r5, set back 8) gives the
  same 9 faces, 30 edges, 22 vertices and the same twelve-edge hole on both kernels.

The module note drops the two refusals; the unsupported test keeps the variable law's
corner and the cliff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 04:41:50 +01:00
Brett Lawson dafb25b4b9 smlib: the body check reads a Caller body's recorded keys on solidcore (solidcore#1584)
solidcore#1585 records the caller keys where a NamingScheme::Caller body is built (an array
import through make_topology_from_data, api_brep_copy), and check_brep checks the body
under them. So `check` runs plain check_brep on every body but an Unknown one, and the
keyed path for our own array imports (Keys::Convert, check_brep_with_keys under
convert::key_of) is gone. A Caller body whose maker recorded no keys (check_brep's
InvalidInput) is refused as not supported, as an Unknown body still is, never a failed
check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 04:36:53 +01:00
Brett Lawson 744f7574d9 smlib: drafted extrudes keep their corners and curve sweeps keep the profile where it lies on solidcore (solidcore#1449, #1450)
Wires the two solidcore fixes into the smlib crate's solidcore port of the capi sweeps:

- solidcore#1449: `fixes()` turns on `taper_tangent_corner_planes`. `extrude`'s draft
  no longer refuses a sharp corner under the curvature rule (or the tangent rule with a
  profile of lines and arcs) as not supported: it calls
  `create_taper_extrude_with_corner` with the rule (curvature -> CurveExtension,
  tangent -> LinearExtension, round -> FilletCorner), and a refused corner is the
  capi's `Error::Corner { why, at }` (Apart, WallCloses, NotExact at the corner's
  point), as the C++ path reports `SmTaperCornerRefusal` and `pOptRefusedCorner`.
  The tangent/sharp/has_corner checks that only fed the refusal are gone.
- solidcore#1450: a sweep along a path that is neither straight nor arcs about one
  axis runs `create_curve_sweep_with_placements` with `profile_at_path_start` (and
  `translational_sweep` for a translating frame), and `placed` takes the first,
  middle and last placements. As in the capi's `smlib_sweep_placed`, round a closed
  path the ends are not capped (the caps would lie on each other inside the solid)
  and the faces are stitched, a lamina left being not solid.

The tests the two issues held back run again (extrude_square_drafted_curvature,
extrude_draft_wall_closes, sweep_along_a_bent_path), with a new case round a closed
rounded rectangle (Pappus). The module note drops the two refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 04:26:40 +01:00
Brett Lawson c8adedaec2 smlib: solidcore's new fixes are on (taper tangent corner planes, holed revolve void, loose tangent edges refused, scaled extrusion origin zero, booleans skip faces apart)
The flags solidcore added since ddfb3895, each a port of a fork commit that SMLIB_REV
(90aabff) already carries, so solidcore and the C++ path agree with them on:

- taper_tangent_corner_planes (WR-521, #1449; tangency-labs/smlib e8f23e0)
- revolve_holed_profile_void (WR-529; 38b4212)
- local_operation_refuses_loose_tangent_edges (WR-530, #402; 23c5b0a)
- extrusion_origin_scaled_zero (WR-511, #1508; tangency-labs/smlib#31)
- boolean_skips_faces_apart (WR-527, #1526/#1527; 60b1c6a)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 04:26:40 +01:00
Brett Lawson d4cec2ca95 solidcore: move the pin to b2450cbf (main, with solidcore#1568, #1579, #1581, #1585, #1597, #1604, #1600: attributes through booleans, taper corner rules, the curve sweep, check_brep naming, fillet thirds and corner marks, WR-302)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 04:25:21 +01:00
Brett Lawson 7b0b1f63b7 Merge develop 322ac259 into solidcore (via ws/develop-sync-1030: SMLib 90aabff test expectations, the closed-sheet offset and cylinder draft ports)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 04:17:44 +01:00
Brett Lawson f23aa76d34 smlib: an offset or other non-NURBS face surface exports through ApproximateSurface on solidcore (solidcore#1441), and the new fixes are on
`to_arrays` approximates a face surface that is not a B-spline kind as `FlatFromBrep` does:
`ApproximateSurface` within the curves' approximation tolerance (`approx_tol3d`), up to 5
subdivisions, B-splines copied; a failed status or no surface fails the export.

`fixes()` turns on fillet_3x2_mixed_concave_side_edge (#1475),
ssi_singular_start_inside_direction (#1522) and fillet_vertex_error_diagnostics (#1469).

offset_sheet_quarter_cylinder now exports and matches its closed form; its parity with the C++
kernel stays ignored on solidcore#1477 (the quarter arc sweeps to an SmSurfOfExtrusion, not the
fork's SmCylinder).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 03:35:44 +01:00
Brett Lawson ec423291b2 Merge develop 322ac259 into solidcore
# Conflicts:
#	xtask/src/kernel_cases.rs
#	xtask/src/main.rs
2026-10-30 03:05:59 +01:00
Brett Lawson 3499802521 smlib tests: the cylinder draft's ignore cites solidcore#1606
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 03:03:20 +01:00
Brett Lawson 2c8a1fa098 smlib: solidcore drafts a cylinder along the pull into a cone, as the capi since tangency-labs/smlib#27
Ports smlib PR #27 (merge f64ff8b6) into core/offset.rs::draft: a picked face on a cylinder
(end radii equal, the #27 test) gets the cone `drafted_cone` gives, through the same
LocalOperation as a tilted plane. As in the capi, the outward normal at the face's internal
point (`outward_normal`: STEP inversion, NURBS uv, the surface normal turned with the outer
faceuse) says pin or hole (s = +1 or -1). The hinge circle is the axis on the neutral plane,
at the cylinder's height v0. The radius changes by -s tan(angle) per unit along the pull. The
pull must run along the axis (to 1e-9), and the axis must cross the neutral plane;
otherwise, or when the cone would close within the face, the draft is Error::Unsupported.
The capi's control-point edit and CopyAndAddAnalytics are solidcore's Cone::lean_cylinder
(solidcore#1545), with slope -s tan(angle) (z . pull) about height v0. A cone that is not a
cylinder (smlib#53's turned generators) stays Unsupported: lean_cylinder takes cylinders only.

draft_a_curved_face (R 5, h 10, 0.1 rad: V = pi h (R^2 + R r + r^2) / 3 = 638.3350153968712,
r = R - h tan 0.1) stays ignored on solidcore. lean_cylinder keeps the clone's cylinder flag.
Under the cylinder_rebuild_mean_radius fix SolidCraft runs with, rebuild_step_from_nurb_parameters
then refuses the leaned NURB (SM_ERR 1001). With that fix off, the port meets the closed form,
but the face is still flagged a cylinder ({cylinder: 1, plane: 2} vs C++'s {cone: 1, plane: 2}).
This is a solidcore bug, to be filed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 02:39:44 +01:00
Brett Lawson accf0aac99 smlib: solidcore offsets a closed sheet, as the capi since tangency-labs/smlib#17
Ports smlib PR #17 (merge b0180137) into core/offset.rs::offset_sheet: the refusal of a
closed manifold solid is dropped, so faces that close into a solid (a sphere's one face, a
block's six) give the offset solid. The port also gains the capi's `take` check it was
missing: a result without faces is SMLIB_ERR_EMPTY (Error::Empty), not an empty body.

Tests (closed forms):
- offset_sheet_a_solid un-ignored: 32 * 22 * 12 = 8448, bounds -1 .. (31, 21, 11).
- offset_sheet_a_sphere (PR #17's capi case): r 10 moved out 2, (4/3) pi 12^3.
- offset_sheet_a_sphere_to_its_centre: r 10 moved in 10 leaves no face: Empty, as C++.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 02:31:44 +01:00
Brett Lawson eca7aec7ff smlib tests: SMLib now drafts a cylinder side and offsets a closed sheet (develop's SMLib 90aabff, fixes #27 and #17)
The C++ kernel at develop's pin (9d0fb564) builds both cases that expected a refusal;
each now expects its closed form:

- offset_sheet_a_solid (tangency-labs/smlib#17): the 30 x 20 x 10 block, every face
  moved out d = 1, convex edges extended: (a + 2d)(b + 2d)(c + 2d) = 32 * 22 * 12 = 8448,
  bounds (-1, -1, -1) to (31, 21, 11), six planes.
- draft_a_curved_face (tangency-labs/smlib#27): the cylinder R = 5, h = 10 drafted
  0.1 rad about its base, leaning in, is the frustum of radii R and r = R - h tan 0.1:
  V = pi h (R^2 + R r + r^2) / 3 = 638.3350153968712 (a cone and two planes).

C++ meets both within the file's 1e-9 relative. The solidcore side of each is ignored:
core/offset.rs still makes the old capi's refusals (a closed solid in offset_sheet,
planes only in draft). With the offset_sheet refusal dropped, solidcore gives the
block exactly; solidcore has Cone::lean_cylinder (solidcore#1545) for the draft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 02:26:23 +01:00
Brett Lawson 2308fa5be7 solidcore: move the pin to ddfb3895 (main, with solidcore#1569 and #1533: the 3x2 mixed corner at a concave unfilleted edge, and SmSurface::ApproximateSurface)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 01:14:40 +01:00
Brett Lawson 921381bcb8 Merge develop 7664925f into solidcore
# Conflicts:
#	crates/ui-egui/src/prefs.rs
2026-10-30 00:47:58 +01:00
Brett Lawson a69016f211 solidcore: move the pin to e4379b45 (main, with solidcore#1573: the #1024 rail-through-boundary-vertex test, plus the CreateCurveApproxPoints and PolyBrepCopy ports)
Rig with convert and primitive on solidcore: 466/483 (was 450/479), 482 agree with SMLib, 1 differs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-30 00:35:20 +01:00
Brett Lawson 76c755508e smlib: a call answers None exactly when solidcore's error stands for an SMLib status (SolidError::sm_status, solidcore#1452)
Same set as before (Upstream and Nlib), now read from solidcore's own classification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 23:03:37 +01:00
Brett Lawson c13d760faa smlib: a copy queried under the arena's identities is a port-side invariant (solidcore#1452)
solidcore#1452 reports a port-side failure as SolidError::PortInvariant with no SMLib status,
where it was InvalidInput.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 23:02:13 +01:00
Brett Lawson 41e902eaa3 solidcore: move the pin to 0ae36782 (main, with solidcore#1452: SolidError::sm_status and a port-invariant variant)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 22:54:08 +01:00
Brett Lawson 0abd028304 Merge ws/check: the body check runs on solidcore's check_brep (solidcore#1419)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 22:46:31 +01:00
Brett Lawson b8c2282090 solidcore: run the body check (smlib_brep_check) on solidcore#1419's check_brep
The check is the capi's call sequence on solidcore's public check API: ValidatePointers, then,
when that fails nothing, AssertValid over the whole body at the requested level. The report
keeps the capi's lines: "<type>::Test #N <name>: <message> [owner <type>]", the line for a
failed status with no report, and "AssertValid skipped". The failure count is the capi's: the
skipped line is not counted.

The check never reports a body valid after running fewer tests than the C++ kernel. A test
solidcore has not ported fails it with "not supported yet". So does a body whose geometry
owners are named by keys the check cannot know: NamingScheme::Unknown, or Caller other than
SolidCraft's own array import. check_brep would check those against arena identities and fail
SmBrep test 61 on every edge. Bodies imported from arrays are checked with the import's key_of.

The primitives' check and check parity tests run again. New tests: a valid box passes level 2
with an empty report on both kernels, and a box with a vertex moved 1 mm off its edges (made
through the arrays) gets the C++ kernel's report, line for line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 22:13:42 +01:00
Brett Lawson 0d3c46dad2 solidcore: move the pin to 6a068272 (main, with solidcore#1419 WP4: the level-2 AssertValid tests and SmSurfOfExtrusion::AssertValid, #1578)
Turns on the new flags transform_scales_each_tolerance and
transform_model_size_from_body_box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 22:06:55 +01:00
Brett Lawson 98e5e7a05d Merge ws/convroute: Convert runs on solidcore by default
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 21:58:47 +01:00
Brett Lawson 1f8d8fa9a8 Kernel routing: records convert on solidcore by default
Op::Convert moves to Backend::Solidcore: a schema 4 record becomes a solidcore body
(Brep::from_record, from_record_keeping_splines) and a body's arrays for the record mapping come
from solidcore (Brep::arrays, so to_native, to_native_report, to_native_ordered). A C++ operation
still gets its own body from solidcore's arrays through the flat import, when it asks for one.

Rig (cargo xtask kernel-cases, 479 cases, solidcore 3c17ddb1):
- convert routed alone (--kernel solidcore --route convert=solidcore): 450/479 against SMLib's
  449; 478 agree, the other (fillets/1557) fails on SMLib and passes on solidcore
- the new default table (--route primitive=solidcore,convert=solidcore): 450/479
- SMLib only (plain run, SOLIDCRAFT_KERNEL=smlib): 449/479; the only case that differs is 1557,
  better on the table

Against SOLIDCRAFT_KERNEL_ROUTES=convert=smlib, no test fails that did not already: smlib with
and without native, solidcraft-kernel --features smlib, the engine lib tests with --features
smlib, and the naming corpus (the same 116 designs differ, with the same output).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 21:53:25 +01:00
Brett Lawson 22a1c20b1d solidcore: move the pin to b3baca8e (main, with solidcore#1490 and #1491: a revolve's torus is never inside out and keeps the flag, and a one-circle full turn puts its seam at the profile)
Turns on the new flags torus_keeps_inside_out and internal_pole_row_through_knot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 21:23:31 +01:00
Brett Lawson 2f623f3f22 Merge ws/devmerge2: develop ac52443d, and ablasik's cone export fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 21:23:05 +01:00
Brett Lawson 9c7c139bdd solidcore: move the pin to b19dbcb5 (main, with solidcore#1476: a shell's implicit offsets take every base upstream casts to a B-spline, and the offset sample bound scales with the input)
Turns on the new flag torus_iso_circle_axis_from_midpoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 20:50:45 +01:00
Brett Lawson 1f6def9d72 smlib: drop the cone test's gate on a solidcore feature this branch doesn't have
The export tests module is already gated on `native`; the cherry-picked test's
`any(native, solidcore)` names a feature that isn't declared here, which
clippy -D warnings rejects as an unexpected cfg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 20:41:47 +01:00
Andrzej Błasik b4fb9188ad fix(smlib): export a cone of rounding-size half angle as a cylinder
The portable record rejects a cone with |half angle| < 1e-12; a52's thread
cones come out of the trims at 1e-13 and failed 10 features with
'cone dimensions'.

(cherry picked from commit d5d74105a2)
2026-10-29 20:34:36 +01:00
Brett Lawson 03f97cd1d1 Merge origin/develop (ac52443d) into solidcore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 20:34:36 +01:00
Brett Lawson 8ec45a0f4d Merge ws/draft: draft faces through SmBrep::LocalOperation on solidcore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 20:04:37 +01:00
Brett Lawson ca6a7ef21e solidcore: draft faces through SmBrep::LocalOperation (solidcore PR #1427)
core::offset::draft now finishes the capi's draft_faces: each picked plane is
copied, turned by the angle about its hinge on the neutral plane
(RotateAboutAxisAtPoint about n x pull), and solidcore's local_operation puts
the tilted planes in, under the offset calls' notification stores; its status
maps through the module's status() and the result through finish(). The tail
is adapted from ablasik's draft in crates/smlib/src/ffi/rs_ops2.rs on
ablasik/solidcraft app/solidcore-backend-on-develop (9c7e8e31), reordered to
follow the capi line for line and using core::offset's errors and checks
(ATTRIBUTION.md, Recovered contributions).

The capi's moved_faces check (SMLIB_ERR_NO_EFFECT when no result face lies on
a marked surface) is not made: solidcore surfaces carry no attributes and the
local operation reports no face history (solidcore#1462). Face moves stay on
SmOffsetFacesExecutive (#1060) and are still refused.

draft_box_sides is no longer ignored; draft_one_side, draft_one_side_out and
draft_one_side_hinged_at_the_top check the single-face wedge (a h^2 tan(t) / 2)
against both kernels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 20:04:09 +01:00
Brett Lawson c191634b54 solidcore: move the pin to 7ca6ce7a (main, with solidcore PR #1427 SmBrep::LocalOperation, #1474 Nx1 corner, and fixes since 3c17ddb1)
Turns on the new flags local_operation_offset_status, torus_major_radius_from_origin,
fillet_corner_sphere_from_four_arcs, fillet_nx1_opposite_continuation_side_edge and
fillet_trace_rejects_short_step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 19:09:36 +01:00
Brett Lawson 095044d539 Merge branch 'solidcore' into ws/convroute 2026-10-29 19:09:33 +01:00
Brett Lawson 376bba92c3 solidcore: move the pin to 3c17ddb1 (main, with solidcore#1480: an intersection edge's end goes to its own vertex, and #1419 WP1's whole-body AssertValid walk)
No new flags: fork ffaf4e8 is folded into imprint_trims_edge_to_proto_vertex and
imprint_extends_edge_to_proto_vertex, both on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 11:14:42 +01:00
Brett Lawson 58ccdf9901 claim: Convert routes to solidcore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 10:38:26 +01:00
Brett Lawson f628ef64c4 Merge ws/innershell: a region of several shells converts, its enclosing shell first (solidcore#1473)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 10:29:29 +01:00
Brett Lawson 1deac22548 solidcore: a region of several shells converts, its enclosing shell first
from_arrays refused a bounded region of several shells (a sealed void),
as solidcore had no SmShell::IsInnerShellOf. solidcore#1473 ports it, so
the conversion now runs the capi's PutEnclosingShellsFirst (fork 41e7dca)
on top of it: candidates are the closed face-use shells whose box can be
computed, largest box first (stable), and the first that every other
shell is SM_TRUE inside becomes the head of the region's list. SM_FALSE
and SM_UNSURE rule a candidate out, as the capi's `== TRUE`; a region
where none is shown enclosing keeps its order. solidcore errors with no
C++ counterpart refuse the conversion, and containment tests are capped.

Tests: a 20 mm box minus a centred 10 mm box crosses the arrays both ways
with its outer shell first, from arrays listing it inner first too, at
7000 mm³ to 1e-9 and the C++ kernel's volume; C++ reads solidcore's
arrays (either order) as a body passing its level-2 check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 09:52:46 +01:00
Brett Lawson 9c7e54be41 solidcore: move the pin to 43ca3745 (main, with solidcore#1473: SmShell::IsInnerShellOf, and #1419's level-0 helpers)
Turns on the new flag shell_inner_shell_of_statuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 09:40:20 +01:00
Brett Lawson 6e5722af98 solidcore: move the pin to fa3caa94 (main, with solidcore#1463: a revolve's cylinder is never inside out, and an inside-out NURB cylinder keeps its frame)
Turns on the new flag nurb_cylinder_inside_out_frame. Also brings solidcore PR #1460
(SmSurface::AssertValid and the surface checks, part of #1419).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 09:05:14 +01:00
Brett Lawson 45d9962167 Merge ws/primroute: primitives run on solidcore by default
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 08:01:04 +01:00
Brett Lawson 689bb5fc3c Kernel routing: primitives run on solidcore by default
Op::Primitive moves to Backend::Solidcore. Rig (cargo xtask kernel-cases, 479 cases):
- SMLib only (--kernel smlib, which sets SOLIDCRAFT_KERNEL=smlib, so primitives on C++): 445/479
- primitives on solidcore (--kernel solidcore --route primitive=solidcore, the new default
  table): 449/479; no case worse, 4 better (1107, 114, 1140, 1195)
- earlier, routed alone: 469/469 agree (solidcore 3546c9bc)

What the flip showed:
- core::make_box checked only finiteness; smlib_make_box also refuses a non-positive size.
  A bad box is Error::Argument on either kernel again (bad_arguments_are_errors checks both).
- Tests that mean the C++ kernel's own body (measure and convert parity, the C-API profiler
  span, the no-SMLib build) build it with the C API (ffi::make_*), whatever the route.
- tess: a plan's node one ulp past a closed spline's interval was wrapped a whole period to the
  other end, and its triangles spanned the period in (u, v). A body that reaches SMLib through
  the flat import (every solidcore primitive) gets such a pcurve end on a cylinder's seam
  (the M10 thread run-out test). wrap_into now takes a rounding past an end as that end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
2026-10-29 08:00:25 +01:00
Brett Lawson cb79c32ee1 smlib: the curved primitives' measures, round trips and two sweep volumes run on solidcore (solidcore#1435 landed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 06:35:51 +01:00
Brett Lawson 53da867349 solidcore: move the pin to 2a47645e (main, with solidcore#1435: precise properties split at the surface's knots)
Turns on the new flag precise_properties_split_at_surface_knots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 06:31:40 +01:00
Brett Lawson 47a97c127e Merge ws/seamfix: the rig drains big outputs; tolerances cross the kernels per entity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 05:56:00 +01:00
Brett Lawson 232c5dd757 smlib: tolerances cross the kernels per entity, not as the body's largest
The flat arrays carry each edge's and vertex's own tolerance, but
to_arrays wrote the largest tolerance as TOLERANCE, and both imports take
TOLERANCE as the B-rep's, which MakeTopologyFromData raises every entity
to. Each crossing gave the whole body its largest tolerance, and a
solidcore chamfer makes its edges at 4x the B-rep's, so a chain of
mixed-route chamfers grew every tolerance fourfold per blend until one
failed (fillets/2341, 1e-5 to 0.164 mm by the 8th chamfer).

- to_arrays writes the B-rep's own tolerance as TOLERANCE (curves are
  still approximated to the largest), so the C++ import keeps each edge's
  and vertex's own.
- from_arrays takes the B-rep's tolerance as the least of the edges' and
  vertices' and each face's as the least of its edges' and loop
  vertices', when the arrays carry them (the C++ export still writes the
  largest as TOLERANCE).

fillets/2341 with blend on solidcore now passes; no other fillets or
booleans case changes status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 05:55:27 +01:00
Brett Lawson f44e08128d smlib: shell_cylinder_open_top runs on solidcore (solidcore#1453 landed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 05:39:42 +01:00
Brett Lawson 30d6331a52 solidcore: move the pin to 19587445 (main, with solidcore#1453: an offset edge between two analytic faces is their exact line or conic)
Turns on the new flag offset_analytic_mate_edges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 05:34:35 +01:00
Brett Lawson 3385da68e5 xtask kernel-cases: read the case's output while it runs, so a result over 64 KiB is not taken for a hang
run_case read stdout and stderr only after the child exited. A child that
writes more than the pipe buffer blocked in write and was killed at the
timeout as a hang (booleans/4623 with boolean routed to solidcore prints
65.9 KB). Two reader threads now drain the pipes while the rig waits; the
timeout still kills the child and reports a hang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 05:26:29 +01:00
Brett Lawson 8493abc90c smlib: shell_walls_touch runs on solidcore (solidcore#1442 landed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 05:11:42 +01:00
Brett Lawson d35dcf0222 solidcore: move the pin to 19d87ca7 (main, with solidcore#1442: walls that meet are refused as WALLS_TOUCH)
Turns on the new flag offset_walls_touch_refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 05:07:41 +01:00
Brett Lawson f8835c84f3 Merge ws/histseam: face tags through a boolean, C++ and solidcore (solidcore#1462)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 04:55:38 +01:00
Brett Lawson 5c7aad8c78 smlib: the solidcore boolean tag test cites solidcore#1462
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 04:55:38 +01:00
Brett Lawson 646db17538 smlib: tests for face tags through a boolean, C++ and solidcore
The C++ boolean of two tagged overlapping cubes gives 12 faces with one origin
each, six per operand. The same boolean routed to solidcore loses every tag:
solidcore's boolean takes no caller attributes and its merge keys are private.
That test is ignored until solidcore offers a way to carry attributes through
an operation (scratchpad bug boolean-operand-attributes-lost.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 04:53:06 +01:00
Brett Lawson 28b22658cf Merge origin/develop into solidcore
Conflicts in xtask/src/kernel_cases.rs and xtask/src/main.rs: both the
multi-backend runs (--kernel, --route) and develop's --known-failing
regression gate are kept. The gate grades the SMLib side: --known-failing
needs --kernel smlib (the default) or both, and is refused with
--kernel solidcore. A gate run saves every backend's results.json and
writes no report or side by side. Both report texts are merged; all tests
from both sides are kept, plus one for the gate's option rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 04:37:04 +01:00
Brett Lawson 1e8a5a3e59 Merge ws/rigroute: per-family kernel routes and rig scores
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 04:29:37 +01:00
Brett Lawson 421d57b1a2 Merge solidcore (pin 3546c9bc) into ws/rigroute
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 03:45:33 +01:00
Brett Lawson 8dfdcefadb smlib: SOLIDCRAFT_KERNEL_ROUTES moves single families; the rig scores one at a time
SOLIDCRAFT_KERNEL_ROUTES=<op>=<smlib|solidcore>[,...] applies on top of SOLIDCRAFT_KERNEL or the
preference, read once; bad, empty, overlong or surplus items are logged and ignored, and `file`
stays on SMLib. diagnostics.kernel reports it as `overrides`.

`cargo xtask kernel-cases --kernel both --route <op>=solidcore[,...]` runs the solidcore side as
SMLib with those families on solidcore, labelled solidcore[ops], into <out>/solidcore-<ops>/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 03:45:27 +01:00
Brett Lawson 04c2af035b solidcore: move the pin to 3546c9bc (main, with solidcore#1437 and #1445)
From f020a066: trim_boundary_seam_wrap keeps the gap lines along a singular
side (#1446, fixes #1437), so a sphere's area, volume and inertia are no
longer 0; they now miss 1e-9 by about 2e-9 (solidcore#1435). And
BrepIdentity::of_brep honours a copy's geometry_owner_keys (#1445, after
#1440): the identity test uses its new form. No new UpstreamFixes flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 03:44:11 +01:00
Brett Lawson c9b2c18eb7 smlib: circle, cylinder and torus NURBS forms come into solidcore (solidcore#1432, #1433)
from_arrays now runs SMLib's own tests where the capi's flat import does
(capi/src/smlib_flat.cpp):
- MakeEdgeCurve: a circle edge given with its NURBS form must pass
  SmCircle::IsNurbCurveCircle (Ellipse::is_nurb_curve_circle); the circle kept
  is the one of its canonical entry's frame and radius, its domain from
  ConicDomainOfNurbs, holding that NURBS (SameNurbsCurve, ported); any failure
  is SM_ERR_INVALID_INPUT.
- AnalyticOfNurbs: SmCylinder::IsNurbSurfaceCylinder and
  SmTorus::IsNurbSurfaceTorus, kept only when their NURBS is the face's.

The cylinder, frustum and torus now round-trip (same record within 1e-12,
volume exact). The cone and sphere records come back the same, but their
volumes still miss: the cone's on solidcore#1435 (1.4e-9 at 1e-8), the
sphere's on #1437 (0); those stay ignored on those issues.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 03:23:30 +01:00
Brett Lawson c11640214a solidcore: move the pin to f020a066 (main, with IsNurbSurfaceCylinder / IsNurbSurfaceTorus, solidcore#1433)
From 4cd55082: the ports of SmCylinder::IsNurbSurfaceCylinder and
SmTorus::IsNurbSurfaceTorus (#1448), with the new opt-in fix
found_torus_sphere_keep_tested_nurb (fork 7ea6a96, WR-493), turned on like
every other flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 03:13:49 +01:00
Brett Lawson f62d6ee6df solidcore: move the pin to 4cd55082 (main, with Ellipse::is_nurb_curve_circle, solidcore#1432)
From 9b76ac13: the port of SmCircle::IsNurbCurveCircle (#1444) and its
difftest cases. No new UpstreamFixes flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 03:08:38 +01:00
Brett Lawson b8c1f168e4 Merge ws/census: solid counts on solidcore through SmApiBrepMaterialCensus (solidcore#1436) 2026-10-29 03:05:02 +01:00
Brett Lawson 6b1b00d2a6 smlib: solid_count on solidcore runs SmApiBrepMaterialCensus (solidcore#1436)
The solidcore backend's solid_count now returns the solids of
brep_material_census, as smlib_brep_solid_count does with
SmApiBrepMaterialCensus (voids dropped, errors through the same mapping
as the other measure calls). The #1436 ignores are gone; only check
stays unported (solidcore#1419). Boolean cases now count solids:
two disjoint boxes unioned give 2, a box with an inner void gives 1,
with C++ parity under native.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 03:04:41 +01:00
Brett Lawson c9aa5116d5 solidcore: move the pin to 9b76ac13 (main, with SmApiBrepMaterialCensus, solidcore#1436)
From b1b95955: the census port (#1447), the fast-drop face filters
reading the trimmed cache in its pre-GetTree state during booleans (#1371),
and difftest-only changes. No new UpstreamFixes flags. crates/smlib's tests
are unchanged: 130 passed, 37 ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 02:55:39 +01:00
Brett Lawson ea52c12c2c Merge origin/develop into solidcore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 01:35:28 +01:00
Brett Lawson 8ed2b2254f Merge ws/triage: kernel-choice tests no longer switch the process to solidcore 2026-10-29 01:35:27 +01:00
Brett Lawson 729a247d53 Kernel routing: tests read each choice's routes without flipping the process to solidcore
The route test in solidcraft-smlib and diagnostics_kernel_reports_and_sets_the_choice in the
engine set the process-wide choice to solidcore for an instant while the other tests in their
binaries build bodies, so any of those could land on solidcore. Op::backend_under(choice) gives
the routes a choice would have without setting it (Op::backend follows it); the tests check
solidcore's routes through it, and set through the preference or the command only a choice
that routes every family as the table does. SMLib's own behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 01:34:06 +01:00
Brett Lawson 4f1ad5cc61 Cylinder shell case waits on solidcore#1453 (port fork fddc366 + 98ab1b0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 01:32:05 +01:00
Brett Lawson 123563717b Merge ws/shellcyl: the cylinder shell case waits on solidcore (marched offset edges) 2026-10-29 01:32:05 +01:00
Brett Lawson 2473633173 smlib: shell_cylinder_open_top stays ignored as a solidcore defect (solidcore#1130)
solidcore's own difftest wiring of api_shell_brep_full (and the test
fixture's OffsetExecutive::shell_brep) gives the same 333.0089389, 7 edges
and 5 vertices with every UpstreamFixes flag on, with none and with each
alone: MateEdges marches the offset edges on the B-spline extensions. The
fork fixed it in fddc366 and 98ab1b0, which solidcore has not ported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 01:30:37 +01:00
Brett Lawson d138b169fd Merge ws/offset: face picks through the copy's effective identity, and solidcore errors no longer read as no answer 2026-10-29 01:15:26 +01:00
Brett Lawson f7c8ff94ff Offset: face picks on the working copy read its proven owners (solidcore#1440)
The working copy every operation makes (copy_of: api_brep_copy naming its
objects in their own key domain) has no naming scheme of its own, so
BrepIdentity::of_brep gave it the arena's identities, which name none of its
geometry owners: every face's closest-point solve failed with InvalidInput
("edge owner is not in source B-rep"). pick_faces and leaves_cavity now key
the live view by BrepQuery::of_brep(..).effective_identity(), which reads the
owners the copy records. Not a solidcore defect.

The failure stayed hidden because answer() turned every solidcore error but
Unported into "no answer". The capi skips a query only when it returns a
status other than SM_SUCCESS, so answer() (now in core.rs) skips only
SolidError::Upstream and SolidError::Nlib and fails on anything else. The
same rule replaces the swallowing in convert.rs (ClassifyEdgeCurve, the plane
map, AnalyticOfNurbs) and sweep.rs (sm_DraftHasCorner's angle).

Tests: faces_are_picked_on_the_box_and_on_its_working_copy (closed-form
distances on the body, on copy_of's copy, and by the maintainers' usage;
the copy under the arena's identities is an error) and
a_solidcore_error_in_a_pick_is_an_error. The #1440 offset and sweep cases
run; shell_cylinder_open_top waits on a stray rim vertex in the shell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 01:14:52 +01:00
Brett Lawson 29a1ac5a6a Sweep cases name their solidcore issues (#1449 draft corners, #1450 curve-sweep placement, #1451 planar sections)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 01:07:59 +01:00
Brett Lawson dca700c89f Merge ws/sweep: extrusions, revolves, sweeps, lofts and screw sweeps on solidcore 2026-10-29 01:05:42 +01:00
Brett Lawson d0a439299b Kernel: extrusions, revolutions, sweeps, lofts and screw sweeps on solidcore
The capi's sweeps (capi/src/smlib_sweeps.cpp) ported onto solidcore in
core::sweep, routed from sweeps.rs when Op::Sweep runs there (the table
is unchanged): planar faces, straight and drafted extrusions, revolutions,
sweeps along straight and arc paths with their placements, lofts, lofts
to a point, sweeps through stations, the screw sweeps (the capi's own
walls, which solidcore leaves to it: solidcore PR #1326), curve
projection and face sections.

Fork-only SMLib code is refused as not supported where the capi reaches
it: drafted corners under the curvature and tangent rules (54148db,
e8f23e0), the curve sweep keeping the profile at the path's start
(2dd71a0, 5d3939e), and SmBrep::CreatePlanarSectionCurves.

core/sweep_tests.rs checks 19 cases against closed forms and, with
native, the C++ kernel: 14 match both; the rest name their solidcore
issue (#1435, #1440) or the missing fork code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 01:04:08 +01:00
Brett Lawson d6b3bf27b5 Shell walls-touch case waits on solidcore#1442 (port fork a759622)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 00:39:57 +01:00
Brett Lawson 263f34850d Offset cases name their solidcore issues (#1440 face picks, #1441 ApproximateSurface, #1060, #1019, PR #1427)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 00:31:16 +01:00
Brett Lawson 36a4897215 Merge ws/offset: shell, thicken, sheet offset, face offset and draft on solidcore 2026-10-29 00:29:26 +01:00
Brett Lawson 8582617ef2 Kernel: shells, thickening and sheet offsets on solidcore
core::offset ports the C API's offset family from the fork's smlib_ops.cpp and
smlib_sheets.cpp: smlib_shell (with the leaves_cavity guard, which still decides
the answer: without it solidcore's inward shell of a box with no cavity fails
with 1001 where the capi says NO_ROOM), smlib_thicken, smlib_offset_sheet,
smlib_offset_faces and smlib_draft, on SmApiShellBrepFull / SmApiOffsetBrepFull
(api_shell_brep_full, api_offset_brep_full), picking faces by point as
smlib_pick.h does. Shell, offset_faces, thicken and offset_sheet route on
Op::Offset, draft on Op::Split; the table is unchanged.

What solidcore lacks is "not supported yet" where the capi first reaches it:
the face move (SmOffsetFacesExecutive, fork a96ce9f, solidcore#1060) and the
draft's SmBrep::LocalOperation (solidcore PR #1427).

core/offset_tests.rs checks each case against its closed form and, with
native, the C++ kernel. Thicken (one-sided and symmetric), the plate's sheet
offset, an outward closed shell and the argument refusals match. The rest wait
on solidcore: its topology point solve finds only vertices, so no face is
picked and leaves_cavity sees no cavity; an offset surface does not export
(no SmSurface::ApproximateSurface); walls that meet are 1027 (#1019).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 00:28:53 +01:00
Brett Lawson 5140556c71 Blend refusals name their solidcore issues (#960, #1438, #1439, #430)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 00:07:03 +01:00
Brett Lawson aa77bcabb1 Merge ws/blend: two-distance and distance-angle chamfers on solidcore 2026-10-29 00:05:35 +01:00
Brett Lawson 4a7b2388ed Kernel: two-distance and angle chamfers on solidcore
chamfer_sides on solidcore ports the capi's outer_faceuse, face_frame and
sides_offsets, and chamfer_offsets now makes the calls the fork's
SetFilletParameters makes with pOptSecondRadii (f653ac9): per edge the
blend edge use, solidcore#1426's constant-radius solver with the second
offset (approximation tolerance 4 x the edge's, 20 and 10 degrees),
load_solver and a linear generator. Equal chamfers take the same path
with the second offset equal to the first, as the capi does.

The two-distance and distance-angle cases match their closed forms and
the C++ kernel (volume 1e-9, counts, faces by kind); a centroid test
checks the first distance goes on the face leaning along `first`.

The cliff-overflow refusal is still told apart by its comment: solidcore
notes it as NoRoom with no code of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-29 00:05:09 +01:00
Brett Lawson d477d76d92 Merge ws/blend: fillets, chamfers, setbacks and variable fillets on solidcore 2026-10-28 23:55:54 +01:00
Brett Lawson 525d8e1e61 Kernel: fillets and chamfers on solidcore
core/blend.rs ports the capi's composition round the fillet executive
(smlib_ops.cpp blend_call, round_edges, chamfer_offsets/chamfer_edges,
chamfer_radius, blend_failure, setbacks_fit, law_ok/law_pinches/law_along,
finish; smlib_pick.h): picking edges and corners by point with the
NoMatch/Ambiguous rule, per-edge kinds and sizes, bevelled chamfer corners,
N x N convex corner setbacks (CornerRefused otherwise), the B-spline
variable law, and the capi's errors (Argument, NoRoom, NoEffect, Empty,
NotSolid, SMLib statuses). ops.rs routes fillet, chamfer, blend,
blend_setback, blend_setback_open, fillet_variable and chamfer_sides on
Op::Blend as the boolean family is routed.

What the capi asks of fork-only SMLib that solidcore lacks is "not
supported yet", refused where the capi first reaches it: curvature blends
(fd3b6a7), two-distance and angle chamfers (f653ac9), open corners
(60af574), laws with a corner (6eecf04), and rails leaving their face, which
the fork may build over the cliff (8ba4667).

core/blend_tests.rs checks closed forms (one edge, all twelve edges, a
chamfer, Pappus on a cylinder rim, a setback corner, a linear variable
round, the errors) and, with native, C++ parity on volume, counts and
faces by kind, or the same error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-28 23:55:16 +01:00
Brett Lawson ce5de8bcfe Sphere measurement cases wait on solidcore#1437 (trim_boundary_seam_wrap at the poles)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-28 23:45:12 +01:00
Brett Lawson 86e4226ae7 solidcore backend: every UpstreamFixes flag on, and the ignored cases name their solidcore issues
Every opt-in fix of an upstream SMLib defect is a fix, so all are on; a test
fails when a solidcore bump adds a flag that is not. The face-properties
accuracy cases wait on solidcore#1435, the census on #1436, the body check on
#1419.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-28 23:37:22 +01:00
Brett Lawson 992a2f64c8 Merge ws/measure: mass properties, area, bounds, distances and checks on solidcore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-28 23:35:26 +01:00
Brett Lawson 1ba63d464f Kernel: the Measure and Check families on solidcore
core/measure.rs composes smlib_measure.cpp on solidcore's ports: mass properties through
SmBrep::ComputePreciseProperties directly (the [1e-8, 1e-1] envelope, material regions, the
face-by-face sheet fallback, moments about the box middle moved to the centroid), the area face
by face, the tight box, the edge/face gap, and IsManifoldSolid. solidcore has no
SmApiBrepMaterialCensus and no topology-level AssertValid, so solid_count and check say
"not supported yet". Each public method routes as Brep::volume does; the table is unchanged.

core/measure_tests.rs checks the primitives against the closed forms (now shared in
measure::closed) and, with SMLib built, against the C++ kernel. The cases solidcore misses are
ignored with their cause: face properties at 1e-8 (no knot-span splitting, fork 08d9a14),
solidcore#1431, the census and AssertValid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-28 23:33:30 +01:00
Brett Lawson 567198a358 Merge ws/boolean: booleans, unions, 2D booleans and imprints on solidcore 2026-10-28 23:32:59 +01:00
Brett Lawson 3a9bed4611 Merge ws/pref: the Geometry kernel preference and diagnostics.kernel tests 2026-10-28 23:32:30 +01:00
Brett Lawson 5cf4cb2121 Kernel: booleans, unions, 2D booleans and imprints on solidcore
Op::Boolean's operations get a solidcore backend (core/boolean.rs), each the
fork capi's logic round SM_API: smlib_boolean_with's operand copies
(SmApiBrepCopy), IsManifoldSolid of the inputs and finish's closed-result
check with non-manifold results allowed (closed_nonmanifold);
smlib_boolean_union_all's balanced pairwise tree; smlib_boolean2d's
SmPrimitiveCreation::Boolean2D with its no-face early return; smlib_imprint's
SmApiBooleanWithOptions(SM_BO_IMPRINT, keep tool) and its no-cut result.
ops.rs routes each like Brep::volume. The routing table is unchanged.

Tests: ten cases against analytic volumes (union, intersection, through slot,
flush notch, void, cylinder hole, 3x3 union_all, disjoint, sphere less a cap,
imprint), every result exported to a validated record; with SMLib built, the
same on C++ gives equal volume, counts and faces by surface kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-28 23:32:19 +01:00
Brett Lawson 22351d84cc Preferences: a Geometry kernel setting, applied through diagnostics.kernel
Preferences > Design gains a Geometry kernel segmented control (Routed, SMLib,
solidcore), saved with the other preferences and applied through the
diagnostics.kernel command at startup and when it changes, so an agent's
choice in between stands. When SOLIDCRAFT_KERNEL overrides it the control is
disabled and a hint says the environment variable wins. ui.prefs refuses an
unknown geometry_kernel.

diagnostics.kernel now refuses a `choice` of the wrong type instead of
reading it as no choice. Tests cover the routing report, setting the choice
(skipped under SOLIDCRAFT_KERNEL), unknown and hostile choices, and put the
choice back to routed however they end. The command's label and the new
strings are in a pending locale file, complete for ar, he, ja and zh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-28 23:31:56 +01:00
Brett Lawson 976acfd76c Conversion tests: each ignored case names its solidcore issue (#1431, #1432, #1433)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-28 23:18:10 +01:00
Brett Lawson e1be233e89 Merge ws/convert: solidcore bodies to and from the flat arrays 2026-10-28 23:17:06 +01:00
Brett Lawson 3eeb83433d Kernel: solidcore bodies to and from SMLib's flat arrays, and its primitives
core::to_arrays ports FlatFromBrep (through BrepData::from_brep: same ids, face/edge/vertex
order, analytic kinds with radian ranges, NURBS forms, SMLib's own UV trims and tolerances);
core::from_arrays ports Validate, FlatToBrepData and BrepFromFlat (MakeTopologyFromData,
healing unless SOURCE says SMLib wrote the arrays). Cylinders, spheres, cones and tori are made
in solidcore when Op::Primitive routes there, like boxes. cpp_arrays copies every array, so
Brep::core gets what the import needs; flat.rs gains WIRE_EDGE_NURBS_FORM.

All six primitives export to records that validate and check, and their arrays match the C++
kernel's (ints exact, reals 1e-12). Where solidcore lacks a port the C++ calls
(SmCircle::IsNurbCurveCircle, SmCylinder::IsNurbSurfaceCylinder, SmTorus::IsNurbSurfaceTorus,
SmSurface::ApproximateSurface, SmShell::IsInnerShellOf) the conversion says "not supported
yet"; the round trips that need them, and a sphere's volume at 1e-8 (SM_ERR in solidcore), are
ignored with the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-28 23:16:31 +01:00
Brett Lawson 12ace49736 diagnostics.kernel: read and set which kernel answers, and see the routing table
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-28 22:53:26 +01:00
Brett Lawson ff909b58d5 Merge ws/rig: kernel-cases --kernel smlib|solidcore|both 2026-10-28 22:48:08 +01:00
Brett Lawson 8c12073be7 Kernel: a solidcore backend behind crates/smlib, routed per operation
crates/smlib's Brep now holds a body of either kernel and converts it for the
other through the flat arrays the first time an operation routed there needs
it. route.rs is the routing table (every family on SMLib until the rig shows
parity), with SOLIDCRAFT_KERNEL=smlib|solidcore|routed and a preference to
override it; a failing solidcore call fails, with no fallback to C++. core.rs
runs every solidcore call on one process kernel under a panic guard, and
wires box, volume and counts. solidcore is a git dependency pinned by rev,
the web app links with an 8 MiB stack, and cargo-deny allows its git source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-28 22:48:08 +01:00
Brett Lawson d7224d4c65 kernel-cases: --kernel smlib|solidcore|both, each case's process on SOLIDCRAFT_KERNEL, and the two side by side
A solidcore run keeps results.json, dumps/ and report.md in <out>/solidcore/; both runs every
case on each backend in one job pool, keeps docs/kernel-cases.md as the SMLib report and writes
docs/kernel-cases-backends.md and <out>/backends.json (cases that differ first). The default
(smlib) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-28 22:46:11 +01:00
Brett Lawson 21eba6a381 claim: solidcore backend for develop
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgExNEwfR9CX2RAzp2Pt
2026-10-28 22:30:05 +01:00
153 changed files with 16227 additions and 5908 deletions
-35
View File
@@ -442,9 +442,6 @@ jobs:
with:
key: quick-compile
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh
- name: Cargo check
run: cargo check --workspace --all-targets --locked
@@ -464,9 +461,6 @@ jobs:
with:
key: native-clippy
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh
- name: Clippy
env:
FULL: ${{ needs.changes.outputs.full }}
@@ -510,9 +504,6 @@ jobs:
with:
key: native-tests
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
@@ -584,9 +575,6 @@ jobs:
with:
key: native-tests
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
@@ -703,9 +691,6 @@ jobs:
with:
key: native-gpu
save-if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && matrix.shard == 1 }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
@@ -862,9 +847,6 @@ jobs:
with:
key: doc-tests
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
@@ -923,9 +905,6 @@ jobs:
with:
key: oracle
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
@@ -965,9 +944,6 @@ jobs:
with:
key: windows-fxc
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh
# One test at a time: WARP's shader JIT crashes now and then (a null read in
# d3d10warp.dll's JIT optimizer, about 1 run in 60) when several devices compile compute
# pipelines at once, which the parallel tests do. Minidumps showed it in WARP itself, not
@@ -992,9 +968,6 @@ jobs:
with:
key: windows-startup
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh
- name: Backend order and probing on Windows
run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests native_frame::tests
- name: Build the app
@@ -1132,9 +1105,6 @@ jobs:
with:
key: wasm
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh wasm32-unknown-unknown
- run: cargo xtask wasm
riscv64-gates:
@@ -1157,9 +1127,6 @@ jobs:
with:
key: riscv64
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh riscv64gc-unknown-linux-gnu
- name: Install cross-compilation dependencies
run: |
# Ubuntu's regular archive serves amd64; RISC-V packages live in ports.
@@ -1180,8 +1147,6 @@ jobs:
- name: Cargo check on RISC-V
env:
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
# SMLib has no prebuilt library for RISC-V: its sources are compiled with this.
CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: 1
-3
View File
@@ -53,9 +53,6 @@ jobs:
with:
key: docker-web
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh wasm32-unknown-unknown
- name: Install trunk
run: |
mkdir -p "$HOME/.local/bin"
-3
View File
@@ -29,9 +29,6 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh
- name: Build the CLI
run: cargo build --profile ci -p solidcraft-cli
- name: Import and check every entry
+3 -8
View File
@@ -1,5 +1,5 @@
# The fillet rig as a regression gate: a pull request that touches fillet code (the paths below)
# runs every case in tests/kernel-cases/fillets/ on SMLib, through one release build of
# runs every case in tests/kernel-cases/fillets/ through one release build of
# solidcraft-cli, and fails on a regression (a case that fails and
# tests/kernel-cases/fillets/KNOWN_FAILING does not list) or on a listed case that now passes
# (it is fixed: remove its line, so the list only shrinks). See tests/kernel-cases/README.md.
@@ -25,7 +25,6 @@ on:
# The rig itself: the grading, the runner and this workflow.
- "apps/solidcraft-cli/src/kernel_cases.rs"
- "xtask/src/kernel_cases.rs"
- "scripts/fetch-smlib.sh"
- ".github/workflows/fillet-rig.yml"
push:
branches: [main, develop]
@@ -42,7 +41,6 @@ on:
# The rig itself: the grading, the runner and this workflow.
- "apps/solidcraft-cli/src/kernel_cases.rs"
- "xtask/src/kernel_cases.rs"
- "scripts/fetch-smlib.sh"
- ".github/workflows/fillet-rig.yml"
workflow_dispatch:
@@ -86,15 +84,12 @@ jobs:
with:
key: fillet-rig
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- name: Build the CLI (release, SMLib)
run: cargo build --release --locked -p solidcraft-cli --features smlib
- name: Build the CLI (release)
run: cargo build --release --locked -p solidcraft-cli
- name: Run the fillet cases against the known-failing list
run: >-
cargo run -q --locked -p xtask -- kernel-cases --area fillets
-3
View File
@@ -48,9 +48,6 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: generated-files
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh
- name: THIRD-PARTY-LICENSES.txt
run: cargo xtask licences
- name: docs/features.md
+4 -9
View File
@@ -2,7 +2,7 @@
# changed files to the rig areas they can affect (scripts/ci/kernel_rig_plan.py): an area runs
# when a file its tests/kernel-cases/<area>/PATHS lists changes, or one of its cases; every area
# runs when the shared core in tests/kernel-cases/PATHS-ALL changes; the ladder runs whenever any
# area does. One job then builds solidcraft-cli once (release, SMLib prebuilt), checks that every
# area does. One job then builds solidcraft-cli once (release), checks that every
# registry command has a case or an exemption (tests/kernel-cases/EXEMPT), and hands the CLI to a
# matrix of shards as an artifact, one per selected area plus the ladder, in parallel, so the
# wall time is the build plus the slowest shard. See tests/kernel-cases/README.md.
@@ -38,7 +38,6 @@ on:
- "apps/solidcraft-cli/src/kernel_cases.rs"
- "xtask/src/kernel_cases.rs"
- "xtask/src/kernel_ladder.rs"
- "scripts/fetch-smlib.sh"
- "scripts/ci/kernel_rig_plan.py"
- "scripts/ci/test_kernel_rig_plan.py"
- ".github/workflows/kernel-rig.yml"
@@ -58,7 +57,6 @@ on:
- "apps/solidcraft-cli/src/kernel_cases.rs"
- "xtask/src/kernel_cases.rs"
- "xtask/src/kernel_ladder.rs"
- "scripts/fetch-smlib.sh"
- "scripts/ci/kernel_rig_plan.py"
- "scripts/ci/test_kernel_rig_plan.py"
- ".github/workflows/kernel-rig.yml"
@@ -141,16 +139,13 @@ jobs:
with:
key: kernel-rig
save-if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- name: Build the CLI (release, SMLib) and the rig's runner
- name: Build the CLI (release) and the rig's runner
run: |
cargo build --release --locked -p solidcraft-cli --features smlib
cargo build --release --locked -p solidcraft-cli
cargo build --locked -p xtask
# A tar keeps the executable bits, which an artifact drops. xtask finds the repository from
# the path it was built in (CARGO_MANIFEST_DIR), which is the same checkout path in every
@@ -209,7 +204,7 @@ jobs:
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: kernel-rig-results-${{ matrix.shard }}
path: ${{ runner.temp }}/kernel-rig/results.json
path: ${{ runner.temp }}/kernel-rig/**/results.json
if-no-files-found: ignore
# One check for the whole gate, for branch protection and the merge queue.
-3
View File
@@ -41,9 +41,6 @@ jobs:
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh wasm32-unknown-unknown
- name: Install mdbook and trunk
run: |
mkdir -p "$HOME/.local/bin"
-15
View File
@@ -126,9 +126,6 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-icons
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh
- run: cargo build --release --locked -p solidcraft-cli
- run: |
. packaging/env.sh
@@ -168,9 +165,6 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh
- name: Build and package (AppImage, deb, rpm, tar.gz)
run: packaging/linux/package.sh
- name: Check the packages
@@ -395,9 +389,6 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-${{ matrix.arch }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh ${{ matrix.target }}
- name: Build
run: packaging/macos/package.sh --build-only ${{ matrix.target }}
# Not named release-*: the release job collects every release-* artifact as an asset.
@@ -496,9 +487,6 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh ${{ matrix.target }}
- name: Install WiX
shell: pwsh
run: |
@@ -630,9 +618,6 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh wasm32-unknown-unknown
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
+10
View File
@@ -810,6 +810,15 @@ in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
The glTF test files are built by the tests themselves (crates/io/src/gltf_in_tests.rs,
crates/engine/src/tests/gltf_import.rs); no third-party models are committed.
## Geometry kernel crates
| Crate | Used for | Licence |
|---|---|---|
| solidcore (https://github.com/tangency-labs/solidcore, pinned by rev in Cargo.toml) | the Rust port of NVIDIA's SMLib: the B-rep kernel behind crates/smlib | Apache-2.0 (its NOTICE credits NVIDIA's USD BRep) |
Linked as a crate; no code from it is copied into SolidCraft. Its licence text and NOTICE go
into THIRD-PARTY-LICENSES.txt when `main` regenerates it.
## FreeCAD and Rhino import library
FreeCAD `.FCStd` and Rhino `.3dm` files are read with [cadmpeg](https://github.com/cadmpeg/cadmpeg)
@@ -825,6 +834,7 @@ copied into SolidCraft.
| SolidCraft file | Original contribution | Licence |
|---|---|---|
| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `axis_view_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solidcraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solidcraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solidcraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the axis gizmo | MIT OR Apache-2.0 |
| crates/smlib/src/core/offset.rs (`draft`: the tilted planes and `SmBrep::LocalOperation`, the tail of the capi's `draft_faces`) | ablasik's `draft` in `crates/smlib/src/ffi/rs_ops2.rs` on [ablasik/solidcraft `app/solidcore-backend-on-develop`](https://github.com/ablasik/solidcraft/blob/9c7e8e31565e1823f0591b58e2967351c32af13f/crates/smlib/src/ffi/rs_ops2.rs), adapted to `core::offset`'s errors and checks | MIT OR Apache-2.0 |
## Vendored third-party code
Generated
+9 -2
View File
@@ -4051,6 +4051,14 @@ dependencies = [
"serde",
]
[[package]]
name = "solidcore"
version = "1.0.1"
source = "git+https://github.com/tangency-labs/solidcore?rev=2dbd88b6e9b858144609690eb2860e0beba27e54#2dbd88b6e9b858144609690eb2860e0beba27e54"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "solidcraft"
version = "0.30.1"
@@ -4277,13 +4285,12 @@ dependencies = [
name = "solidcraft-smlib"
version = "0.30.1"
dependencies = [
"cc",
"flate2",
"log",
"serde_json",
"solidcore",
"solidcraft-geom",
"solidcraft-profile",
"web-time",
]
[[package]]
+5
View File
@@ -31,6 +31,11 @@ solidcraft-space-mouse = { path = "crates/space-mouse" }
solidcraft-mcp = { path = "crates/mcp" }
solidcraft-derive = { path = "crates/derive" }
solidcraft-3dx-client = { path = "crates/threedx-client" }
# The Rust port of NVIDIA's SMLib (Apache-2.0), the B-rep kernel behind crates/smlib. Pinned;
# move the rev on its own commit. For a
# local checkout, add `[patch."https://github.com/tangency-labs/solidcore"] solidcore = { path =
# "../solidcore/crates/solidcore" }` to a `.cargo/config.toml` outside the repository.
solidcore = { git = "https://github.com/tangency-labs/solidcore", rev = "2dbd88b6e9b858144609690eb2860e0beba27e54" }
# test tooling: a dev-dependency only (xtask/src/layers.rs)
solidcraft-testkit = { path = "crates/testkit" }
+1 -4
View File
@@ -20,12 +20,9 @@ name = "solvecraft-cli"
path = "src/alias.rs"
[features]
default = ["raytrace-cpu", "smlib", "profile"]
default = ["raytrace-cpu", "profile"]
bundled-render-library = ["solidcraft-engine/bundled-render-library"]
raytrace-cpu = ["solidcraft-engine/raytrace-cpu"]
# Link SMLib into the kernel (solidcraft-engine/smlib; needs scripts/fetch-smlib.sh). SMLib is the
# only kernel, so without it the command line models nothing.
smlib = ["solidcraft-engine/smlib"]
# The profiler, compiled in so any build can say where its time goes (`--profile`,
# SOLIDCRAFT_PROFILE=1); it records nothing until asked.
profile = ["solidcraft-engine/profile"]
+6 -13
View File
@@ -9,9 +9,9 @@ use serde_json::{Value, json};
use crate::oracle::{ABS_FRAC, Checks, REL_TOL, totals, v3};
use crate::{read_json, recipe};
/// The kernel the cases run on: SMLib, the only one.
/// The kernel the cases run on: solidcore, the only one.
fn kernel() -> String {
"smlib".into()
"solidcore".into()
}
/// How many cases a chain of `base` recipes may go through (a ladder case builds on the one
@@ -692,28 +692,21 @@ fn check_case(dir: &str, dumps: Option<&std::path::Path>, validation: solidcraft
return out;
}
};
if !solidcraft_engine::kernel::smlib_built() {
out["status"] = json!("unsupported");
out["error"] = json!("this build has no SMLib: build solidcraft-cli with --features smlib");
return out;
}
// `refused`: only a clean refusal is right; `solid_or_refused`: a clean refusal, or a result
// graded as usual (where the geometry has no manifold answer, such as bodies touching along a
// curve only).
let outcome = want["outcome"].as_str().unwrap_or("solid");
let refused = outcome == "refused";
let may_refuse = refused || outcome == "solid_or_refused";
// The SMLib calls that fail in this case leave their inputs in its own folder.
// The kernel calls that fail in this case leave their inputs in its own folder.
if let Some(root) = dumps {
let folder = root.join(format!("{area}-{name}"));
if let Err(e) = std::fs::create_dir_all(&folder)
.map_err(|e| e.to_string())
.and_then(|()| solidcraft_engine::kernel::smlib_dump_dir(Some(&folder)).map_err(|e| e.to_string()))
{
if let Err(e) = std::fs::create_dir_all(&folder) {
out["status"] = json!("fail");
out["error"] = json!(format!("--dumps: {}: {e}", folder.display()));
return out;
}
solidcraft_engine::kernel::smlib_dump_dir(Some(&folder));
}
let mut s = solidcraft_engine::Session::default();
s.set_validation(validation);
@@ -985,7 +978,7 @@ pub fn run(args: &[String]) -> Result<(), String> {
};
let results: Vec<Value> = dirs.iter().map(|d| check_case(d, dumps.as_deref(), validation)).collect();
if dumps.is_some() {
solidcraft_engine::kernel::smlib_dump_dir(None).map_err(|e| format!("kernel-cases: --dumps: {e}"))?;
solidcraft_engine::kernel::smlib_dump_dir(None);
}
report(args, &results);
let bad = results.iter().filter(|r| matches!(r["status"].as_str(), Some("fail" | "crash"))).count();
+1 -1
View File
@@ -1,5 +1,5 @@
//! `--profile` and `--profile-out FILE` for `run`, `eval` and `bench-open` (and
//! `SOLIDCRAFT_PROFILE=1` for any command): record where the time goes, SMLib's calls to the
//! `SOLIDCRAFT_PROFILE=1` for any command): record where the time goes, the kernel's calls to the
//! commands, print the table to stderr when the command ends (stdout stays the command's JSON)
//! and write the spans as a Chrome trace for chrome://tracing or Perfetto.
+1 -5
View File
@@ -14,12 +14,8 @@ name = "solidcraft-web"
path = "src/main.rs"
[features]
default = ["smlib", "profile"]
default = ["profile"]
bundled-render-library = ["solidcraft-engine/bundled-render-library"]
# Link SMLib into the kernel (solidcraft-engine/smlib; needs scripts/fetch-smlib.sh
# wasm32-unknown-unknown, or the sources and wasi-sdk in WASI_SDK_PATH, crates/smlib/build.rs). SMLib
# is the only kernel, so without it the web app models nothing.
smlib = ["solidcraft-engine/smlib"]
# The profiler, compiled in so any build can say where its time goes (the Profile operations
# preference, SOLIDCRAFT_PROFILE=1, diagnostics.profile); it records nothing until asked.
profile = ["solidcraft-engine/profile"]
+9
View File
@@ -0,0 +1,9 @@
//! Links the browser build with an 8 MiB stack: solidcore's kernel algorithms (subdivision,
//! intersection, filleting) recurse legitimately, and wasm has no guard page to catch an
//! overflow (solidcore's README recommends 8 MiB).
fn main() {
if std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32") {
println!("cargo:rustc-link-arg=-zstack-size=8388608");
}
}
+1 -4
View File
@@ -8,12 +8,9 @@ publish = false
default-run = "solidcraft"
[features]
default = ["raytrace-cpu", "bundled-render-library", "smlib", "profile"]
default = ["raytrace-cpu", "bundled-render-library", "profile"]
bundled-render-library = ["solidcraft-engine/bundled-render-library"]
raytrace-cpu = ["solidcraft-engine/raytrace-cpu"]
# Link SMLib into the kernel (solidcraft-engine/smlib; needs scripts/fetch-smlib.sh). SMLib is the
# only kernel, so without it the app models nothing.
smlib = ["solidcraft-engine/smlib"]
# The profiler, compiled in so any build can say where its time goes (the Profile operations
# preference, SOLIDCRAFT_PROFILE=1, diagnostics.profile); it records nothing until asked.
profile = ["solidcraft-engine/profile"]
+1
View File
@@ -4248,6 +4248,7 @@
"ui.agent_chat_no_images": "هذا الوكيل لا يقبل الصور",
"ui.agent_chat_no_picture": "لا توجد صورة في الحافظة",
"ui.agent_chat_no_tool_call": "لم يستخدم النموذج أدوات SolidCraft، لذا لم يتغيّر التصميم. اختر نموذجًا يدعم استدعاء الأدوات؛ راجع «النماذج المحلية» في دليل محادثة الوكيل.",
"ui.agent_chat_node_too_old": "إصدار Node.js ‏{version} قديم جدًا لهذا الوكيل. ثبّت Node.js 20.10 أو أحدث، ثم اضغط على تحديث.",
"ui.agent_chat_none_found": "لم يُعثر على وكيل برمجي. ثبّت أحد هذه ثم اضغط تحديث:",
"ui.agent_chat_none_found_short": "لم يُعثر على وكيل",
"ui.agent_chat_not_installed": "غير مثبت",
+1
View File
@@ -4231,6 +4231,7 @@
"ui.agent_chat_no_images": "Dieser Agent nimmt keine Bilder an",
"ui.agent_chat_no_picture": "In der Zwischenablage befindet sich kein Bild",
"ui.agent_chat_no_tool_call": "Das Modell hat die Werkzeuge von SolidCraft nicht verwendet, daher wurde das Design nicht geändert. Wählen Sie ein Modell, das Werkzeugaufrufe unterstützt; siehe „Lokale Modelle“ im Leitfaden zum Agenten-Chat.",
"ui.agent_chat_node_too_old": "Node.js {version} ist für diesen Agenten zu alt. Installieren Sie Node.js 20.10 oder neuer und klicken Sie dann auf Aktualisieren.",
"ui.agent_chat_none_found": "Kein Coding-Agent gefunden. Installieren Sie einen davon und klicken Sie dann auf Aktualisieren:",
"ui.agent_chat_none_found_short": "Kein Agent gefunden",
"ui.agent_chat_not_installed": "Nicht installiert",
+1
View File
@@ -4248,6 +4248,7 @@
"ui.agent_chat_no_images": "This agent doesn't take pictures",
"ui.agent_chat_no_picture": "There is no picture on the clipboard",
"ui.agent_chat_no_tool_call": "The model didn't use SolidCraft's tools, so the design wasn't changed. Choose a model that supports tool calling; see Local models in the agent chat guide.",
"ui.agent_chat_node_too_old": "Node.js {version} is too old for this agent. Install Node.js 20.10 or newer, then press Refresh.",
"ui.agent_chat_none_found": "No coding agent found. Install one of these, then press Refresh:",
"ui.agent_chat_none_found_short": "No agent found",
"ui.agent_chat_not_installed": "Not installed",
+1
View File
@@ -4248,6 +4248,7 @@
"ui.agent_chat_no_images": "הסוכן הזה לא מקבל תמונות",
"ui.agent_chat_no_picture": "אין תמונה בלוח",
"ui.agent_chat_no_tool_call": "המודל לא השתמש בכלים של SolidCraft, ולכן התכן לא השתנה. יש לבחור מודל שתומך בקריאה לכלים; ראו „מודלים מקומיים” במדריך צ'אט הסוכן.",
"ui.agent_chat_node_too_old": "גרסת Node.js ‏{version} ישנה מדי עבור סוכן זה. יש להתקין Node.js 20.10 ומעלה, ואז ללחוץ על רענון.",
"ui.agent_chat_none_found": "לא נמצא סוכן קוד. יש להתקין אחד מאלה ואז ללחוץ על רענון:",
"ui.agent_chat_none_found_short": "לא נמצא סוכן",
"ui.agent_chat_not_installed": "לא מותקן",
+1
View File
@@ -4248,6 +4248,7 @@
"ui.agent_chat_no_images": "このエージェントは画像を受け付けません",
"ui.agent_chat_no_picture": "クリップボードに画像がありません",
"ui.agent_chat_no_tool_call": "モデルが SolidCraft のツールを使わなかったため、デザインは変更されませんでした。ツール呼び出しに対応したモデルを選んでください。エージェントチャットのガイドの「ローカルモデル」を参照してください。",
"ui.agent_chat_node_too_old": "Node.js {version} はこのエージェントには古すぎます。Node.js 20.10 以降をインストールしてから、更新を押してください。",
"ui.agent_chat_none_found": "コーディングエージェントが見つかりません。次のいずれかをインストールしてから「再検索」を押してください:",
"ui.agent_chat_none_found_short": "エージェントが見つかりません",
"ui.agent_chat_not_installed": "未インストール",
+1
View File
@@ -3059,6 +3059,7 @@
"ui.agent_chat_no_control": "에이전트 채팅 엔드포인트가 시작되지 않아 에이전트가 디자인에 접근할 수 없습니다.",
"ui.agent_chat_no_images": "이 에이전트는 이미지를 받지 않습니다",
"ui.agent_chat_no_picture": "클립보드에 이미지가 없습니다",
"ui.agent_chat_node_too_old": "Node.js {version}은 이 에이전트에 사용하기에는 너무 오래된 버전입니다. Node.js 20.10 이상을 설치한 후 새로 고침을 누르세요.",
"ui.agent_chat_none_found": "코딩 에이전트를 찾지 못했습니다. 다음 중 하나를 설치한 뒤 새로 고침을 누르세요:",
"ui.agent_chat_none_found_short": "에이전트를 찾지 못함",
"ui.agent_chat_not_installed": "설치되지 않음",
+1
View File
@@ -4248,6 +4248,7 @@
"ui.agent_chat_no_images": "AI Agent này không hỗ trợ tiếp nhận hình ảnh",
"ui.agent_chat_no_picture": "Không có hình ảnh trong bộ nhớ tạm",
"ui.agent_chat_no_tool_call": "Mô hình không sử dụng các công cụ của SolidCraft nên thiết kế không thay đổi. Hãy chọn mô hình hỗ trợ gọi công cụ; xem mục Mô hình cục bộ trong hướng dẫn trò chuyện AI.",
"ui.agent_chat_node_too_old": "Node.js {version} quá cũ đối với tác nhân này. Hãy cài đặt Node.js 20.10 trở lên, rồi nhấn Làm mới.",
"ui.agent_chat_none_found": "Không tìm thấy tác tử lập trình nào. Hãy cài đặt một trong các tác tử sau, rồi nhấn Làm mới:",
"ui.agent_chat_none_found_short": "Không tìm thấy tác tử nào",
"ui.agent_chat_not_installed": "Chưa cài đặt",
+1
View File
@@ -4248,6 +4248,7 @@
"ui.agent_chat_no_images": "该智能体不接收图片",
"ui.agent_chat_no_picture": "剪贴板上没有图片",
"ui.agent_chat_no_tool_call": "模型没有使用 SolidCraft 的工具,因此设计没有更改。请选择支持工具调用的模型;参见智能体聊天指南中的“本地模型”。",
"ui.agent_chat_node_too_old": "Node.js {version} 版本过旧,无法运行此代理。请安装 Node.js 20.10 或更新版本,然后点击刷新。",
"ui.agent_chat_none_found": "未找到编程智能体。请安装以下之一,然后点击刷新:",
"ui.agent_chat_none_found_short": "未找到智能体",
"ui.agent_chat_not_installed": "未安装",
-2
View File
@@ -13,8 +13,6 @@ default = []
# Optional authored texture/HDR bytes; the command registry and metadata remain available.
bundled-render-library = []
raytrace-cpu = ["solidcraft-render/raytrace-cpu"]
# Link SMLib into the kernel (solidcraft-kernel/smlib; needs scripts/fetch-smlib.sh).
smlib = ["solidcraft-kernel/smlib"]
# Turns on the parameter-contract audit recorder (`SOLIDCRAFT_PARAM_AUDIT`, see
# `src/schema/record.rs`), for checking a new contract against the whole test suite.
param-audit = []
+1 -1
View File
@@ -55,7 +55,7 @@ pub static COMMANDS: &[CommandSpec] = &[
trace?: path (write the recorded spans as a Chrome trace-event file, for chrome://tracing or Perfetto). Returns what \
was recorded before enabled and reset apply: {compiled, enabled, wall_ms, spans, dropped, by_category: [{category, \
self_ms, share}], rows: [{category, name, count, total_ms, self_ms, max_ms}], rows_total, trace?}, the rows the most \
self time first. Categories: smlib (SMLib's C API calls, timed by the C API), kernel, feature (one per feature \
self time first. Categories: solidcore (the kernel's calls), kernel, feature (one per feature \
computed, by its type), doc, command, import (STEP/IGES stages), io. Self time is a span's time less the spans \
inside it; total counts its outermost run only. Recording also starts with SOLIDCRAFT_PROFILE=1, the CLI's \
--profile and the app's preference; off, it costs nothing measurable. Read-only",
+2 -3
View File
@@ -1124,8 +1124,7 @@ mod tests {
use super::*;
/// While the profiler records, a STEP import's reply carries its own profile: the import's
/// stages and SMLib's calls, as diagnostics.profile reports them.
#[cfg(feature = "smlib")]
/// stages and the kernel's calls, as diagnostics.profile reports them.
#[test]
fn a_step_import_reports_its_profile_while_the_profiler_records() {
let _serial = crate::schema::diagnostics::PROFILE_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -1144,7 +1143,7 @@ mod tests {
let p = &out["profile"];
let rows = p["rows"].as_array().unwrap();
let has = |cat: &str| rows.iter().any(|r| r["category"] == cat);
assert!(has("import") && has("smlib"), "{p}");
assert!(has("import") && has("solidcore"), "{p}");
assert!(p["by_category"].as_array().is_some_and(|c| !c.is_empty()) && p["wall_ms"].as_f64().is_some_and(|w| w > 0.0), "{p}");
}
+1 -1
View File
@@ -34,7 +34,7 @@ mod component_clone;
mod component_clone_assembly;
mod construct;
mod delete;
mod diagnostics;
pub(crate) mod diagnostics;
mod edit;
mod face;
mod features;
+1 -2
View File
@@ -197,8 +197,7 @@ pub struct ProfileParams {
/// One category's share of the recorded self time.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ProfileCategory {
/// `smlib` (SMLib's own time, measured by its C API), `kernel`, `feature`, `doc`, `command`,
/// `import`, `io`.
/// `solidcore` (the kernel's own calls), `kernel`, `feature`, `doc`, `command`, `import`, `io`.
pub category: String,
pub self_ms: f64,
/// Of all recorded self time, 0–1.
+17 -1
View File
@@ -109,12 +109,25 @@ fn clamped(p: usize, n: usize, k: &[f64]) -> bool {
p >= 1 && k.len() == n + p + 1 && ends(1..=p) && ends(n..=n + p - 1)
}
/// `x` moved by whole periods `b − a` into `[a, b]` when it lies outside.
/// How far past an end of the interval, relative to its size, a parameter is still that end:
/// rounding, not a period away.
const WRAP_SLACK: f64 = 1e-12;
/// `x` moved by whole periods `b − a` into `[a, b]` when it lies outside. A parameter a rounding
/// past an end (a pcurve ending on the seam one ulp beyond it) is that end, not moved a period
/// to the other one: a plan's node there would put its triangles across the whole period.
pub(crate) fn wrap_into(x: f64, (a, b): (f64, f64)) -> f64 {
let period = b - a;
if (a..=b).contains(&x) || !(period > 0.0) {
return x;
}
let slack = WRAP_SLACK * period.max(a.abs()).max(b.abs());
if x > b && x - b <= slack {
return b;
}
if x < a && a - x <= slack {
return a;
}
let w = x - ((x - a) / period).floor() * period;
w.clamp(a, b)
}
@@ -410,6 +423,9 @@ mod tests {
}
assert_eq!(s.wrap([1.0, 0.5]), [1.0, 0.5]);
assert_eq!(s.wrap([1.25, 2.0]), [0.25, 2.0]);
// A rounding past an end is that end, not the other one a period away.
assert_eq!(s.wrap([1.0 + f64::EPSILON, 0.5]), [1.0, 0.5]);
assert_eq!(s.wrap([-1e-17, 0.5]), [0.0, 0.5]);
// Projection finds a point near the seam from either side.
let p = s.derivs(0.02, 0.5).map(|d| d.0).unwrap_or(Vec3::ZERO);
let dom = domain(&s);
+3 -3
View File
@@ -26,9 +26,9 @@ wgpu = { workspace = true, features = ["metal", "vulkan", "dx12", "gles"] }
[dev-dependencies]
image = { workspace = true }
solidcraft-kernel = { workspace = true }
# Headless pictures of real designs (capture tests), built by the real kernel: SMLib
# (scripts/fetch-smlib.sh), as in the apps.
solidcraft-engine = { workspace = true, features = ["bundled-render-library", "smlib"] }
# Headless pictures of real designs (capture tests), built by the real kernel, solidcore, as in the
# apps.
solidcraft-engine = { workspace = true, features = ["bundled-render-library"] }
solidcraft-io = { workspace = true }
serde_json = { workspace = true }
# The HLSL the D3D12 backend compiles (tests check it suits FXC).
+13 -2
View File
@@ -1215,7 +1215,7 @@ mod tests {
let tree: Value = serde_json::from_slice(manifest).unwrap();
assert_eq!(serde_json::to_vec(&tree).unwrap(), *manifest);
assert_eq!(tree["model"], model.records);
assert_eq!(tree.as_object().unwrap().len(), 11, "{:?}", tree.as_object().unwrap().keys().collect::<Vec<_>>());
assert_eq!(tree.as_object().unwrap().len(), 12, "{:?}", tree.as_object().unwrap().keys().collect::<Vec<_>>());
}
/// #9170: a mesh body (an imported STL) is kept in the model cache with the rest of the
@@ -1474,7 +1474,7 @@ mod tests {
let doc = document();
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&native_geometry())).unwrap();
let original = parts(&bytes);
for key in ["format", "producer", "evaluation", "document", "payload"] {
for key in ["format", "producer", "evaluation", "kernel", "document", "payload"] {
let mut entries = original.clone();
let mut manifest: Value = serde_json::from_slice(&entries["cache/model.json"]).unwrap();
manifest[key] = json!("unsupported-or-stale");
@@ -1548,6 +1548,17 @@ mod tests {
manifest.as_object_mut().unwrap().remove("evaluation");
entries.insert("cache/model.json".into(), serde_json::to_vec(&manifest).unwrap());
assert!(read_design_geometry(&package(&entries)).is_none());
// The kernel that computed it is recorded, and a model the C++ kernel computed (an earlier
// build's) is evaluated again (a cache without one is a miss too).
assert_eq!(written(&original)["kernel"], json!(native_cache::KERNEL));
let bytes = with(&[("kernel", "smlib")]);
assert!(read_design_geometry(&bytes).is_none(), "the C++ kernel's cache");
assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json());
let mut entries = original.clone();
let mut manifest = written(&entries);
manifest.as_object_mut().unwrap().remove("kernel");
entries.insert("cache/model.json".into(), serde_json::to_vec(&manifest).unwrap());
assert!(read_design_geometry(&package(&entries)).is_none());
}
#[test]
+11 -3
View File
@@ -1,7 +1,7 @@
//! The model cache: a design's evaluated timeline (every feature's result and the state after
//! it, [`SavedModel`]), saved with it so opening need not evaluate it again. Bodies use
//! SolidCraft's exact native B-rep encoding (a mesh body, its own triangles); the key ties the
//! cache to the exact document, and to the source and platform that computed it
//! cache to the exact document, and to the source, platform and geometry kernel that computed it
//! (docs/design-container.md).
use super::*;
use solidcraft_doc::{ModelState, SavedModel};
@@ -29,6 +29,11 @@ fn evaluation() -> String {
format!("{}/{}-{}", env!("SOLIDCRAFT_EVALUATION_SOURCE"), std::env::consts::ARCH, std::env::consts::OS)
}
/// The geometry kernel that computed a cache. A cache another kernel wrote (the C++ SMLib, which
/// earlier builds could run on) is not this build's result: the kernels differ in tolerances and in
/// what they refuse, so a design opened from it evaluates again.
pub(super) const KERNEL: &str = "solidcore";
/// A 4 x 4 matrix as its 16 numbers, in storage order.
fn matrix(t: &[[f64; 4]; 4]) -> Vec<f64> {
t.iter().flatten().copied().collect()
@@ -296,6 +301,7 @@ impl Encoded {
document: digest(document),
evaluation: evaluation(),
format: FORMAT,
kernel: KERNEL,
model: records,
payload: digest(&data),
producer: PRODUCER,
@@ -320,6 +326,7 @@ struct Manifest<'a> {
document: String,
evaluation: String,
format: &'a str,
kernel: &'a str,
model: &'a Value,
payload: String,
producer: &'a str,
@@ -336,14 +343,15 @@ pub fn read_design_geometry(bytes: &[u8]) -> Option<std::sync::Arc<ModelState>>
DesignContainerReader::new(Cursor::new(bytes)).ok()?.geometry()?.final_state().ok()
}
/// Whether `manifest` is a model cache of this build's evaluation source for `document`. The
/// application version and commit that wrote it are not asked: another version of the same
/// Whether `manifest` is a model cache of this build's evaluation source and kernel for `document`.
/// The application version and commit that wrote it are not asked: another version of the same
/// evaluation source computed the same model.
fn current(manifest: &Value, document: &[u8]) -> bool {
let is = |key: &str, value: &str| manifest.get(key).and_then(Value::as_str) == Some(value);
is("format", FORMAT)
&& is("producer", PRODUCER)
&& is("evaluation", &evaluation())
&& is("kernel", KERNEL)
&& is("document", &digest(document))
&& source_trusted(env!("SOLIDCRAFT_EVALUATION_SOURCE"), cfg!(any(test, feature = "trust-unknown-source")))
}
+1 -7
View File
@@ -1,6 +1,6 @@
[package]
name = "solidcraft-kernel"
description = "SolidCraft B-rep kernel boundary: solids, extrude/revolve/booleans/fillets, meshes, measures and STEP, behind our own API (implemented with SMLib)"
description = "SolidCraft B-rep kernel boundary: solids, extrude/revolve/booleans/fillets, meshes, measures and STEP, behind our own API (implemented with solidcore)"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -8,12 +8,6 @@ license.workspace = true
[lints]
workspace = true
[features]
# Link NVIDIA's SMLib (tangency-labs/smlib) into the kernel; needs a C++ toolchain and
# scripts/fetch-smlib.sh (ADR 0002). Without it every geometric operation answers that SMLib
# was not built.
smlib = ["solidcraft-smlib/native"]
[dependencies]
solidcraft-geom = { workspace = true }
solidcraft-smlib = { workspace = true }
File diff suppressed because it is too large Load Diff
+1 -10
View File
@@ -2,21 +2,13 @@
//! component is its own product with a package body and its own pins (nothing shared).
//!
//! ```sh
//! scripts/fetch-smlib.sh
//! SOLIDCRAFT_STEP_PCB_COMPONENTS=600 SOLIDCRAFT_STEP_PCB_PINS=15 \
//! cargo run --release -p solidcraft-kernel --features smlib --example step_board_fixture -- board.step
//! cargo run --release -p solidcraft-kernel --example step_board_fixture -- board.step
//! ```
//!
//! 600 components of 15 pins give 52 MB. The defaults are 500 and 16; the path defaults to
//! `board.step`.
#[cfg(not(feature = "smlib"))]
fn main() {
eprintln!("step_board_fixture needs SMLib: run scripts/fetch-smlib.sh, then add --features smlib");
std::process::exit(2);
}
#[cfg(feature = "smlib")]
fn main() {
if let Err(e) = run::main() {
eprintln!("step_board_fixture: {e}");
@@ -24,7 +16,6 @@ fn main() {
}
}
#[cfg(feature = "smlib")]
mod run {
use solidcraft_geom::Vec3;
use solidcraft_kernel::{Body, ExportBody, ExportProduct, StepHeader, box_solid, cylinder, fillet, step_export_products};
+2 -9
View File
@@ -444,15 +444,8 @@ mod imp {
"not supported yet: the {what} meets a corner of this kind, which SMLib's blends do not handle yet (where its edges \
run on tangentially into each other, or meet in a way no blend corner builds)"
)),
// A setback too short for its corner's fill (SMLib certifies it convex only from
// its least on) names that least; any other is a blend too large for its room.
SmError::NoRoom => match solidcraft_smlib::blend_fit() {
Some(solidcraft_smlib::Fit::SetbackAtLeast(least)) => KernelError::Invalid(format!(
"the setback is too short for its corner: {}{least:.4} mm (the corner's patch is convex only from there)",
crate::SETBACK_AT_LEAST
)),
_ => KernelError::TooLarge(format!("the {what} is larger than the faces beside its edges allow")),
},
SmError::NoRoom => KernelError::TooLarge(format!("the {what} is larger than the faces beside its edges allow")),
SmError::Unported(why) => KernelError::Invalid(format!("not supported yet: {why}")),
e => failed(what, "operation", e),
})?;
// Checked here unless the evaluation's validation mode leaves it to the feature's end
@@ -429,7 +429,6 @@ fn stacked_pieces_of_one_cylinder_keep_their_walls() {
/// The same booleans through SMLib: its tags, renumbered into the operands' and the result's
/// face and edge order, give a history that holds against the geometry.
#[cfg(feature = "smlib")]
mod smlib {
use super::*;
use crate::smlib_boolean::smlib_boolean;
+2 -2
View File
@@ -45,8 +45,8 @@ fn a_setback_off_the_rounded_corner_is_refused() {
/// A setback that does not reach past where the corner's rounds meet without one (on faces square
/// to each other, the radius) is refused before SMLib runs, naming that least setback. Past it, a
/// setback still too short for SMLib's corner fill (certified convex only from a setback on,
/// smlib_blend_fit_size's SMLIB_FIT_SETBACK_AT_LEAST) is refused naming the smallest that builds,
/// setback still too short for the corner fill (certified convex only from a setback on, as SMLib
/// f916a8d's setback corner does) is refused naming the smallest that builds,
/// at most twice the one asked for (the certificate scales the setbacks up to 2); that one builds.
#[test]
fn a_setback_not_past_the_radius_is_refused_naming_the_least() {
+1 -1
View File
@@ -236,7 +236,7 @@ pub use topo::{CylinderFace, cylinder_end_at, cylinder_face, cylinder_face_at, m
pub use topo::{TopoCounts, merged_topology, seam_flags};
pub use tspline::tspline_sheet;
pub use validation::{Validation, check_kept, validating};
pub use validity::{SmlibCheck, Validity, smlib_built, smlib_dump_dir, validity};
pub use validity::{SmlibCheck, Validity, smlib_dump_dir, validity};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum KernelError {
@@ -523,7 +523,6 @@ fn chamfer_after_box_cuts() {
/// Shell, offset faces and draft straight through `crate::offset_backend`: refusals, and their
/// histories checked against the geometry and their numbers against the analytic ones.
#[cfg(feature = "smlib")]
mod smlib {
use super::*;
use crate::offset_backend;
-25
View File
@@ -108,7 +108,6 @@ struct Regions {
touching: bool,
}
#[cfg(feature = "smlib")]
mod sheets {
use solidcraft_geom::Loop2;
use solidcraft_geom::Vec3;
@@ -289,30 +288,6 @@ mod sheets {
}
}
#[cfg(not(feature = "smlib"))]
mod sheets {
use super::*;
fn refused<T>() -> Result<T> {
Err(KernelError::Failed("not supported yet: planar region booleans run on SMLib (build with the feature `smlib`)".into()))
}
/// A sheet stands for nothing without SMLib.
pub(super) enum Sheet {}
pub(super) fn sheet(_: &[Region2], _: f64) -> Result<Sheet> {
refused()
}
pub(super) fn combine(a: &Sheet, _: &Sheet, _: BoolOp) -> Result<Option<Sheet>> {
match *a {}
}
pub(super) fn regions(sheet: &Sheet, _: f64) -> Result<Regions> {
match *sheet {}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod planar_tests {
+1 -2
View File
@@ -302,8 +302,7 @@ pub(crate) fn segment(run: &Run, plane: &Plane, tol: f64, work: &mut usize) -> O
/// `run` in `plane` exactly as segments: as [`segment`], and a curve of several Bézier spans
/// (a chain of a font's curves, a spline through points) as one segment per span, each the
/// line, conic or Bézier it is, for a planar region's outline. `None` when the edge doesn't lie
/// in a plane parallel to `plane` or isn't read exactly. Only planar sheets read on SMLib use it.
#[cfg(feature = "smlib")]
/// in a plane parallel to `plane` or isn't read exactly.
pub(crate) fn projected(run: &Run, plane: &Plane, tol: f64, work: &mut usize) -> Option<Vec<Seg2>> {
read(run, plane, tol, work, true)
}
+4 -3
View File
@@ -192,9 +192,10 @@ mod tests {
let k = row("kernel", "boolean").unwrap_or_else(|| panic!("{rep:?}"));
assert!(k.count >= 1 && row("kernel", "box_solid").is_some(), "{rep:?}");
if r.is_ok() {
// With SMLib built, its calls are children of the boolean: their time is not its self time.
let smlib: std::time::Duration = rep.rows.iter().filter(|r| r.category == "smlib").map(|r| r.total).sum();
assert!(smlib > std::time::Duration::ZERO && k.self_time < k.total, "{rep:?}");
// The geometry kernel's calls are children of the boolean: their time is not its self
// time.
let calls: std::time::Duration = rep.rows.iter().filter(|r| r.category == "solidcore").map(|r| r.total).sum();
assert!(calls > std::time::Duration::ZERO && k.self_time < k.total, "{rep:?}");
}
}
}
-1
View File
@@ -1,6 +1,5 @@
//! The sweeps through SMLib ([`crate::smlib_sweeps`]): analytic volumes, and the history and
//! construction sources the builders' vocabulary gives each face.
#![cfg(feature = "smlib")]
use std::f64::consts::{PI, TAU};
+1 -13
View File
@@ -540,17 +540,6 @@ fn offset_surface_cube_import_is_closed_and_editable() {
assert!(rel(measure(&result).unwrap().volume, 1000.0 - 40.0 * std::f64::consts::PI) < 1e-3);
}
/// A solid whose B-rep SMLib refuses, its faces too (here every SMLib call: the build has no
/// SMLib), is refused with the kernel's reason, naming the solid. It once came in as a mesh of
/// its faces, which the planner could still triangulate.
#[cfg(not(feature = "smlib"))]
#[test]
fn a_solid_smlib_refuses_with_its_faces_is_refused_not_meshed() {
let text = format!("{HEAD}{}#60=SHAPE_REPRESENTATION('',(#101),$);\n{TAIL}", cube_entities());
let why = step_import(&text).map(|imp| imp.warnings).unwrap_err().to_string();
assert!(why.contains("solid 'Cube': not imported: ") && why.contains("SMLib"), "{why}");
}
/// A hand-written unit cube brep (`#100`…) in a representation with the given unit and items.
fn cube_entities() -> String {
let mut s = String::new();
@@ -2013,11 +2002,10 @@ pub(super) fn schema_4_volume(text: &str) -> f64 {
measure(&Body::from_record(r.solids[0].brep.clone()).unwrap()).unwrap().volume
}
/// The step-conformance parts read into SMLib (`SOLIDCRAFT_KERNEL=smlib`): the STEP reader's
/// The step-conformance parts read into the kernel: the STEP reader's
/// schema 4 record, SMLib's body of it, back as a kernel body. Each has its exact volume and
/// face count, and every face is traced to a face of the file (the NURBS cylinder, dome and
/// torus ring among them: their faces wind around a spline closed on itself, and get a seam).
#[cfg(feature = "smlib")]
#[test]
fn step_conformance_parts_read_into_smlib() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/step-conformance");
+17 -2
View File
@@ -1,9 +1,14 @@
use super::*;
fn wedge() -> Body {
/// A wedge 40 x 20, 15 high, its top front edge along x from 0 to 40.
fn wedge_body() -> Body {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let outer = Loop2::polygon(&[Vec2::new(0., 0.), Vec2::new(40., 0.), Vec2::new(0., 20.)]);
let body = crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0., 15.).unwrap().pop().unwrap();
crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0., 15.).unwrap().pop().unwrap()
}
fn wedge() -> Body {
let body = wedge_body();
let at = Vec3::new(20., 0., 15.);
// The room's taper, closing to zero at the wedge's tip, given as a spline fillet: R16 itself
// is built over the wedge's cliffs (the blend-overflow decision), so a tapered fillet no
@@ -12,6 +17,16 @@ fn wedge() -> Body {
fillet_variable_spline(&body, at, t.start, &t.spline).unwrap()
}
/// The wedge's variable round takes off the union of its balls (smlib#78, solidcore#2262):
/// `removed_by_balls` along the 40 mm edge, about 754.28, on whichever kernel the test runs.
#[test]
fn the_wedge_round_keeps_its_volume() {
let v = crate::measure(&wedge()).unwrap().volume;
let law = taper(&wedge_body(), Vec3::new(20., 0., 15.), 16.).unwrap().spline;
let want = 6000.0 - super::tests::removed_by_balls(40.0, &|u| law.eval(u), &|_, _| f64::INFINITY, &[]);
assert!(((v - want) / want).abs() < 1e-4, "{v} vs {want}");
}
/// The display mesh of a fillet tapering to zero at a wedge's tip converges to the B-rep: each
/// mesh lies within its chord tolerance of the faces, so its volume is within the body's area
/// times that tolerance of the exact one (SMLib's, integrated on the surfaces, to 1e-8). Only
-10
View File
@@ -373,7 +373,6 @@ fn closed_display_mesh(name: &str, b: &Body, tol: f64) -> Mesh {
/// A sphere from SMLib cut through its centre: the half sphere is bounded by its rim and its
/// seam from the pole up to the rim, used both ways. The seam's two uses share their nodes, so
/// the mesh has no crack along it and nothing degenerate at the pole.
#[cfg(feature = "smlib")]
#[test]
fn a_half_sphere_from_smlib_is_closed_along_its_seam() {
use solidcraft_smlib::{BooleanOp, Brep as S};
@@ -406,7 +405,6 @@ fn display_plans_stay_within_the_chord_tolerance() {
}
/// SMLib's torus is one face bounded by its two seams: a grid over the whole parameter box.
#[cfg(feature = "smlib")]
#[test]
fn an_smlib_torus_stays_within_the_chord_tolerance() {
let Ok(ring) = solidcraft_smlib::Brep::make_torus([0.0; 3], 22.0, 4.0) else { panic!("torus") };
@@ -424,7 +422,6 @@ fn an_smlib_torus_stays_within_the_chord_tolerance() {
/// A groove of trapezoid `section` (radius, axial offset) in the plane through the z axis along
/// y, carried `turns` turns of `pitch` about z from `base`, as an SMLib solid.
#[cfg(feature = "smlib")]
fn smlib_groove(section: [(f64, f64); 4], base: f64, pitch: f64, turns: f64) -> solidcraft_smlib::Brep {
use solidcraft_geom::{Loop2, Vec2};
smlib_screw(Loop2::polygon(&section.map(|(r, z)| Vec2::new(r, z))), base, pitch, turns)
@@ -432,7 +429,6 @@ fn smlib_groove(section: [(f64, f64); 4], base: f64, pitch: f64, turns: f64) ->
/// The `section` (radius, axial offset) in the plane through the z axis along y, carried
/// `turns` turns of `pitch` about z from `base`, as an SMLib solid.
#[cfg(feature = "smlib")]
fn smlib_screw(section: solidcraft_geom::Loop2, base: f64, pitch: f64, turns: f64) -> solidcraft_smlib::Brep {
use solidcraft_geom::{Plane, Region2};
let origin = Vec3::new(0.0, 0.0, base);
@@ -444,7 +440,6 @@ fn smlib_screw(section: solidcraft_geom::Loop2, base: f64, pitch: f64, turns: f6
ok(crate::native::smlib_body(&groove))
}
#[cfg(feature = "smlib")]
fn smlib_cut(a: &solidcraft_smlib::Brep, b: &solidcraft_smlib::Brep) -> solidcraft_smlib::Brep {
match a.boolean(b, solidcraft_smlib::BooleanOp::Subtract) {
Ok(Some(r)) => r,
@@ -455,7 +450,6 @@ fn smlib_cut(a: &solidcraft_smlib::Brep, b: &solidcraft_smlib::Brep) -> solidcra
/// The ISO basic profile's groove for pitch `p` and major diameter `d`: internal, from 0.1 p
/// inside the hole wall of radius `wall` out to the major radius; external, from the root out
/// past the shaft's surface (radius `wall`).
#[cfg(feature = "smlib")]
fn iso_groove(p: f64, d: f64, wall: f64, internal: bool) -> [(f64, f64); 4] {
let h = p * 3f64.sqrt() / 2.0;
let slope = (PI / 6.0).tan();
@@ -478,7 +472,6 @@ fn iso_groove(p: f64, d: f64, wall: f64, internal: bool) -> [(f64, f64); 4] {
/// parabolic segment) that flat triangles bridge rather than follow: a face no wider than its
/// chords sag (the thread's crest band where it runs out through the block's end, 0.06 mm sag
/// at 0.1) takes up to that much more or less area however it is triangulated inside.
#[cfg(feature = "smlib")]
fn assert_faces_lie_flat(name: &str, plan: &solidcraft_geom::tess::TessPlan, tol: f64) {
for fp in &plan.faces {
let Some(s) = fp.surface.and_then(|i| plan.surfaces.get(i as usize)) else { continue };
@@ -530,7 +523,6 @@ fn assert_faces_lie_flat(name: &str, plan: &solidcraft_geom::tess::TessPlan, tol
/// #1995: an M8x1 thread through a 7 mm hole in a 20 mm block, both ends open. Truck meshed the
/// groove's winding walls with triangles reaching past both ends of the block; the body's mesh
/// now comes from the display plan, inside the block and with the analytic volume.
#[cfg(feature = "smlib")]
#[test]
fn a_modelled_thread_in_a_bore_meshes_inside_its_block() {
use solidcraft_smlib::Brep as S;
@@ -562,7 +554,6 @@ fn a_modelled_thread_in_a_bore_meshes_inside_its_block() {
/// a triangle's side, with no four corners to blend. The butterfly of a round face made it a
/// grid of ~200 000 points whose triangles zigzagged along the strip (six times the band's area,
/// and off the surface by more than the tolerance); it is its boundary nodes alone now.
#[cfg(feature = "smlib")]
#[test]
fn a_thread_root_band_running_out_through_an_end_face_lies_on_its_surface() {
let Ok(shaft) = solidcraft_smlib::Brep::make_cylinder([0.0; 3], 5.0, 30.0) else { panic!("shaft") };
@@ -587,7 +578,6 @@ fn a_thread_root_band_running_out_through_an_end_face_lies_on_its_surface() {
/// halves go to the display plan for their mesh; at the measuring tolerance a half's wall wants
/// more cells than a grid may have, and its cut grid gave up for an under-refined Delaunay (the
/// halves measured 1.5% short of the whole). The cut grid now thins its lines to fit.
#[cfg(feature = "smlib")]
#[test]
fn the_halves_of_a_split_smlib_coil_measure_as_the_whole() {
use solidcraft_geom::{Loop2, Vec2};
+41 -8
View File
@@ -1724,17 +1724,14 @@ fn tabbed_plate_area(da: f64, dt: f64) -> f64 {
0.5 * (big * big * (PI + 2.0 * at) + tab) + (4.0 - dt) * (cy - yc)
}
#[test]
fn fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole() {
/// The tabbed plate's outer outline, reconstructed from its seven public pick coordinates: an R25
/// plate, x = ±4 tab sides, R2 corners centred at (±2, 280/9) and a 4 mm straight top.
fn tabbed_plate_outline() -> Loop2 {
use solidcraft_geom::Seg2;
// Reconstruct the outer outline from its seven public pick coordinates:
// R25 plate, x=+/-4 tab sides, R2 corners and a 4 mm straight top.
// The hole's dimensions come from the public reproduction, not an original design file.
let theta = (4.0_f64 / 25.0).acos();
let y = (625.0_f64 - 16.0).sqrt();
let corner_y = 280.0 / 9.0;
let hole_y = 262.0 / 9.0;
let outline = Loop2 {
Loop2 {
segs: vec![
Seg2::Arc { center: Vec2::ZERO, radius: 25.0, start: PI - theta, sweep: PI + 2.0 * theta },
Seg2::Line { a: Vec2::new(4.0, y), b: Vec2::new(4.0, corner_y) },
@@ -1743,7 +1740,43 @@ fn fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole() {
Seg2::Arc { center: Vec2::new(-2.0, corner_y), radius: 2.0, start: PI / 2.0, sweep: PI / 2.0 },
Seg2::Line { a: Vec2::new(-4.0, corner_y), b: Vec2::new(-4.0, y) },
],
};
}
}
/// solidcore#2223: the tabbed plate's R25 rim and one R2 tab corner arc, which share no vertex,
/// rounded together at r 0.5 on the plate 2 high (no hole). Each rounds alone; together they
/// failed 1001 in cliff_overflow. They take off the sum: the rim alone's 7.958674274526857
/// (solidcore#2176's pinned value) and the corner arc's Pappus volume with flat ends,
/// 0.15913583972191622 (the spandrel (1 − π/4) r² about the corner's axis at 2 − its centroid
/// over a quarter turn), 8.117810114.
#[test]
fn fillet_tabbed_plate_rim_and_one_corner_arc_together() {
let body = extrude(&Plane::XY, &[Region2 { outer: tabbed_plate_outline(), holes: vec![] }], 0.0, 2.0).unwrap().pop().unwrap();
let r = 0.5;
let (spandrel, centroid) = ((1.0 - PI / 4.0) * r * r, r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
let corner = spandrel * (PI / 2.0) * (2.0 - centroid);
assert!((corner - 0.159_135_839_721_916_22).abs() < 1e-12, "{corner}");
let want = 7.958_674_274_526_857 + corner;
let picks = [
Vec3::new(-24.91935125502191, -2.0064727829797544, 2.0),
Vec3::new(24.919351255429795, -2.006472777913911, 2.0),
Vec3::new(3.4142135598750105, 32.525324672801695, 2.0),
];
let blended = fillet(&body, &picks, r).unwrap_or_else(|e| panic!("rim and corner arc together: {e}"));
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
assert_eq!(blended.face_count(), 12);
let got = measure(&body).unwrap().volume - measure(&blended).unwrap().volume;
assert!((got - want).abs() < 1e-7, "removed {got}, expected {want}");
}
#[test]
fn fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole() {
// The hole's dimensions come from the public reproduction, not an original design file.
let theta = (4.0_f64 / 25.0).acos();
let y = (625.0_f64 - 16.0).sqrt();
let corner_y = 280.0 / 9.0;
let hole_y = 262.0 / 9.0;
let outline = tabbed_plate_outline();
let body =
extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![Loop2::circle(Vec2::new(0.0, hole_y), 1.5)] }], 0.0, 2.0).unwrap().pop().unwrap();
let r = 0.5;
-1
View File
@@ -226,7 +226,6 @@ mod tests {
/// A box with one corner moved off its three edges: SMLib's check refuses it. Under each_step
/// the boolean that takes it refuses its result there; under each_feature it is not checked,
/// says the check was left for later, and the check of the body kept refuses it.
#[cfg(feature = "smlib")]
#[test]
fn a_body_the_check_refuses_is_refused_at_the_step_or_at_the_end() {
use solidcraft_geom::Vec3;
+5 -18
View File
@@ -54,22 +54,9 @@ fn smlib_validity(body: &Body) -> Option<SmlibCheck> {
})
}
/// Whether this build links SMLib (the `smlib` feature).
pub fn smlib_built() -> bool {
cfg!(feature = "smlib")
}
/// Where every SMLib call that fails from now on leaves its inputs for replay (the C API's
/// `smlib_set_dump_dir`: the call's arguments and B-reps, which `smlib_brep_read` reads back);
/// `None` stops it. A build without SMLib makes no calls to dump.
pub fn smlib_dump_dir(dir: Option<&std::path::Path>) -> crate::Result<()> {
#[cfg(feature = "smlib")]
{
solidcraft_smlib::set_dump_dir(dir).map_err(|e| crate::KernelError::Failed(format!("SMLib dump folder: {e}")))
}
#[cfg(not(feature = "smlib"))]
{
let _ = dir;
Ok(())
}
/// Where every kernel call that fails from now on leaves its inputs for replay
/// (`solidcraft_smlib::set_dump_dir`: the call and its B-reps, which `Brep::read` reads back);
/// `None` stops it.
pub fn smlib_dump_dir(dir: Option<&std::path::Path>) {
solidcraft_smlib::set_dump_dir(dir);
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "solidcraft-profile"
description = "SolidCraft profiler: nested, per-thread timed spans of SMLib calls, kernel operations, feature evaluation, commands and imports, as a table and a Chrome trace"
description = "SolidCraft profiler: nested, per-thread timed spans of kernel calls, kernel operations, feature evaluation, commands and imports, as a table and a Chrome trace"
version.workspace = true
edition.workspace = true
license.workspace = true
+1 -1
View File
@@ -36,7 +36,7 @@ pub const OTHER: &str = "other";
/// One span name's aggregate.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Row {
/// What the span is: `smlib`, `kernel`, `feature`, `command`, `import`, …
/// What the span is: `solidcore`, `kernel`, `feature`, `command`, `import`, …
pub category: &'static str,
pub name: &'static str,
/// Times the span ran.
+3 -3
View File
@@ -41,8 +41,8 @@ fn nested_spans_have_self_and_total_time() {
let _inner = span("test", "inner");
spin(4);
}
// Timed by someone else (SMLib's C API): a child of `outer`.
record("smlib", "call", Duration::from_millis(2));
// Timed by someone else: a child of `outer`.
record("external", "call", Duration::from_millis(2));
}
set_enabled(false);
let r = report();
@@ -57,7 +57,7 @@ fn nested_spans_have_self_and_total_time() {
let cats = r.by_category();
assert_eq!(cats.iter().map(|c| c.0).collect::<Vec<_>>().len(), 2);
let t = r.table(10);
assert!(t.contains("test outer") && t.contains("smlib call") && t.contains("self time by category"), "{t}");
assert!(t.contains("test outer") && t.contains("external call") && t.contains("self time by category"), "{t}");
}
#[test]
+4 -25
View File
@@ -1,40 +1,19 @@
[package]
name = "solidcraft-smlib"
description = "SolidCraft: NVIDIA's SMLib B-rep kernel (tangency-labs/smlib, Apache-2.0), statically linked behind its C API, with a safe Rust wrapper"
description = "SolidCraft: the B-rep kernel, solidcore (tangency-labs/solidcore, the Rust port of NVIDIA's SMLib, Apache-2.0), behind a safe wrapper"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
links = "smlib"
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/ffi.rs can allow it.
# Calling a C library 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"
[features]
# Compile and link SMLib. Off, the crate builds without a C++ toolchain or the SMLib sources, and
# every call returns `Error::NotBuilt`, so workspace-wide builds and checks need neither.
native = []
[lints]
workspace = true
[dependencies]
log = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-profile = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# The clocks SMLib's C library asks for in a browser build (src/ffi.rs).
web-time = { workspace = true }
[build-dependencies]
cc = { version = "1.2", features = ["parallel"] }
solidcore = { workspace = true }
[dev-dependencies]
solidcraft-profile = { workspace = true, features = ["enabled"] }
-10
View File
@@ -1,10 +0,0 @@
# Rust target, SHA-256 of smlib-<target>.tar.gz in the release prebuilt-<SMLIB_REV> of
# tangency-labs/smlib. Written by scripts/fetch-smlib.sh --pin; never edited by hand.
aarch64-apple-darwin b045d887901556d6c8f60a38fbbecdda765d8dafa6f7a5f72100a7deba3d1d6b
aarch64-pc-windows-msvc c5dbd7dc9221e8638cc9e09273f9e2fe19720f0a02dd40c66d709c3d8effe0c0
aarch64-unknown-linux-gnu f3ad86d6cd79570c428905548fabdb92005a78a9fc8d200d12047a2e972894ed
i686-pc-windows-msvc 1e4e3999663f4cae113149bcf84d970264b776cd762fd2b7d2dfcf49f9f35772
wasm32-unknown-unknown 33702afaa8af13e38c9aa8b894fa38f8e69b147c5cda0606c16b0c786dd46389
x86_64-apple-darwin b41d081c90d0028d76d92c08c72465da39d52976ab416c878d5ff0ecb6eccb73
x86_64-pc-windows-msvc 10252e3cd701be53697eea11ce68d29b244a2d25b677b57abf632f9f00bbc05b
x86_64-unknown-linux-gnu e6a81eb955405c815e3efc05ac3ab6bd78df83e43d244a6b33bbb390a197c2db
-1
View File
@@ -1 +0,0 @@
a18bbe523b9c7819f1a9da8c40a54c15f9936125
-179
View File
@@ -1,179 +0,0 @@
//! Links SMLib, SM_API and their C API (tangency-labs/smlib, at the commit in `SMLIB_REV`) as
//! static libraries, when the `native` feature is on. In order:
//! - `SOLIDCRAFT_SMLIB_DIR`: a checkout, compiled here;
//! - the prebuilt library for the target, as `scripts/fetch-smlib.sh` leaves it in
//! `target/smlib-<rev>-prebuilt/<target>`, when its SHA-256 is the one in `SMLIB_PREBUILT`;
//! - the sources in `target/smlib-<rev>` (`scripts/fetch-smlib.sh --source`), compiled here.
//!
//! The prebuilt libraries come from the fork's `capi/CMakeLists.txt`. The compiled path follows
//! it: the same file lists, defines and include order (SMLib/inc and SM_API/inc each have a
//! StdAfx.h, so each part must find its own first).
use std::path::{Path, PathBuf};
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=SMLIB_REV");
println!("cargo:rerun-if-changed=SMLIB_PREBUILT");
println!("cargo:rerun-if-env-changed=SOLIDCRAFT_SMLIB_DIR");
println!("cargo:rerun-if-env-changed=WASI_SDK_PATH");
if std::env::var_os("CARGO_FEATURE_NATIVE").is_none() {
return;
}
if let Err(e) = build() {
// A build script has no other way to fail the build.
eprintln!("error: {e}");
std::process::exit(1);
}
}
fn build() -> Result<(), String> {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?);
let rev = std::fs::read_to_string(manifest.join("SMLIB_REV")).map_err(|e| format!("SMLIB_REV: {e}"))?.trim().to_string();
let rev12 = rev.get(..12).unwrap_or(&rev);
let target = std::env::var("TARGET").unwrap_or_default();
let fetched = manifest.join("../../target");
let root = match std::env::var_os("SOLIDCRAFT_SMLIB_DIR") {
Some(d) => PathBuf::from(d),
None => {
let prebuilt = fetched.join(format!("smlib-{rev12}-prebuilt")).join(&target);
if let Some(sha) = pinned(&manifest, &target)? {
println!("cargo:rerun-if-changed={}", prebuilt.join(".sha256").display());
let have = std::fs::read_to_string(prebuilt.join(".sha256")).unwrap_or_default();
if have.trim() == sha {
return link_prebuilt(&prebuilt, &target);
}
}
fetched.join(format!("smlib-{rev12}"))
}
};
if !root.join("capi/include/smlib.h").is_file() {
return Err(format!(
"SMLib {rev} for {target} is not fetched (feature `native`): run scripts/fetch-smlib.sh {target}, or set SOLIDCRAFT_SMLIB_DIR to a tangency-labs/smlib checkout at that commit"
));
}
println!("cargo:rerun-if-changed={}", root.join("capi").display());
// SMLib's own sources too: a fix to the kernel must rebuild it.
println!("cargo:rerun-if-changed={}", root.join("source").display());
let src = root.join("source");
let core = sources(&src.join("SMLib/src"), &["StdAfx.cpp", "SmDllMain.cpp"])?;
let api = sources(&src.join("SM_API/src"), &["StdAfx.cpp"])?;
let msvc = target.contains("msvc");
let wasi = if target == "wasm32-unknown-unknown" { Some(wasi_sdk()?) } else { None };
let base = || {
let mut b = cc::Build::new();
b.cpp(true).std("c++17").define("NDEBUG", None).define("NOMINMAX", None).define("SM_STATIC", None).define("SM_RAND_SEED", "20240101u");
// Rust links the MSVC runtime as a DLL; the cc crate's default (/MD) matches.
if msvc {
b.flag("/EHsc").flag("/bigobj");
} else {
// No fused multiply-add, as the prebuilt libraries (capi/CMakeLists.txt): SMLib's
// tangent cases decide on the last bits. MSVC does not contract by default.
b.flag("-ffp-contract=off");
}
if let Some(sdk) = &wasi {
b.compiler(sdk.join("bin/clang++")).archiver(sdk.join("bin/llvm-ar")).target(WASI_TARGET);
b.flag(format!("--sysroot={}", sdk.join("share/wasi-sysroot").display())).flag("-fwasm-exceptions");
// The exnref encoding, as wasi-sdk's libc++ and Rust use: a module may not mix it with
// the legacy one clang still defaults to.
b.flag("-mllvm").flag("-wasm-use-legacy-eh=false");
}
b
};
let quiet = |b: &mut cc::Build| {
b.warnings(false);
if msvc {
b.flag("/w");
} else {
b.flag("-w");
}
};
// Link order: the C API needs SM_API, which needs SMLib.
let capi = sources(&root.join("capi/src"), &[])?;
let mut c = base();
c.files(&capi).include(root.join("capi/include")).include(src.join("SM_API/inc")).include(src.join("SMLib/inc"));
quiet(&mut c);
c.try_compile("smlib_capi").map_err(|e| e.to_string())?;
let mut a = base();
a.files(&api).include(src.join("SM_API/inc")).include(src.join("SMLib/inc"));
quiet(&mut a);
a.try_compile("smlib_api").map_err(|e| e.to_string())?;
let mut k = base();
k.files(&core).include(src.join("SMLib/inc"));
quiet(&mut k);
k.try_compile("smlib_core").map_err(|e| e.to_string())?;
// The C++ runtime and the C library SMLib needs, from wasi-sdk: the exception-handling build of
// libc++. The few WASI calls libc makes are answered by src/wasi.rs.
if let Some(sdk) = &wasi {
let lib = sdk.join("share/wasi-sysroot/lib").join(WASI_TARGET);
println!("cargo:rustc-link-search=native={}", lib.join("eh").display());
println!("cargo:rustc-link-search=native={}", lib.display());
for l in ["c++", "c++abi", "unwind", "c"] {
println!("cargo:rustc-link-lib=static={l}");
}
}
Ok(())
}
/// The SHA-256 `SMLIB_PREBUILT` pins for `target`'s prebuilt archive, if there is one.
fn pinned(manifest: &Path, target: &str) -> Result<Option<String>, String> {
let pins = std::fs::read_to_string(manifest.join("SMLIB_PREBUILT")).map_err(|e| format!("SMLIB_PREBUILT: {e}"))?;
Ok(pins.lines().filter(|l| !l.starts_with('#')).find_map(|l| {
let mut f = l.split_whitespace();
(f.next() == Some(target)).then(|| f.next().map(str::to_string)).flatten()
}))
}
/// Links the prebuilt C API in `dir` (one archive, `smlib`) and the C++ runtime it was compiled
/// against: the system's, or for wasm wasi-sdk's, which the archive carries.
fn link_prebuilt(dir: &Path, target: &str) -> Result<(), String> {
let lib = dir.join("lib");
if !lib.is_dir() {
return Err(format!("{} has no lib folder: run scripts/fetch-smlib.sh {target} again", dir.display()));
}
println!("cargo:rustc-link-search=native={}", lib.display());
println!("cargo:rustc-link-lib=static=smlib");
if target == "wasm32-unknown-unknown" {
for l in ["c++", "c++abi", "unwind", "c"] {
println!("cargo:rustc-link-lib=static={l}");
}
} else if target.contains("apple") {
println!("cargo:rustc-link-lib=c++");
} else if !target.contains("msvc") {
println!("cargo:rustc-link-lib=stdc++");
}
Ok(())
}
/// The C target SMLib is compiled for inside a `wasm32-unknown-unknown` build: wasi-sdk's, the
/// one with a C++ standard library.
const WASI_TARGET: &str = "wasm32-wasip1";
/// wasi-sdk, from `WASI_SDK_PATH` (github.com/WebAssembly/wasi-sdk, release 34 or later).
fn wasi_sdk() -> Result<PathBuf, String> {
let sdk =
std::env::var_os("WASI_SDK_PATH").map(PathBuf::from).ok_or("building SMLib for wasm32-unknown-unknown needs wasi-sdk: set WASI_SDK_PATH")?;
if !sdk.join("bin/clang++").is_file() {
return Err(format!("WASI_SDK_PATH={} has no bin/clang++", sdk.display()));
}
Ok(sdk)
}
/// The `.cpp` files in `dir`, sorted, without `skip`.
fn sources(dir: &Path, skip: &[&str]) -> Result<Vec<PathBuf>, String> {
let mut v: Vec<PathBuf> = std::fs::read_dir(dir)
.map_err(|e| format!("{}: {e}", dir.display()))?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "cpp") && !p.file_name().is_some_and(|n| skip.iter().any(|s| n == *s)))
.collect();
v.sort();
if v.is_empty() {
return Err(format!("no sources in {}", dir.display()));
}
Ok(v)
}
+3 -14
View File
@@ -1,7 +1,7 @@
//! SMLib's own validity check: `SmBrep::ValidatePointers`, then `AssertValid` over the whole
//! topology graph. Tests run it on every SMLib result and every import.
use crate::{Brep, Error, Result, error, ffi};
use crate::{Brep, Result, core};
/// How many of SMLib's tests to run.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -53,22 +53,11 @@ impl Brep {
/// Runs SMLib's validity check at `level`. A body that fails it is still `Ok`, with the
/// failures in the report; `Err` means the check could not run.
pub fn check(&self, level: CheckLevel) -> Result<CheckReport> {
let mut buf = vec![0u8; 1 << 16];
let (len, failures) = match ffi::check::check(&self.0, level as i32, &mut buf) {
Ok(r) => r,
// Too long for the first buffer: once more with the length it asked for.
Err((ffi::ERR_BUFFER, needed)) => {
buf = vec![0u8; needed.saturating_add(1)];
ffi::check::check(&self.0, level as i32, &mut buf).map_err(|(s, _)| error(s))?
}
Err((s, _)) => return Err(error(s)),
};
let text = String::from_utf8_lossy(buf.get(..len).ok_or(Error::Argument)?).into_owned();
Ok(CheckReport { failures: usize::try_from(failures).unwrap_or(0), text })
core::check(self.core(), level)
}
}
#[cfg(all(test, feature = "native"))]
#[cfg(test)]
mod tests {
use super::*;
+567
View File
@@ -0,0 +1,567 @@
//! The solidcore backend: one kernel for the process (SMLib's context is process-global too, and
//! some of its state, such as the fillet solver's latched step, lives for the whole process),
//! every call under its lock and a panic guard.
//!
//! Upstream fixes ([`fixes`]) are solidcore's opt-in corrections of SMLib defects that the C++
//! fork fixed in its own sources; each one turned on here says which fork commit it matches.
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::{Mutex, PoisonError};
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
use solidcore::geom::vector3d::Vec3;
use solidcore::kernel::{Kernel, KernelOptions, UpstreamFixes};
use solidcore::mass::integrator::properties::{PropertyAttributes, brep_volume};
use solidcore::primitive::api_primitives as primitives;
use crate::{Error, Result};
pub(crate) mod blend;
#[cfg(test)]
mod blend_tests;
pub(crate) mod boolean;
#[cfg(test)]
mod boolean_tests;
mod convert;
#[cfg(test)]
mod convert_tests;
pub(crate) mod dump;
pub(crate) mod file;
pub(crate) mod history;
mod measure;
#[cfg(test)]
mod measure_tests;
pub(crate) mod merge;
#[cfg(test)]
mod merge_tests;
pub(crate) mod offset;
#[cfg(test)]
mod offset_tests;
pub(crate) mod split;
#[cfg(test)]
mod split_tests;
pub(crate) mod ssi;
pub(crate) mod sweep;
#[cfg(test)]
mod sweep_tests;
pub(crate) mod transform;
#[cfg(test)]
mod transform_tests;
pub(crate) mod trim;
#[cfg(test)]
mod trim_tests;
pub(crate) use convert::{from_arrays, to_arrays};
pub(crate) use measure::{area, bounds, check, classify_point, edge_face_distance, is_manifold_solid, mass_properties, solid_count};
/// A solidcore body. Its own lock: solidcore's operations take `&mut Brep`, even to measure.
pub(crate) struct Body(pub(crate) Mutex<solidcore::topology::Brep>);
impl Body {
pub(crate) fn new(b: solidcore::topology::Brep) -> Body {
Body(Mutex::new(b))
}
}
/// The fixes this backend runs with: every one of solidcore's opt-in fixes of upstream SMLib
/// defects (`UpstreamFixes`, each naming its record and issue), whether or not the C++ fork has it,
/// unless it appears broken. A test checks none is left off but those in `HELD_BACK`, so moving the
/// solidcore pin to a rev with a new flag fails until it is added here.
pub(crate) fn fixes() -> UpstreamFixes {
let mut f = UpstreamFixes::default();
f.fillet_retry_radius_as_first = true;
f.fillet_rail_rim_on_face = true;
f.fillet_refuse_rails_leaving_faces = true;
f.fillet_refuse_face_consuming_blends = true;
f.fillet_refuse_non_manifold_result = true;
f.fillet_vertex_error_diagnostics = true;
f.fillet_corner_ball_patch = true;
f.fillet_chamfer_cone_on_arc = true;
f.fillet_torus_center_on_axis = true;
f.offset_excess_faces_after_merge = true;
f.offset_clip_right_side_out = true;
f.revolution_offset_axis_both_sides = true;
f.offset_extended_edges_mated = true;
f.remove_slit_loops_where_solids_touch = true;
f.keep_operand_uv_trims = true;
f.proto_vertex_miss_is_false = true;
f.check_loop_vertex_tolerance = true;
f.check_loop_projected_seam_ends = true;
f.trim_boundary_seam_wrap = true;
f.bspline_check_min_radius = true;
f.oscillating_skips_failed_guesses = true;
f.refine_cc_range_ends = true;
f.flat_corners_test_bits = true;
f.cylinder_rebuild_mean_radius = true;
f.planar_path_uncapped_open_profiles = true;
f.revolve_analytic_profile_faces = true;
f.revolve_arc_centre_near_axis_is_sphere = true;
f.taper_pivot_nearest_corner = true;
f.taper_tangent_corner_planes = true;
f.revolve_nested_shells_as_cavities = true;
f.revolve_holed_profile_void = true;
f.curve_sweep_caps_at_sweep_ends = true;
f.join_touching_loops = true;
f.uv_trim_reversal_on_surface = true;
f.offset_resolve_crossing_loops = true;
f.offset_walls_touch_refused = true;
f.offset_analytic_mate_edges = true;
f.precise_properties_split_at_surface_knots = true;
f.nurb_cylinder_inside_out_frame = true;
f.shell_inner_shell_of_statuses = true;
f.local_operation_offset_status = true;
f.local_operation_refuses_loose_tangent_edges = true;
f.torus_major_radius_from_origin = true;
f.fillet_corner_sphere_from_four_arcs = true;
f.fillet_nx1_opposite_continuation_side_edge = true;
f.fillet_trace_rejects_short_step = true;
f.torus_iso_circle_axis_from_midpoint = true;
f.torus_keeps_inside_out = true;
f.internal_pole_row_through_knot = true;
f.extrusion_origin_scaled_zero = true;
f.transform_scales_each_tolerance = true;
f.transform_model_size_from_body_box = true;
f.offset_collapsed_rounds_mated = true;
f.offset_missing_surface_fails = true;
f.fillet_nx1_end_rib_global_merge = true;
f.uv_trim_corner_end_points_only = true;
f.bspline_trim_preserves_uv_spans = true;
f.cone_parallel_line_midpoint = true;
f.cone_parallel_line_domains = true;
f.proto_edge_self_loop_on_vertex_degenerate = true;
f.slice_coincident_faces_sheet_side = true;
f.boolean_outer_shell_first = true;
f.edge_reversal_keeps_failed_edge = true;
f.topology_failed_evaluations_propagate = true;
f.heal_stage3_failures_propagate = true;
f.sweep_frame_on_planar_path_normal = true;
f.cover_refuses_folded_continuation = true;
f.boolean_vertex_by_best_crossing = true;
f.boolean_cleanup_removes_slits = true;
f.classify_edge_interval = true;
f.ellipse_trim_seam_lines_span_cone_height = true;
f.degenerate_proto_edge_in_one_vertex = true;
f.new_edge_vertex_tolerance_covers_gap = true;
f.torus_signed_gen_curve_interval = true;
f.sheet_sweep_joins_profile_wires = true;
f.point_solve_keeps_triangle_guess = true;
f.fillet_setback_from_start_vertex = true;
f.imprint_trims_edge_to_proto_vertex = true;
f.imprint_extends_edge_to_proto_vertex = true;
f.fillet_point_step_per_point = true;
f.analytic_copy_keeps_parameterization = true;
f.fillet_point_bigger_step_retry = true;
f.fillet_through_extended_rail_end = true;
f.precise_properties_body_length_scale = true;
f.offset_extension_from_base = true;
f.corner_blend_stays_inside_neighbours = true;
f.fillet_cleanup_keeps_target_faces = true;
f.fillet_nx1_tangent_side_edge = true;
f.fillet_3x2_mixed_concave_side_edge = true;
f.offset_closed_curve_trim_arc = true;
f.offset_stitch_at_body_tolerance = true;
f.curve_contacts_at_curve_ends = true;
f.shell_empty_cavity_fails = true;
f.fillet_refuse_ball_not_fitting = true;
f.boolean_lists_virtual_surface_copies = true;
f.sphere_small_circle_not_touching = true;
f.ssi_tangent_contact_points = true;
f.ssi_trace_tangent_curves_once = true;
f.ssi_singular_start_inside_direction = true;
f.found_torus_sphere_keep_tested_nurb = true;
f.boolean_skips_faces_apart = true;
f.uv_trim_checks_on_surface = true;
f.loop_orientation_floor_from_loop_box = true;
f.offset_extends_cones_as_themselves = true;
f.local_operation_box_holds_new_surfaces = true;
f.uv_trim_joins_drop_pieces = true;
f.shell_edges_on_analytics_exact = true;
f.ssi_no_reverse_of_empty_trace = true;
f.ssi_pole_point_keeps_its_tangent = true;
f.iso_curve_sliver_domain_is_degenerate = true;
f.corner_blend_coons_patch_tangent_to_sides = true;
f.fillet_patch_keeps_fillet_edges = true;
f.fillet_corner_ball_center_from_axes = true;
f.seam_point_solutions_are_one = true;
f.boolean2d_plane_covers_far_faces = true;
f.brep_area_between_solid_and_void = true;
f.boolean_splits_definition_across_split = true;
f.curve_tight_box_interval_knots = true;
f.fillet_refuse_overlapping_bands = true;
f.ssi_tangent_curve_tracer = true;
f.torus_check_reads_v_interval = true;
f.torus_check_spindle_apex = true;
f.local_operation_step_walls = true;
f.edge_check_orders_tangent_faces = true;
f.revolve_arc_touching_axis_is_horn_torus = true;
f.thicken_planar_side_walls = true;
f.fillet_torus_span_from_start_section = true;
f.fillet_knife_edge_rounds = true;
f.taper_wall_closes_at_apex = true;
f.revolution_round_offset_collapses = true;
f.fillet_chamfer_cone_stored_as_cone = true;
f.offset_point_vertex_caps = true;
f.approximate_surface_bezier_starter = true;
f.proto_edge_splits_at_captured_vertices = true;
f.stitch_solid_nests_cavities = true;
f.local_operation_closes_walls_to_nothing = true;
f.linear_sweep_arc_to_cylinder_zero = true;
f.imprint_reuses_coincident_face_edge = true;
f.boolean_new_face_closure_both_sides = true;
f.linear_sweep_arc_is_cylinder = true;
f.offset_closes_loop_gaps = true;
f.cylinder_plane_tangent_within_tolerance = true;
f.boolean_wire_edge_keeps_faceuse_shell = true;
f.fillet_nx1_tangent_corner_edge = true;
f.fillet_torus_rail_on_axis = true;
f.loops_go_on_round_the_vertex = true;
f.revolve_splits_curves_on_axis = true;
f.cover_wraps_across_closed_seam = true;
f.imprint_extension_keeps_analytic_curve = true;
f.fillet_trace_from_tangent_point_toward_next = true;
f.fillet_tangent_point_one_sided_step = true;
f.fillet_rails_meet_at_one_end_point = true;
f.fillet_trace_reaches_rails = true;
f.delete_vertex_joins_seam_iso_curve = true;
f.box_face_failures_propagate = true;
f.ellipse_adjust_step_interval_keeps_turns = true;
f.fillet_cleanup_removes_input_slits = true;
f.fillet_rail_end_on_continued_surface = true;
f.glue_vertices_joins_every_region = true;
f.fillet_nx1_tangent_side_edge_same_convexity = true;
f.fillet_removal_takes_ring_fillets = true;
// The fork's commits that reached `SMLIB_REV` at eaa998a (tangency-labs/smlib#68, #69, #70).
f.fillet_trim_inside_by_best_dot = true;
f.fillet_half_degree_crease = true;
f.fillet_split_edge_keeps_near_half = true;
f.curve_loop_normal_from_area = true;
// The fork's commits in `SMLIB_REV` 2fc375f, ported to solidcore since.
f.fillet_cliff_overflow = true;
f.fillet_3x2_tangent_side_edge_roles = true;
f.offset_extension_as_itself = true;
f.fillet_mitre_concave_face_corners = true;
f.fillet_mitre_gap_patch = true;
f.fillet_run_on_past_far_end_only = true;
f.good_drop_result_distances_in_order = true;
f.face_tight_box_searches_trim = true;
f.ssi_low_angle_start_on_probation = true;
f.fillet_variable_span_follows_fillet = true;
f.simple_coincidence_crossing_is_point = true;
f.boolean_temporary_transform_statuses = true;
f.primitive_path_curve_statuses = true;
f.planar_path_sweep_statuses = true;
f.planar_path_profile_placement_statuses = true;
f.fillet_trim_status = true;
f.perspective_sphere_offsets = true;
f.plane_recognition_origin_after_axes = true;
f.fillet_corner_face_curve_halves = true;
f.brep_edit_statuses_pass_on = true;
f.fillet_run_on_bspline_trims = true;
f.offset_tangent_gap_statuses = true;
f.ssi_alignment_statuses = true;
f.fillet_corner_statuses = true;
f.fillet_edge_curve_domain_statuses = true;
f.query_statuses_pass_on = true;
f.sweep_statuses_pass_on = true;
f.ssi_proto_topology_statuses_pass_on = true;
f.analytic_inversion_answers_apart = true;
f.fillet_statuses_pass_on = true;
f.fillet_corner_per_vertex_fan = true;
f.fillet_rail_trim_at_near_knot = true;
f.offset_splits_corner_vertices = true;
f.offset_keep_cavity_regions = true;
f.offset_four_face_vertex_split = true;
f.gap_samples_non_bspline_v_stride = true;
f.gap_array_append_updates_longest = true;
f.imprint_skips_collapsed_definition = true;
f.precise_properties_trim_base_v = true;
f.fillet_blend_curve_revolved_on_arc = true;
f.temporary_radius_setup_errors = true;
f.ellipse_step_interval_checks = true;
f.corner_patch_face_side_twist = true;
f.boolean_union_refuses_solid_leak = true;
f.curve_loop_start_after_previous_end = true;
f.loop_use_reports_open_gaps = true;
f.face_points_span_domain = true;
f.drop_curve_v_domain_degeneracy = true;
f.mirror_curve_keeps_euclidean_control_points = true;
f.fillet_corner_variable_radius_rail_on_opposite_fillet = true;
f.sphere_from_arcs_common_centre = true;
f.dupin_bottom_hyperbola_axis = true;
f.extent3d_contains_both_segment_endpoints = true;
f.translation_sweep_regularize = true;
f.gap_samples_use_trimmed_spans = true;
f.twist_simple_duw_sign = true;
f.offset_swap_uv_extended_distance = true;
f.circumscribe_axis_map_by_basis = true;
f.planar_path_profile_in_path_frame = true;
f.validation_does_not_make_uv_trims = true;
f.iso_curve_uv_line_both_params = true;
f.extrusion_step_uses_clamped_uv = true;
f.analytic_corner_failure_is_false = true;
f.defeature_refuses_open_base = true;
f.ssi_start_loop_trace_budget = true;
f.fillet_refuse_crossing_rails = true;
f.proto_edge_split_upper_keeps_the_old_max = true;
f.point_on_start_point_squared_tolerance = true;
f.bspline_trim_keeps_rational_end_spans = true;
f.fillet_exact_round_classes = true;
f.curve_check_zero_speed_tangent_end = true;
f.cpoly_remove_poly_face_check_member = true;
f.check_seam_crossing_unclosed = true;
f.ascii_doubles_round_trip = true;
f.transform_aligned_bbox_image = true;
f.bspline_simple_offset_matches_parameterization = true;
f.offset_edges_keep_source_tolerance = true;
f.best_fit_line_largest_scatter_eigenvector = true;
f.circle_silhouette_perspective_center = true;
f.ellipse_convert_to_2d_uses_both_axes = true;
f.circle_offset_antiparallel_normal = true;
f.masked_array_uses_bit_shift = true;
f.global_solver_identical_prefers_larger_when_maximizing = true;
f.surface_continuity_checks_last_v_knot = true;
f.surface_point_solver_propagates_bbox_failure = true;
f.volume_lift_third_derivative_terms = true;
f.volume_discontinuity_checks_vw_unaligned = true;
f.periodic_extent_add_empty_interval = true;
f.stop_tracking_checks_own_callback = true;
f.plane_reparameterize_moves_origin = true;
f.crv_in_volume_mults_by_break_index = true;
f.curve_bounded_surface_read_uv_type_and_implicit_outer = true;
f.polar_box_region_to_arc_transposes_domain = true;
f.point_set_centroid_all_blocks = true;
f.local_solve1d_unbounded_newton_step = true;
f.composite_curve_bbox_segment_intervals = true;
f.knot_vector_valid_requires_span = true;
f.projected_curve_transform_when_owned = true;
f.curve_property_gs_branch_bounds = true;
f.volume_representational_end_continuities = true;
f.integrator_propagates_failure = true;
f.precise_properties_span_end_from_below = true;
f.offset_open_edge_second_trim_point = true;
f.close_loop_relates_only_coincident_edges = true;
f.ssi_split_curves_at_singular_points = true;
f.surface_parallel_to_vector_checks = true;
f.fillet_round_ends_at_tangent_continuation = true;
f.geometric_g2_continuity = true;
f.hash_table_grow_preserves_removed_records = true;
f.bend_volume_bbox_contains_wide_angle_and_inf_u = true;
f.bend_volume_trim_param_box_across_period = true;
f.point_set_tight_pbox_projects_points = true;
f.degen_param_tiebreak_uses_actual_param = true;
f.conic_evaluate_uses_computed_second_deriv = true;
f.cylinder_plane_section_trims_exact = true;
f.local_surface_intersect_guards_max_boundary_retry = true;
f.curve_max_distance_single_sample_midpoint = true;
f.curve_sweep_closed_path_joins_ends = true;
f.validate_tolerances_fixes_reported_gaps = true;
f.edgeuse_convexity_propagates_step_off_failure = true;
f.mark_lock_pair_advances_and_locks_second_context = true;
f.is_iso_curve_at_propagates_canonical_failure = true;
f.database_io_byte_swaps_read_boolean_and_u16 = true;
f.is_point_on_curve_propagates_eval_failure = true;
f.is_topological_vertex_rejects_cusps_and_tangent_errors = true;
f.cone_create_canonical_preserves_inside_out_and_checks_height = true;
f.point_class_gap_3d_propagates_no_drop_error = true;
f.fillet_surface_plane_curve_on_plane_equation = true;
f.fillet_extended_rail_end_at_side_edge_crossing = true;
f.fillet_gn_section_generator = true;
f.face_trim_curves_kept_where_exact = true;
f.swap_uv_keeps_analytic_definition = true;
f.face_shrink_bound_on_knot = true;
f.offset_updates_result_tolerances = true;
f.cone_iso_circle_at_axial_height = true;
f.boolean_coincident_strip_drops_crossing = true;
f.fillet_overflow_trim_at_creases = true;
f.fillet_section_end_trim_at_creases = true;
f
}
/// The fixes held off, each with the case and the numbers that show it broken: a volume or topology
/// wrong against the analytic value, a check failure, a crash, sound geometry refused that builds
/// without the flag, or a large slowdown. Differing from the C++ kernel is no such evidence: a fix
/// the fork doesn't have stays on, and where solidcore builds what SMLib refuses, a result right
/// against its closed form is a win.
#[cfg(test)]
const HELD_BACK: &[&str] = &[];
fn fresh() -> Kernel {
let mut k = Kernel::new(&KernelOptions::default());
k.fixes = fixes();
k
}
pub(crate) static KERNEL: Mutex<Option<Kernel>> = Mutex::new(None);
/// Runs `f` on the process's kernel. A panic in solidcore is caught: the kernel, which it may have
/// left half-updated, is replaced by a fresh one and the call fails with [`Error::Internal`].
pub(crate) fn run<T: dump::Answer>(call: &'static str, f: impl FnOnce(&mut Kernel) -> std::result::Result<T, solidcore::SolidError>) -> Result<T> {
let mut guard = KERNEL.lock().unwrap_or_else(PoisonError::into_inner);
let k = guard.get_or_insert_with(fresh);
// A profiler span per call: where the kernel's time goes.
let _span = solidcraft_profile::span("solidcore", call);
dump::begin();
dump::set_current(Some(call));
let r = match catch_unwind(AssertUnwindSafe(|| f(k))) {
Ok(Ok(v)) => Ok(v),
Ok(Err(e)) => Err(Error::Solidcore(format!("{call}: {e}"))),
Err(p) => {
*guard = Some(fresh());
let why = p.downcast_ref::<&str>().map(|s| (*s).to_owned()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
log::warn!(target: "smlib", "solidcore panicked in {call}: {why}");
Err(Error::Internal(format!("{call}: {why}")))
}
};
dump::set_current(None);
match &r {
Ok(v) => {
if let Some(why) = v.refusal() {
dump::failed(call, &why);
}
}
Err(e) => dump::failed(call, &e.to_string()),
}
r
}
/// A call the capi checks for `SM_SUCCESS`, treating any other status as no answer: an error
/// that stands for an SMLib status exactly ([`SolidError::sm_status`](solidcore::SolidError::sm_status),
/// solidcore#1452) is `None`. Every other solidcore error
/// (unported code, invalid input to an adapter, a missing record...) is not a status the C++
/// path returns, so it fails the call instead of hiding as a skipped entity.
pub(crate) fn answer<T>(r: std::result::Result<T, solidcore::SolidError>) -> std::result::Result<Option<T>, solidcore::SolidError> {
match r {
Ok(v) => Ok(Some(v)),
Err(e) if e.sm_status().is_some() => Ok(None),
Err(e) => Err(e),
}
}
/// Runs `f` on the kernel and `body`, locking the kernel first.
pub(crate) fn with_body<T: dump::Answer>(
call: &'static str,
body: &Body,
f: impl FnOnce(&mut Kernel, &mut solidcore::topology::Brep) -> std::result::Result<T, solidcore::SolidError>,
) -> Result<T> {
// A body a panic interrupted may be half-edited: it is never used again.
if body.0.is_poisoned() {
return Err(Error::Internal(format!("{call}: the body was left unfinished by an earlier panic")));
}
run(call, |k| {
let mut b = body.0.lock().unwrap_or_else(PoisonError::into_inner);
dump::input(k, "brep", &b);
f(k, &mut b)
})
}
/// An axis-aligned box from its lower corner (`SmApiCreateBox`, as `smlib_make_box` checks it:
/// every edge length positive).
pub(crate) fn make_box(origin: [f64; 3], size: [f64; 3]) -> Result<Body> {
if !origin.iter().chain(&size).all(|x| x.is_finite()) || !size.iter().all(|&s| s > 0.0) {
return Err(Error::Argument);
}
let [x, y, z] = size;
run("create_box", |k| primitives::create_box(k, Vec3::new(origin[0], origin[1], origin[2]), x, y, z)).map(Body::new)
}
fn point(p: [f64; 3]) -> Result<Vec3> {
if !p.iter().all(|x| x.is_finite()) {
return Err(Error::Argument);
}
Ok(Vec3::new(p[0], p[1], p[2]))
}
/// A cylinder about +Z from its base centre (`SmApiCreateCylinder`, as `smlib_make_cylinder`
/// checks it: radius and height positive).
pub(crate) fn make_cylinder(origin: [f64; 3], radius: f64, height: f64) -> Result<Body> {
let o = point(origin)?;
if !(radius > 0.0 && height > 0.0 && radius.is_finite() && height.is_finite()) {
return Err(Error::Argument);
}
run("create_cylinder", |k| primitives::create_cylinder(k, o, radius, height)).map(Body::new)
}
/// A sphere (`SmApiCreateSphere`; radius positive).
pub(crate) fn make_sphere(center: [f64; 3], radius: f64) -> Result<Body> {
let o = point(center)?;
if !(radius > 0.0 && radius.is_finite()) {
return Err(Error::Argument);
}
run("create_sphere", |k| primitives::create_sphere(k, o, radius)).map(Body::new)
}
/// A cone or frustum about +Z (`SmApiCreateCone`; `smlib_make_cone`'s checks: base radius and
/// height positive, top radius at least 0 and not the base radius).
pub(crate) fn make_cone(origin: [f64; 3], base_radius: f64, top_radius: f64, height: f64) -> Result<Body> {
let o = point(origin)?;
let ok = base_radius > 0.0 && top_radius >= 0.0 && height > 0.0 && top_radius != base_radius;
if !(ok && base_radius.is_finite() && top_radius.is_finite() && height.is_finite()) {
return Err(Error::Argument);
}
run("create_cone", |k| primitives::create_cone(k, o, base_radius, top_radius, height)).map(Body::new)
}
/// A ring torus about +Z (`SmApiCreateTorus`; major radius > minor radius > 0).
pub(crate) fn make_torus(center: [f64; 3], major_radius: f64, minor_radius: f64) -> Result<Body> {
let o = point(center)?;
if !(minor_radius > 0.0 && minor_radius < major_radius && major_radius.is_finite()) {
return Err(Error::Argument);
}
run("create_torus", |k| primitives::create_torus(k, o, major_radius, minor_radius)).map(Body::new)
}
/// The enclosed volume (`SmApiBrepComputeVolume`).
pub(crate) fn volume(body: &Body, relative_accuracy: f64) -> Result<f64> {
if !relative_accuracy.is_finite() {
return Err(Error::Argument);
}
with_body("brep_volume", body, |k, b| {
let store = AttributeStore::default();
let attrs = PropertyAttributes { store: &store, key_of: &|_| ObjectKey(0) };
brep_volume(k, b, relative_accuracy, &attrs)
})
}
/// Face, edge and vertex counts.
pub(crate) fn counts(body: &Body) -> Result<[usize; 3]> {
with_body("counts", body, |_, b| Ok([b.face_list().len(), b.edge_list().len(), b.vertex_list().len()]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_upstream_fix_is_on_unless_shown_broken() {
let f = fixes();
let flags: Vec<(&str, bool)> = f.flags().collect();
let off: Vec<&str> = flags.iter().filter(|(name, on)| !on && !HELD_BACK.contains(name)).map(|(name, _)| *name).collect();
assert!(off.is_empty(), "UpstreamFixes flags are off: {off:?}");
for name in HELD_BACK {
assert!(flags.contains(&(*name, false)), "{name} is on or gone: drop it from HELD_BACK");
}
}
#[test]
fn a_box_measures_exactly() {
let b = make_box([1.0, -2.0, 3.0], [10.0, 20.0, 30.0]).unwrap();
assert_eq!(counts(&b).unwrap(), [6, 12, 8]);
let v = volume(&b, 1e-8).unwrap();
assert!((v - 6000.0).abs() < 6000.0 * 1e-9, "{v}");
}
#[test]
fn hostile_numbers_are_refused() {
assert!(make_box([f64::NAN, 0.0, 0.0], [1.0; 3]).is_err());
assert!(make_box([0.0; 3], [1.0, -1.0, 1.0]).is_err());
let b = make_box([0.0; 3], [1.0; 3]).unwrap();
assert!(volume(&b, f64::INFINITY).is_err());
}
#[test]
fn a_panic_is_an_error_and_the_kernel_is_fresh_after() {
let r: Result<()> = run("test", |_| panic!("boom"));
assert!(matches!(r, Err(Error::Internal(ref m)) if m.contains("boom")), "{r:?}");
assert!(make_box([0.0; 3], [1.0; 3]).is_ok());
}
}
File diff suppressed because it is too large Load Diff
+859
View File
@@ -0,0 +1,859 @@
//! Fillets and chamfers on solidcore, against closed forms: every result exports to a record that
//! validates.
//!
//! The bodies are solidcore's own boxes and cylinders.
use std::collections::BTreeMap;
use std::f64::consts::PI;
use std::sync::mpsc;
use std::time::Duration;
use super::blend::{Corners, blend, blend_dropping, blend_holes, chamfer_sides, fillet_variable};
use super::boolean::boolean;
use super::{Body, counts, make_box, make_cylinder, to_arrays, volume};
use crate::export::native;
use crate::{Blend, BooleanOp, ChamferSecond, Error};
/// A case longer than this is a hang.
const TIMEOUT: Duration = Duration::from_secs(300);
/// How near a pick point must be.
const TOL: f64 = 0.01;
#[derive(Clone, Copy, Debug)]
enum Shape {
Box([f64; 3], [f64; 3]),
Cylinder([f64; 3], f64, f64),
/// A box (corner, size) less a cylinder along z (base centre, radius, height).
Holed([f64; 3], [f64; 3], [f64; 3], f64, f64),
}
impl Shape {
fn core(self) -> Body {
match self {
Shape::Box(o, s) => make_box(o, s),
Shape::Cylinder(o, r, h) => make_cylinder(o, r, h),
Shape::Holed(o, s, c, r, h) => {
boolean(&make_box(o, s).unwrap(), &make_cylinder(c, r, h).unwrap(), BooleanOp::Subtract).map(|b| b.unwrap())
}
}
.unwrap()
}
}
#[derive(Clone, Debug)]
enum Op {
/// `Brep::blend` of one kind, one size for every edge.
Blend(Shape, Blend, Vec<[f64; 3]>, f64),
/// `Brep::chamfer_sides`.
Sides(Shape, Vec<[f64; 3]>, f64, ChamferSecond, [f64; 3]),
/// `Brep::blend_setback` of rounds: edges, radius, corners, setbacks.
Setback(Shape, Vec<[f64; 3]>, f64, Vec<[f64; 3]>, Vec<f64>),
/// `Brep::fillet_variable`: edge point, start, degree, knots, radii.
Variable(Shape, [f64; 3], [f64; 3], usize, Vec<f64>, Vec<f64>),
}
/// What a case must give.
#[derive(Clone, Debug)]
enum Expect {
/// A body of this volume (relative 1e-9).
Volume(f64),
/// A body of this volume to this relative accuracy.
Near(f64, f64),
/// A body whose volume lies strictly between these.
Between(f64, f64),
/// This error.
Fails(Error),
}
struct Case {
op: Op,
expect: Expect,
}
/// A 30 x 20 x 10 block from the origin.
const BLOCK: Shape = Shape::Box([0.0; 3], [30.0, 20.0, 10.0]);
const ABC: f64 = 6000.0;
/// Its top front edge, along x at y = 0, z = 10, 30 long.
const TOP_FRONT: [f64; 3] = [15.0, 0.0, 10.0];
/// The cross-section a round of radius r takes from a right-angled edge: (1 - π/4) r².
fn spandrel(r: f64) -> f64 {
(1.0 - PI / 4.0) * r * r
}
fn case(name: &str) -> Case {
let c = |op, expect| Case { op, expect };
let r = 2.0;
match name {
// abc - (1 - π/4) r² L.
"fillet_one_edge" => c(Op::Blend(BLOCK, Blend::Round, vec![TOP_FRONT], r), Expect::Volume(ABC - spandrel(r) * 30.0)),
// Every edge rounded at r, the corners spheres: (a-2r)(b-2r)(c-2r) + 2r Σ(faces less
// 2r) + πr² Σ(edges less 2r) + 4/3 πr³.
"fillet_all_edges" => {
let (a, b, h) = (30.0 - 2.0 * r, 20.0 - 2.0 * r, 10.0 - 2.0 * r);
let v = a * b * h + 2.0 * r * (a * b + b * h + h * a) + PI * r * r * (a + b + h) + 4.0 / 3.0 * PI * r * r * r;
let mut edges = Vec::new();
for (y, z) in [(0.0, 0.0), (20.0, 0.0), (0.0, 10.0), (20.0, 10.0)] {
edges.push([15.0, y, z]);
}
for (x, z) in [(0.0, 0.0), (30.0, 0.0), (0.0, 10.0), (30.0, 10.0)] {
edges.push([x, 10.0, z]);
}
for (x, y) in [(0.0, 0.0), (30.0, 0.0), (0.0, 20.0), (30.0, 20.0)] {
edges.push([x, y, 5.0]);
}
c(Op::Blend(BLOCK, Blend::Round, edges, r), Expect::Volume(v))
}
// The curvature section's default thumbweight is equal area (fork 6148773): on the
// round's rails it takes off what the ball's circle does, abc - (1 - π/4) r² L.
"fillet_curvature_one_edge" => {
c(Op::Blend(BLOCK, Blend::Curvature, vec![TOP_FRONT], r), Expect::Volume(ABC - (1.0 - PI / 4.0) * r * r * 30.0))
}
// abc - d² L / 2.
"chamfer_one_edge" => c(Op::Blend(BLOCK, Blend::Chamfer, vec![TOP_FRONT], r), Expect::Volume(ABC - r * r * 30.0 / 2.0)),
// abc - d1 d2 L / 2: 2 back along the top (the face leaning most along +z), 3 down the
// front.
"chamfer_two_distances" => {
c(Op::Sides(BLOCK, vec![TOP_FRONT], 2.0, ChamferSecond::Distance(3.0), [0.0, 0.0, 1.0]), Expect::Volume(ABC - 2.0 * 3.0 * 30.0 / 2.0))
}
// 2 back along the top, at 30° to it: d tan 30° down the front.
"chamfer_distance_angle" => c(
Op::Sides(BLOCK, vec![TOP_FRONT], 2.0, ChamferSecond::Angle(PI / 6.0), [0.0, 0.0, 1.0]),
Expect::Volume(ABC - 2.0 * 2.0 * (PI / 6.0).tan() * 30.0 / 2.0),
),
// A 5 mm cylinder 10 high, its top rim rounded at 1. Pappus: the spandrel (1 - π/4) r²,
// its centroid (10 - 3π)/(12 - 3π) r in from the rim, swept round the axis.
"fillet_cylinder_rim" => {
let (big, h, r) = (5.0, 10.0, 1.0);
let inset = (10.0 - 3.0 * PI) / (12.0 - 3.0 * PI) * r;
c(
Op::Blend(Shape::Cylinder([0.0; 3], big, h), Blend::Round, vec![[0.0, big, h]], r),
Expect::Volume(PI * big * big * h - 2.0 * PI * (big - inset) * spandrel(r)),
)
}
// The three edges at the corner (30, 20, 10) rounded at r, the corner set back 4 along
// each. Without a setback the corner is a sphere's octant, and the block keeps
// abc - (1 - π/4) r² Σ(L - r) - (1 - π/6) r³; the setback changes only what lies within
// s + r of the corner, a cube of side s + r.
"setback_corner" => {
let s = 4.0;
let plain = ABC - spandrel(r) * (30.0 + 20.0 + 10.0 - 3.0 * r) - (1.0 - PI / 6.0) * r * r * r;
let near = (s + r) * (s + r) * (s + r);
c(
Op::Setback(BLOCK, vec![[15.0, 20.0, 10.0], [30.0, 10.0, 10.0], [30.0, 20.0, 5.0]], r, vec![[30.0, 20.0, 10.0]], vec![s]),
Expect::Between(plain - near, plain + near),
)
}
// The top front edge rounded from 1 at x = 0 to 3 at x = 30, linearly, each section
// square to the edge a quarter circle: abc - (1 - π/4) ∫ r(x)² dx
// = abc - (1 - π/4) L (r0² + r0 r1 + r1²) / 3. The round is a B-spline surface fitted to
// those sections, which both kernels build 2.5e-7 short of it (and within 1e-9 of each
// other), within the solver's fitting tolerance.
// The union of the balls, r from 1 to 3 along the 30 mm edge: in each plane across the
// edge, the area between the corner and where each ray from it first enters a ball
// (Gauss-Legendre in the ray's angle, Simpson along the edge, converged to 1e-8 of the
// body), 27.857434; the balls' great circles would take (1 - π/4) L (r0² + r0 r1 + r1²) / 3,
// 27.898239.
"fillet_variable_linear" => {
c(Op::Variable(BLOCK, TOP_FRONT, [0.0, 0.0, 10.0], 1, vec![0.0, 0.0, 1.0, 1.0], vec![1.0, 3.0]), Expect::Near(ABC - 27.857434, 1e-6))
}
// A round of 12 on a 10 mm cube's top front edge is larger than either face beside it:
// the fork builds it over the cliffs, on the faces' continuations, and cuts away all of
// the cube outside the round's cylinder (axis y = 12, z = -2, radius 12). What stays is
// 10 times the square's part inside that circle, ∫ (√(144 - u²) - 2) du over
// u = y - 12 from -√140 to -2: 72 (asin(√140/12) - asin(1/6)) - 2 (√140 - 2).
"fillet_over_a_cliff" => {
let w = 140f64.sqrt();
let area = 72.0 * ((w / 12.0).asin() - (1.0f64 / 6.0).asin()) - 2.0 * (w - 2.0);
c(Op::Blend(Shape::Box([0.0; 3], [10.0; 3]), Blend::Round, vec![[5.0, 0.0, 10.0]], 12.0), Expect::Volume(10.0 * area))
}
// A round of 6 on the top rim of a cylinder of radius 5 is wider than the cylinder.
"fillet_too_large" => {
c(Op::Blend(Shape::Cylinder([0.0; 3], 5.0, 10.0), Blend::Round, vec![[0.0, 5.0, 10.0]], 6.0), Expect::Fails(Error::NoRoom))
}
"pick_near_nothing" => {
c(Op::Blend(BLOCK, Blend::Round, vec![TOP_FRONT, [100.0, 100.0, 100.0]], r), Expect::Fails(Error::NoMatch { point: 1 }))
}
// A 20 mm cube with a hole of radius 5 along z whose wall touches the face y = 0 along
// x = 10 (#1390): a 3 mm chamfer of the hole's top rim runs out of that face where the
// rim meets it tangentially, a vertex no blend corner handles. The tangent chain the app
// blends is picked: the rim at 135° and 315° round the hole (away from the cylinder's
// seam at 0° and the face at 270°), and the two pieces of the top's front edge, which the
// rim runs on tangentially into at (10, 0, 20).
"chamfer_hole_rim_touching_a_face" => {
let d = 5.0 * std::f64::consts::FRAC_1_SQRT_2;
c(
Op::Blend(
Shape::Holed([0.0; 3], [20.0; 3], [10.0, 5.0, -1.0], 5.0, 22.0),
Blend::Chamfer,
vec![[10.0 - d, 5.0 + d, 20.0], [10.0 + d, 5.0 - d, 20.0], [5.0, 0.0, 20.0], [15.0, 0.0, 20.0]],
3.0,
),
Expect::Fails(Error::CornerUnsupported),
)
}
// √3 from all three edges at the corner (0, 0, 10).
"pick_ambiguous" => c(Op::Blend(BLOCK, Blend::Round, vec![[-1.0, -1.0, 11.0]], r), Expect::Fails(Error::Ambiguous { point: 0 })),
_ => panic!("{name}"),
}
}
/// The tolerance a case picks with: the ambiguous point lies √3 from its edges.
fn tolerance(name: &str) -> f64 {
if name == "pick_ambiguous" { 5.0 } else { TOL }
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs()
}
#[derive(Debug, PartialEq)]
struct Measured {
volume: f64,
counts: [usize; 3],
kinds: BTreeMap<String, usize>,
}
fn kinds(rec: &solidcraft_geom::brep::Brep) -> BTreeMap<String, usize> {
let mut out = BTreeMap::new();
for f in &rec.faces {
let v = serde_json::to_value(&f.surface).unwrap();
*out.entry(v["kind"].as_str().unwrap_or("?").to_owned()).or_default() += 1;
}
out
}
fn on_core(name: &str, c: &Case) -> crate::Result<Body> {
let tol = tolerance(name);
match &c.op {
Op::Blend(s, kind, points, size) => blend(&s.core(), *kind, points, &[*size], tol, &[], &[], Corners::Filled),
Op::Sides(s, points, d, second, first) => chamfer_sides(&s.core(), points, tol, *d, *second, *first),
Op::Setback(s, points, size, corners, setbacks) => {
blend(&s.core(), Blend::Round, points, &vec![*size; points.len()], tol, corners, setbacks, Corners::Filled)
}
Op::Variable(s, point, start, degree, knots, radii) => fillet_variable(&s.core(), *point, tol, *start, *degree, knots, radii),
}
}
/// Runs `f` on its own thread: a hang fails the test instead of stopping the run.
fn timed<T: Send + 'static>(name: &str, f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = mpsc::channel();
let worker = std::thread::spawn(move || {
let _ = tx.send(f());
});
match rx.recv_timeout(TIMEOUT) {
Ok(v) => v,
Err(mpsc::RecvTimeoutError::Disconnected) => match worker.join() {
Err(p) => std::panic::resume_unwind(p),
Ok(()) => panic!("{name}: no result"),
},
Err(mpsc::RecvTimeoutError::Timeout) => panic!("{name}: solidcore hung (no result after {TIMEOUT:?})"),
}
}
type Got = std::result::Result<Measured, Error>;
/// Checks a kernel's result against the case's closed form; what it measured, or its error.
fn judge(name: &str, who: &str, expect: &Expect, got: Got) -> Got {
match (expect, &got) {
(Expect::Fails(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"),
(Expect::Fails(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"),
(_, Err(g)) => panic!("{name}: {who} failed: {g}"),
(Expect::Volume(v), Ok(m)) => assert!(rel(m.volume, *v) < 1e-9, "{name}: {who} volume {} vs analytic {v}", m.volume),
(Expect::Near(v, within), Ok(m)) => assert!(rel(m.volume, *v) < *within, "{name}: {who} volume {} vs analytic {v}", m.volume),
(Expect::Between(lo, hi), Ok(m)) => assert!(*lo < m.volume && m.volume < *hi, "{name}: {who} volume {} not in ({lo}, {hi})", m.volume),
}
got
}
fn solidcore_case(name: &'static str) -> Got {
timed(name, move || {
let c = case(name);
let got = on_core(name, &c).map(|body| {
let v = volume(&body, 1e-8).unwrap_or_else(|e| panic!("{name}: volume: {e}"));
let n = counts(&body).unwrap();
let (rec, _) = native(&to_arrays(&body).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
Measured { volume: v, counts: n, kinds: kinds(&rec) }
});
judge(name, "solidcore", &c.expect, got)
})
}
macro_rules! cases {
($($m:ident $(: $ign:meta)?),* $(,)?) => {$(
mod $m {
#[test]
$(#[$ign])?
fn solidcore_matches_the_closed_form() {
let _ = super::solidcore_case(stringify!($m));
}
}
)*};
}
cases!(
fillet_one_edge,
fillet_all_edges,
fillet_curvature_one_edge,
chamfer_one_edge,
chamfer_two_distances,
chamfer_distance_angle,
fillet_cylinder_rim,
setback_corner,
fillet_variable_linear,
fillet_over_a_cliff,
fillet_too_large,
chamfer_hole_rim_touching_a_face,
pick_near_nothing,
pick_ambiguous,
);
/// What solidcore does not have yet is refused as not supported, after the capi's own checks.
#[test]
fn fork_only_blends_are_not_supported_yet() {
let r = timed("unsupported", || {
let b = BLOCK.core();
// 1 to 2 and back to 1: a corner at the middle.
let bent = fillet_variable(&b, TOP_FRONT, TOL, [0.0, 0.0, 10.0], 1, &[0.0, 0.0, 0.5, 1.0, 1.0], &[1.0, 2.0, 1.0]).err();
// A pick that fails still says so first.
let far = chamfer_sides(&b, &[[100.0; 3]], TOL, 2.0, ChamferSecond::Distance(3.0), [0.0, 0.0, 1.0]).err();
(bent, far)
});
assert_eq!(r.0, Some(Error::Unsupported));
assert_eq!(r.1, Some(Error::NoMatch { point: 0 }));
}
/// A cliff overflow the fork refuses too (`fixes.fillet_cliff_overflow`, solidcore#960): the
/// three edges at a 20 mm cube's corner rounded r20, their fillet's boundary on two faces'
/// surfaces, has no room, as on the fork, not "not supported".
#[test]
fn a_cliff_overflow_the_fork_refuses_has_no_room() {
let r = timed("cliff_refused", || {
let corner = [[10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]];
blend(&Shape::Box([0.0; 3], [20.0; 3]).core(), Blend::Round, &corner, &[20.0; 3], TOL, &[], &[], Corners::Filled).err()
});
assert_eq!(r, Some(Error::NoRoom));
}
/// The capi's `overflow_goes_first` (fork 241f7ba): R21 on a 20 cube's edge takes all of both faces
/// that edge lies on, and rounds with it on other edges go after it. With R5 on the corner's other
/// two edges those edges are gone (the two R5 points flagged) and the result is the R21's alone,
/// 20 (F(w) - F(1) - (w - 1)) with F the integral of sqrt(441 - u²) and w = sqrt(440). With R5 on
/// the back upright that upright's round runs above the R21 cylinder, taking the corner's piece
/// over it too (Simpson on 4000 spans, against a bound of its quartic end edge's tolerance).
#[test]
fn overflow_goes_first() {
let f = |u: f64| 0.5 * (u * (441.0 - u * u).sqrt() + 441.0 * (u / 21.0).asin());
let w = 440f64.sqrt();
let alone = 20.0 * (f(w) - f(1.0) - (w - 1.0));
let strip = |t: f64| {
let y = 15.0 + 5.0 * t.sin();
let zc = 21.0 - (441.0 - (y - 21.0) * (y - 21.0)).sqrt();
5.0 * (1.0 - t.cos()) * (20.0 - zc) * 5.0 * t.cos()
};
let (n, h) = (4000, 0.5 * PI / 4000.0);
let mut sum = strip(0.0) + strip(0.5 * PI);
for i in 1..n {
sum += f64::from(if i % 2 == 1 { 4 } else { 2 }) * strip(f64::from(i) * h);
}
let corner = sum * h / 3.0;
assert!((corner - 106.612_300_588).abs() < 1e-6, "{corner}");
let cases: [(&[[f64; 3]], &[f64], &[bool], f64, f64); 2] = [
(&[[10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]], &[21.0, 5.0, 5.0], &[false, true, true], alone, 1e-9 * alone),
(&[[10.0, 0.0, 0.0], [0.0, 20.0, 10.0]], &[21.0, 5.0], &[false, false], alone - corner, 0.02),
];
for (points, sizes, dropped, want, bound) in cases {
let got = timed("overflow_first", move || {
let (b, d) = blend_dropping(&Shape::Box([0.0; 3], [20.0; 3]).core(), Blend::Round, points, sizes, 1.0).unwrap();
native(&to_arrays(&b).unwrap()).unwrap();
(volume(&b, 1e-8).unwrap(), d)
});
println!("overflow first {sizes:?}: {:.9}, expected {want:.9}", got.0);
assert_eq!(got.1, dropped, "{sizes:?}");
assert!((got.0 - want).abs() <= bound, "{sizes:?}: {} vs {want}", got.0);
}
}
/// The capi's `rounds_at_a_concave_face_corner_are_mitred` (`fixes.fillet_mitre_concave_face_corners`,
/// fork 4f06e72): each edge loses (convex) or gains (concave) the spandrel r²(1 - π/4) along its
/// length, and in the quadrant beyond both walls the mitred rounds take or add r³(5/3 - π/2): a
/// 60 x 40 x 10 plate's 30 x 20 x 5 pocket rim at r2 (15 faces) and r5 (11), a 60 x 60 x 5 plate's
/// 20 x 20 x 15 boss root at r4 (15). And the pocket rim chamfered 5.5, past the pocket's depth:
/// 21000 - (50 d² + 4/3 d³).
#[test]
fn rounds_at_a_concave_face_corner_are_mitred() {
let made = |a: Body, b: Body, op| boolean(&a, &b, op).unwrap().unwrap();
let pocket =
|| made(make_box([0.0; 3], [60.0, 40.0, 10.0]).unwrap(), make_box([15.0, 10.0, 5.0], [30.0, 20.0, 6.0]).unwrap(), BooleanOp::Subtract);
let boss = || made(make_box([0.0; 3], [60.0, 60.0, 5.0]).unwrap(), make_box([20.0, 20.0, 5.0], [20.0, 20.0, 15.0]).unwrap(), BooleanOp::Union);
let rim = [[30.0, 10.0, 10.0], [30.0, 30.0, 10.0], [15.0, 20.0, 10.0], [45.0, 20.0, 10.0]];
let root = [[30.0, 20.0, 5.0], [40.0, 30.0, 5.0], [30.0, 40.0, 5.0], [20.0, 30.0, 5.0]];
let want = |v0: f64, length: f64, r: f64, sign: f64| v0 + sign * (length * r * r * (1.0 - PI / 4.0) + 4.0 * r * r * r * (5.0 / 3.0 - PI / 2.0));
for (body, edges, r, v, faces) in [
(pocket(), rim, 2.0, want(21000.0, 100.0, 2.0, -1.0), 15),
(pocket(), rim, 5.0, want(21000.0, 100.0, 5.0, -1.0), 11),
(boss(), root, 4.0, want(24000.0, 80.0, 4.0, 1.0), 15),
] {
let b = blend(&body, Blend::Round, &edges, &[r], 0.1, &[], &[], Corners::Filled).unwrap();
native(&to_arrays(&b).unwrap()).unwrap();
let got = volume(&b, 1e-12).unwrap();
println!("mitred r{r}: {} faces, {got:.9}, expected {v:.9}", counts(&b).unwrap()[0]);
assert_eq!(counts(&b).unwrap()[0], faces, "r{r}");
assert!(rel(got, v) < 2e-8, "r{r}: {got} vs {v}");
}
// Through the flat arrays, as bodies reach a blend in the app (pocket_chamfer_on_a_boolean_body).
let d = 5.5;
let flat = super::from_arrays(&to_arrays(&pocket()).unwrap()).unwrap();
let b = blend(&flat, Blend::Chamfer, &rim, &[d], 0.1, &[], &[], Corners::Filled).unwrap();
let (got, v) = (volume(&b, 1e-8).unwrap(), 21000.0 - (50.0 * d * d + 4.0 / 3.0 * d * d * d));
println!("mitred chamfer {d}: {got:.9}, expected {v:.9}");
assert!(rel(got, v) < 1e-8, "{got} vs {v}");
}
/// Solidcore alone: edge attributes across a cliff overflow. A 40 x 30 x 10 block notched below its
/// top at (40, 0), its front and right top edges rounded r8 (their rails run off over the notch).
/// The back and left top edges are each trimmed 8 by those rounds: (0, 30)-(32, 30) and (0, 8)-(0, 30)
/// at z = 10, and each piece keeps its edge's attribute (`Behavior::Copy`), as on the fork.
#[test]
fn an_edge_tag_crosses_a_cliff_overflow() {
use solidcore::blend::fillet_executive::session::FilletSession;
use solidcore::boolean::api_brep::boolean_difference;
use solidcore::geom::attribute::{Attribute, Behavior};
use solidcore::primitive::api_primitives::create_box;
use solidcore::topology::{Brep, EdgeId, edge, vertex};
use solidcore::{EntityRef, Kernel, Point3};
fn between(b: &Brep, a: Point3, q: Point3) -> Option<EdgeId> {
b.edges().ids().find(|&e| {
let (s, t) = edge::start_end_vertices(b, e).unwrap();
let (s, t) = (vertex::point(b, s.unwrap()).unwrap(), vertex::point(b, t.unwrap()).unwrap());
(s.distance(a) < 1e-6 && t.distance(q) < 1e-6) || (s.distance(q) < 1e-6 && t.distance(a) < 1e-6)
})
}
let p = Point3::new;
let mut k = Kernel::default();
k.fixes.fillet_refuse_rails_leaving_faces = true;
k.fixes.fillet_trace_reaches_rails = true;
k.fixes.fillet_rail_end_on_continued_surface = true;
k.fixes.fillet_cliff_overflow = true;
let block = create_box(&mut k, Point3::ZERO, 40.0, 30.0, 10.0).unwrap();
let notch = create_box(&mut k, p(35.0, -1.0, -1.0), 6.0, 6.0, 4.0).unwrap();
let mut b = boolean_difference(&mut k, block, notch).unwrap();
const ID: u64 = 0x7000_0001;
let edge_at = |b: &Brep, a, q| between(b, a, q).unwrap();
for (a, q, v) in [(p(0.0, 30.0, 10.0), p(40.0, 30.0, 10.0), 1), (p(0.0, 0.0, 10.0), p(0.0, 30.0, 10.0), 2)] {
let e = edge_at(&b, a, q);
b.add_attribute(EntityRef::Edge(e), Attribute::long(ID, v, Behavior::Copy)).unwrap();
}
let rounds = [edge_at(&b, p(0.0, 0.0, 10.0), p(40.0, 0.0, 10.0)), edge_at(&b, p(40.0, 0.0, 10.0), p(40.0, 30.0, 10.0))];
let mut s = FilletSession::new(&mut k, &mut b).unwrap();
s.prepare_constant_radius_edges(&mut k, &rounds, 8.0, 1, 1, 1.0).unwrap();
s.do_fillet(&mut k).unwrap();
drop(s);
b.refresh_cached_topology();
for (a, q, which) in [(p(0.0, 30.0, 10.0), p(32.0, 30.0, 10.0), "back"), (p(0.0, 8.0, 10.0), p(0.0, 30.0, 10.0), "left")] {
let e = edge_at(&b, a, q);
assert!(b.entity_attributes(EntityRef::Edge(e)).iter().any(|(_, x)| x.attribute_id() == ID), "the {which} edge lost its attribute");
}
}
/// Solidcore alone: the pocket rim chamfered 5.5, as `fillet_mitre_concave_face_corners`' own test
/// does it, with `keep_operand_uv_trims` on too, on the body the boolean gives:
/// 21000 - (50 d² + 4/3 d³) = 19265.666666667 (the fork, and the same body through the flat
/// arrays). Without `fillet_run_on_bspline_trims` it is refused: error 3, Corner Edge Computation
/// Failure (solidcore#1981).
#[test]
fn pocket_chamfer_on_a_boolean_body() {
use solidcore::blend::fillet_executive::session::FilletSession;
use solidcore::boolean::api_brep::boolean_difference;
use solidcore::primitive::api_primitives::create_box;
use solidcore::topology::{EdgeId, edge, vertex};
use solidcore::{Kernel, Point3};
let p = Point3::new;
let mut k = Kernel::default();
k.fixes.fillet_refuse_rails_leaving_faces = true;
k.fixes.fillet_trace_reaches_rails = true;
k.fixes.fillet_rail_end_on_continued_surface = true;
k.fixes.fillet_cliff_overflow = true;
k.fixes.fillet_mitre_concave_face_corners = true;
k.fixes.keep_operand_uv_trims = true;
k.fixes.fillet_run_on_bspline_trims = true;
let plate = create_box(&mut k, Point3::ZERO, 60.0, 40.0, 10.0).unwrap();
let cut = create_box(&mut k, p(15.0, 10.0, 5.0), 30.0, 20.0, 6.0).unwrap();
let mut b = boolean_difference(&mut k, plate, cut).unwrap();
let c = [p(15.0, 10.0, 10.0), p(45.0, 10.0, 10.0), p(45.0, 30.0, 10.0), p(15.0, 30.0, 10.0)];
let on = |x: Point3, a: Point3, q: Point3| x.distance(a) < 1e-9 || x.distance(q) < 1e-9;
let ids: Vec<EdgeId> = (0..4)
.map(|i| {
let (a, q) = (c[i], c[(i + 1) % 4]);
b.edges()
.ids()
.find(|&e| {
let (s, t) = edge::start_end_vertices(&b, e).unwrap();
on(vertex::point(&b, s.unwrap()).unwrap(), a, q) && on(vertex::point(&b, t.unwrap()).unwrap(), a, q) && s != t
})
.unwrap()
})
.collect();
let mut s = FilletSession::new(&mut k, &mut b).unwrap();
s.prepare_constant_radius_edges(&mut k, &ids, 5.5, 0, 1, 1.0).unwrap();
let r = s.do_fillet(&mut k);
assert!(r.is_ok(), "{} {}", s.error_info().code, s.error_info().comment);
drop(s);
b.refresh_cached_topology();
let v = volume(&Body::new(b), 1e-8).unwrap();
let d: f64 = 5.5;
assert!(rel(v, 21000.0 - (50.0 * d * d + 4.0 / 3.0 * d * d * d)) < 1e-8, "{v}");
}
/// The corner of r5 rounds at (60, 40, 20) on a 60 x 40 x 20 box, set back 8 and left open: the
/// box's six faces and the three rounds (9 faces, 30 edges, 22 vertices), a sheet, and one hole
/// of twelve edges (each section and face curve in two), chained end to start, each beside a face
/// of the sheet.
#[test]
fn a_setback_corner_left_open_returns_its_hole() {
let edges = [[30.0, 40.0, 20.0], [60.0, 20.0, 20.0], [60.0, 40.0, 10.0]];
let (open, holes) = timed("setback_open", move || {
let b = Shape::Box([0.0; 3], [60.0, 40.0, 20.0]).core();
blend_holes(&b, Blend::Round, &edges, &[5.0; 3], 0.1, &[[60.0, 40.0, 20.0]], &[8.0], Corners::Open).unwrap()
});
assert_eq!(counts(&open).unwrap(), [9, 30, 22]);
assert!(!super::is_manifold_solid(&open).unwrap());
let [hole] = holes.as_slice() else { panic!("one corner: {holes:?}") };
assert_eq!(hole.edges.len(), 12);
let flat = to_arrays(&open).unwrap();
let (rec, _) = native(&flat).unwrap();
rec.validate().unwrap();
let ends = &flat.ints[&crate::flat::EDGE_VERTICES];
let end = |h: &crate::HoleEdge, last: bool| ends[2 * h.edge + usize::from(h.forward == last)];
for (k, h) in hole.edges.iter().enumerate() {
let next = &hole.edges[(k + 1) % 12];
assert_eq!(end(h, true), end(next, false), "edge {k} ends where the next starts");
assert!(h.face < 9);
}
// A corner where an edge is not rounded builds no setback, and the refusal names it.
let refused = timed("setback_open_refused", move || {
let b = Shape::Box([0.0; 3], [60.0, 40.0, 20.0]).core();
blend_holes(&b, Blend::Round, &edges[..2], &[5.0; 2], 0.1, &[[60.0, 40.0, 20.0]], &[8.0], Corners::Open).err()
});
assert_eq!(refused, Some(Error::CornerRefused { corner: 0 }));
}
/// A curvature-continuous round at the equal-area thumbweight takes off what the ball's circle
/// does, r² (cot(α/2) - (π - α)/2) per unit length on a wedge α (solidcore#2261): on a 40 x 30 x 20
/// box with its top back edge chamfered 5, r 2 along the chamfer's top edge (α 135°) takes
/// 0.0860579227 per mm, along the bottom front edge (α 90°) 0.8584073464. Both in one call are
/// refused: every edge takes the first one's thumbweight, so the other would take 17% too little
/// or 21% too much (solidcore#2250's comment 6563419897), whichever order they come in.
#[test]
fn a_curvature_round_takes_its_wedges_equal_area_and_mixed_wedges_are_refused() {
let r = 2.0;
let cut = |alpha: f64| r * r * (1.0 / (alpha / 2.0).tan() - (PI - alpha) / 2.0);
let chamfered =
blend(&Shape::Box([0.0; 3], [40.0, 30.0, 20.0]).core(), Blend::Chamfer, &[[20.0, 30.0, 20.0]], &[5.0], TOL, &[], &[], Corners::Filled)
.unwrap();
let v0 = volume(&chamfered, 1e-8).unwrap();
assert!(rel(v0, 23500.0) < 1e-9, "{v0}");
let (a, b) = ([20.0, 25.0, 20.0], [20.0, 0.0, 0.0]);
for (at, alpha) in [(a, 0.75 * PI), (b, 0.5 * PI)] {
let round = blend(&chamfered, Blend::Curvature, &[at], &[r], TOL, &[], &[], Corners::Filled).unwrap();
let (rec, _) = native(&to_arrays(&round).unwrap()).unwrap();
rec.validate().unwrap();
let removed = v0 - volume(&round, 1e-8).unwrap();
assert!(rel(removed, 40.0 * cut(alpha)) < 1e-6, "{removed} vs {}", 40.0 * cut(alpha));
}
for both in [[a, b], [b, a]] {
let refused = blend(&chamfered, Blend::Curvature, &both, &[r], TOL, &[], &[], Corners::Filled).err();
assert_eq!(refused, Some(Error::Unported(super::blend::CURVATURE_MIXED_WEDGES)));
assert!(refused.is_some_and(|e| e.to_string().contains("issuecomment-6563419897")));
}
// Edges of one wedge go together.
let two = blend(&chamfered, Blend::Curvature, &[b, [20.0, 0.0, 20.0]], &[r], TOL, &[], &[], Corners::Filled).unwrap();
assert!(rel(v0 - volume(&two, 1e-8).unwrap(), 80.0 * cut(0.5 * PI)) < 1e-6);
// A pick that misses is still named first.
assert_eq!(blend(&chamfered, Blend::Curvature, &[[90.0; 3]], &[r], TOL, &[], &[], Corners::Filled).err(), Some(Error::NoMatch { point: 0 }));
}
/// A variable round is the union of its balls (solidcore#2262, `fillet_gn_section_generator`).
/// Along the 30 mm top front edge: a law of one radius 2 takes the constant round's
/// (1 - π/4) 4 · 30 = 25.752220; 1 to 3, the balls' union's 27.857434 (the great circles would take
/// 27.898239), either way along the edge. Both come out 5.3e-5 of that over, as on the C++
/// kernel: the solver's surface fit. A law whose radius climbs faster than a rolling ball can
/// follow (1 to 4 within a mm) folds its balls over one another: refused, not built.
#[test]
fn a_variable_round_is_the_union_of_its_balls() {
let b = BLOCK.core();
let removed = |radii: &[f64], start: [f64; 3]| {
let r = fillet_variable(&b, TOP_FRONT, TOL, start, 1, &[0.0, 0.0, 1.0, 1.0], radii).unwrap();
let (rec, _) = native(&to_arrays(&r).unwrap()).unwrap();
rec.validate().unwrap();
ABC - volume(&r, 1e-8).unwrap()
};
let constant = removed(&[2.0, 2.0], [0.0, 0.0, 10.0]);
assert!(rel(constant, (1.0 - PI / 4.0) * 4.0 * 30.0) < 1e-4, "{constant}");
let linear = removed(&[1.0, 3.0], [0.0, 0.0, 10.0]);
assert!(rel(linear, 27.857434) < 1e-4, "{linear}");
assert!(rel(removed(&[1.0, 3.0], [30.0, 0.0, 10.0]), linear) < 1e-8);
let steep = [0.0, 0.0, 0.0, 0.0, 0.02, 0.04, 1.0, 1.0, 1.0, 1.0];
let folds = fillet_variable(&b, TOP_FRONT, TOL, [0.0, 0.0, 10.0], 3, &steep, &[1.0, 1.0, 4.0, 4.0, 4.0, 4.0]).err();
assert_eq!(folds, Some(Error::NoRoom));
// A pick that misses is still named first.
assert_eq!(fillet_variable(&b, [90.0; 3], TOL, [0.0; 3], 1, &[0.0, 0.0, 1.0, 1.0], &[1.0, 3.0]).err(), Some(Error::NoMatch { point: 0 }));
}
/// The capi's argument checks, before any kernel call.
#[test]
fn hostile_arguments_are_refused() {
let b = BLOCK.core();
let none = Corners::Filled;
assert_eq!(blend(&b, Blend::Round, &[], &[1.0], TOL, &[], &[], none).err(), Some(Error::Argument));
assert_eq!(blend(&b, Blend::Round, &[[f64::NAN; 3]], &[1.0], TOL, &[], &[], none).err(), Some(Error::Argument));
assert_eq!(blend(&b, Blend::Round, &[TOP_FRONT], &[-1.0], TOL, &[], &[], none).err(), Some(Error::Argument));
assert_eq!(blend(&b, Blend::Round, &[TOP_FRONT], &[1.0], 0.0, &[], &[], none).err(), Some(Error::Argument));
assert_eq!(blend(&b, Blend::Round, &[TOP_FRONT], &[f64::INFINITY], TOL, &[], &[], none).err(), Some(Error::Argument));
// A chamfer's corners are bevelled, which builds no setback.
assert_eq!(blend(&b, Blend::Chamfer, &[TOP_FRONT], &[1.0], TOL, &[[0.0, 0.0, 10.0]], &[1.0], none).err(), Some(Error::Unsupported));
assert_eq!(fillet_variable(&b, TOP_FRONT, TOL, [0.0; 3], 1, &[0.0, 1.0], &[1.0, 2.0]).err(), Some(Error::Argument));
// A radius that pinches to nothing inside the edge.
assert_eq!(fillet_variable(&b, TOP_FRONT, TOL, [0.0; 3], 1, &[0.0, 0.0, 0.5, 1.0, 1.0], &[1.0, 0.0, 1.0]).err(), Some(Error::Unsupported));
assert_eq!(chamfer_sides(&b, &[TOP_FRONT], TOL, 1.0, ChamferSecond::Angle(4.0), [0.0, 0.0, 1.0]).err(), Some(Error::Argument));
assert_eq!(chamfer_sides(&b, &[TOP_FRONT], TOL, 1.0, ChamferSecond::Distance(1.0), [0.0; 3]).err(), Some(Error::Argument));
}
/// A setback longer than an edge at its corner leaves no room, before any blend is made.
#[test]
fn a_setback_past_an_edge_has_no_room() {
let r = timed("setback_long", || {
let b = BLOCK.core();
let corner = [[15.0, 20.0, 10.0], [30.0, 10.0, 10.0], [30.0, 20.0, 5.0]];
blend(&b, Blend::Round, &corner, &[2.0; 3], TOL, &[[30.0, 20.0, 10.0]], &[10.0], Corners::Filled).err()
});
assert_eq!(r, Some(Error::NoRoom));
}
/// The body blended is never changed.
#[test]
fn the_body_stays_as_it_was() {
let r = timed("operand", || {
let b = BLOCK.core();
let _ = blend(&b, Blend::Round, &[TOP_FRONT], &[2.0], TOL, &[], &[], Corners::Filled);
(counts(&b).unwrap(), volume(&b, 1e-8).unwrap())
});
assert_eq!(r.0, [6, 12, 8]);
assert!(rel(r.1, ABC) < 1e-12, "{}", r.1);
}
/// The chamfer's first distance goes on the face leaning most along `first`: 2 along the top and
/// 3 down the front for +z, the other way round for -y. The volumes are the same; the centroid
/// tells them apart. The removed prism's section is the triangle with legs t (back along the
/// top, in y) and f (down the front, in z) at the edge (y = 0, z = 10), centroid
/// (t/3, 10 - f/3), area t f / 2: the block keeps ȳ = (6000·10 - 15 t f · t/3) / (6000 - 15 t f)
/// and z̄ = (6000·5 - 15 t f (10 - f/3)) / (6000 - 15 t f).
#[test]
fn the_first_distance_goes_on_the_face_along_first() {
for (first, top, front) in [([0.0, 0.0, 1.0], 2.0, 3.0), ([0.0, -1.0, 0.0], 3.0, 2.0)] {
let r = timed("sides_first", move || {
let b = BLOCK.core();
chamfer_sides(&b, &[TOP_FRONT], TOL, 2.0, ChamferSecond::Distance(3.0), first).and_then(|c| super::mass_properties(&c, 1e-8))
});
let m = r.unwrap_or_else(|e| panic!("{first:?}: {e}"));
let cut = 15.0 * top * front;
let y = (6000.0 * 10.0 - cut * top / 3.0) / (6000.0 - cut);
let z = (6000.0 * 5.0 - cut * (10.0 - front / 3.0)) / (6000.0 - cut);
assert!(rel(m.volume, 6000.0 - cut) < 1e-9, "{first:?}: volume {}", m.volume);
assert!((m.centroid[1] - y).abs() < 1e-7 && (m.centroid[2] - z).abs() < 1e-7, "{first:?}: centroid {:?} vs ({y}, {z})", m.centroid);
}
}
/// The capi's `knife_edge_chamfers` (fork e96898d): a 10 high extrusion of the profile whose arc
/// of radius R = 21 runs out tangent into its top line at the tip x = 62 1/6 (a knife edge, a
/// cusp), chamfered d at the tip. No ball lies d back along both faces there; the chamfer is the
/// chord between the contacts of the ball of radius d, sqrt(4 R d) back on the flat face and where
/// the line from the arc's axis to the ball's centre meets the wall. It takes the triangle flat
/// contact, tip, wall contact less the wall's circular segment R²/2 (b - sin b),
/// b = acos((R - d)/(R + d)): at d = 2 the chord is 3.822 wide and takes 159.23645 (each to
/// 1e-12). Mirrored in x too.
#[test]
fn knife_edge_chamfers() {
use super::sweep::extrude;
use super::transform::transform;
use crate::sweeps::{Curve, DraftCorner};
const R: f64 = 21.0;
const TIP: f64 = 62.0 + 1.0 / 6.0;
const H: f64 = 10.0;
let line = |a: [f64; 2], b: [f64; 2]| Curve::Line { start: [a[0], a[1], 0.0], end: [b[0], b[1], 0.0] };
let profile = [
line([0.0, 0.0], [0.0, -R]),
line([0.0, -R], [TIP - R, -R]),
Curve::Arc { center: [TIP, -R, 0.0], axis: [0.0, 0.0, -1.0], reference: [1.0, 0.0, 0.0], radius: R, start: PI, end: 1.5 * PI },
line([TIP, 0.0], [0.0, 0.0]),
];
let part = extrude(&profile, [0.0, 0.0, 1.0], H, 0.0, DraftCorner::Curvature, true).unwrap();
let before = (TIP - R) * R * H + R * R * (1.0 - PI / 4.0) * H;
assert!((volume(&part, 1e-8).unwrap() - before).abs() < 1e-9 * before);
for d in [1.0, 2.0, 5.0] {
let flat = TIP - (4.0 * R * d).sqrt();
let (wx, wy) = (TIP - (4.0 * R * d).sqrt() * R / (R + d), -R + (R - d) * R / (R + d));
let triangle = 0.5 * ((TIP - flat) * wy).abs();
let b = ((R - d) / (R + d)).acos();
let removed = (triangle - 0.5 * R * R * (b - b.sin())) * H;
if d == 2.0 {
assert!((removed - 159.236_450_742_901_18).abs() < 1e-12 * 159.24);
assert!(((wx - flat).hypot(wy) - 3.822_132_343_623_634_7).abs() < 1e-12 * 3.83);
}
for mirrored in [false, true] {
let sx = if mirrored { -1.0 } else { 1.0 };
let m = [[sx, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
let body = transform(&part, &m).unwrap();
let tip = [sx * TIP, 0.0, H / 2.0];
let cut = blend(&body, Blend::Chamfer, &[tip], &[d], TOL, &[], &[], Corners::Filled).unwrap();
let v = volume(&cut, 1e-8).unwrap();
let want = before - removed;
assert!((v - want).abs() < 1e-9 * want, "d {d} mirrored {mirrored}: {v} vs {want}");
native(&to_arrays(&cut).unwrap()).unwrap();
}
}
}
/// The part from SolidCraft #871: a plate with a narrower block on top and a window through both.
fn part_871() -> Body {
let (a, b) = (12.74437365538526, 22.69275963366936);
let at = |lo: [f64; 3], hi: [f64; 3]| make_box(lo, [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]).unwrap();
let joined = boolean(&at([9.5, 8.0, 0.0], [23.5, 29.5, 10.0]), &at([9.5, a, 10.0], [23.5, b, 20.0]), BooleanOp::Union).unwrap().unwrap();
boolean(&joined, &at([9.5, 14.0, 7.0], [23.5, 19.2, 14.1]), BooleanOp::Subtract).unwrap().unwrap()
}
/// The 12 edges round each of the part's two end faces.
fn edges_871() -> Vec<[f64; 3]> {
let yz = [
(12.74437365538526, 15.0),
(10.37218682769263, 10.0),
(8.0, 5.0),
(18.75, 0.0),
(29.5, 5.0),
(26.09637981683468, 10.0),
(22.69275963366936, 15.0),
(17.71856664452731, 20.0),
(16.6, 7.0),
(14.0, 10.55),
(16.6, 14.100000000000001),
(19.200000000000003, 10.55),
];
[23.5, 9.5].iter().flat_map(|x| yz.iter().map(move |(y, z)| [*x, *y, *z])).collect()
}
/// #871's rounds at r 0.5 fit (the wall between the window and the block's side is 1.26 thick):
/// 38 faces, and the closed form `issue_871_tests::rounds_that_fit_are_closed` derives. At r 1 they
/// overlap, which is refused.
#[test]
fn rounds_on_the_871_part() {
let p = part_871();
let e = edges_871();
let sizes = |r: f64| vec![r; e.len()];
let part_volume = 14.0 * (21.5 * 10.0 + (22.69275963366936 - 12.74437365538526) * 10.0 - 5.2 * 7.1);
let exact = part_volume - 2.0 * (2.0 * (21.5 + 20.0) + 2.0 * (5.2 + 7.1)) * spandrel(0.5);
let fit = blend(&p, Blend::Round, &e, &sizes(0.5), TOL, &[], &[], Corners::Filled).unwrap();
let (v, n) = (volume(&fit, 1e-8).unwrap(), counts(&fit).unwrap());
let refused = blend(&p, Blend::Round, &e, &sizes(1.0), TOL, &[], &[], Corners::Filled).map(|_| ()).unwrap_err();
assert_eq!(refused, Error::NoRoom);
assert_eq!(n[0], 38);
assert!((v - exact).abs() < 1e-3, "{v} vs {exact}");
}
/// The fork's `tabbed_plate_rounds_mitred_on_the_arcs_continuation`: a plate 2 thick, its outline an
/// R25 arc to the sides x = +-4 of a tab whose top is 4 wide with R2 corners, every top edge of the
/// outline rounded r 0.5. The arc's round and the tab side's are mitred on the arc wall's
/// continuation. Removed: the outline's area less the area of the outline moved in by
/// d = r (1 - cos phi), at the height r sin phi into the round (midpoint rule, 8192 steps; the area
/// by Green's theorem).
#[test]
fn tabbed_plate_rounds_mitred_on_the_arcs_continuation() {
use super::sweep::extrude;
use crate::sweeps::{Curve, DraftCorner};
const Z: [f64; 3] = [0.0, 0.0, 1.0];
let theta = (4.0_f64 / 25.0).acos();
let y = 609.0_f64.sqrt();
let cy = 280.0 / 9.0;
let line = |a: [f64; 2], b: [f64; 2]| Curve::Line { start: [a[0], a[1], 0.0], end: [b[0], b[1], 0.0] };
let arc = |c: [f64; 2], radius, start, end| Curve::Arc { center: [c[0], c[1], 0.0], axis: Z, reference: [1.0, 0.0, 0.0], radius, start, end };
let outline = [
arc([0.0, 0.0], 25.0, PI - theta, 2.0 * PI + theta),
line([4.0, y], [4.0, cy]),
arc([2.0, cy], 2.0, 0.0, PI / 2.0),
line([2.0, cy + 2.0], [-2.0, cy + 2.0]),
arc([-2.0, cy], 2.0, PI / 2.0, PI),
line([-4.0, cy], [-4.0, y]),
];
let area = |da: f64, dt: f64| {
let (big, rho) = (25.0 - da, 2.0 - dt);
let tab = rho * rho * PI + 2.0 * rho * (2.0 + cy) + 4.0 * (cy + rho);
let yc = (big * big - (4.0 - dt) * (4.0 - dt)).sqrt();
let at = yc.atan2(4.0 - dt);
0.5 * (big * big * (PI + 2.0 * at) + tab) + (4.0 - dt) * (cy - yc)
};
let (r, dphi) = (0.5, (PI / 2.0) / 8192.0);
let want: f64 = (0..8192)
.map(|i| {
let phi = (f64::from(i) + 0.5) * dphi;
let d = r * (1.0 - phi.cos());
(area(0.0, 0.0) - area(d, d)) * r * phi.cos() * dphi
})
.sum();
let top = [
[0.0, -25.0, 2.0],
[4.0, 27.8945, 2.0],
[3.41421356, 32.5253247, 2.0],
[0.0, cy + 2.0, 2.0],
[-3.41421356, 32.5253247, 2.0],
[-4.0, 27.8945, 2.0],
];
let plate = extrude(&outline, Z, 2.0, 0.0, DraftCorner::default(), true).unwrap();
assert!(rel(volume(&plate, 1e-8).unwrap(), 2.0 * area(0.0, 0.0)) < 1e-10);
let round = blend(&plate, Blend::Round, &top, &[r; 6], TOL, &[], &[], Corners::Filled).unwrap();
let removed = volume(&plate, 1e-8).unwrap() - volume(&round, 1e-8).unwrap();
assert!(rel(removed, want) < 1e-6, "removed {removed}, expected {want}");
let (rec, _) = native(&to_arrays(&round).unwrap()).unwrap();
rec.validate().unwrap();
assert!(super::is_manifold_solid(&round).unwrap());
assert!(super::check(&round, crate::check::CheckLevel::All).unwrap().is_valid());
}
/// Face and edge tags (`Brep::tagged`) through blends: a 20 x 30 x 10 box tagged as operand 0,
/// with one blend on its top front edge (y = 0, z = 10), leaves the bottom and back as they were,
/// trims the top, front and both sides, and makes the blend face.
mod history {
use solidcraft_geom::brep::history::Change;
use crate::history::Raw;
use crate::{Blend, Brep};
fn one_edge(kind: Blend, size: f64) -> Raw {
let a = Brep::make_box([0.0; 3], [20.0, 30.0, 10.0]).unwrap().tagged(0).unwrap();
let edge = [[10.0, 0.0, 10.0]];
let core = super::blend(a.core(), kind, &edge, &[size], super::TOL, &[], &[], super::Corners::Filled).unwrap();
Brep::made_core(core).history().unwrap()
}
fn check(kind: Blend, size: f64) {
let h = one_edge(kind, size);
let mut changes: Vec<Change> = h.faces.iter().map(|f| f.change).collect();
changes.sort_by_key(|c| *c as u8);
let want = [Change::Kept, Change::Kept, Change::Modified, Change::Modified, Change::Modified, Change::Modified, Change::Generated];
assert_eq!(changes, want, "{kind:?}: {h:?}");
}
/// A 2 mm round on the top front edge of a box: the top and front are trimmed, the round is
/// new.
#[test]
fn tags_cross_a_fillet() {
check(Blend::Round, 2.0);
}
/// A 2 mm chamfer on the same edge: the same faces kept, trimmed and made as the round.
#[test]
fn tags_cross_a_chamfer() {
check(Blend::Chamfer, 2.0);
}
}
+243
View File
@@ -0,0 +1,243 @@
//! Booleans on solidcore: the C API's own logic round `SM_API`, ported from the fork's
//! `capi/src/smlib_ops.cpp` (`smlib_boolean_with`, `smlib_boolean2d`,
//! `smlib_boolean_union_all`, `finish`, `closed_nonmanifold`) and
//! `capi/src/smlib_imprint.cpp` (`smlib_imprint`).
//!
//! As there, every operation works on copies of its operands (`SmApiBrepCopy`): `SM_API` edits
//! the first operand in place and consumes the second, and a SolidCraft body never changes.
use std::sync::PoisonError;
use solidcore::Result as SolidResult;
use solidcore::SolidError;
use solidcore::boolean::api_brep::{self as sm, Boolean2dOutcome};
use solidcore::boolean::merge::MergeOptions;
use solidcore::geom::a_object::BooleanOperation;
use solidcore::kernel::Kernel;
use solidcore::primitive::primitive_creation::p1::Boolean2d;
use solidcore::topology::brep::p3::brep_copy;
use solidcore::topology::brep::{p1, p2};
use solidcore::topology::{Brep, edge};
use super::{Body, run};
use crate::{BooleanOp, Error, Result};
/// What an operation's `SM_API` call gave, before it is a SolidCraft result: the capi's
/// statuses besides success.
enum Done {
Body(Box<Brep>),
/// `SMLIB_ERR_EMPTY`.
Empty,
/// `SMLIB_ERR_NOT_SOLID`.
NotSolid,
/// `SMLIB_ERR_NO_CUT` (`smlib_imprint`).
NoCut,
}
impl super::dump::Answer for Done {
fn refusal(&self) -> Option<String> {
matches!(self, Done::NotSolid).then(|| Error::NotSolid.to_string())
}
}
/// `GetNumFaces`.
fn num_faces(b: &Brep) -> usize {
b.face_list().len()
}
/// `closed_nonmanifold` (smlib_ops.cpp): a closed body whose solids touch only along edges or at
/// vertices: at least two regions, no shell vertices, no wire edge, and four edgeuses (two faces,
/// both sides) per face round every edge. As there, an edge whose query fails (`e == nullptr`, a
/// failed `GetEdgeuses`) makes it false: [`edge_closes`].
fn closed_nonmanifold(r: &Brep) -> SolidResult<bool> {
if p1::regions(r, None)?.len() < 2 {
return Ok(false);
}
if !p2::shell_vertices(r, None)?.is_empty() {
return Ok(false);
}
for e in p1::edges(r, None)? {
if !edge_closes(edge::is_wire(r, e), edge::edgeuses(r, e, None, None).map(|u| u.len())) {
return Ok(false);
}
}
Ok(true)
}
/// The capi's test of one edge in `closed_nonmanifold`, given its `IsWire` and the count of its
/// `GetEdgeuses`: not a wire, and four edgeuses per face round it. Either query failing is
/// false, not an error: the capi returns false where `GetEdgeuses` fails.
pub(super) fn edge_closes(wire: SolidResult<bool>, uses: SolidResult<usize>) -> bool {
matches!(wire, Ok(false)) && matches!(uses, Ok(n) if n >= 4 && n % 4 == 0)
}
/// `finish` (smlib_ops.cpp): the result must have faces, and a solid input must give a closed
/// manifold solid or (`nonmanifold`) a closed one whose solids touch along an edge or at a vertex.
fn finish(r: Brep, solid_in: bool, nonmanifold: bool) -> SolidResult<Done> {
if num_faces(&r) == 0 {
return Ok(Done::Empty);
}
if solid_in && !p2::is_manifold_solid(&r)? && !(nonmanifold && closed_nonmanifold(&r)?) {
return Ok(Done::NotSolid);
}
Ok(Done::Body(Box::new(r)))
}
/// What a failing call's dump calls its first operands.
const OPERANDS: [&str; 4] = ["a", "b", "c", "d"];
/// Copies of `bodies`, in order (a body given twice is copied twice, as the capi copies each
/// operand), all made under the kernel's lock.
fn copies(k: &mut Kernel, bodies: &[&Body]) -> SolidResult<Vec<Brep>> {
// Under the kernel's lock (`run` holds it), as every body lock is: two never deadlock.
let mut out = Vec::with_capacity(bodies.len());
for (n, b) in bodies.iter().enumerate() {
let g = b.0.lock().unwrap_or_else(PoisonError::into_inner);
super::dump::input(k, OPERANDS.get(n).copied().unwrap_or("operand"), &g);
let c = brep_copy(k, &g)?;
drop(g);
out.push(c);
}
Ok(out)
}
fn result(d: Done, empty: Result<Option<Body>>) -> Result<Option<Body>> {
match d {
Done::Body(b) => Ok(Some(Body::new(*b))),
Done::Empty => empty,
Done::NotSolid => Err(Error::NotSolid),
Done::NoCut => Ok(None),
}
}
fn operation(op: BooleanOp) -> BooleanOperation {
match op {
BooleanOp::Union => BooleanOperation::Union,
BooleanOp::Subtract => BooleanOperation::Difference,
BooleanOp::Intersect => BooleanOperation::Intersection,
}
}
/// Whether every body is a manifold solid (`IsManifoldSolid` of each copy).
fn all_solid(bodies: &[Brep]) -> SolidResult<bool> {
for b in bodies {
if !p2::is_manifold_solid(b)? {
return Ok(false);
}
}
Ok(true)
}
/// Fails when a panic left one of `bodies` half-edited: it is never used again.
fn usable(call: &'static str, bodies: &[&Body]) -> Result<()> {
if bodies.iter().any(|b| b.0.is_poisoned()) {
return Err(Error::Internal(format!("{call}: the body was left unfinished by an earlier panic")));
}
Ok(())
}
/// `smlib_boolean` (`smlib_boolean_with` with `SMLIB_RESULT_NONMANIFOLD`): `SmApiBoolean` on
/// copies of `a` and `b`; `None` when the result is empty (`SMLIB_ERR_EMPTY`), and
/// [`Error::NotSolid`] when solid operands give a body that is not closed.
pub(crate) fn boolean(a: &Body, b: &Body, op: BooleanOp) -> Result<Option<Body>> {
usable("SmApiBoolean", &[a, b])?;
let d = run("SmApiBoolean", |k| {
let mut c = copies(k, &[a, b])?.into_iter();
let (Some(ca), Some(cb)) = (c.next(), c.next()) else {
return Err(SolidError::invalid_input("smlib_boolean", "no operand"));
};
let solid_in = p2::is_manifold_solid(&ca)? && p2::is_manifold_solid(&cb)?;
let r = sm::boolean(k, ca, cb, operation(op))?;
finish(r.result, solid_in, true)
})?;
result(d, Ok(None))
}
/// `smlib_boolean_union_all`: copies of `bodies` unioned pairwise, level by level (an odd one
/// carried to the next level as it is), each union `SmApiBoolean`'s; then `finish` with
/// non-manifold results allowed. An empty result is [`Error::Empty`].
pub(crate) fn union_all(bodies: &[&Body]) -> Result<Body> {
if bodies.is_empty() || bodies.len() > crate::MAX_UNION_BREPS {
return Err(Error::Argument);
}
usable("smlib_boolean_union_all", bodies)?;
let d = run("smlib_boolean_union_all", |k| {
let mut level = copies(k, bodies)?;
let solid_in = all_solid(&level)?;
while level.len() > 1 {
let mut next = Vec::with_capacity(level.len().div_ceil(2));
let mut it = level.into_iter();
while let Some(a) = it.next() {
match it.next() {
Some(b) => next.push(sm::boolean(k, a, b, BooleanOperation::Union)?.result),
None => next.push(a),
}
}
level = next;
}
match level.pop() {
Some(r) => finish(r, solid_in, true),
None => Ok(Done::Empty),
}
})?;
result(d, Err(Error::Empty))?.ok_or(Error::Empty)
}
/// `smlib_boolean2d`: `SmApiBoolean2d` (`SmPrimitiveCreation::Boolean2D`) on copies of `a` and
/// `b`, sheets of planar faces on one plane; `None` when either has no face (where
/// `SmApiBoolean2d` succeeds without a result) or the result is empty.
pub(crate) fn boolean2d(a: &Body, b: &Body, op: BooleanOp) -> Result<Option<Body>> {
usable("SmApiBoolean2d", &[a, b])?;
let d = run("SmApiBoolean2d", |k| {
let mut c = copies(k, &[a, b])?.into_iter();
let (Some(ca), Some(cb)) = (c.next(), c.next()) else {
return Err(SolidError::invalid_input("smlib_boolean2d", "no operand"));
};
if num_faces(&ca) == 0 || num_faces(&cb) == 0 {
return Ok(Done::Empty);
}
let kind = match op {
BooleanOp::Union => Boolean2d::Union,
BooleanOp::Subtract => Boolean2d::Difference,
BooleanOp::Intersect => Boolean2d::Intersection,
};
match sm::boolean_2d(k, ca, cb, kind)? {
Boolean2dOutcome::Result(r) => finish(r, false, false),
Boolean2dOutcome::Unassigned { .. } => Ok(Done::Empty),
}
})?;
result(d, Ok(None))
}
/// `smlib_imprint`: `SmApiBooleanWithOptions(SM_BO_IMPRINT)` on copies of `body` and `tool`,
/// imprinting and keeping the tool (cookie cutter off, imprinting on, post-process off,
/// imprint-and-classify off, keep other on). `None` when no face was cut
/// (`SMLIB_ERR_NO_CUT`); an empty result is [`Error::Empty`]; the same body twice is
/// [`Error::Argument`].
pub(crate) fn imprint(body: &Body, tool: &Body) -> Result<Option<Body>> {
if std::ptr::eq(body, tool) {
return Err(Error::Argument);
}
usable("SmApiBooleanWithOptions", &[body, tool])?;
let d = run("SmApiBooleanWithOptions", |k| {
let mut c = copies(k, &[body, tool])?.into_iter();
let (Some(a), Some(t)) = (c.next(), c.next()) else {
return Err(SolidError::invalid_input("smlib_imprint", "no operand"));
};
let before = num_faces(&a);
let options = MergeOptions::new(false, true, false, false, true);
let r = sm::boolean_with_options(k, a, t, BooleanOperation::Imprint, options)?.result;
let after = num_faces(&r);
Ok(if after == 0 {
Done::Empty
} else if after == before {
Done::NoCut
} else {
Done::Body(Box::new(r))
})
})?;
match d {
Done::Empty => Err(Error::Empty),
d => result(d, Err(Error::Empty)),
}
}
+496
View File
@@ -0,0 +1,496 @@
//! Booleans on solidcore, against analytic volumes: every result exports to a record that validates
//! and counts the solids it should.
//!
//! Curved operands are built on solidcore itself.
use std::collections::BTreeMap;
use std::f64::consts::PI;
use std::sync::mpsc;
use std::time::Duration;
use solidcore::geom::axis2_placement::Axis2Placement;
use solidcore::geom::vector3d::Vec3;
use solidcore::mass::integrator::properties::brep_area;
use solidcore::primitive::primitive_creation::p1::create_rectangle;
use super::boolean::{boolean, boolean2d, edge_closes, imprint, union_all};
use super::{Body, counts, make_box, make_cylinder, make_sphere, run, solid_count, to_arrays, volume, with_body};
use crate::export::native;
use crate::{BooleanOp, Error};
/// A case longer than this is a hang.
const TIMEOUT: Duration = Duration::from_secs(300);
/// An operand.
#[derive(Clone, Copy, Debug)]
enum Shape {
Box([f64; 3], [f64; 3]),
Cylinder([f64; 3], f64, f64),
Sphere([f64; 3], f64),
}
impl Shape {
fn core(self) -> Body {
match self {
Shape::Box(o, s) => make_box(o, s),
Shape::Cylinder(o, r, h) => make_cylinder(o, r, h),
Shape::Sphere(c, r) => make_sphere(c, r),
}
.unwrap()
}
}
/// What a case does.
#[derive(Clone, Debug)]
enum Op {
Boolean(Shape, Shape, BooleanOp),
UnionAll(Vec<Shape>),
Imprint(Shape, Shape),
}
/// A case: the operation, its analytic volume (`None`: the result is empty), the faces it
/// must have where they are known analytically, and its solids (`smlib_brep_solid_count`).
struct Case {
op: Op,
volume: Option<f64>,
faces: Option<usize>,
solids: usize,
}
const A: Shape = Shape::Box([0.0; 3], [10.0; 3]);
fn case(name: &str) -> Case {
let plain = |op, volume: Option<f64>, faces| Case { op, volume, faces, solids: 1 };
match name {
// |A| + |B| - |A ∩ B|: two 10 mm cubes overlapping in a 5 mm cube.
"union_overlapping" => plain(Op::Boolean(A, Shape::Box([5.0; 3], [10.0; 3]), BooleanOp::Union), Some(1000.0 + 1000.0 - 125.0), Some(12)),
// Two 10 mm cubes 10 mm apart: one body of two solids, 12 faces.
"union_disjoint" => {
Case { solids: 2, ..plain(Op::Boolean(A, Shape::Box([20.0, 0.0, 0.0], [10.0; 3]), BooleanOp::Union), Some(2000.0), Some(12)) }
}
"intersect_overlapping" => plain(Op::Boolean(A, Shape::Box([5.0; 3], [10.0; 3]), BooleanOp::Intersect), Some(125.0), Some(6)),
// A slot 5 mm wide and 5 mm deep from the top, right through a 30 x 20 x 10 block.
"subtract_through_slot" => plain(
Op::Boolean(Shape::Box([0.0; 3], [30.0, 20.0, 10.0]), Shape::Box([10.0, -1.0, 5.0], [5.0, 22.0, 6.0]), BooleanOp::Subtract),
Some(6000.0 - 5.0 * 20.0 * 5.0),
Some(10),
),
// A notch whose tool is flush with the block's front, back and top faces.
"subtract_flush_notch" => plain(
Op::Boolean(Shape::Box([0.0; 3], [30.0, 20.0, 10.0]), Shape::Box([10.0, 0.0, 5.0], [10.0, 20.0, 5.0]), BooleanOp::Subtract),
Some(6000.0 - 10.0 * 20.0 * 5.0),
Some(10),
),
// A 4 mm cube inside a 10 mm one: a void, still one solid.
"subtract_inside_void" => plain(Op::Boolean(A, Shape::Box([3.0; 3], [4.0; 3]), BooleanOp::Subtract), Some(1000.0 - 64.0), Some(12)),
// abc - πr²c: a 3 mm hole through a 20 x 20 x 10 block.
"subtract_cylinder_hole" => plain(
Op::Boolean(Shape::Box([0.0; 3], [20.0, 20.0, 10.0]), Shape::Cylinder([10.0, 10.0, -1.0], 3.0, 12.0), BooleanOp::Subtract),
Some(4000.0 - PI * 9.0 * 10.0),
None,
),
// Nine 10 mm cubes, each touching its neighbours face to face.
"union_all_grid" => plain(
Op::UnionAll((0..9).map(|i| Shape::Box([f64::from(i % 3) * 10.0, f64::from(i / 3) * 10.0, 0.0], [10.0; 3])).collect()),
Some(9000.0),
None,
),
"intersect_disjoint" => plain(Op::Boolean(A, Shape::Box([20.0; 3], [10.0; 3]), BooleanOp::Intersect), None, None),
// A 10 mm sphere less a cap of height h = 5: 4/3 πr³ - πh²(3r - h)/3.
// (solidcore#1431, brep_volume of a whole sphere failing at 1e-8, does not touch the cut
// sphere: it measures at 1e-8.)
"sphere_minus_box" => plain(
Op::Boolean(Shape::Sphere([0.0; 3], 10.0), Shape::Box([-20.0, -20.0, 5.0], [40.0, 40.0, 20.0]), BooleanOp::Subtract),
Some(4.0 / 3.0 * PI * 1000.0 - PI * 25.0 * 25.0 / 3.0),
Some(2),
),
// A 4 mm square tool through the cube's top face: that face is cut in two (a square and
// the ring round it), nothing else changes.
"imprint_top_face" => plain(Op::Imprint(A, Shape::Box([3.0, 3.0, 8.0], [4.0; 3])), Some(1000.0), Some(7)),
_ => panic!("{name}"),
}
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs()
}
/// What a kernel's result measures.
#[derive(Debug, PartialEq)]
struct Measured {
volume: f64,
counts: [usize; 3],
/// The record's faces by surface kind.
kinds: BTreeMap<String, usize>,
solids: usize,
}
fn kinds(rec: &solidcraft_geom::brep::Brep) -> BTreeMap<String, usize> {
let mut out = BTreeMap::new();
for f in &rec.faces {
let v = serde_json::to_value(&f.surface).unwrap();
*out.entry(v["kind"].as_str().unwrap_or("?").to_owned()).or_default() += 1;
}
out
}
/// The case on solidcore: `None` for an empty result.
fn on_core(c: &Case) -> crate::Result<Option<Body>> {
match &c.op {
Op::Boolean(a, b, op) => boolean(&a.core(), &b.core(), *op),
Op::UnionAll(shapes) => {
let bodies: Vec<Body> = shapes.iter().map(|s| s.core()).collect();
union_all(&bodies.iter().collect::<Vec<_>>()).map(Some)
}
Op::Imprint(a, t) => imprint(&a.core(), &t.core()),
}
}
/// Runs `f` on its own thread: a hang fails the test instead of stopping the run.
fn timed<T: Send + 'static>(name: &str, f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = mpsc::channel();
let worker = std::thread::spawn(move || {
let _ = tx.send(f());
});
match rx.recv_timeout(TIMEOUT) {
Ok(v) => v,
// The case panicked: its own message is the failure.
Err(mpsc::RecvTimeoutError::Disconnected) => match worker.join() {
Err(p) => std::panic::resume_unwind(p),
Ok(()) => panic!("{name}: no result"),
},
Err(mpsc::RecvTimeoutError::Timeout) => panic!("{name}: solidcore hung (no result after {TIMEOUT:?})"),
}
}
/// Runs the case on solidcore and checks it against the analytic answer; what it measured.
fn solidcore_case(name: &'static str) -> Option<Measured> {
timed(name, move || {
let c = case(name);
let r = on_core(&c).unwrap_or_else(|e| panic!("{name}: solidcore failed: {e}"));
let (Some(body), Some(expected)) = (&r, c.volume) else {
assert!(r.is_none() && c.volume.is_none(), "{name}: solidcore gave {}, expected {:?}", r.is_some(), c.volume);
return None;
};
let v = volume(body, 1e-8).unwrap_or_else(|e| panic!("{name}: volume: {e}"));
assert!(rel(v, expected) < 1e-9, "{name}: solidcore volume {v} vs analytic {expected}");
let n = counts(body).unwrap();
if let Some(f) = c.faces {
assert_eq!(n[0], f, "{name}: faces {n:?}");
}
let solids = solid_count(body).unwrap_or_else(|e| panic!("{name}: solid count: {e}"));
assert_eq!(solids, c.solids, "{name}: solids");
let (rec, _) = native(&to_arrays(body).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
Some(Measured { volume: v, counts: n, kinds: kinds(&rec), solids })
})
}
macro_rules! cases {
($($m:ident $(: $ign:meta)?),* $(,)?) => {$(
mod $m {
#[test]
$(#[$ign])?
fn solidcore_matches_the_analytic_volume() {
super::solidcore_case(stringify!($m));
}
}
)*};
}
cases!(
union_overlapping,
union_disjoint,
intersect_overlapping,
subtract_through_slot,
subtract_flush_notch,
subtract_inside_void,
subtract_cylinder_hole,
union_all_grid,
intersect_disjoint,
sphere_minus_box,
imprint_top_face,
);
/// A `w` x `h` planar sheet on z = 0 from `(x, y)`, solidcore's `CreateRectangle`.
fn rectangle(x: f64, y: f64, w: f64, h: f64) -> Body {
let frame = Axis2Placement::new(Vec3::new(x, y, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0));
run("create_rectangle", |k| create_rectangle(k, 0.0, w, h, &frame)).map(Body::new).unwrap()
}
fn area(b: &Body) -> f64 {
with_body("brep_area", b, |k, b| brep_area(k, b, 1e-8)).unwrap()
}
/// Two 10 mm squares overlapping in a 5 mm one, as regions of their plane.
#[test]
fn boolean2d_combines_overlapping_squares() {
for (op, expected) in [(BooleanOp::Union, 175.0), (BooleanOp::Intersect, 25.0), (BooleanOp::Subtract, 75.0)] {
let (a, b) = (rectangle(0.0, 0.0, 10.0, 10.0), rectangle(5.0, 5.0, 10.0, 10.0));
let r = timed("boolean2d", move || boolean2d(&a, &b, op).map(|r| r.map(|r| (area(&r), counts(&r).unwrap()))));
let (got, n) = r.unwrap_or_else(|e| panic!("{op:?}: {e}")).unwrap_or_else(|| panic!("{op:?}: empty"));
assert!(rel(got, expected) < 1e-9, "{op:?}: area {got} vs {expected}");
assert_eq!(n[0], 1, "{op:?}: {n:?}");
}
let (a, far) = (rectangle(0.0, 0.0, 10.0, 10.0), rectangle(20.0, 0.0, 10.0, 10.0));
assert!(boolean2d(&a, &far, BooleanOp::Intersect).unwrap().is_none(), "disjoint squares intersected");
}
/// `closed_nonmanifold`'s test of an edge, as the capi's: an edge whose wire or edgeuse query
/// fails makes the body not closed non-manifold (false), never an error that fails the boolean.
#[test]
fn closed_nonmanifold_takes_a_failed_edge_query_as_false() {
let failed = || solidcore::SolidError::null_pointer("GetEdgeuses");
assert!(edge_closes(Ok(false), Ok(4)));
assert!(edge_closes(Ok(false), Ok(8)));
assert!(!edge_closes(Ok(false), Ok(2)));
assert!(!edge_closes(Ok(false), Ok(6)));
assert!(!edge_closes(Ok(true), Ok(4)));
assert!(!edge_closes(Ok(false), Err(failed())));
assert!(!edge_closes(Err(failed()), Ok(4)));
}
/// Two 10 mm cubes touching along one edge, joined: a closed body whose two solids meet along
/// that edge (four edgeuses round it), 2000 mm³.
#[test]
fn cubes_touching_along_an_edge_join_closed_nonmanifold() {
let r = timed("edge_touching", || {
let a = Shape::Box([0.0; 3], [10.0; 3]).core();
let b = Shape::Box([10.0, 10.0, 0.0], [10.0; 3]).core();
boolean(&a, &b, BooleanOp::Union).map(|r| r.map(|r| (volume(&r, 1e-8).unwrap(), solid_count(&r).unwrap())))
});
let (v, solids) = r.unwrap().unwrap();
assert!(rel(v, 2000.0) < 1e-9, "volume {v}");
assert_eq!(solids, 2);
}
/// The capi's argument checks: the same body imprinted on itself, an empty union.
#[test]
fn hostile_operands_are_refused() {
let a = A.core();
assert_eq!(imprint(&a, &a).err(), Some(Error::Argument));
assert_eq!(union_all(&[]).err(), Some(Error::Argument));
}
/// A tool that meets no face cuts none.
#[test]
fn imprint_of_a_far_tool_is_none() {
let r = timed("imprint_far", || imprint(&A.core(), &Shape::Box([20.0; 3], [1.0; 3]).core()).map(|r| r.is_none()));
assert!(r.unwrap());
}
/// An operand is never changed: the same body twice, and again after.
#[test]
fn operands_stay_as_they_were() {
let r = timed("operands", || {
let a = A.core();
let b = Shape::Box([5.0; 3], [10.0; 3]).core();
let u = boolean(&a, &b, BooleanOp::Union).map(|r| r.is_some());
let same = boolean(&a, &a, BooleanOp::Union).map(|r| r.map(|r| volume(&r, 1e-8)));
(u, same, counts(&a).unwrap(), volume(&a, 1e-8).unwrap(), counts(&b).unwrap())
});
let (u, same, ca, va, cb) = r;
assert!(u.unwrap());
assert_eq!(ca, [6, 12, 8]);
assert_eq!(cb, [6, 12, 8]);
assert!(rel(va, 1000.0) < 1e-12, "{va}");
let v = same.unwrap().unwrap().unwrap();
assert!(rel(v, 1000.0) < 1e-9, "A ∪ A: {v}");
}
/// `moved`: a closest-point query that fails, even with an error that is no SMLib status (an edge
/// without its curve here), counts as off the piece, as `SmApiEdgeClosestPoint` failing does in the
/// capi (smlib_history.cpp:246): the face is Modified, the operation stands.
#[test]
fn a_failed_closest_point_counts_as_off_the_piece() {
use solidcore::query::api_queries::edge_closest_point;
use solidcore::topology::edge;
let body = A.core();
super::with_body("moved", &body, |k, b| {
let e = b.edge_list()[0];
let (Some(s), Some(f)) = edge::start_end_vertices(b, e)? else { panic!("an edge with no ends") };
let (s, f) = (b.vertex(s)?.point, b.vertex(f)?.point);
let at = [s, f, s + (f - s) * 0.5];
let tol = b.tolerance();
assert!(!super::history::moved(k, b, &[(e, &at)], tol)?, "still where it lay");
b.edge_mut(e)?.curve = None;
assert!(edge_closest_point(k, b, e, at[2]).is_err_and(|e| e.sm_status().is_none()), "fails with no status");
assert!(super::history::moved(k, b, &[(e, &at)], tol)?, "off the piece");
Ok(())
})
.unwrap();
}
/// Rig case booleans/2928, reduced (fork c2e3d9d): a ring, r 3..5 about (0, 10), z 0..2, unioned
/// with a half annulus prism, r 4..5 about (e, 10), z 2..20, whose cap sits on the ring's top. Two
/// equal circles a little apart cross; they do not coincide over a tiny edge. The stack's volume
/// is 113 pi whatever e is.
/// This path passes with `simple_coincidence_crossing_is_point` off too, so the test holds the
/// result here and does not single out that flag (solidcore's own test does).
#[test]
fn a_ring_and_a_lip_with_walls_nearly_together() {
use super::sweep::extrude;
use crate::sweeps::Curve;
use crate::sweeps::DraftCorner;
const Z: [f64; 3] = [0.0, 0.0, 1.0];
let up = |p: &[Curve], d| extrude(p, Z, d, 0.0, DraftCorner::default(), true).unwrap();
let arc = |c: [f64; 3], radius, start, end| Curve::Arc { center: c, axis: Z, reference: [1.0, 0.0, 0.0], radius, start, end };
let disc = |r| up(&[arc([0.0, 10.0, 0.0], r, 0.0, 2.0 * PI)], 2.0);
let ring = boolean(&disc(5.0), &disc(3.0), BooleanOp::Subtract).unwrap().unwrap();
for e in [0.0, 1e-4, 1e-3, 1e-2] {
let c = [e, 10.0, 2.0];
let lip = up(
&[
arc(c, 5.0, 0.0, PI),
Curve::Line { start: [e - 5.0, 10.0, 2.0], end: [e - 4.0, 10.0, 2.0] },
Curve::Arc { center: c, axis: [0.0, 0.0, -1.0], reference: [-1.0, 0.0, 0.0], radius: 4.0, start: 0.0, end: PI },
Curve::Line { start: [e + 4.0, 10.0, 2.0], end: [e + 5.0, 10.0, 2.0] },
],
18.0,
);
let u = boolean(&ring, &lip, BooleanOp::Union).unwrap().unwrap();
let v = volume(&u, 1e-8).unwrap();
let report = super::check(&u, crate::check::CheckLevel::All).unwrap();
assert!(report.is_valid(), "e {e}: {report}");
assert_eq!(solid_count(&u).unwrap(), 1, "e {e}");
assert!(super::is_manifold_solid(&u).unwrap(), "e {e}");
assert!((v - 113.0 * PI).abs() < 1e-3, "e {e}: {v}");
}
}
/// Face and edge tags (`Brep::tagged`) through booleans: each operand tagged, the result's history
/// ([`Raw`]) read back.
mod history {
use solidcraft_geom::brep::history::Change;
use crate::history::Raw;
use crate::{BooleanOp, Brep};
fn tagged_box(origin: [f64; 3], size: [f64; 3], operand: usize) -> Brep {
Brep::make_box(origin, size).unwrap().tagged(operand).unwrap()
}
fn tagged_cylinder(origin: [f64; 3], radius: f64, height: f64, operand: usize) -> Brep {
Brep::make_cylinder(origin, radius, height).unwrap().tagged(operand).unwrap()
}
/// A 40 x 40 x 10 plate with a round hole r 5 at (20, 20), made before tagging: its hole's rims
/// are whole circles, edges closed on themselves.
fn holed_plate(operand: usize) -> Brep {
let plate = Brep::make_box([0.0; 3], [40.0, 40.0, 10.0]).unwrap();
let rod = Brep::make_cylinder([20.0, 20.0, -5.0], 5.0, 20.0).unwrap();
plate.boolean(&rod, BooleanOp::Subtract).unwrap().unwrap().tagged(operand).unwrap()
}
fn boolean(a: &Brep, b: &Brep, op: BooleanOp) -> Raw {
a.boolean(b, op).unwrap().unwrap().history().unwrap()
}
fn imprint(a: &Brep, b: &Brep) -> Raw {
a.imprint(b).unwrap().unwrap().history().unwrap()
}
/// Faces of operand 0 with this change.
fn of_first(h: &Raw, c: Change) -> usize {
h.faces.iter().filter(|f| !f.origins.is_empty() && f.origins.iter().all(|o| o.0 == 0) && f.change == c).count()
}
/// Two 10 mm cubes overlapping in a 5 mm one (the capi's `overlapping_boxes_union`): 12
/// faces, each from one face of one box, six from each.
#[test]
fn tags_cross_a_boolean() {
let h = boolean(&tagged_box([0.0; 3], [10.0; 3], 0), &tagged_box([5.0; 3], [10.0; 3], 1), BooleanOp::Union);
assert_eq!(h.faces.len(), 12, "{h:?}");
assert!(h.faces.iter().all(|f| f.origins.len() == 1), "a face without exactly one origin: {h:?}");
for operand in [0, 1] {
assert_eq!(h.faces.iter().filter(|f| f.origins[0].0 == operand).count(), 6, "operand {operand}: {h:?}");
}
}
/// `SmApiBrepCopy` (the copy every solidcore operation starts from) keeps every tag: the
/// copy's history is the operand's own, every face Kept.
#[test]
fn tags_cross_a_copy() {
let a = tagged_box([0.0; 3], [10.0, 20.0, 30.0], 0);
let h = a.try_clone().unwrap().history().unwrap();
assert_eq!(h.faces.len(), 6, "{h:?}");
assert!(h.faces.iter().all(|f| f.change == Change::Kept), "{h:?}");
}
/// A 10 mm wide bar laid across the top of a 40 x 30 x 20 block, overhanging its front and
/// back, imprinted on it: the bar's sides cut the top in three, and cut the top edges of the
/// faces y = 0 and y = 30 only by a vertex each (where they cross them at x = 10 and x = 20).
/// Those faces are still all of themselves, their boundaries where they were: Kept, as are
/// x = 0, x = 40 and the bottom.
#[test]
fn a_face_whose_edge_is_only_cut_by_a_vertex_is_kept() {
let h = imprint(&tagged_box([0.0; 3], [40.0, 30.0, 20.0], 0), &tagged_box([10.0, -5.0, 20.0], [10.0, 40.0, 10.0], 1));
assert_eq!(of_first(&h, Change::Kept), 5, "{h:?}");
assert_eq!(of_first(&h, Change::Modified), 0, "{h:?}");
}
/// A 10 mm post standing on a 20 mm cube's top against its x = 0 side, imprinted on it: the
/// post's foot cuts the top's edge along x = 0 by two vertices. Each piece of that edge is a
/// split child, standing for the edge and lying where it lay (the capi's Split gives an untagged
/// child the split edge's `at`), so the side x = 0 is still all of itself and Kept.
#[test]
fn an_imprint_split_edge_lies_where_the_edge_lay() {
let h = imprint(&tagged_box([0.0; 3], [20.0; 3], 0), &tagged_box([0.0, 5.0, 20.0], [10.0; 3], 1));
// The top's edge along x = 0 is in three pieces, each standing for it alone.
let pieces = |e: usize| h.edges.iter().filter(|x| x.origins == [(0, e)]).count();
assert!((0..12).any(|e| pieces(e) == 3), "{h:?}");
assert_eq!(of_first(&h, Change::Kept), 5, "{h:?}");
assert_eq!(of_first(&h, Change::Modified), 0, "{h:?}");
assert_eq!(of_first(&h, Change::Split), 2, "{h:?}");
}
/// A 10 mm post standing on a 20 mm cube's top, subtracted from it: the post only touches the
/// top, which the boolean copies the post's foot onto (`SmAttribute::Copy`). SMLib's Copy
/// leaves a target that already has a tag alone, so the top stands for the cube's top only.
#[test]
fn a_copy_onto_a_tagged_face_keeps_its_own_tag() {
let h = boolean(&tagged_box([0.0; 3], [20.0; 3], 0), &tagged_box([5.0, 5.0, 20.0], [10.0; 3], 1), BooleanOp::Subtract);
assert_eq!(h.faces.len(), 6, "{h:?}");
assert!(h.faces.iter().all(|f| f.change == Change::Kept && f.origins.len() == 1 && f.origins[0].0 == 0), "{h:?}");
}
/// A corner cut far from the hole: the hole's wall keeps its two whole circles, each a closed
/// edge still where it lay (`moved`'s `open.is_empty()`), so it is Kept, with the two sides the
/// corner misses.
#[test]
fn a_face_bounded_by_closed_edges_is_kept() {
let h = boolean(&holed_plate(0), &tagged_box([35.0, 35.0, -5.0], [10.0, 10.0, 20.0], 1), BooleanOp::Subtract);
assert_eq!(of_first(&h, Change::Kept), 3, "{h:?}");
}
/// A cylinder and a sphere cut by a slab across their seams (angle 0, along +x): each curved
/// face is split in two across its seam.
#[test]
fn a_curved_face_split_across_its_seam_is_split() {
let slab = || tagged_box([-20.0, -1.0, -20.0], [40.0, 2.0, 40.0], 1);
let sphere = Brep::make_sphere([0.0; 3], 7.0).unwrap().tagged(0).unwrap();
for (name, a) in [("cylinder", tagged_cylinder([0.0, 0.0, -5.0], 5.0, 10.0, 0)), ("sphere", sphere)] {
let h = boolean(&a, &slab(), BooleanOp::Subtract);
assert!(of_first(&h, Change::Split) >= 2, "{name}: {h:?}");
}
}
/// A chain of three overlapping boxes in one `union_all`: the result names all three.
#[test]
fn a_union_of_three_names_each() {
let (a, b, c) = (tagged_box([0.0; 3], [10.0; 3], 0), tagged_box([5.0; 3], [10.0; 3], 1), tagged_box([10.0; 3], [10.0; 3], 2));
let h = Brep::union_all(&[&a, &b, &c]).unwrap().history().unwrap();
assert_eq!(h.faces.iter().flat_map(|f| f.origins.iter().map(|o| o.0)).max(), Some(2), "{h:?}");
}
/// A row of four boxes sharing their top and bottom planes, united in pairs and the pairs
/// united: each top merged with its neighbour's stands for two faces, and the merge of those
/// stands for all four.
#[test]
fn a_merge_of_several_origins_on_each_side_stands_for_all() {
let boxes: Vec<Brep> = (0..4u32).map(|i| tagged_box([f64::from(i) * 10.0, 0.0, 0.0], [10.0; 3], i as usize)).collect();
let u = |x: &Brep, y: &Brep| x.boolean(y, BooleanOp::Union).unwrap().unwrap();
let h = u(&u(&boxes[0], &boxes[1]), &u(&boxes[2], &boxes[3])).history().unwrap();
assert!(h.faces.iter().any(|f| f.origins.len() == 4), "{h:?}");
}
}
File diff suppressed because it is too large Load Diff
+284
View File
@@ -0,0 +1,284 @@
//! solidcore's primitives through the flat arrays: each exports to a valid record of the right
//! volume, and the record comes back to an equal body. A body with a sealed void crosses with each region's enclosing shell first,
//! whatever order its arrays list the shells in (the capi's `PutEnclosingShellsFirst`).
use std::f64::consts::PI;
use solidcraft_geom::brep as b;
use super::boolean::boolean;
use super::{Body, from_arrays, make_box, make_cone, make_cylinder, make_sphere, make_torus, to_arrays, volume};
use crate::BooleanOp;
use crate::export::native;
use crate::import::{self, Arrays};
/// A primitive: solidcore's body and its analytic volume.
struct Case {
body: Body,
volume: f64,
}
fn case(name: &str) -> Case {
let (body, volume) = match name {
"box" => (make_box([1.0, -2.0, 3.0], [10.0, 20.0, 30.0]), 6000.0),
"cylinder" => (make_cylinder([1.0, 2.0, -3.0], 5.0, 12.0), PI * 25.0 * 12.0),
"sphere" => (make_sphere([1.0, 2.0, 3.0], 7.0), 4.0 / 3.0 * PI * 343.0),
"cone" => (make_cone([0.0, 1.0, 2.0], 6.0, 0.0, 9.0), PI * 36.0 * 9.0 / 3.0),
"frustum" => (make_cone([0.0, 1.0, 2.0], 6.0, 2.0, 9.0), PI * 9.0 / 3.0 * (36.0 + 12.0 + 4.0)),
"torus" => (make_torus([1.0, -1.0, 0.5], 10.0, 3.0), 2.0 * PI * PI * 10.0 * 9.0),
_ => panic!("{name}"),
};
Case { body: body.unwrap(), volume }
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs()
}
/// The record of solidcore's arrays, validated and its geometry checked.
fn record(a: &Arrays) -> b::Brep {
let (rec, _) = native(a).unwrap();
rec.validate().unwrap();
rec.check_geometry().unwrap();
rec
}
/// Every number in `a` within `tol` (relative past 1) of `b`'s, everything else equal.
fn close(a: &serde_json::Value, b: &serde_json::Value, tol: f64, at: &str) {
use serde_json::Value as V;
match (a, b) {
(V::Number(x), V::Number(y)) => {
let (x, y) = (x.as_f64().unwrap(), y.as_f64().unwrap());
assert!((x - y).abs() <= tol * x.abs().max(1.0), "{at}: {x} vs {y}");
}
(V::Array(x), V::Array(y)) => {
assert_eq!(x.len(), y.len(), "{at}: length");
for (i, (x, y)) in x.iter().zip(y).enumerate() {
close(x, y, tol, &format!("{at}[{i}]"));
}
}
(V::Object(x), V::Object(y)) => {
assert_eq!(x.keys().collect::<Vec<_>>(), y.keys().collect::<Vec<_>>(), "{at}: keys");
for (k, x) in x {
close(x, &y[k], tol, &format!("{at}.{k}"));
}
}
_ => assert_eq!(a, b, "{at}"),
}
}
/// to_arrays gives a record that validates and whose geometry checks.
fn exports(name: &str) {
record(&to_arrays(&case(name).body).unwrap());
}
/// solidcore's body measures its analytic volume.
fn measures(name: &str) {
let c = case(name);
let v = volume(&c.body, 1e-8).unwrap();
assert!(rel(v, c.volume) < 1e-9, "{name}: solidcore volume {v} vs {}", c.volume);
}
/// record -> arrays -> from_arrays -> to_arrays -> record is the record, the volume exact.
fn round_trips(name: &str) {
let c = case(name);
let rec = record(&to_arrays(&c.body).unwrap());
let back = from_arrays(&import::arrays(&rec, true).unwrap()).unwrap();
let again = record(&to_arrays(&back).unwrap());
close(&serde_json::to_value(&rec).unwrap(), &serde_json::to_value(&again).unwrap(), 1e-12, name);
let v = volume(&back, 1e-8).unwrap();
assert!(rel(v, c.volume) < 1e-9, "{name}: {v} vs {}", c.volume);
}
macro_rules! primitive {
($m:ident, $name:literal $(, volume: $vol:meta)? $(, round_trip: $rt:meta)?) => {
mod $m {
#[test]
fn exports_a_valid_record() {
super::exports($name);
}
#[test]
$(#[$vol])?
fn measures_its_volume() {
super::measures($name);
}
#[test]
$(#[$rt])?
fn round_trips_through_the_record() {
super::round_trips($name);
}
}
};
}
primitive!(box_, "box");
primitive!(cylinder, "cylinder");
primitive!(sphere, "sphere");
primitive!(cone, "cone");
primitive!(frustum, "frustum");
primitive!(torus, "torus");
#[test]
fn hostile_arrays_are_refused() {
let c = case("box");
let good = to_arrays(&c.body).unwrap();
assert!(from_arrays(&Arrays::default()).is_err());
for id in good.ints.keys().copied().collect::<Vec<_>>() {
let mut a = good.clone();
if let Some(v) = a.ints.get_mut(&id) {
v.push(7);
}
let _ = from_arrays(&a);
let mut a = good.clone();
if let Some(v) = a.ints.get_mut(&id).and_then(|v| v.first_mut()) {
*v = i64::MAX;
}
let _ = from_arrays(&a);
}
for id in good.reals.keys().copied().collect::<Vec<_>>() {
let mut a = good.clone();
if let Some(v) = a.reals.get_mut(&id).and_then(|v| v.first_mut()) {
*v = f64::NAN;
assert!(from_arrays(&a).is_err(), "NaN in {id}");
}
}
}
/// `GetOrCreateGwNurbPointer` on a parabola: the one with its NURB gives it; one built over an
/// interval too small for two end tangents to cross has none and makes none, an error naming
/// `MakeNurb`.
#[test]
fn a_parabola_gives_its_nurb_or_says_it_cannot() {
use solidcore::curve::curve::Curve;
use solidcore::curve::parabola::Parabola;
use solidcore::geom::extent1d::Extent1d;
use solidcore::geom::vector3d::{Point3, Vec3};
let (x, y) = (Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0));
let part = |lo: f64, hi: f64| -> crate::Result<bool> {
let c = Curve::from(Parabola::new(Point3::new(0.0, 0.0, 0.0), x, y, Extent1d::from_min_max(lo, hi), 1.0, 3));
let k = solidcore::kernel::Kernel::default();
Ok(super::convert::bspline_curve_part(&k, &c)?.is_some_and(|b| b.gw_nurb().is_some()))
};
assert_eq!(part(-1.0, 1.0), Ok(true));
let none = part(0.5, 0.5);
assert!(matches!(&none, Err(e) if e.to_string().contains("MakeNurb")), "{none:?}");
}
/// Each edge's and vertex's tolerance crosses the kernels as it is. One box edge given eight times
/// the box's tolerance, in arrays whose TOLERANCE is the largest (as SMLib's export wrote them),
/// keeps it round after round, and the B-rep and every other edge and vertex keep the box's. Every
/// crossing used to give every entity the largest, so a chain of blends on a mixed route grew the
/// whole body's tolerance fourfold per blend (fillets/2341).
#[test]
fn tolerances_cross_the_arrays_per_entity() {
use crate::flat::{EDGE_TOLERANCE, TOLERANCE, VERTEX_TOLERANCE};
let mut a = to_arrays(&case("box").body).unwrap();
let base = a.reals[&TOLERANCE][0];
assert!(a.reals[&EDGE_TOLERANCE].iter().chain(&a.reals[&VERTEX_TOLERANCE]).all(|&t| t == base), "{a:?}");
a.reals.get_mut(&EDGE_TOLERANCE).unwrap()[0] = 8.0 * base;
a.reals.get_mut(&TOLERANCE).unwrap()[0] = 8.0 * base;
for round in 0..3 {
let body = from_arrays(&a).unwrap();
assert_eq!(body.0.lock().unwrap().tolerance(), base / 2.0, "round {round}");
a = to_arrays(&body).unwrap();
assert_eq!(a.reals[&TOLERANCE], [base], "round {round}");
let edges = &a.reals[&EDGE_TOLERANCE];
assert_eq!(edges[0], 8.0 * base, "round {round}");
assert!(edges[1..].iter().chain(&a.reals[&VERTEX_TOLERANCE]).all(|&t| t == base), "round {round}: {a:?}");
}
}
/// A 20 mm box minus a centred 10 mm box: a sealed void, so its solid region has two shells.
fn sealed_void() -> Body {
boolean(&make_box([-10.0; 3], [20.0; 3]).unwrap(), &make_box([-5.0; 3], [10.0; 3]).unwrap(), BooleanOp::Subtract).unwrap().unwrap()
}
/// 20³ − 10³.
const SEALED_VOID_VOLUME: f64 = 8000.0 - 1000.0;
/// Each bounded region's shells, as each shell's reach: the largest |coordinate| of its vertices
/// (10 for the 20 mm box, 5 for the 10 mm one).
fn reaches(rec: &b::Brep) -> Vec<Vec<f64>> {
let reach = |s: u32| {
let Some(b::Shell::Faces { faces, .. }) = rec.shells.get(s as usize) else { panic!("shell {s}") };
let mut r = 0.0f64;
for f in faces {
for l in &rec.faces[f.face as usize].loops {
let b::Loop::Edges { edges } = l else { continue };
for e in edges {
for &v in &rec.edges[e.edge as usize].vertices {
r = rec.vertices[v as usize].point.iter().fold(r, |r, c| r.max(c.abs()));
}
}
}
}
r
};
rec.regions.iter().skip(1).map(|r| r.shells.iter().map(|&s| reach(s)).collect()).collect()
}
/// The sealed void's record: the solid region lists the outer box first, then the void's box;
/// the void region its box alone.
fn outer_first(a: &Arrays, at: &str) {
let mut got = reaches(&record(a));
got.sort_by_key(|x| std::cmp::Reverse(x.len()));
assert_eq!(got, [vec![10.0, 5.0], vec![5.0]], "{at}");
}
/// The arrays with every region's shells listed in reverse: each shell's block (its counts and
/// its face uses) moved, nothing else touched.
fn shells_reversed(a: &Arrays) -> Arrays {
use crate::flat::{FACEUSE_FACE, FACEUSE_ORIENTATION, REGION_SHELL_COUNT, SHELL_FACEUSE_COUNT, SHELL_POINT_TYPE, SHELL_WIRE_EDGE_COUNT};
let mut p = a.clone();
let blocks = [SHELL_FACEUSE_COUNT, SHELL_WIRE_EDGE_COUNT, SHELL_POINT_TYPE, FACEUSE_FACE, FACEUSE_ORIENTATION];
for id in blocks {
p.ints.get_mut(&id).unwrap().clear();
}
let (mut shell, mut fu) = (0usize, 0usize);
for &n in &a.ints[&REGION_SHELL_COUNT] {
let mut region = Vec::new();
for _ in 0..n {
let uses = usize::try_from(a.ints[&SHELL_FACEUSE_COUNT][shell]).unwrap();
region.push((shell, fu..fu + uses));
shell += 1;
fu += uses;
}
for (s, uses) in region.into_iter().rev() {
for id in [SHELL_FACEUSE_COUNT, SHELL_WIRE_EDGE_COUNT, SHELL_POINT_TYPE] {
let v = a.ints[&id][s];
p.ints.get_mut(&id).unwrap().push(v);
}
for id in [FACEUSE_FACE, FACEUSE_ORIENTATION] {
p.ints.get_mut(&id).unwrap().extend_from_slice(&a.ints[&id][uses.clone()]);
}
}
}
assert_ne!(p.ints, a.ints, "nothing to reverse");
p
}
/// solidcore's sealed void exports with its outer shell first, comes back through the arrays
/// with it first, and keeps its volume.
#[test]
fn a_sealed_void_crosses_the_arrays_outer_shell_first() {
let body = sealed_void();
let v = volume(&body, 1e-9).unwrap();
assert!(rel(v, SEALED_VOID_VOLUME) < 1e-9, "{v}");
let a = to_arrays(&body).unwrap();
outer_first(&a, "export");
let back = from_arrays(&import::arrays(&record(&a), true).unwrap()).unwrap();
outer_first(&to_arrays(&back).unwrap(), "round trip");
let v = volume(&back, 1e-9).unwrap();
assert!(rel(v, SEALED_VOID_VOLUME) < 1e-9, "round trip: {v}");
}
/// Arrays listing the solid region's void shell before the box around it import with the box
/// first (SMLib takes a region's first shell as its enclosing one), the volume unchanged.
#[test]
fn shells_listed_inner_first_import_outer_first() {
let a = shells_reversed(&to_arrays(&sealed_void()).unwrap());
let back = from_arrays(&a).unwrap();
outer_first(&to_arrays(&back).unwrap(), "inner first");
let v = volume(&back, 1e-9).unwrap();
assert!(rel(v, SEALED_VOID_VOLUME) < 1e-9, "inner first: {v}");
}
+132
View File
@@ -0,0 +1,132 @@
//! What a failing solidcore call leaves behind, and which one is running: `smlib_set_dump_dir` and
//! `smlib_current_call` for this kernel. While a folder is set, each call's B-rep inputs are
//! written to memory before it runs (it may edit them) and to files only if it fails:
//! `<n>-<call>.txt`, what failed, and `<n>-<call>-<input>.smb`, each input as a binary `.smb`, which
//! `Brep::read` reads back.
use std::path::PathBuf;
use std::sync::atomic::{AtomicU8, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Mutex, PoisonError};
use solidcore::kernel::Kernel;
use solidcore::topology::Brep;
use crate::{Error, Result};
static DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
/// The inputs of the call running now, in the order it took them.
static INPUTS: Mutex<Vec<(&'static str, std::result::Result<Vec<u8>, String>)>> = Mutex::new(Vec::new());
/// Where failing calls leave their inputs; `None` stops it.
pub(crate) fn set_dir(dir: Option<PathBuf>) {
*DIR.lock().unwrap_or_else(PoisonError::into_inner) = dir;
}
fn dumping() -> bool {
DIR.lock().unwrap_or_else(PoisonError::into_inner).is_some()
}
/// A new call, with no inputs yet.
pub(super) fn begin() {
if dumping() {
INPUTS.lock().unwrap_or_else(PoisonError::into_inner).clear();
}
}
/// `b` is an input of the running call, by the name `what`.
pub(crate) fn input(k: &Kernel, what: &'static str, b: &Brep) {
if dumping() {
let bytes = super::file::bytes(k, b, false).map_err(|e| e.to_string());
INPUTS.lock().unwrap_or_else(PoisonError::into_inner).push((what, bytes));
}
}
fn safe(call: &str) -> String {
call.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }).collect()
}
/// The running call failed: its inputs go to the folder. Failing to write them fails nothing else.
pub(super) fn failed(call: &str, why: &str) {
let Some(dir) = DIR.lock().unwrap_or_else(PoisonError::into_inner).clone() else { return };
let n = SEQUENCE.fetch_add(1, Ordering::Relaxed);
let stem = format!("{n:06}-{}", safe(call));
let mut text = format!("{call} -> {why}\nnumeric arguments are not recorded\n");
for (what, bytes) in INPUTS.lock().unwrap_or_else(PoisonError::into_inner).drain(..) {
match bytes {
Ok(b) => {
let name = format!("{stem}-{what}.smb");
if let Err(e) = std::fs::write(dir.join(&name), b) {
log::warn!(target: "smlib", "{name}: {e}");
}
text.push_str(&format!("input {what}: {name}\n"));
}
Err(e) => text.push_str(&format!("input {what}: could not be written: {e}\n")),
}
}
if let Err(e) = std::fs::write(dir.join(format!("{stem}.txt")), text) {
log::warn!(target: "smlib", "{stem}.txt: {e}");
}
}
/// How a call's value reads: whether it is a refusal, for the calls that answer with one.
pub(crate) trait Answer {
fn refusal(&self) -> Option<String> {
None
}
}
impl<T> Answer for Result<T> {
fn refusal(&self) -> Option<String> {
self.as_ref().err().map(Error::to_string)
}
}
/// The values that are no refusal.
macro_rules! plain {
($($t:ty),* $(,)?) => { $(impl Answer for $t {})* };
}
plain!(
(),
bool,
f64,
usize,
[usize; 3],
([f64; 3], [f64; 3]),
Vec<u8>,
Vec<[f64; 3]>,
Brep,
solidcore::geom::vector3d::Point3,
crate::history::Raw,
crate::MassProperties
);
impl<T> Answer for Option<T> {}
/// The longest call name [`current`] gives.
const NAME_MAX: usize = 96;
static NAME: [AtomicU8; NAME_MAX] = [const { AtomicU8::new(0) }; NAME_MAX];
static NAME_LEN: AtomicUsize = AtomicUsize::new(0);
/// `call` is running now (`None`: none is). One at a time, under the kernel's lock.
pub(super) fn set_current(call: Option<&str>) {
NAME_LEN.store(0, Ordering::SeqCst);
let Some(call) = call else { return };
let bytes = call.as_bytes();
let n = bytes.len().min(NAME_MAX);
for (slot, b) in NAME.iter().zip(bytes) {
slot.store(*b, Ordering::Relaxed);
}
NAME_LEN.store(n, Ordering::SeqCst);
}
/// The call running now. Takes no lock, for crash reports, so it may be cut short or mixed with the
/// next call's name if read just as one starts.
pub(crate) fn current() -> Option<String> {
let n = NAME_LEN.load(Ordering::SeqCst);
if n == 0 {
return None;
}
let bytes: Vec<u8> = NAME.iter().take(n).map(|b| b.load(Ordering::Relaxed)).collect();
Some(String::from_utf8_lossy(&bytes).into_owned())
}
+60
View File
@@ -0,0 +1,60 @@
//! `.smb` files on solidcore (`smlib_brep_read`, `smlib_brep_write`): the B-rep's contents through
//! `SmApiReadBrepFromFile` and `SmApiWriteBrepToFile`, the file itself read and written here (the
//! library touches no files, so that it builds for the browser).
use std::path::Path;
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
use solidcore::kernel::Kernel;
use solidcore::topology::api_files::{read_brep_from_file, write_brep_to_file};
use solidcore::topology::brep::p10::TopologyEnv;
use solidcore::topology::brep_data_write::FromBrepEnv;
use solidcore::topology::topology::NotifyRecords;
use solidcore::topology::{Brep, EdgeId, EdgeuseId, FaceId};
use super::convert::key_of;
use super::{Body, run, with_body};
use crate::{Error, Result};
/// Where the keys of a file's curves and surfaces start, clear of [`key_of`]'s entity keys.
const FILE_KEYS: u64 = 1 << 50;
fn io(what: &str, path: &Path, e: &std::io::Error) -> Error {
Error::Solidcore(format!("{what} {}: {e}", path.display()))
}
/// The body in the file at `path`, ASCII or binary. Its UV trims are checked, not rebuilt, as the
/// C API reads them.
pub(crate) fn read(path: &Path, ascii: bool) -> Result<Body> {
let data = std::fs::read(path).map_err(|e| io("reading", path, &e))?;
let b = run("smlib_brep_read", |k| {
let mut attributes = AttributeStore::default();
let mut records = NotifyRecords::new();
let mut env = TopologyEnv { all_attributes: &[], attributes: &mut attributes, key_of: &key_of, records: &mut records, next_key: FILE_KEYS };
let mut b = read_brep_from_file(k, Some(data), ascii, false, &mut env)?;
b.refresh_cached_topology();
Ok(b)
})?;
Ok(Body::new(b))
}
/// `b` as the contents of a `.smb` file, ASCII or binary.
pub(super) fn bytes(k: &Kernel, b: &Brep, ascii: bool) -> solidcore::Result<Vec<u8>> {
let store = AttributeStore::default();
let records = NotifyRecords::new();
let env = FromBrepEnv {
attributes: &store,
key_of: &key_of,
records: &records,
curve_key: &|_: EdgeId| ObjectKey(0),
uv_curve_key: &|_: EdgeuseId| ObjectKey(0),
surface_key: &|_: FaceId| ObjectKey(0),
};
write_brep_to_file(k, Some(b), &env, ascii)
}
/// `body` written to the file at `path`, ASCII or binary.
pub(crate) fn write(body: &Body, path: &Path, ascii: bool) -> Result<()> {
let bytes = with_body("smlib_brep_write", body, |k, b| bytes(k, b, ascii))?;
std::fs::write(path, bytes).map_err(|e| io("writing", path, &e))
}
+330
View File
@@ -0,0 +1,330 @@
//! Face and edge history on solidcore: a port of the fork's `capi/src/smlib_history.cpp`
//! (`smlib_history_tag`, `smlib_history_read`) onto solidcore's B-rep-owned attribute store
//! ([`Brep::add_attribute`]), which solidcore's operations carry through their edits as SMLib's
//! `SmAObject::Notify` does: Split when a face or edge is cut in two, Merge when two become one,
//! Copy when an object is copied (a boolean copies the second operand into the first), Coincident
//! where a boolean finds one operand's entity lying on the other's. New topology (a boolean's
//! intersection edges) is made without any.
//!
//! The capi's `Tag` is one application attribute ([`Attribute::application`]) per face or edge,
//! with the capi's own handlers ([`AttributeHandlers`]): a split child, a merge's result and the
//! entity a coincidence lands on join the origins in, where SMLib's defaults keep only one; a copy
//! is SMLib's default. The data, the handlers and the reading are the capi's.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use solidcore::geom::attribute::{
ApplicationValue, Attribute, AttributeHandle, AttributeHandlers, AttributeValue, AttributeView, Behavior, ObjectKey, Placement, ids,
};
use solidcore::kernel::Kernel;
use solidcore::query::api_queries::edge_closest_point;
use solidcore::topology::face::p4 as face4;
use solidcore::topology::{Brep, EdgeId, VertexId, edge};
use solidcore::{EntityRef, Point3, SolidError};
use solidcraft_geom::brep::history::Change;
use super::{Body, answer, with_body};
use crate::history::{Raw, RawEdge, RawFace};
use crate::{Error, Result};
use solidcore::topology::brep::p3::brep_copy;
/// `TAG_ID`: above SMLib's own ids (all below `SM_AI_USER_1`).
const TAG_ID: u64 = ids::USER_1 + 7;
/// Operand numbers and indices a tag accepts (`TAG_MAX`: keeps every value inside 30 bits).
const TAG_MAX: usize = 1 << 30;
/// One (operand, index) pair a face or edge stands for.
type Origin = (usize, usize);
/// The capi's `Tag`: what a face or edge stands for, the operand entities it came from, for a face
/// tagged directly its own boundary edges then (to tell a face that is still whole), and for an
/// edge tagged directly where it lay then (points along it, ends first: to tell an edge that
/// moved). Copy behaviour (`SM_AB_COPY`); a copy (`MakeCopy`) is the same data.
#[derive(Debug, Clone, PartialEq, Default)]
struct Tag {
origins: BTreeSet<Origin>,
edges: BTreeSet<i64>,
at: Vec<Point3>,
}
impl Tag {
fn attribute(self) -> Attribute {
Attribute::application(TAG_ID, Behavior::Copy, self)
}
}
/// `Tag::on`: the tag on `x`, if the attribute of its id there is a [`Tag`].
fn on<'a>(view: &AttributeView<'a>, x: ObjectKey) -> Option<(AttributeHandle, &'a Tag)> {
let (h, a) = view.find_attribute(x, TAG_ID)?;
a.application_value::<Tag>().map(|t| (h, t))
}
/// `Tag::join`: adds `o` and `e` to `x`'s tag (its `at` as it was), giving it one with no `at` if
/// it has none (an attribute of the tag's id that is no tag is replaced). Nothing for no `o`.
fn join(view: &AttributeView<'_>, x: ObjectKey, o: &BTreeSet<Origin>, e: &BTreeSet<i64>) -> Vec<Placement> {
if o.is_empty() {
return Vec::new();
}
if let Some((handle, t)) = on(view, x) {
let mut t = t.clone();
t.origins.extend(o.iter().copied());
t.edges.extend(e.iter().copied());
return vec![Placement::Edit { handle, value: AttributeValue::Application(ApplicationValue::new(t)) }];
}
set(view, x, o.clone(), e.clone(), Vec::new())
}
/// `Tag::set`: replaces whatever tag `x` has (the attribute of the tag's id) with exactly `o`, `e`
/// and `p`.
fn set(view: &AttributeView<'_>, x: ObjectKey, o: BTreeSet<Origin>, e: BTreeSet<i64>, p: Vec<Point3>) -> Vec<Placement> {
let mut out = Vec::with_capacity(2);
if let Some((handle, _)) = view.find_attribute(x, TAG_ID) {
out.push(Placement::Remove { on: x, handle });
}
out.push(Placement::Add { on: x, attribute: Tag { origins: o, edges: e, at: p }.attribute() });
out
}
impl AttributeHandlers for Tag {
/// A split: both children stand for what the split object stood for, and lie where it lay
/// (each a piece of it): a child with no tag gets this one's `at` too.
fn split(&self, view: &AttributeView<'_>, split: ObjectKey, a: ObjectKey, b: ObjectKey) -> solidcore::Result<Vec<Placement>> {
let mut out = Vec::new();
for x in [a, b] {
if x == split {
continue;
}
if on(view, x).is_none() && !self.origins.is_empty() {
out.extend(set(view, x, self.origins.clone(), self.edges.clone(), self.at.clone()));
} else {
out.extend(join(view, x, &self.origins, &self.edges));
}
}
Ok(out)
}
/// A merge: the result stands for both (the tags on the sources other than the result).
fn merge(&self, view: &AttributeView<'_>, a: ObjectKey, b: ObjectKey, result: ObjectKey) -> solidcore::Result<Vec<Placement>> {
let (mut o, mut e) = (BTreeSet::new(), BTreeSet::new());
for x in [a, b] {
if x != result
&& let Some((_, t)) = on(view, x)
{
o.extend(t.origins.iter().copied());
e.extend(t.edges.iter().copied());
}
}
Ok(join(view, result, &o, &e))
}
/// One operand's entity found lying on the other's, which survives: it stands for both.
fn coincident(&self, view: &AttributeView<'_>, _from: ObjectKey, to: ObjectKey) -> solidcore::Result<Vec<Placement>> {
Ok(join(view, to, &self.origins, &self.edges))
}
}
/// `read`: what `e`'s tag says (the attribute of the tag's id, if it is a [`Tag`]).
fn tag_on(b: &Brep, e: EntityRef) -> Option<&Tag> {
let (_, a) = b.entity_attributes(e).into_iter().find(|(_, a)| a.attribute_id() == TAG_ID)?;
a.application_value::<Tag>()
}
/// `Tag::set` at tagging: replaces whatever tag `e` has with exactly origin `o`, the boundary
/// edges `edges` (a face's) and the points `at` (an edge's).
fn set_on(b: &mut Brep, e: EntityRef, o: Origin, edges: BTreeSet<i64>, at: Vec<Point3>) -> std::result::Result<(), SolidError> {
let old = b.entity_attributes(e).into_iter().find(|(_, a)| a.attribute_id() == TAG_ID).map(|(h, _)| h);
if let Some(h) = old {
b.remove_attribute(e, h)?;
}
b.add_attribute(e, Tag { origins: BTreeSet::from([o]), edges, at }.attribute())?;
Ok(())
}
/// `samples`: points along `e`, its two ends, then three between, evenly in its parameter. Empty
/// where it can't be evaluated.
fn samples(k: &Kernel, b: &Brep, e: EdgeId) -> std::result::Result<Vec<Point3>, SolidError> {
let edge = b.edge(e)?;
let Some(curve) = edge.curve.as_ref() else { return Ok(Vec::new()) };
let (lo, hi) = (edge.interval.min(), edge.interval.max());
let mut p = Vec::with_capacity(5);
for t in [0.0, 1.0, 0.25, 0.5, 0.75] {
let Some(v) = answer(curve.evaluate(k, lo + t * (hi - lo), 0, true, false))? else { return Ok(Vec::new()) };
let Some(v) = v.first() else { return Ok(Vec::new()) };
p.push(Point3::new(v.x, v.y, v.z));
}
Ok(p)
}
/// `moved`: have `pieces`, the result edges standing for one edge, moved from where its tag says
/// it lay (`at`, the first piece's record of at least two points)? Together they must run between
/// its ends (the vertices only one piece ends on; none for an edge closed on itself, whose end some
/// piece still ends on), and every point it ran through must lie on one of them (within `tol`).
/// One piece is the edge still whole; more, the edge cut by vertices. Pieces with no record of
/// where the edge lay have not moved.
pub(super) fn moved(k: &Kernel, b: &Brep, pieces: &[(EdgeId, &[Point3])], tol: f64) -> std::result::Result<bool, SolidError> {
let Some(at) = pieces.iter().map(|(_, at)| *at).find(|at| at.len() >= 2) else { return Ok(false) };
let [a, z, ..] = at else { return Ok(false) };
let near = |p: &Point3, q: &Point3| p.distance(*q) <= tol;
let mut ends: BTreeMap<VertexId, usize> = BTreeMap::new();
for (e, _) in pieces {
let (Some(s), Some(f)) = edge::start_end_vertices(b, *e)? else { return Ok(true) };
*ends.entry(s).or_default() += 1;
*ends.entry(f).or_default() += 1;
}
let mut open = Vec::new();
for (v, n) in &ends {
if n % 2 == 1 {
open.push(b.vertex(*v)?.point);
}
}
let span = match open.as_slice() {
[p, q] => (near(p, a) && near(q, z)) || (near(p, z) && near(q, a)),
[] => {
let mut on_end = false;
for v in ends.keys() {
on_end = on_end || near(&b.vertex(*v)?.point, a);
}
near(a, z) && on_end
}
_ => false,
};
if !span {
return Ok(true);
}
for q in at.iter().skip(2) {
// `SmApiEdgeClosestPoint` failing, however it fails, counts as off a piece, as a distance
// above `tol` does: the face is then Modified, and the operation stands.
if !pieces.iter().any(|(e, _)| edge_closest_point(k, b, *e, *q).is_ok_and(|c| c.closest.distance <= tol)) {
return Ok(true);
}
}
Ok(false)
}
/// `smlib_history_tag`: a copy of `body` (`SmApiBrepCopy`) whose face i and edge i stand for face
/// i and edge i of operand `operand` (in its face and edge order, which the copy keeps), a face
/// also recording its boundary edges then, an edge the points it ran through.
pub(crate) fn tag(body: &Body, operand: usize) -> Result<Body> {
if operand >= TAG_MAX {
return Err(Error::Argument);
}
with_body("smlib_history_tag", body, |k, b| {
let mut r = brep_copy(k, b)?;
let faces = r.face_list().to_vec();
let edges = r.edge_list().to_vec();
if faces.len() >= TAG_MAX || edges.len() >= TAG_MAX {
return Ok(Err(Error::Argument));
}
let edge_at: HashMap<EdgeId, usize> = edges.iter().enumerate().map(|(i, e)| (*e, i)).collect();
for (i, f) in faces.iter().enumerate() {
let own: BTreeSet<i64> =
face4::edges(&r, *f, None, false, None)?.iter().filter_map(|e| edge_at.get(e).and_then(|j| i64::try_from(*j).ok())).collect();
set_on(&mut r, EntityRef::Face(*f), (operand, i), own, Vec::new())?;
}
for (i, e) in edges.iter().enumerate() {
let at = samples(k, &r, *e)?;
set_on(&mut r, EntityRef::Edge(*e), (operand, i), BTreeSet::new(), at)?;
}
Ok(Ok(r))
})?
.map(Body::new)
}
/// `smlib_history_read`: each face's and edge's origins in `result`, a face's change from how
/// many origins it has, how many result faces share them and whether its boundary is still
/// exactly its origin's edges; each edge's faces. In the body's own face and edge order.
pub(crate) fn read(result: &Body) -> Result<Raw> {
with_body("smlib_history_read", result, |k, b| {
let faces = b.face_list().to_vec();
let edges = b.edge_list().to_vec();
let face_at: HashMap<_, usize> = faces.iter().enumerate().map(|(i, f)| (*f, i)).collect();
let edge_at: HashMap<EdgeId, usize> = edges.iter().enumerate().map(|(i, e)| (*e, i)).collect();
let tol = b.tolerance();
// Each face's origins and the boundary edges they had; each edge's origins and points.
let mut fo: Vec<BTreeSet<Origin>> = Vec::with_capacity(faces.len());
let mut fe: Vec<BTreeSet<usize>> = Vec::with_capacity(faces.len());
let mut eo: Vec<BTreeSet<Origin>> = Vec::with_capacity(edges.len());
let mut at: Vec<Vec<Point3>> = Vec::with_capacity(edges.len());
let mut face_uses: BTreeMap<Origin, usize> = BTreeMap::new();
let mut edge_uses: BTreeMap<Origin, usize> = BTreeMap::new();
for f in &faces {
let (o, e) = tag_on(b, EntityRef::Face(*f)).map(|t| (t.origins.clone(), t.edges.clone())).unwrap_or_default();
for x in &o {
*face_uses.entry(*x).or_default() += 1;
}
fe.push(e.into_iter().filter_map(|x| usize::try_from(x).ok()).collect());
fo.push(o);
}
for e in &edges {
let (o, p) = tag_on(b, EntityRef::Edge(*e)).map(|t| (t.origins.clone(), t.at.clone())).unwrap_or_default();
for x in &o {
*edge_uses.entry(*x).or_default() += 1;
}
at.push(p);
eo.push(o);
}
let mut out_faces = Vec::with_capacity(faces.len());
for (i, f) in faces.iter().enumerate() {
let (Some(o), Some(own)) = (fo.get(i), fe.get(i)) else { continue };
let change = match o.iter().next() {
None => Change::Generated,
Some(_) if o.len() > 1 => Change::Merged,
Some(first) if face_uses.get(first).copied().unwrap_or(0) > 1 => Change::Split,
Some(first) => {
// Kept when its boundary is still exactly where the edges it had were: each
// boundary edge one of its own operand's, standing for one edge only, every
// piece of that edge on this face (an edge only cut by a vertex, as splitting
// the face beside it cuts it, is still all there), the pieces where it lay (an
// edge an operation moved, as a draft moves a tilted face's neighbours' edges,
// leaves those faces trimmed differently), and those edges exactly the ones it
// had.
let mut pieces: BTreeMap<usize, Vec<(EdgeId, &[Point3])>> = BTreeMap::new();
let mut whole = true;
for e in face4::edges(b, *f, None, false, None)? {
let Some(j) = edge_at.get(&e).copied() else {
whole = false;
break;
};
let (Some(origins), Some(points)) = (eo.get(j), at.get(j)) else {
whole = false;
break;
};
let only = match origins.iter().next() {
Some(x) if origins.len() == 1 => *x,
_ => {
whole = false;
break;
}
};
whole = only.0 == first.0;
if !whole {
break;
}
let p = pieces.entry(only.1).or_default();
if !p.iter().any(|(x, _)| *x == e) {
p.push((e, points.as_slice()));
}
}
let mut now = BTreeSet::new();
for (edge, p) in &pieces {
whole = whole && p.len() == edge_uses.get(&(first.0, *edge)).copied().unwrap_or(0) && !moved(k, b, p, tol)?;
now.insert(*edge);
}
if whole && now == *own { Change::Kept } else { Change::Modified }
}
};
out_faces.push(RawFace { change, origins: o.iter().copied().collect() });
}
let mut out_edges = Vec::with_capacity(edges.len());
for (i, e) in edges.iter().enumerate() {
let bounds: BTreeSet<usize> = edge::faces(b, *e, None)?.iter().filter_map(|f| face_at.get(f).copied()).collect();
let origins = eo.get(i).map(|o| o.iter().copied().collect()).unwrap_or_default();
out_edges.push(RawEdge { faces: bounds.into_iter().collect(), origins });
}
Ok(Raw { faces: out_faces, edges: out_edges })
})
}
+354
View File
@@ -0,0 +1,354 @@
//! The Measure and Check families on solidcore: what `capi/src/smlib_measure.cpp`,
//! `smlib_brep_solid_count` (`smlib_ops.cpp`), `smlib_brep_is_manifold_solid`
//! (`smlib_flat_api.cpp`) and `smlib_brep_check` (`smlib_check.cpp`) do on the C++ kernel, here
//! composed from solidcore's ports of the same SMLib routines.
//!
//! [`check`] fails with "not supported yet" while a test it reaches is unported in solidcore.
use solidcore::error::EntityRef;
use solidcore::geom::assert_array::{AssertArray, AssertReport, obj_type_string};
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
use solidcore::geom::tol::{SmallTopoType, brep_zone_tol_3d, context_xsect_tol_3d};
use solidcore::heal::gap::GapFunction;
use solidcore::intersect::point_class::PointClassType;
use solidcore::mass::integrator::properties::{MassPropertyBehavior, PropertyAttributes, brep_properties, face_properties};
use solidcore::nurbs::b_spline_surface_n_lib::p2::OrientType;
use solidcore::query::api_queries::{brep_bounding_box, brep_is_manifold_solid, brep_material_census, face_bounding_box};
use solidcore::query::brep::BrepClassifier;
use solidcore::topology::brep::p1 as bl;
use solidcore::topology::brep::validation::{CheckLevel as SolidcoreCheckLevel, check_brep};
use solidcore::topology::face::p2::{PPF_AREA, PPF_AREA_FIRST_MOMENTS};
use solidcore::topology::{FaceId, FaceuseId, NamingScheme};
use solidcore::{Kernel, Point3, SolidError, Vec3};
use super::blend::status;
use super::{Body, with_body};
use crate::check::{CheckLevel, CheckReport};
use crate::measure::{MassProperties, PointClass};
use crate::{Error, Result};
const OP: &str = "smlib_measure";
/// The accuracy the C API passes on: finite, clamped to [1e-8, 1e-1].
fn accuracy(relative_accuracy: f64) -> Result<f64> {
if !relative_accuracy.is_finite() {
return Err(Error::Argument);
}
Ok(relative_accuracy.clamp(1e-8, 1e-1))
}
fn invalid(why: &'static str) -> SolidError {
SolidError::invalid_input(OP, why)
}
fn finite(v: &[f64]) -> bool {
v.iter().all(|x| x.is_finite())
}
/// `faces_area` of `smlib_measure.cpp`: every face counted once (SmApiBrepComputeArea, with the
/// [1e-8, 1e-1] envelope instead of its [1e-4, 1e-1]), the total area and, when `first` is set,
/// the area's first moments about `o`. With `only_sheets`, only [`sheet_face`]s (the capi's
/// `counted`).
fn faces_area(k: &Kernel, b: &mut solidcore::Brep, accuracy: f64, o: Point3, first: bool, only_sheets: bool) -> solidcore::Result<(f64, Vec3)> {
let mut area = 0.0;
let mut sum = Vec3::new(0.0, 0.0, 0.0);
let flags = if first { PPF_AREA | PPF_AREA_FIRST_MOMENTS } else { PPF_AREA };
for f in bl::faces(b, None)? {
if only_sheets && !sheet_face(b, f)? {
continue;
}
let size = face_bounding_box(k, b, f, false)?.size();
let edge = size.z.max(size.x.max(size.y));
let p = face_properties(k, b, f, OrientType::Same, accuracy, o, edge * edge / 100.0, 0.0, flags)?;
// The area and its moments share one integrand, so a trim boundary integrated the other
// way round flips both.
let sign = if p.area < 0.0 { -1.0 } else { 1.0 };
area += sign * p.area;
if first {
sum += p.moments[0] * sign;
}
}
if !area.is_finite() {
return Err(invalid("non-finite area"));
}
Ok((area, sum))
}
/// `sheet_face`: a face with material on neither side, standing free or hung on a solid (a blend
/// sheet stitched between two boxes). `SmBrep::ComputePreciseProperties` takes its area only from
/// faces between a solid region and a void, so such a face is counted on its own.
fn sheet_face(b: &solidcore::Brep, f: FaceId) -> solidcore::Result<bool> {
let void_side = |u: Option<FaceuseId>| -> solidcore::Result<bool> {
let Some(u) = u else { return Ok(true) };
let Some(shell) = b.faceuse(u)?.shell else { return Ok(true) };
let Some(region) = b.shell(shell)?.region else { return Ok(true) };
// IsVoid's UNSURE (2) is true in C++: only FALSE is material.
Ok(b.region(region)?.is_void() != 0)
};
let (x, y) = solidcore::topology::face::p2::faceuses(b, f)?;
Ok(void_side(x)? && void_side(y)?)
}
/// `smlib_brep_mass_properties`: `SmBrep::ComputePreciseProperties` directly, as the C API calls
/// it (not `SmApiBrepComputeMassProperties`: the [1e-8, 1e-1] accuracy envelope, no manifold
/// refusal, the sum over material regions), the face-by-face area and its centroid for a body
/// with no material, and moments about the middle of the box moved to the centroid. A body's area
/// is every face's: the solid's boundary and its sheet faces (fork 9767916).
pub(crate) fn mass_properties(body: &Body, relative_accuracy: f64) -> Result<MassProperties> {
let accuracy = accuracy(relative_accuracy)?;
with_body("mass_properties", body, |k, b| {
let mut material = false;
for r in bl::regions(b, None)? {
// IsVoid's UNSURE (2) is true in C++: only FALSE is material.
material |= b.region(r)?.is_void() == 0;
}
// The C API refuses a body with per-entity mass attributes (they would override the unit
// density). Bodies here carry none: the attribute store passed in is empty.
let store = AttributeStore::default();
let attrs = PropertyAttributes { store: &store, key_of: &|_| ObjectKey(0) };
// Moments about the middle of the box, moved to the centroid below: about the world
// origin, a body far from it would lose its inertia to cancellation.
let o = brep_bounding_box(k, b, false)?.mid();
if !finite(&[o.x, o.y, o.z]) {
return Err(invalid("non-finite box"));
}
if !material {
let (area, first) = faces_area(k, b, accuracy, o, true, false)?;
if !(area > 0.0) {
return Err(invalid("no area"));
}
let c = [o.x + first.x / area, o.y + first.y / area, o.z + first.z / area];
if !finite(&[area, c[0], c[1], c[2]]) {
return Err(invalid("non-finite result"));
}
return Ok(MassProperties { area, volume: 0.0, centroid: c, moments: [0.0; 3], products: [0.0; 3] });
}
let p = brep_properties(k, b, accuracy, o, MassPropertyBehavior { density: 1.0, ..MassPropertyBehavior::default() }, &attrs)?;
let (mass, volume) = (p.mass, p.volume);
if !mass.is_finite() || !(mass > 0.0) || !(volume > 0.0) {
return Err(invalid("no mass"));
}
// m[4]: first moments about o; m[7]: (y²+z², z²+x², x²+y²) about o; m[6]: (yz, zx, xy)
// about o. Parallel axes from o to the centroid (unit density: mass = volume).
let [_, _, _, _, m4, _, m6, m7] = p.moments;
let d = [m4.x / mass, m4.y / mass, m4.z / mass];
let (sheets, _) = faces_area(k, b, accuracy, o, false, true)?;
let r = MassProperties {
area: p.area + sheets,
volume,
centroid: [o.x + d[0], o.y + d[1], o.z + d[2]],
moments: [
m7.x - mass * (d[1] * d[1] + d[2] * d[2]),
m7.y - mass * (d[0] * d[0] + d[2] * d[2]),
m7.z - mass * (d[0] * d[0] + d[1] * d[1]),
],
products: [m6.x - mass * d[1] * d[2], m6.y - mass * d[2] * d[0], m6.z - mass * d[0] * d[1]],
};
if !finite(&[r.area, r.volume]) || !finite(&r.centroid) || !finite(&r.moments) || !finite(&r.products) {
return Err(invalid("non-finite result"));
}
Ok(r)
})
}
/// `smlib_brep_area`: every face counted once.
pub(crate) fn area(body: &Body, relative_accuracy: f64) -> Result<f64> {
let accuracy = accuracy(relative_accuracy)?;
with_body("area", body, |k, b| faces_area(k, b, accuracy, Point3::new(0.0, 0.0, 0.0), false, false).map(|(a, _)| a))
}
/// `smlib_brep_bounds`: `SmBrep::CalculateTightBoundingBox`.
pub(crate) fn bounds(body: &Body) -> Result<([f64; 3], [f64; 3])> {
with_body("bounds", body, |k, b| {
let x = brep_bounding_box(k, b, true)?;
let (lo, hi) = (x.min(), x.max());
let (lo, hi) = ([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
if !finite(&lo) || !finite(&hi) || lo.iter().zip(&hi).any(|(a, b)| a > b) {
return Err(invalid("no box"));
}
Ok((lo, hi))
})
}
/// `smlib_brep_edge_face_distance`: SMLib's own edge/face gap (`SmEdgeuse::CalcMaxEdgeFaceGap`'s
/// `SmCrvSrfGapFunction` with 20 samples, its largest sample). Indices in the body's edge and
/// face order.
pub(crate) fn edge_face_distance(body: &Body, edge: usize, face: usize) -> Result<f64> {
let found = with_body("edge_face_distance", body, |k, b| {
let (Some(&e), Some(&f)) = (bl::edges(b, None)?.get(edge), bl::faces(b, None)?.get(face)) else {
return Ok(None);
};
let interval = b.edge(e)?.interval;
let (Some(curve), Some(surface)) = (b.edge(e)?.curve.as_ref(), b.face(f)?.surface.as_ref()) else {
return Err(invalid("edge without curve or face without surface"));
};
if !(interval.max() > interval.min()) {
return Err(invalid("empty edge interval"));
}
// SmTol::GetXSectTol3d(brep's context): the process context. The tolerance only
// classifies the samples; it does not change the distances.
let tol = context_xsect_tol_3d(k, None, SmallTopoType::NormalSize);
let mut gaps = GapFunction::crv_srf(tol, curve, &interval, surface, 20);
let d = gaps.max_gap_sample(k, false)?.length();
if !d.is_finite() {
return Err(invalid("non-finite gap"));
}
Ok(Some(d))
})?;
found.ok_or(Error::Argument)
}
/// `smlib_brep_classify_point`: `SmBrep::Point3DClassify` at the body's own zone tolerance
/// (`SmTol::GetZoneTol3d` of the body), with boundary intersections, so a point within that
/// tolerance of a vertex, an edge or a face is on the boundary and never decided by a ray; else
/// the region it lies in, outside when that region is void (`IsVoid() == TRUE`; `UNSURE` is
/// material, as the capi compares with `TRUE`). Runs on a private copy when a solve needs one
/// (`BrepClassifier`): upstream's `const` method may add UV trim curves to the body.
pub(crate) fn classify_point(body: &Body, p: [f64; 3]) -> Result<PointClass> {
if !finite(&p) {
return Err(Error::Argument);
}
with_body("classify_point", body, |k, b| {
let tol = brep_zone_tol_3d(k, b, SmallTopoType::NormalSize);
let mut classifier = BrepClassifier::of_brep(b);
let pc = match classifier.point_3d_classify(k, Point3::new(p[0], p[1], p[2]), tol, true) {
Ok(pc) => pc,
// Its status, as the capi passes it on (no ray finding a region is SM_ERR).
Err(e) => return Ok(Err(status(e))),
};
Ok(match (pc.point_class(), pc.object()) {
(PointClassType::Vertex | PointClassType::Edge | PointClassType::Face, _) => Ok(PointClass::OnBoundary),
(PointClassType::Region, Some(EntityRef::Region(r))) => {
Ok(if classifier.brep().region(r)?.is_void() == 1 { PointClass::Outside } else { PointClass::Inside })
}
// Point3DClassify reports only the four classes above on success: SM_ERR.
_ => Err(Error::Status(SM_ERR)),
})
})?
}
/// `SM_ERR`.
const SM_ERR: i32 = 1001;
/// `smlib_brep_is_manifold_solid`: `SmApiBrepIsManifoldSolid`.
pub(crate) fn is_manifold_solid(body: &Body) -> Result<bool> {
with_body("is_manifold_solid", body, |_, b| brep_is_manifold_solid(b))
}
/// `smlib_brep_solid_count`: the solids of `SmApiBrepMaterialCensus` (its voids are dropped).
pub(crate) fn solid_count(body: &Body) -> Result<usize> {
with_body("solid_count", body, |_, b| brep_material_census(b).map(|(solids, _voids)| solids))
}
/// SMLib's `SM_SUCCESS`, the status `SmBrep::ValidatePointers` returns when it finds nothing.
const SM_SUCCESS: i32 = 1000;
/// The capi's `text()`: an empty string reads as `?`.
fn text(s: &str) -> &str {
if s.is_empty() { "?" } else { s }
}
/// The capi's `line()`: `<reporting type>::Test #<n> <name>: <message> [owner <type>]`, the owner
/// tag only when the report names the owner's type.
fn report_line(r: &AssertReport) -> String {
let msg = r.format_log_message();
let mut s = format!("{}::Test #{} {}: {}", text(obj_type_string(r.reporting_type)), r.test_index, text(r.name), text(&msg));
if let Some(owner) = r.owner_type_string.filter(|o| !o.is_empty()) {
s.push_str(" [owner ");
s.push_str(owner);
s.push(']');
}
s
}
/// The capi's `collect()`: appends the failed reports of `list`, one line each, and counts them;
/// SMLib's warnings ([`AssertReport::is_warning`]: `SmBrep` test 21, "Suspicious Shell Count", a
/// body of several faces with one shell each, is one) are neither.
fn collect(list: &AssertArray, out: &mut String, failures: &mut usize) {
for r in list.reports().iter().filter(|r| !r.ok && !r.is_warning()) {
*failures += 1;
out.push_str(&report_line(r));
out.push('\n');
}
}
/// Why [`check`] could not run.
enum Refusal {
/// A test solidcore has not ported (its [`SolidError::Unported`]).
Unported(&'static str, &'static str),
/// A body whose geometry owners' identities the check cannot know: a
/// [`NamingScheme::Unknown`] body.
Naming(NamingScheme),
}
impl<T> super::dump::Answer for std::result::Result<T, Refusal> {
fn refusal(&self) -> Option<String> {
self.is_err().then(|| "the check could not run".to_string())
}
}
/// `smlib_brep_check` (`smlib_check.cpp`): `SmBrep::ValidatePointers` at `level`, then, when it
/// failed nothing, `SmBrep::AssertValid` at `level` over the whole topology graph, with every
/// failed test as one line of the report and the capi's own lines for a failed status without a
/// report. solidcore's [`check_brep`] runs the same two calls.
///
/// The geometry owners are named as the body's naming scheme names them: `check_brep` reads a
/// [`NamingScheme::Caller`] body's keys from where its maker recorded them (solidcore#1585: an
/// array import through `make_topology_from_data`, `api_brep_copy`); where it recorded none, the
/// arena identities the binders name such an entity by. A [`NamingScheme::Unknown`] body would be
/// checked under arena identities no maker uses: every edge would fail `SmBrep` test 61 on the
/// owner of its curve.
///
/// A test solidcore has not ported yet (its [`SolidError::Unported`]), or a body whose geometry
/// owners' identities the check cannot know, fails the check with "not supported yet": the check
/// never reports a body valid on fewer tests than the C++ kernel runs.
pub(crate) fn check(body: &Body, level: CheckLevel) -> Result<CheckReport> {
let lv = match level {
CheckLevel::Basic => SolidcoreCheckLevel::Level0,
CheckLevel::Geometry => SolidcoreCheckLevel::Level1,
CheckLevel::All => SolidcoreCheckLevel::Level2,
};
let c = match with_body("check", body, |k, b| {
let naming = b.naming();
if naming == NamingScheme::Unknown {
return Ok(Err(Refusal::Naming(naming)));
}
match check_brep(k, b, lv) {
Err(SolidError::Unported { upstream, unit }) => Ok(Err(Refusal::Unported(upstream, unit))),
r => r.map(Ok),
}
})? {
Ok(c) => c,
Err(Refusal::Unported(upstream, unit)) => {
return Err(Error::Solidcore(format!("smlib_brep_check: {upstream}: not supported yet: solidcore has not ported it (unit {unit})")));
}
Err(Refusal::Naming(n)) => {
return Err(Error::Solidcore(format!(
"smlib_brep_check: not supported yet: the body names its geometry owners by its maker's keys (NamingScheme::{n:?}), which the check cannot know"
)));
}
};
let mut text = String::new();
let mut failures = 0usize;
collect(&c.pointer_reports, &mut text, &mut failures);
if c.pointers_status != SM_SUCCESS && failures == 0 {
failures += 1;
text.push_str(&format!("SmBrep::ValidatePointers failed with status {}\n", c.pointers_status));
}
if failures > 0 {
// The capi does not run AssertValid after a failed pointer check. check_brep runs it when
// the status failed without a report; that result is dropped here, as the capi never
// computes it.
text.push_str("AssertValid skipped: ValidatePointers failed\n");
} else {
let Some(ok) = c.assert_valid else {
return Err(Error::Solidcore("smlib_brep_check: AssertValid did not run after a clean pointer check".into()));
};
collect(&c.reports, &mut text, &mut failures);
if !ok && failures == 0 {
failures += 1;
text.push_str("SmBrep::AssertValid failed without a report\n");
}
}
Ok(CheckReport { failures, text })
}
+309
View File
@@ -0,0 +1,309 @@
//! The Measure and Check families on solidcore's primitives: mass properties, area and bounds
//! against their closed forms ([`crate::measure::closed`]), one solid each, edges on their faces.
use super::{Body, area, bounds, check, edge_face_distance, is_manifold_solid, make_box, make_cone, make_cylinder, make_sphere, make_torus};
use super::{counts, mass_properties, solid_count};
use crate::check::CheckLevel;
use crate::measure::MEASURE_ACCURACY;
use crate::measure::closed::{self, Closed};
/// The tolerance every quantity meets, relative.
const TOL: f64 = 1e-9;
/// The primitives, as `convert_tests` makes them, with their closed forms.
fn case(name: &str) -> (Body, Closed) {
let (body, c) = match name {
"box" => (make_box([1.0, -2.0, 3.0], [10.0, 20.0, 30.0]), closed::cuboid([1.0, -2.0, 3.0], [10.0, 20.0, 30.0])),
"cylinder" => (make_cylinder([1.0, 2.0, -3.0], 5.0, 12.0), closed::cylinder([1.0, 2.0, -3.0], 5.0, 12.0)),
"sphere" => (make_sphere([1.0, 2.0, 3.0], 7.0), closed::sphere([1.0, 2.0, 3.0], 7.0)),
"cone" => (make_cone([0.0, 1.0, 2.0], 6.0, 0.0, 9.0), closed::cone([0.0, 1.0, 2.0], 6.0, 0.0, 9.0)),
"frustum" => (make_cone([0.0, 1.0, 2.0], 6.0, 2.0, 9.0), closed::cone([0.0, 1.0, 2.0], 6.0, 2.0, 9.0)),
"torus" => (make_torus([1.0, -1.0, 0.5], 10.0, 3.0), closed::torus([1.0, -1.0, 0.5], 10.0, 3.0)),
_ => panic!("{name}"),
};
(body.unwrap(), c)
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs()
}
/// The body's size: twice its largest radius of gyration, the scale of centroid errors.
fn size(c: &Closed) -> f64 {
2.0 * (c.moments.iter().fold(0.0f64, |a, &b| a.max(b)) / c.volume).sqrt()
}
fn mass_properties_match(name: &str) {
let (b, c) = case(name);
let m = mass_properties(&b, MEASURE_ACCURACY).unwrap();
assert!(rel(m.area, c.area) < TOL, "{name}: area {} vs {}", m.area, c.area);
assert!(rel(m.volume, c.volume) < TOL, "{name}: volume {} vs {}", m.volume, c.volume);
let imax = c.moments.iter().fold(0.0f64, |a, &b| a.max(b));
for i in 0..3 {
assert!((m.centroid[i] - c.centroid[i]).abs() < TOL * size(&c), "{name}: centroid {:?} vs {:?}", m.centroid, c.centroid);
assert!(rel(m.moments[i], c.moments[i]) < TOL, "{name}: moments {:?} vs {:?}", m.moments, c.moments);
assert!(m.products[i].abs() < TOL * imax, "{name}: products {:?}", m.products);
}
}
fn area_matches(name: &str) {
let (b, c) = case(name);
let a = area(&b, MEASURE_ACCURACY).unwrap();
assert!(rel(a, c.area) < TOL, "{name}: area {a} vs {}", c.area);
}
fn bounds_match(name: &str) {
let (b, c) = case(name);
let (lo, hi) = bounds(&b).unwrap();
let s = size(&c);
for i in 0..3 {
assert!((lo[i] - c.bounds.0[i]).abs() < TOL * s, "{name}: low {lo:?} vs {:?}", c.bounds.0);
assert!((hi[i] - c.bounds.1[i]).abs() < TOL * s, "{name}: high {hi:?} vs {:?}", c.bounds.1);
}
}
fn one_solid(name: &str) {
let (b, _) = case(name);
assert_eq!(solid_count(&b).unwrap(), 1, "{name}");
}
fn manifold_solid(name: &str) {
let (b, _) = case(name);
assert!(is_manifold_solid(&b).unwrap(), "{name}");
}
/// Every edge lies on the faces it bounds: its distance to the nearest face is 0.
fn edges_lie_on_their_faces(name: &str) {
let (b, c) = case(name);
let [faces, edges, _] = counts(&b).unwrap();
for e in 0..edges {
let mut d: Vec<f64> = (0..faces).filter_map(|f| edge_face_distance(&b, e, f).ok()).collect();
d.sort_by(f64::total_cmp);
assert!(d.first().is_some_and(|&d| d < TOL * size(&c)), "{name}: edge {e}: {d:?}");
}
assert!(edge_face_distance(&b, edges, 0).is_err() && edge_face_distance(&b, 0, faces).is_err());
}
fn checks_valid(name: &str) {
let (b, _) = case(name);
for level in [CheckLevel::Basic, CheckLevel::Geometry, CheckLevel::All] {
let r = check(&b, level).unwrap();
assert!(r.is_valid(), "{name}, {level:?}: {r}");
}
}
/// Refusing is decided on the body's naming scheme alone. An Unknown body is refused rather than
/// reported invalid on every edge; a Caller one without recorded keys is checked under the arena
/// identities (`check_brep` has no error for it, so no error of its can turn into a refusal).
#[test]
fn check_refuses_only_an_unknown_naming() {
use solidcore::topology::NamingScheme;
let name = |naming| {
let (b, _) = case("box");
b.0.lock().unwrap().set_naming(naming);
check(&b, CheckLevel::All)
};
let refused = name(NamingScheme::Unknown).unwrap_err().to_string();
assert!(refused.contains("not supported yet") && refused.contains("NamingScheme::Unknown"), "{refused}");
name(NamingScheme::Caller).unwrap();
}
macro_rules! primitive {
($m:ident, $name:literal $(, mass: $mass:meta)? $(, area: $area:meta)?) => {
mod $m {
use super::*;
#[test]
$(#[$mass])?
fn mass_properties_match_the_closed_forms() {
mass_properties_match($name);
}
#[test]
$(#[$area])?
fn area_matches_the_closed_form() {
area_matches($name);
}
#[test]
fn bounds_match_the_closed_form() {
bounds_match($name);
}
#[test]
fn is_one_solid() {
one_solid($name);
}
#[test]
fn is_a_manifold_solid() {
manifold_solid($name);
}
#[test]
fn edges_lie_on_their_faces() {
super::edges_lie_on_their_faces($name);
}
#[test]
fn checks_valid() {
super::checks_valid($name);
}
}
};
}
primitive!(box_, "box");
primitive!(cylinder, "cylinder");
primitive!(sphere, "sphere");
primitive!(cone, "cone");
primitive!(frustum, "frustum");
primitive!(torus, "torus");
/// A valid box passes every test at level 2 (SolidCraft's level), with an empty report.
#[test]
fn a_valid_box_passes_level_2() {
let (b, _) = case("box");
let r = check(&b, CheckLevel::All).unwrap();
assert_eq!((r.failures, r.text.as_str()), (0, ""), "{r}");
}
/// A box with one corner moved 1 mm off its edges, through the arrays: the check finds it broken.
#[test]
fn a_vertex_off_its_edges_fails_the_check() {
use crate::flat::VERTEX_POSITION;
let (b, _) = case("box");
let mut a = super::to_arrays(&b).unwrap();
a.reals.get_mut(&VERTEX_POSITION).unwrap()[0] += 1.0;
let broken = super::from_arrays(&a).unwrap();
let r = check(&broken, CheckLevel::All).unwrap();
assert!(!r.is_valid(), "{r}");
assert_eq!(r.failures, r.text.lines().filter(|l| l.contains("::Test #")).count(), "{r}");
}
#[test]
fn hostile_numbers_are_refused() {
let (b, _) = case("box");
assert_eq!(mass_properties(&b, f64::NAN).unwrap_err(), crate::Error::Argument);
assert_eq!(area(&b, f64::INFINITY).unwrap_err(), crate::Error::Argument);
assert_eq!(edge_face_distance(&b, usize::MAX, 0).unwrap_err(), crate::Error::Argument);
// Out of range is clamped, not refused.
assert!(rel(mass_properties(&b, 1e-30).unwrap().volume, 6000.0) < TOL);
assert!(rel(mass_properties(&b, 10.0).unwrap().volume, 6000.0) < TOL);
}
/// A 20 mm box minus a centred 10 mm box (a sealed void), as solidcore's boolean makes it and as
/// it comes back from arrays listing its solid region's shells inner first.
fn sealed_voids() -> [(&'static str, Body); 2] {
use crate::BooleanOp;
use crate::flat::{FACEUSE_FACE, FACEUSE_ORIENTATION, REGION_SHELL_COUNT, SHELL_FACEUSE_COUNT};
let body = super::boolean::boolean(&make_box([-10.0; 3], [20.0; 3]).unwrap(), &make_box([-5.0; 3], [10.0; 3]).unwrap(), BooleanOp::Subtract)
.unwrap()
.unwrap();
// The bounded solid region of two shells: swap their blocks (the shell counts match, as do
// the face-use counts, six faces each), so only the face uses move.
let mut a = super::to_arrays(&body).unwrap();
let regions = &a.ints[&REGION_SHELL_COUNT];
let first = regions.iter().position(|&n| n == 2).unwrap();
let shell: usize = regions[..first].iter().map(|&n| usize::try_from(n).unwrap()).sum();
let counts = &a.ints[&SHELL_FACEUSE_COUNT];
assert_eq!((counts[shell], counts[shell + 1]), (6, 6));
let fu: usize = counts[..shell].iter().map(|&n| usize::try_from(n).unwrap()).sum();
for id in [FACEUSE_FACE, FACEUSE_ORIENTATION] {
let v = a.ints.get_mut(&id).unwrap();
let (x, y) = v[fu..fu + 12].split_at_mut(6);
x.swap_with_slice(y);
}
let reordered = super::from_arrays(&a).unwrap();
[("solidcore's", body), ("inner first", reordered)]
}
/// The sealed void's mass properties: 8000 − 1000 to 1e-9, centroid at the origin, one solid,
/// in either shell order.
#[test]
fn a_sealed_void_measures_its_volume() {
let want = 8000.0 - 1000.0;
// Ixx of a cube of side s about its centre: s⁵ / 6.
let moment = (20.0f64.powi(5) - 10.0f64.powi(5)) / 6.0;
for (at, b) in sealed_voids() {
let m = mass_properties(&b, MEASURE_ACCURACY).unwrap();
assert!(rel(m.volume, want) < TOL, "{at}: volume {}", m.volume);
assert!(m.centroid.iter().all(|c| c.abs() < TOL * 20.0), "{at}: centroid {:?}", m.centroid);
assert!(m.moments.iter().all(|&i| rel(i, moment) < TOL), "{at}: moments {:?} vs {moment}", m.moments);
assert_eq!(solid_count(&b).unwrap(), 1, "{at}");
}
}
/// Two 10 mm squares apart in z = 0, one body of two faces with a shell each (as the top and
/// front of a box offset apart across their edge make, kernel case
/// `surfaces/cov-offset-two-faces-across-a-sharp-edge-come-apart`): `SmBrep` test 21,
/// "Suspicious Shell Count", warns of it, and the capi's check counts no warning.
#[test]
fn faces_apart_with_a_shell_each_check_valid() {
use solidcore::geom::axis2_placement::Axis2Placement;
use solidcore::geom::vector3d::Vec3;
use solidcore::primitive::primitive_creation::p1::create_rectangle;
use super::boolean::boolean2d;
use super::run;
use crate::BooleanOp;
let square = |x: f64| {
let frame = Axis2Placement::new(Vec3::new(x, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0));
run("create_rectangle", |k| create_rectangle(k, 0.0, 10.0, 10.0, &frame)).map(Body::new).unwrap()
};
let b = boolean2d(&square(0.0), &square(20.0), BooleanOp::Union).unwrap().unwrap();
assert_eq!(counts(&b).unwrap()[0], 2);
for level in [CheckLevel::Basic, CheckLevel::Geometry, CheckLevel::All] {
let r = check(&b, level).unwrap();
assert!(r.is_valid(), "{level:?}: {r}");
}
}
/// `smlib_brep_classify_point` (fork 34d6b2b), the capi test's cases: a 10 x 20 x 30 box at
/// `(1, -2, 3)`: inside, outside, on a face, an edge and a vertex; the cylinder of radius 5 about
/// the z axis through `(1, 2)`: off its curved wall by 1e-8 on it (within the zone tolerance), by
/// 1e-4 inside or outside, on its axis inside, on its top's centre on it.
#[test]
fn points_classify_as_the_capi() {
use super::classify_point;
use crate::measure::PointClass::{Inside, OnBoundary, Outside};
let (bx, _) = case("box");
let (cyl, _) = case("cylinder");
let c45 = std::f64::consts::FRAC_1_SQRT_2;
let wall = |r: f64| [1.0 + r * c45, 2.0 + r * c45, 3.0];
let cases = [
("box", [6.0, 8.0, 18.0], Inside),
("box", [6.0, 8.0, 34.0], Outside),
("box", [0.0, -3.0, 2.0], Outside),
("box", [6.0, 8.0, 33.0], OnBoundary),
("box", [11.0, 8.0, 33.0], OnBoundary),
("box", [11.0, 18.0, 33.0], OnBoundary),
("box", [1.0, -2.0, 3.0], OnBoundary),
("cylinder", wall(5.0 + 1e-8), OnBoundary),
("cylinder", wall(5.0 - 1e-8), OnBoundary),
("cylinder", wall(5.0 + 1e-4), Outside),
("cylinder", wall(5.0 - 1e-4), Inside),
("cylinder", [1.0, 2.0, 3.0], Inside),
("cylinder", [1.0, 2.0, 9.0], OnBoundary),
];
for (name, p, want) in cases {
let body = if name == "box" { &bx } else { &cyl };
assert_eq!(classify_point(body, p).unwrap(), want, "{name} {p:?}");
}
assert_eq!(classify_point(&bx, [f64::NAN, 0.0, 0.0]).unwrap_err(), crate::Error::Argument);
assert_eq!(classify_point(&bx, [0.0, f64::INFINITY, 0.0]).unwrap_err(), crate::Error::Argument);
}
/// A sheet hung on a solid (fork 9767916's capi test): a 10 mm cube and a 10 x 10 sheet on from its
/// top front edge, merged into one body. Its area is every face's, the cube's 600 and the sheet's
/// 100, not the cube's boundary alone; its volume the cube's.
#[test]
fn a_sheet_hung_on_a_solid_counts_in_the_area() {
use solidcore::boolean::api_brep::boolean_merge;
use solidcore::geom::vector3d::Vec3;
use solidcore::primitive::api_primitives::{create_box, create_plane};
let both = super::run("cube and flap", |k| {
let cube = create_box(k, Vec3::new(0.0, 0.0, 0.0), 10.0, 10.0, 10.0)?;
let flap = create_plane(k, Vec3::new(10.0, 0.0, 10.0), 10.0, 10.0)?;
boolean_merge(k, cube, flap)
})
.map(Body::new)
.unwrap();
let m = mass_properties(&both, 1e-8).unwrap();
assert!((m.area - 700.0).abs() < 1e-6 && (m.volume - 1000.0).abs() < 1e-6, "{m:?}");
}
+178
View File
@@ -0,0 +1,178 @@
//! Sewing and merging on solidcore: the C API's `smlib_stitch` (the fork's
//! `capi/src/smlib_sheets.cpp`) and `smlib_merge_faces` (`capi/src/smlib_ops.cpp`, with
//! `capi/src/smlib_pick.h`'s `pick_edges`).
//!
//! As there, each works on a copy of its body (`SmApiBrepCopy`). A stitch is `SmBrep::StitchFaces`
//! at the caller's tolerance, once, then `SmBrep::OrientTrimmedSurfaces`: outward where the faces
//! close into a solid, else only made to agree across their shared edges. `StitchAndOrient` is not
//! used, as the capi does not use it: it caps the tolerance and stitches a second time, so the
//! caller's tolerance would not be the one that decides.
//!
//! `StitchFaces` glues an edge only to an edge one face uses: a sheet laid on edges two faces of
//! a solid already share (a cover over a box's cut top) is left beside the solid. As the fork's
//! e5d9bba, edges still lamina after that pass are glued at the same tolerance to the edges they
//! lie on whatever uses those (`bAllowSpineEdges`), on a copy ([`close_onto_shared_edges`]).
use solidcore::kernel::Kernel;
use solidcore::topology::brep::p1 as bl;
use solidcore::topology::brep::p2::{ArenaNotifyState, is_manifold_solid};
use solidcore::topology::brep::p3::{DeleteFaceOptions, delete_face, orient_trimmed_surfaces, with_kernel_callbacks};
use solidcore::topology::brep::p4::stitch_faces;
use solidcore::topology::brep::p5::remove_topological_edges_and_vertices;
use solidcore::topology::face::p2 as face_p2;
use solidcore::topology::topology_traverser::Marks;
use solidcore::topology::{Brep, FaceId, RegionId, edge};
use super::Body;
use super::blend::{edge_miss, finish, on_copy, pick_edges, points_ok, status, tolerance_ok};
use crate::{Error, Result};
use solidcore::topology::brep::p3::brep_copy;
type Out<T> = std::result::Result<T, Error>;
/// `smlib_stitch`: the faces of a copy of `body` sewn along the vertices and edges that coincide
/// within `tolerance` (finite, positive, at most 1e3), then oriented; with whether the result is a
/// closed solid (no lamina edge left and `IsManifoldSolid`).
pub(crate) fn stitch(body: &Body, tolerance: f64) -> Result<(Body, bool)> {
if !tolerance.is_finite() || !(tolerance > 0.0) || tolerance > 1e3 {
return Err(Error::Argument);
}
let mut closed = false;
let r = on_copy("SmBrep::StitchFaces", body, |k, mut b| {
let mut lamina = match stitch_faces(k, &mut b, tolerance, None, false) {
Ok(o) => o.lamina_edges,
Err(e) => return Ok(Err(status(e))),
};
if lamina != 0 {
b = match close_onto_shared_edges(k, b, tolerance, &mut lamina)? {
Ok(b) => b,
Err(e) => return Ok(Err(e)),
};
}
let oriented = with_kernel_callbacks(k, &mut b, |k, b, env| {
let mut marks = Marks::default();
orient_trimmed_surfaces(k, b, env, &mut marks, true, lamina != 0, None)
});
if let Err(e) = oriented {
return Ok(Err(status(e)));
}
b.refresh_cached_topology();
closed = lamina == 0 && is_manifold_solid(&b)?;
Ok(Ok(b))
})?;
Ok((r, closed))
}
/// How many regions of `b` are material: neither the infinite region nor a void (`IsVoid`'s
/// `UNSURE` counts as void, as the capi's `!IsVoid()`).
fn material_regions(b: &Brep) -> solidcore::Result<usize> {
let infinite = b.infinite_region();
let mut n = 0;
for r in bl::regions(b, None)? {
n += usize::from(Some(r) != infinite && b.region(r)?.is_void() == 0);
}
Ok(n)
}
/// The region on the side of `f`'s faceuse `fu` (its shell's), if any.
fn region_of(b: &Brep, fu: Option<solidcore::topology::FaceuseId>) -> solidcore::Result<Option<RegionId>> {
let Some(fu) = fu else { return Ok(None) };
match b.faceuse(fu)?.shell {
Some(sh) => Ok(b.shell(sh)?.region),
None => Ok(None),
}
}
/// `remove_faces_inside`: every face of `b` lying between two material regions is inside the
/// part and removed (`DeleteFace(f, TRUE, TRUE)` with the B-rep open for editing): its regions
/// are joined, and its edges and vertices nothing else uses go with it.
fn remove_faces_inside(k: &mut Kernel, b: &mut Brep) -> solidcore::Result<Out<()>> {
let infinite = b.infinite_region();
let mut inside: Vec<FaceId> = Vec::new();
for f in bl::faces(b, None)? {
let (u1, u2) = face_p2::faceuses(b, f)?;
let (Some(r1), Some(r2)) = (region_of(b, u1)?, region_of(b, u2)?) else { continue };
let material = |r: RegionId| -> solidcore::Result<bool> { Ok(Some(r) != infinite && b.region(r)?.is_void() == 0) };
if r1 != r2 && material(r1)? && material(r2)? {
inside.push(f);
}
}
let editing = b.editing_enabled();
b.set_editing_enabled(true);
let mut deleted = Ok(());
{
let mut state = ArenaNotifyState::new(b);
let mut env = state.env(None);
let mut marks = Marks::default();
for f in inside {
let options = DeleteFaceOptions { delete_edges_vertices: true, do_region_nesting: true, ..DeleteFaceOptions::default() };
if let Err(e) = delete_face(k, b, &mut env, &mut marks, f, options) {
deleted = Err(e);
break;
}
}
}
b.set_editing_enabled(editing);
b.refresh_cached_topology();
Ok(deleted.map_err(status))
}
/// `close_onto_shared_edges` (fork e5d9bba): `b`, stitched as a manifold with `lamina` edges
/// left free, those glued at `tolerance` to the edges they lie on whatever uses those
/// (`StitchFaces` with `bAllowSpineEdges`) on a copy. The copy is taken, with its count of
/// lamina edges, when the gluing closed a space (a new, material region) and, the faces then
/// between two material regions removed, no edge has more than two faces; a sheet that closes
/// nothing with the solid's faces stays as the manifold stitch left it. A gluing that leaves
/// fewer material regions than it found (a sheet with no sector between the faces at its rim:
/// glued elsewhere it joins the solid's inside to the outside) is refused, as the capi's
/// `SMLIB_ERR_UNSUPPORTED`.
fn close_onto_shared_edges(k: &mut Kernel, b: Brep, tolerance: f64, lamina: &mut u32) -> solidcore::Result<Out<Brep>> {
let mut c = brep_copy(k, &b)?;
let material = material_regions(&c)?;
let outcome = match stitch_faces(k, &mut c, tolerance, None, true) {
Ok(o) => o,
Err(e) => return Ok(Err(status(e))),
};
if outcome.stitched_edges == 0 {
return Ok(Ok(b));
}
if material_regions(&c)? < material {
return Ok(Err(Error::Unsupported));
}
if let Err(e) = remove_faces_inside(k, &mut c)? {
return Ok(Err(e));
}
for e in bl::edges(&c, None)? {
if edge::is_spine(&c, e)? {
return Ok(Ok(b));
}
}
*lamina = outcome.lamina_edges;
Ok(Ok(c))
}
/// `smlib_merge_faces`: `SmBrep::RemoveTopologicalEdgesAndVertices` on a copy of `body`, keeping
/// the edges nearest `keep` (each within `tolerance`); then `finish` (faces left, and a solid in
/// gives a manifold solid).
pub(crate) fn merge_faces(body: &Body, keep: &[[f64; 3]], tolerance: f64) -> Result<Body> {
if !points_ok(keep, true) || !tolerance_ok(tolerance) {
return Err(Error::Argument);
}
on_copy("SmBrep::RemoveTopologicalEdgesAndVertices", body, |k, mut b| {
let kept = if keep.is_empty() {
Vec::new()
} else {
match pick_edges(k, &b, keep, tolerance)? {
Ok((edges, _)) => edges,
Err(m) => return Ok(Err(edge_miss(m))),
}
};
let solid_in = is_manifold_solid(&b)?;
let keep_edges = (!kept.is_empty()).then_some(kept.as_slice());
if let Err(e) = remove_topological_edges_and_vertices(k, &mut b, keep_edges, None) {
return Ok(Err(status(e)));
}
b.refresh_cached_topology();
finish(b, solid_in)
})
}
+356
View File
@@ -0,0 +1,356 @@
//! Stitch and merge faces on solidcore, against closed forms: counts, volumes (or areas) and
//! whether the result closes, and every result exports to a record that validates.
//!
//! A stitch's input is six (or five) separate planar faces of a 10 mm cube, each its own face of
//! one body with its own edges, none shared; a merge's is a cube with four faces cut across, or
//! two boxes unioned, on solidcore.
use std::collections::BTreeMap;
use solidcore::curve::curve::Curve;
use solidcore::curve::line::Line;
use solidcore::geom::extent2d::Extent2d;
use solidcore::geom::vector2d::Vec2;
use solidcore::geom::vector3d::Vec3;
use solidcore::kernel::Kernel;
use solidcore::nurbs::b_spline_curve::p1::KnotType;
use solidcore::nurbs::b_spline_surface::p1::{BSplineSurface, BSplineSurfaceForm, CanonicalSurfaceData};
use solidcore::primitive::api_primitives::{create_box, create_plane};
use solidcore::primitive::api_trim_ops::trim_surface_with_3d_curves;
use solidcore::surface::plane::p1::Plane;
use solidcore::surface::surface::Surface;
use solidcore::topology::Brep;
use solidcore::topology::brep::p2::ArenaNotifyState;
use solidcore::topology::brep::p3::{merge_brep, new_brep};
use solidcore::topology::brep::p8::create_face_from_surface;
use super::merge::{merge_faces, stitch};
use super::sweep::build_faces;
use super::{Body, area, bounds, counts, make_box, run, to_arrays, volume};
use crate::export::native;
use crate::{BooleanOp, Error};
const TOL: f64 = 0.01;
/// The sides of the cube [0, 10]^3 as (origin, u axis, v axis), each over [0, 10]^2.
const SIDES: [([f64; 3], [f64; 3], [f64; 3]); 6] = [
([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]),
([10.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]),
([0.0, 10.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
([0.0, 0.0, 10.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
];
fn v3(p: [f64; 3]) -> Vec3 {
Vec3::new(p[0], p[1], p[2])
}
/// The sides `sides` of the cube as separate faces of one body, the top lifted by `lift`.
fn loose_faces(sides: &[usize], lift: f64) -> Body {
run("loose faces", |k| {
let mut b = new_brep(k, solidcore::geom::tol::USE_DEFAULT, solidcore::geom::tol::USE_DEFAULT)?;
for &i in sides {
let Some(&(mut o, u, v)) = SIDES.get(i) else { continue };
if i == 5 {
o[2] += lift;
}
let plane = Plane::new(v3(o), v3(u), v3(v), Vec2::new(1.0, 1.0), &Extent2d::from_bounds(0.0, 0.0, 10.0, 10.0));
let domain = Extent2d::from_bounds(0.0, 0.0, 10.0, 10.0);
build_faces(&mut b, |b, fenv| create_face_from_surface(k, b, Some(Surface::from(plane)), &domain, fenv))?;
}
b.refresh_cached_topology();
Ok(b)
})
.map(Body::new)
.unwrap()
}
/// Two 10 mm cubes side by side along x, unioned: one 20 x 10 x 10 block.
fn two_boxes() -> Body {
let a = make_box([0.0; 3], [10.0; 3]).unwrap();
let b = make_box([10.0, 0.0, 0.0], [10.0; 3]).unwrap();
super::boolean::boolean(&a, &b, BooleanOp::Union).unwrap().unwrap()
}
/// The cube with its four faces across x = 5 cut there (`split_faces`): ten faces.
fn split_cube() -> Body {
let plane = crate::ssi::Spec::Plane { origin: [5.0, 0.0, 0.0], xaxis: [0.0, 1.0, 0.0], yaxis: [0.0, 0.0, 1.0], u: [-1.0, 11.0], v: [-1.0, 11.0] };
let cube = make_box([0.0; 3], [10.0; 3]).unwrap();
super::split::split_faces(&cube, &[0, 1, 2, 3, 4, 5], &[&plane]).unwrap().unwrap().0
}
#[derive(Clone, Copy, Debug)]
enum Input {
SplitCube,
/// [`loose_faces`]: which sides, the top's lift.
Loose(&'static [usize], f64),
TwoBoxes,
Box,
}
impl Input {
fn core(self) -> Body {
match self {
Input::Loose(sides, lift) => loose_faces(sides, lift),
Input::TwoBoxes => two_boxes(),
Input::SplitCube => split_cube(),
Input::Box => make_box([0.0; 3], [10.0; 3]).unwrap(),
}
}
}
#[derive(Clone, Copy, Debug)]
enum Op {
Stitch(Input, f64),
Merge(Input, &'static [[f64; 3]], f64),
}
#[derive(Clone, Debug, PartialEq)]
enum Expect {
/// A solid: volume, counts, closed.
Solid(f64, [usize; 3], bool),
/// A sheet: area, counts, closed.
Sheet(f64, [usize; 3], bool),
Fails(Error),
}
const ALL: &[usize] = &[0, 1, 2, 3, 4, 5];
const OPEN: &[usize] = &[0, 1, 2, 3, 4];
fn case(name: &str) -> (Op, Expect) {
match name {
"stitch_six_faces_into_a_cube" => (Op::Stitch(Input::Loose(ALL, 0.0), TOL), Expect::Solid(1000.0, [6, 12, 8], true)),
"stitch_five_faces_into_an_open_box" => (Op::Stitch(Input::Loose(OPEN, 0.0), TOL), Expect::Sheet(500.0, [5, 12, 8], false)),
// The top 0.05 mm above the walls: farther than the tolerance, so it stays apart.
"stitch_leaves_a_gap_wider_than_the_tolerance" => (Op::Stitch(Input::Loose(ALL, 0.05), TOL), Expect::Sheet(600.0, [6, 16, 12], false)),
// Within a 0.1 mm tolerance, the same gap closes.
// (the top stays on its own plane, so the solid reaches up to it)
"stitch_closes_a_gap_within_the_tolerance" => (Op::Stitch(Input::Loose(ALL, 0.05), 0.1), Expect::Solid(1005.0, [6, 12, 8], true)),
"stitch_a_cube_already_sewn" => (Op::Stitch(Input::Box, TOL), Expect::Solid(1000.0, [6, 12, 8], true)),
"merge_two_boxes_into_one_block" => (Op::Merge(Input::TwoBoxes, &[], TOL), Expect::Solid(2000.0, [6, 12, 8], false)),
"merge_a_split_cube_into_one" => (Op::Merge(Input::SplitCube, &[], TOL), Expect::Solid(1000.0, [6, 12, 8], false)),
"merge_a_box_changes_nothing" => (Op::Merge(Input::Box, &[], TOL), Expect::Solid(1000.0, [6, 12, 8], false)),
"merge_keeps_a_picked_edge" => (Op::Merge(Input::SplitCube, &[[5.0, 5.0, 10.0]], TOL), Expect::Solid(1000.0, [7, 15, 10], false)),
"merge_picks_near_nothing" => {
(Op::Merge(Input::SplitCube, &[[5.0, 5.0, 10.0], [50.0, 50.0, 50.0]], TOL), Expect::Fails(Error::NoMatch { point: 1 }))
}
_ => panic!("no case {name}"),
}
}
#[derive(Debug, PartialEq)]
struct Measured {
size: f64,
counts: [usize; 3],
closed: bool,
bounds: ([f64; 3], [f64; 3]),
kinds: BTreeMap<String, usize>,
}
type Got = std::result::Result<Measured, Error>;
fn kinds(rec: &solidcraft_geom::brep::Brep) -> BTreeMap<String, usize> {
let mut out = BTreeMap::new();
for f in &rec.faces {
let v = serde_json::to_value(&f.surface).unwrap();
*out.entry(v["kind"].as_str().unwrap_or("?").to_owned()).or_default() += 1;
}
out
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs()
}
fn judge(name: &str, who: &str, got: Got) -> Got {
let (op, expect) = case(name);
match (&expect, &got) {
(Expect::Fails(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"),
(Expect::Fails(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"),
(_, Err(g)) => panic!("{name}: {who} failed: {g}"),
(Expect::Solid(s, n, closed) | Expect::Sheet(s, n, closed), Ok(m)) => {
assert!(rel(m.size, *s) < 1e-9, "{name}: {who} size {} vs analytic {s}", m.size);
assert_eq!(m.counts, *n, "{name}: {who} counts");
if matches!(op, Op::Stitch(..)) {
assert_eq!(m.closed, *closed, "{name}: {who} closed");
}
}
}
got
}
fn is_sheet(name: &str) -> bool {
matches!(case(name).1, Expect::Sheet(..))
}
fn solidcore_case(name: &str) -> Got {
let (op, _) = case(name);
let r = match op {
Op::Stitch(input, tol) => stitch(&input.core(), tol),
Op::Merge(input, keep, tol) => merge_faces(&input.core(), keep, tol).map(|b| (b, false)),
};
let got = r.map(|(b, closed)| {
let (rec, _) = native(&to_arrays(&b).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
let size = if is_sheet(name) { area(&b, 1e-8) } else { volume(&b, 1e-8) }.unwrap();
Measured { size, counts: counts(&b).unwrap(), closed, bounds: bounds(&b).unwrap(), kinds: kinds(&rec) }
});
judge(name, "solidcore", got)
}
macro_rules! cases {
($($m:ident $(: $ign:meta)?),* $(,)?) => {$(
mod $m {
#[test]
$(#[$ign])?
fn solidcore_matches_the_closed_form() {
let _ = super::solidcore_case(stringify!($m));
}
}
)*};
}
cases!(
stitch_six_faces_into_a_cube,
stitch_five_faces_into_an_open_box,
stitch_leaves_a_gap_wider_than_the_tolerance,
stitch_closes_a_gap_within_the_tolerance,
stitch_a_cube_already_sewn,
merge_two_boxes_into_one_block,
merge_a_split_cube_into_one,
merge_a_box_changes_nothing,
merge_keeps_a_picked_edge,
merge_picks_near_nothing,
);
/// The capi's argument checks, before any kernel call.
#[test]
fn hostile_arguments_are_refused() {
let b = make_box([0.0; 3], [10.0; 3]).unwrap();
for t in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e3 + 1.0] {
assert_eq!(stitch(&b, t).err(), Some(Error::Argument), "stitch tolerance {t}");
}
for t in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e6] {
assert_eq!(merge_faces(&b, &[], t).err(), Some(Error::Argument), "merge tolerance {t}");
}
assert_eq!(merge_faces(&b, &[[f64::NAN, 0.0, 0.0]], TOL).err(), Some(Error::Argument));
let many = vec![[0.0; 3]; crate::MAX_POINTS + 1];
assert_eq!(merge_faces(&b, &many, TOL).err(), Some(Error::Argument));
}
/// A 10 mm cube and, unsewn in the same body (`MergeBrep`), a sheet made on the kernel.
fn cube_and_sheet(sheet: impl FnOnce(&mut Kernel) -> solidcore::Result<Brep>) -> Body {
run("cube and sheet", |k| {
let mut cube = create_box(k, Vec3::new(0.0, 0.0, 0.0), 10.0, 10.0, 10.0)?;
let sheet = sheet(k)?;
let mut state = ArenaNotifyState::new(&cube);
let mut env = state.env(None);
merge_brep(k, &mut env, &mut cube, &sheet, &solidcore::topology::brep::p2::copy_source_ref, None, None, true)?;
cube.refresh_cached_topology();
Ok(cube)
})
.map(Body::new)
.unwrap()
}
/// The capi's `cube_and_cover` (capi/tests/sheets.cpp): the cube and a cover over its top, a
/// height field z(x, y) over [0, 10]^2 of `degree` and uniform `spans`, its control points at
/// their Greville abscissae (so its parameters are x and y), z = 10 but for the middle one,
/// 10 + `rise`, trimmed to the square by lines (`SmApiTrimSurfaceWith3dCurves`), so its rim is the
/// cube's top edges.
fn cube_and_cover(degree: usize, spans: usize, rise: f64) -> Body {
cube_and_sheet(|k| {
let n = spans + degree;
let step = 10.0 / spans as f64;
let unique: Vec<f64> = (0..=spans).map(|k| step * k as f64).collect();
let mult: Vec<usize> = (0..=spans).map(|k| if k == 0 || k == spans { degree + 1 } else { 1 }).collect();
let knots: Vec<f64> = unique.iter().zip(&mult).flat_map(|(&u, &m)| std::iter::repeat_n(u, m)).collect();
let greville: Vec<f64> = (0..n).map(|i| knots[i + 1..=i + degree].iter().sum::<f64>() / degree as f64).collect();
let mut net = Vec::with_capacity(n * n);
for i in 0..n {
for j in 0..n {
let z = if 2 * i + 1 == n && 2 * j + 1 == n { 10.0 + rise } else { 10.0 };
net.push(Vec3::new(greville[i], greville[j], z));
}
}
let surface = BSplineSurface::create_canonical(&CanonicalSurfaceData {
u_degree: degree,
v_degree: degree,
control_points: &net,
form: BSplineSurfaceForm::Unspecified,
u_multiplicities: &mult,
v_multiplicities: &mult,
u_knots: &unique,
v_knots: &unique,
knot_type: KnotType::Unspecified,
weights: None,
uv_domain: None,
})?;
let corner = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]].map(|[x, y]| Vec3::new(x, y, 10.0));
let mut sides = Vec::with_capacity(4);
for i in 0..4 {
sides.push(Curve::from(Line::create_line_segment(3, corner[i], corner[(i + 1) % 4], None)?));
}
trim_surface_with_3d_curves(k, Surface::from(surface), &[4], sides)
})
}
/// A biquadratic Bezier cover rising 9 at its middle control point,
/// z = 10 + 9 * 2s(1-s) * 2t(1-t) (s = x/10, t = y/10): the capi's cover.
fn cube_and_biquadratic_cover() -> Body {
cube_and_cover(2, 1, 9.0)
}
/// A bicubic cover over 8 spans whose only raised control point is the middle one: the capi's
/// cover lying on the cube's top for 2.5 mm in from every edge.
fn cube_and_bicubic_cover() -> Body {
cube_and_cover(3, 8, 9.0)
}
/// The capi's flap: a 10 x 10 sheet on from the cube's top edge at x = 10, in the plane z = 10.
fn cube_and_flap() -> Body {
cube_and_sheet(|k| create_plane(k, Vec3::new(10.0, 0.0, 10.0), 10.0, 10.0))
}
/// The space between the cover and the cube's top encloses 9 * (10/3)^2 = 100 mm^3: sewn, it is
/// part of the solid and the top, now inside it, goes. A closed solid of the cube's five other
/// faces and the cover, 1100 mm^3.
#[test]
fn a_cover_sews_onto_a_cube_into_a_closed_solid() {
let (sewn, closed) = stitch(&cube_and_biquadratic_cover(), 1e-6).unwrap();
assert!(closed);
assert_eq!(counts(&sewn).unwrap()[0], 6);
let v = volume(&sewn, 1e-8).unwrap();
assert!((v - 1100.0).abs() < 1e-6, "volume {v}");
}
/// The bicubic cover overlaps the top: no sector between the faces at the rim to sew it into, so
/// it is refused, not sewn so that the cube's inside joins the outside.
#[test]
fn a_cover_lying_on_the_cube_is_refused() {
assert_eq!(stitch(&cube_and_bicubic_cover(), 1e-6).err(), Some(Error::Unsupported));
}
/// The flap closes nothing with the cube: it stays the sheet beside it the manifold stitch
/// leaves, not glued into an edge three faces would share.
#[test]
fn a_flap_on_a_cube_edge_stays_beside_it() {
let (sewn, closed) = stitch(&cube_and_flap(), 1e-6).unwrap();
assert!(!closed);
assert_eq!(counts(&sewn).unwrap()[0], 7);
let (v, a) = (volume(&sewn, 1e-8).unwrap(), area(&sewn, 1e-8).unwrap());
assert!((v - 1000.0).abs() < 1e-6 && (a - 700.0).abs() < 1e-6, "volume {v}, area {a}");
}
/// The inputs are never changed.
#[test]
fn the_body_stays_as_it_was() {
let loose = loose_faces(ALL, 0.0);
let _ = stitch(&loose, TOL).unwrap();
assert_eq!(counts(&loose).unwrap(), [6, 24, 24]);
let block = two_boxes();
let was = counts(&block).unwrap();
let _ = merge_faces(&block, &[], TOL).unwrap();
assert_eq!(counts(&block).unwrap(), was);
}
+660
View File
@@ -0,0 +1,660 @@
//! Shells, face offsets, thickening, sheet offsets and drafts on solidcore: the C API's logic
//! round SMLib's offset executive, ported from the fork's `capi/src/smlib_ops.cpp`
//! (`smlib_shell`, `smlib_offset_faces`, `smlib_thicken`, `smlib_draft`, `leaves_cavity`,
//! `FaceSet`, `outer_faceuse`, `move_faces`, `draft_faces`), `capi/src/smlib_sheets.cpp`
//! (`smlib_offset_sheet`) and `capi/src/smlib_pick.h` (`pick_faces`).
//!
//! As there, every operation works on a copy of its body (`SmApiBrepCopy`) and picks faces by
//! point; the shell and the offsets are `SmApiShellBrepFull` and `SmApiOffsetBrepFull`, with
//! convex edges extended and the results merged. A sheet offset takes faces that close into a
//! solid as the capi does since tangency-labs/smlib#17.
//!
//! A draft tilts planes and, since tangency-labs/smlib#27, turns a cylinder about the pull into
//! a cone (solidcore's `Cone::lean_cylinder`, solidcore#1545), and since smlib#53 a cone about the
//! pull into the cone its generators turned make. A draft's tilted faces go in through solidcore's
//! `SmBrep::LocalOperation` ([`local_operation`]). The capi's check that it moved some face
//! (`moved_faces`) marks the replacement surfaces; solidcore names a surface by its owning face, so
//! the mark goes on the picked faces, which it stays with through the operation (solidcore#1875).
use solidcore::EntityRef;
use solidcore::SolidError;
use solidcore::geom::attribute::{Attribute, Behavior, ids};
use solidcore::geom::axis2_placement::Axis2Placement;
use solidcore::geom::object::Shape;
use solidcore::geom::vector3d::{Point3, Vec3};
use solidcore::intersect::point_class::PointClassType;
use solidcore::kernel::Kernel;
use solidcore::math::Point2;
use solidcore::nurbs::b_spline_curve::ControlPointForm;
use solidcore::nurbs::b_spline_surface::p1::SurfParamType;
use solidcore::nurbs::b_spline_surface_n_lib::p2::OrientType;
use solidcore::nurbs::knots::Side;
use solidcore::offset::offset_executive::p2::{OffsetOptions, ShellOptions, local_operation_simple, offset_brep_simple, shell_brep_simple};
use solidcore::offset::offset_faces::{OffsetFacesExecutive, OffsetFacesRefusal};
use solidcore::query::api_queries::{face_bounding_box, face_classify_uv, face_closest_point_in_shape, face_internal_point};
use solidcore::query::brep::{BrepIdentity, BrepQuery, LiveBrepObjects};
use solidcore::surface::cone::Cone;
use solidcore::surface::surface::{Surface, SurfaceKind};
use solidcore::topology::brep::{p1 as brep_p1, p2 as brep_p2};
use solidcore::topology::face::p2 as face_p2;
use solidcore::topology::{Brep, BrepId, FaceId, FaceuseId};
use super::blend::{Miss, on_copy, p3, pick, points_ok, status as blend_status, tolerance_ok};
use super::{Body, answer};
use crate::{Error, Result};
/// The capi's statuses as SolidCraft errors: an operation's own result.
type Out<T> = std::result::Result<T, Error>;
/// `SM_ERR_INVALID_INPUT`.
const INVALID_INPUT: i32 = 1007;
/// `SM_ERR_OFFSET_WALLS_TOUCH` (fork a759622), which `status` (smlib_shim.h) reports as
/// `SMLIB_ERR_WALLS_TOUCH`.
const OFFSET_WALLS_TOUCH: i32 = 1042;
/// `status` (smlib_shim.h): an offset whose walls touch is [`Error::WallsTouch`]; a vertex moved
/// past an edge's other end (`SM_ERR_OFFSET_EDGE_VANISHES`, fork 34318f4) is
/// `SMLIB_ERR_UNSUPPORTED`; any other status as the blends pass it on.
fn status(e: SolidError) -> Error {
match e {
SolidError::Upstream { code: OFFSET_WALLS_TOUCH, .. } => Error::WallsTouch,
e if e.is_offset_edge_vanishes() => Error::Unsupported,
e => blend_status(e),
}
}
/// `std::fabs(x) > 1e6` and the rest of the capi's size checks: finite, not zero, at most 1e6.
fn distance_ok(x: f64) -> bool {
x.is_finite() && x != 0.0 && x.abs() <= 1e6
}
/// The B-rep id every shape and face key in this module names the one body by.
const ONE: BrepId = BrepId::from_index(0);
/// Faces with their loose bounding boxes, for nearest-face queries (`FaceSet`).
struct FaceSet {
faces: Vec<FaceId>,
boxes: Vec<(Point3, Point3)>,
/// Each face's shape (`SmApiFaceClosestPoint` makes one per query; it depends only on the
/// face, so it is made once).
shapes: Vec<Shape>,
}
impl FaceSet {
fn new() -> FaceSet {
FaceSet { faces: Vec::new(), boxes: Vec::new(), shapes: Vec::new() }
}
/// `add`: false when the face's box cannot be measured.
fn add(&mut self, k: &mut Kernel, b: &Brep, f: FaceId) -> solidcore::Result<bool> {
let Some(bx) = answer(face_bounding_box(k, b, f, false))? else { return Ok(false) };
let Some(shape) = answer(Shape::new(k, &(ONE, b), &[], &[(ONE, f)], &[], &[]))? else { return Ok(false) };
self.faces.push(f);
self.boxes.push((bx.min(), bx.max()));
self.shapes.push(shape);
Ok(true)
}
/// `nearest`: the distance from `p` to the nearest face, with the nearest point; infinite
/// when none answers. A face whose box is farther than the nearest face so far is skipped.
fn nearest(&self, k: &Kernel, live: &LiveBrepObjects<'_, '_>, p: Point3) -> solidcore::Result<(f64, Option<Point3>)> {
let (mut best, mut at) = (f64::INFINITY, None);
for ((&f, (lo, hi)), shape) in self.faces.iter().zip(&self.boxes).zip(&self.shapes) {
let mut bx = 0.0;
for (l, (h, x)) in [lo.x, lo.y, lo.z].into_iter().zip([hi.x, hi.y, hi.z].into_iter().zip([p.x, p.y, p.z])) {
let out = (l - x).max((x - h).max(0.0));
bx += out * out;
}
if f64::sqrt(bx) >= best {
continue;
}
if let Some(c) = answer(face_closest_point_in_shape(k, shape, live, (ONE, f), p))?
&& c.distance.is_finite()
&& c.distance < best
{
best = c.distance;
at = Some(c.point);
}
}
Ok((best, at))
}
}
/// The live view of `b` the closest-point solves read, under `identity`: `query`'s effective
/// identity ([`BrepQuery::effective_identity`]).
fn live<'a, 'i>(b: &'a mut Brep, identity: &'a BrepIdentity<'i>) -> solidcore::Result<LiveBrepObjects<'a, 'i>> {
LiveBrepObjects::new(b, identity, None)
}
/// `pick_faces` (`face_distance`: `SmApiFaceClosestPoint`; a face whose query returns a status
/// other than success is skipped, [`answer`]).
pub(super) fn pick_faces(k: &mut Kernel, b: &mut Brep, points: &[[f64; 3]], tol: f64) -> solidcore::Result<std::result::Result<Vec<FaceId>, Miss>> {
let all = brep_p1::faces(b, None)?;
let model_tol = b.tolerance();
let mut shapes = Vec::with_capacity(all.len());
for &f in &all {
shapes.push(answer(Shape::new(k, &(ONE, &*b), &[], &[(ONE, f)], &[], &[]))?);
}
let query = BrepQuery::of_brep(b);
let identity = query.effective_identity();
let objects = live(b, &identity)?;
let k: &Kernel = k;
let indices: Vec<usize> = (0..all.len()).collect();
let picked = pick(&indices, points, tol, model_tol, |i, p| {
let (Some(&f), Some(Some(shape))) = (all.get(i), shapes.get(i)) else { return Ok(None) };
Ok(answer(face_closest_point_in_shape(k, shape, &objects, (ONE, f), p))?.map(|c| c.distance))
})?;
Ok(picked.map(|(p, _)| p.iter().filter_map(|&i| all.get(i).copied()).collect()))
}
/// A face pick's failure as `pick_error` (ops.rs) reads it.
pub(super) fn face_miss(m: Miss) -> Error {
match m {
Miss::None(point) => Error::NoMatch { point },
Miss::Ambiguous(point) => Error::Ambiguous { point },
}
}
/// `outer_faceuse`: the faceuse of `f` on the outside of its body, the one bounding the infinite
/// region (for a sheet, both do; then the upward one).
fn outer_faceuse(b: &Brep, f: FaceId) -> solidcore::Result<Option<FaceuseId>> {
let Some(fu) = face_p2::upward_faceuse(b, f)? else { return Ok(None) };
let use_ = b.faceuse(fu)?;
let outside = match use_.shell {
Some(s) => b.shell(s)?.region.is_some() && b.shell(s)?.region == b.infinite_region(),
None => false,
};
Ok(if outside { Some(fu) } else { use_.mate })
}
/// `leaves_cavity`: whether an inward offset of `wall` (> 0) from every face of the solid `b`
/// but `open` leaves a cavity: a point inside `b` farther than `wall`, and a margin, from every
/// face that is offset. A point counts only as the centre of a ball inside `b`, reached from a
/// face sample by stepping inward along the normal, then from ball to ball. The capi refuses a
/// wall this finds no room for before SMLib sees it (C++ SMLib corrupts memory there,
/// solidcore#380); the same refusal here keeps the C++ path's answer.
fn leaves_cavity(k: &mut Kernel, b: &mut Brep, open: &[FaceId], wall: f64) -> solidcore::Result<bool> {
let list = brep_p1::faces(b, None)?;
let (mut all, mut walls) = (FaceSet::new(), FaceSet::new());
for &f in &list {
if !all.add(k, b, f)? {
return Ok(false);
}
if !open.contains(&f) && !walls.add(k, b, f)? {
return Ok(false);
}
}
if walls.faces.is_empty() {
return Ok(true);
}
let tol = b.tolerance();
let margin = (10.0 * tol).max(1e-3 * wall);
let need = wall + margin;
// Face samples: a grid over each offset face's domain, the points inside its trims, each
// with the inward unit normal there.
const GRID: usize = 5;
const MAX_SAMPLES: usize = 2048;
let mut samples: Vec<(Point3, Vec3)> = Vec::new();
for &f in &walls.faces {
let Some(fu) = outer_faceuse(b, f)? else { continue };
let inward = if b.faceuse(fu)?.orientation == OrientType::Same { -1.0 } else { 1.0 };
let dom = b.face(f)?.uv_domain;
for i in 0..GRID {
for j in 0..GRID {
if samples.len() >= MAX_SAMPLES {
break;
}
let uv: Point2 = dom.evaluate((i as f64 + 0.5) / GRID as f64, (j as f64 + 0.5) / GRID as f64);
if answer(face_classify_uv(k, b, f, uv))? != Some(PointClassType::Face) {
continue;
}
let Some(surf) = b.face(f)?.surface.as_ref() else { break };
let (Some(at), Some(n)) = (answer(surf.evaluate_point(k, uv))?, answer(surf.evaluate_normal(k, uv, Side::Left, Side::Left))?) else {
continue;
};
let len = n.length();
if !(len > 0.0) || !len.is_finite() {
continue;
}
samples.push((at, n * (inward / len)));
}
}
}
let query = BrepQuery::of_brep(b);
let identity = query.effective_identity();
let objects = live(b, &identity)?;
let k: &Kernel = k;
// A step of `h` inward from a sample ends at a ball's centre when no face is nearer to it
// than `h` (the step crossed none, and the ball touches only the sample's face).
let ball = |s: Point3, n: Vec3, h: f64| -> solidcore::Result<(bool, Point3, f64)> {
let c = s + n * h;
let (r, _) = all.nearest(k, &objects, c)?;
Ok((r.is_finite() && r >= h * (1.0 - 1e-9), c, r))
};
// First the direct step of `need` from each sample.
for &(s, n) in &samples {
let (inside, c, _) = ball(s, n, need)?;
if inside && walls.nearest(k, &objects, c)?.0 >= need {
return Ok(true);
}
}
// Then climbing, from a small ball at each sample: step away from the nearest offset face by
// most of the ball's radius (staying inside the ball, so inside the body) while the distance
// from the offset faces grows.
const STEPS: usize = 48;
for &(s, n) in &samples {
let (inside, mut c, mut r) = ball(s, n, margin.max(1e3 * tol))?;
if !inside {
continue;
}
for _ in 0..STEPS {
let (d, q) = walls.nearest(k, &objects, c)?;
if d >= need {
return Ok(true);
}
let Some(q) = q.filter(|_| d > 0.0 && d.is_finite()) else { break };
let next = c + (c - q) * (0.9 * r / d);
let (rn, _) = all.nearest(k, &objects, next)?;
if !(rn > 0.0) || !rn.is_finite() || walls.nearest(k, &objects, next)?.0 <= d * (1.0 + 1e-9) {
break;
}
c = next;
r = rn;
}
}
Ok(false)
}
/// `SmApiShellBrepFull(b, distance, TRUE, TRUE, TRUE, open, r)`: convex edges extended, results
/// merged, side walls made at a sheet's edges, and (as the fork does, 5448b9a) a thickened
/// sheet's coplanar side walls one face.
fn shell_brep_full(k: &mut Kernel, b: &Brep, distance: f64, open: &[FaceId]) -> solidcore::Result<Brep> {
let options = ShellOptions {
offset: OffsetOptions { distance, extend_convex_edges: true, merge_results: true },
create_offset_solid: true,
merge_sheet_side_walls: true,
};
shell_brep_simple(k, b, options, open)
}
/// `SmApiOffsetBrepFull(b, distance, TRUE, TRUE, r)`.
fn offset_brep_full(k: &mut Kernel, b: &mut Brep, distance: f64) -> solidcore::Result<Brep> {
offset_brep_simple(k, b, OffsetOptions { distance, extend_convex_edges: true, merge_results: true })
}
/// `finish`: the result must have faces, and a solid input must give a closed manifold solid.
fn finish(b: Brep, solid_in: bool) -> solidcore::Result<Out<Brep>> {
if b.face_list().is_empty() {
return Ok(Err(Error::Empty));
}
if solid_in && !brep_p2::is_manifold_solid(&b)? {
return Ok(Err(Error::NotSolid));
}
Ok(Ok(b))
}
/// An SMLib call's own status passed on (`status(...)` in the capi), else the value.
fn called<T>(r: solidcore::Result<T>) -> solidcore::Result<Out<T>> {
match r {
Ok(v) => Ok(Ok(v)),
Err(e) => Ok(Err(status(e))),
}
}
/// `smlib_shell`: the faces nearest `points` (each within `tolerance`) opened, the rest offset
/// by `thickness` (negative: inward) into a wall; none gives a closed hollow body. An inward
/// wall of a solid that leaves no cavity is [`Error::NoRoom`], refused before the shell.
pub(crate) fn shell(body: &Body, points: &[[f64; 3]], tolerance: f64, thickness: f64) -> Result<Body> {
if !points_ok(points, true) || !tolerance_ok(tolerance) || !distance_ok(thickness) {
return Err(Error::Argument);
}
on_copy("smlib_shell", body, |k, mut w| {
let picked = match pick_faces(k, &mut w, points, tolerance)? {
Ok(p) => p,
Err(m) => return Ok(Err(face_miss(m))),
};
let solid_in = brep_p2::is_manifold_solid(&w)?;
if thickness < 0.0 && solid_in && !leaves_cavity(k, &mut w, &picked, -thickness)? {
return Ok(Err(Error::NoRoom));
}
match called(shell_brep_full(k, &w, thickness, &picked))? {
Ok(r) => finish(r, solid_in),
Err(e) => Ok(Err(e)),
}
})
}
/// `smlib_offset_faces`: its checks, the pick, the solid check, (moving faces in) the cavity
/// check, then `move_faces`.
pub(crate) fn offset_faces(body: &Body, points: &[[f64; 3]], tolerance: f64, distance: f64) -> Result<Body> {
if !points_ok(points, false) || !tolerance_ok(tolerance) || !distance_ok(distance) {
return Err(Error::Argument);
}
on_copy("smlib_offset_faces", body, |k, mut w| {
let picked = match pick_faces(k, &mut w, points, tolerance)? {
Ok(p) => p,
Err(m) => return Ok(Err(face_miss(m))),
};
if !brep_p2::is_manifold_solid(&w)? {
return Ok(Err(Error::Argument));
}
// Faces moved in so far that no material is left behind them: the body must reach
// deeper than the move behind the moved faces.
if distance < 0.0 {
let others: Vec<FaceId> = brep_p1::faces(&w, None)?.into_iter().filter(|f| !picked.contains(f)).collect();
if !leaves_cavity(k, &mut w, &others, -distance)? {
return Ok(Err(Error::NoRoom));
}
}
match move_faces(k, &mut w, &picked, distance) {
Ok(()) => finish(w, true),
Err(e) => Ok(Err(e)),
}
})
}
/// `move_faces`: moves the picked faces of `b` (in place) `distance` along their outward normals:
/// each gets its surface's offset, and the local operation extends or trims the faces around them
/// to meet it; the tangent blends on them follow (`SmOffsetFacesExecutive`: taken off, the faces
/// moved, put back at their own sizes). A blend that no longer fits is [`Error::NoRoom`], one that
/// cannot be put back as it was [`Error::Unsupported`], one that would need a continuation that
/// cannot be made exactly [`Error::NotExact`], a move that moved nothing [`Error::NoEffect`].
fn move_faces(k: &mut Kernel, b: &mut Brep, picked: &[FaceId], distance: f64) -> Out<()> {
let mut exec = OffsetFacesExecutive::new();
exec.offset_faces(k, b, picked, distance).map_err(|e| match exec.refusal() {
OffsetFacesRefusal::NoRoom => Error::NoRoom,
OffsetFacesRefusal::Unsupported => Error::Unsupported,
OffsetFacesRefusal::NotExact => Error::NotExact,
OffsetFacesRefusal::NoEffect => Error::NoEffect,
// Not in the fork (solidcore#2020, #2027): the blends cannot be put back, the fillet's
// status kept. SolidCraft has no such error: a move solidcore does not make.
OffsetFacesRefusal::NotPutBack => Error::Unsupported,
OffsetFacesRefusal::None => status(e),
})
}
/// `smlib_thicken`: a sheet thickened by `thickness` along its normals, or (symmetric) moved back
/// by half first; a closed solid is [`Error::Argument`].
pub(crate) fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result<Body> {
if !distance_ok(thickness) {
return Err(Error::Argument);
}
on_copy("smlib_thicken", body, |k, mut w| {
if brep_p2::is_manifold_solid(&w)? {
return Ok(Err(Error::Argument));
}
if symmetric {
w = match called(offset_brep_full(k, &mut w, -0.5 * thickness))? {
Ok(moved) => moved,
Err(e) => return Ok(Err(e)),
};
}
match called(shell_brep_full(k, &w, thickness, &[]))? {
Ok(r) => finish(r, true),
Err(e) => Ok(Err(e)),
}
})
}
/// `smlib_offset_sheet`: every face of a sheet moved onto its surface's offset by `distance`
/// along its normal; faces that close into a solid (a sphere's one face, a block's six) give the
/// offset solid (tangency-labs/smlib#17, which dropped the capi's refusal of a closed solid).
pub(crate) fn offset_sheet(body: &Body, distance: f64) -> Result<Body> {
if !distance_ok(distance) {
return Err(Error::Argument);
}
on_copy("smlib_offset_sheet", body, |k, mut w| {
// `take`: a result without faces (a sphere moved in to its centre) is SMLIB_ERR_EMPTY.
match called(offset_brep_full(k, &mut w, distance))? {
Ok(r) => finish(r, false),
Err(e) => Ok(Err(e)),
}
})
}
/// A unit vector from `v`, as `smlib_draft`'s `unit`; `None` for a vector too short or not
/// finite.
fn unit(v: [f64; 3]) -> Option<Vec3> {
if !v.iter().all(|x| x.is_finite()) {
return None;
}
let u = p3(v);
let len = u.length();
(len > 1e-12 && len.is_finite()).then(|| u * (1.0 / len))
}
/// `outward_normal`: the outward normal of face `f` (its outer faceuse `fu`) at `at`, a point on
/// it: its surface's normal there, turned with the outer faceuse. `None` where SMLib cannot place
/// the point on the surface (the capi's `STEPInversionAnswered` also takes a point outside the
/// domain or off the surface by more than its tolerance; solidcore's inversion answers those with
/// a status, so they are `None` here: the face's own internal point is neither).
fn outward_normal(k: &Kernel, b: &Brep, f: FaceId, fu: FaceuseId, at: Point3) -> solidcore::Result<Option<Vec3>> {
let Some(surf) = b.face(f)?.surface.as_ref() else { return Ok(None) };
let Some((step, _)) = answer(surf.step_inversion(k, &surf.step_uv_domain(), at, 1e6, None))? else { return Ok(None) };
let Some(nurb) = answer(surf.convert_uv_from_step_to_nurbs(k, step))? else { return Ok(None) };
let Some(mut n) = answer(surf.evaluate_normal(k, nurb, Side::Left, Side::Left))? else { return Ok(None) };
if b.faceuse(fu)?.orientation != OrientType::Same {
n = -n;
}
Ok(Some(n))
}
/// `drafted_cone` (tangency-labs/smlib#27, #53): the cone a face on the cone or cylinder `cyl` about
/// the pull (its outward normal `n` at `inside`) becomes drafted by `angle` about its circle on the
/// neutral plane (through `origin`, normal `normal`), so that it leans in going along `pull`: its
/// generators turned by `angle` in their planes through the axis, about that circle. With R0 the
/// radius on the neutral plane and t the slope of the radius going along the pull (0 for a
/// cylinder), the radius h along the pull from that circle is R0 + h tan(atan(t) - s angle), s = 1
/// where the face looks away from the axis (a pin), -1 where it looks towards it (a hole). It is
/// the surface's own NURB with each control point moved radially by the new radius over the old at
/// its height, so it keeps the surface's parameters and the face's UV trims stay valid: for a
/// cylinder solidcore's `Cone::lean_cylinder` (solidcore#1545), for a cone the same edit on
/// [`Cone::base_mut`]. [`Error::Unsupported`] when the pull does not run along the axis, the axis
/// lies in the neutral plane, or the cone would close within the face's span.
#[allow(clippy::too_many_arguments)] // the capi's drafted_cone
fn drafted_cone(
k: &Kernel,
cyl: &Cone,
n: Vec3,
inside: Point3,
origin: Point3,
normal: Vec3,
pull: Vec3,
angle: f64,
) -> solidcore::Result<Out<Cone>> {
let frame = cyl.canonical().origin;
let z = frame.x_axis().cross(frame.y_axis());
let len = z.length();
if !(len > 0.0 && len.is_finite()) {
return Ok(Err(Error::Unsupported));
}
let z = z * (1.0 / len);
let sense = z.dot(pull);
if (sense.abs() - 1.0).abs() > 1e-9 {
return Ok(Err(Error::Unsupported));
}
let axis = frame.origin();
let toward = z.dot(normal);
if toward.abs() < 1e-12 {
return Ok(Err(Error::Unsupported));
}
// The hinge circle's centre: the axis on the neutral plane, at the cone's height v0.
let v0 = (origin - axis).dot(normal) / toward;
let centre = axis + z * v0;
let radial = (inside - centre) - pull * (inside - centre).dot(pull);
let s = if radial.dot(n) >= 0.0 { 1.0 } else { -1.0 };
// The cone's radius is linear in its height along z; along the pull it changes by `slope` per
// unit, and drafted by `turned`.
let r0 = cyl.radius(v0);
let slope = (cyl.radius(1.0) - cyl.radius(0.0)) * sense;
let turned = (slope.atan() - s * angle).tan();
if !(r0 > 0.0) || !turned.is_finite() {
return Ok(Err(Error::Unsupported));
}
if cyl.is_cylinder() {
return Ok(answer(cyl.lean_cylinder(k, turned * sense, v0))?.ok_or(Error::Unsupported));
}
let mut cone = cyl.clone();
let net = cone.base_mut();
let (nu, nv) = (net.number_control_points(SurfParamType::U), net.number_control_points(SurfParamType::V));
for i in 0..nu {
for j in 0..nv {
let Some((p, w)) = answer(net.control_point(ControlPointForm::EuclideanRational, i, j))? else {
return Ok(Err(Error::Unsupported));
};
let h = (p - centre).dot(pull);
let (was, now) = (cyl.radius(v0 + h * sense), r0 + h * turned);
if !(was > 0.0 && now > 0.0) {
return Ok(Err(Error::Unsupported));
}
let foot = centre + pull * h;
if answer(net.set_control_point(ControlPointForm::EuclideanRational, i, j, foot + (p - foot) * (now / was), w))?.is_none() {
return Ok(Err(Error::Unsupported));
}
}
}
// `CopyAndAddAnalytics`: the cone's analytic data from its edited NURB.
if answer(cone.rebuild_step_from_nurb_parameters(k))?.is_none() {
return Ok(Err(Error::Unsupported));
}
Ok(Ok(cone))
}
/// The capi's `Before` mark on what `SmBrep::LocalOperation` is to move: above the history's tag.
const MOVED_MARK: u64 = ids::USER_1 + 8;
/// `Before::mark` on each replacement surface. A surface has no identity apart from its owning
/// face here, and `faces[i]`'s offset face takes `surfaces[i]`, so the mark goes on the picked face
/// and travels as the surface's does (Copy behaviour; solidcore#1875). The one difference, recorded
/// in solidcore's `topology.brep_local_operation--1875-face-mark-for-surface-mark` finding: a merge
/// keeping an unpicked face over a picked one hands that survivor the mark, where SMLib's survivor
/// keeps its own unmarked surface.
pub(super) fn mark_moved(w: &mut Brep, picked: &[FaceId]) -> solidcore::Result<()> {
for &f in picked {
w.add_attribute(EntityRef::Face(f), Attribute::long(MOVED_MARK, 0, Behavior::Copy))?;
}
Ok(())
}
/// `moved_faces`'s reading (`read_marks`): whether any face of `w` carries the mark, every mark
/// removed.
pub(super) fn moved_faces(w: &mut Brep) -> solidcore::Result<bool> {
let mut moved = false;
for f in w.face_list().to_vec() {
let e = EntityRef::Face(f);
let marks: Vec<_> = w.entity_attributes(e).into_iter().filter(|(_, a)| a.attribute_id() == MOVED_MARK).map(|(h, _)| h).collect();
moved = moved || !marks.is_empty();
for h in marks {
w.remove_attribute(e, h)?;
}
}
Ok(moved)
}
/// `smlib_draft`: its checks, the solid check, the pick, then `draft_faces`: each picked face (a
/// plane parallel to the pull) gets a copy of its plane turned by `angle` about its line on the
/// neutral plane, and a cylinder about the pull the cone `drafted_cone` gives
/// (tangency-labs/smlib#27); `SmBrep::LocalOperation` extends or trims the faces around them to
/// meet the tilted surfaces.
pub(crate) fn draft(
body: &Body,
points: &[[f64; 3]],
tolerance: f64,
neutral_origin: [f64; 3],
neutral_normal: [f64; 3],
pull: [f64; 3],
angle: f64,
) -> Result<Body> {
let (Some(normal), Some(along)) = (unit(neutral_normal), unit(pull)) else { return Err(Error::Argument) };
if !points_ok(points, false)
|| !tolerance_ok(tolerance)
|| !neutral_origin.iter().all(|x| x.is_finite() && x.abs() <= 1e9)
|| !angle.is_finite()
|| angle == 0.0
|| angle.abs() >= 1.5697963267948966
{
return Err(Error::Argument);
}
on_copy("smlib_draft", body, |k, mut w| {
if !brep_p2::is_manifold_solid(&w)? {
return Ok(Err(Error::Argument));
}
let picked = match pick_faces(k, &mut w, points, tolerance)? {
Ok(p) => p,
Err(m) => return Ok(Err(face_miss(m))),
};
// `draft_faces`.
let mut surfaces = Vec::with_capacity(picked.len());
for &f in &picked {
let fu = outer_faceuse(&w, f)?;
let face = w.face(f)?;
let (Some(surf), Some(fu)) = (face.surface.as_ref(), fu) else {
return Ok(Err(Error::Status(INVALID_INPUT)));
};
// A cylinder about the pull drafted becomes a cone (`drafted_cone`).
if let SurfaceKind::Cone(c) = surf.kind_with_nurb(k)? {
let cyl = (**c).clone();
let Some(inside) = answer(face_internal_point(k, &mut w, f))? else {
return Ok(Err(Error::Status(INVALID_INPUT)));
};
let Some(n) = outward_normal(k, &w, f, fu, inside)? else {
return Ok(Err(Error::Status(INVALID_INPUT)));
};
match drafted_cone(k, &cyl, n, inside, p3(neutral_origin), normal, along, angle)? {
Ok(cone) => surfaces.push(Surface::from(cone)),
Err(e) => return Ok(Err(e)),
}
continue;
}
let SurfaceKind::Plane(plane) = surf.kind_with_nurb(k)? else {
return Ok(Err(Error::Unsupported));
};
// The face's outward normal: the plane's, turned with the outer faceuse.
let mut n = plane.position().z_axis();
if w.faceuse(fu)?.orientation != OrientType::Same {
n = -n;
}
if n.dot(along).abs() > 1e-6 {
return Ok(Err(Error::Unsupported));
}
let Some(inside) = answer(face_internal_point(k, &mut w, f))? else {
return Ok(Err(Error::Status(INVALID_INPUT)));
};
let toward = along.dot(normal);
if toward.abs() < 1e-12 {
return Ok(Err(Error::Argument));
}
let hinge = inside + along * ((p3(neutral_origin) - inside).dot(normal) / toward);
// Turning n about n x pull by `angle` gives n cos(angle) + pull sin(angle).
let axis = n.cross(along);
let len = axis.length();
if !(len > 0.5) {
return Ok(Err(Error::Unsupported));
}
let axis = axis * (1.0 / len);
let Some(surf) = w.face(f)?.surface.as_ref() else {
return Ok(Err(Error::Status(INVALID_INPUT)));
};
let Some(mut tilted) = answer(surf.copy(k))? else {
return Ok(Err(Error::Status(INVALID_INPUT)));
};
let mut turn = Axis2Placement::IDENTITY;
turn.rotate_about_axis_at_point(angle, hinge, axis);
if answer(tilted.transform(k, &turn, None))?.is_none() {
return Ok(Err(Error::Status(INVALID_INPUT)));
}
surfaces.push(tilted);
}
// `moved_faces(b, status(b->LocalOperation(faces, surfaces)))`, its marks on the picked
// faces (`mark_moved`).
mark_moved(&mut w, &picked)?;
if let Err(e) = called(local_operation_simple(k, &mut w, &picked, surfaces))? {
return Ok(Err(e));
}
if !moved_faces(&mut w)? {
return Ok(Err(Error::NoEffect));
}
finish(w, true)
})
}
+642
View File
@@ -0,0 +1,642 @@
//! Shells, face offsets, thickening, sheet offsets and drafts on solidcore, against closed forms:
//! every result exports to a record that validates.
//!
//! The bodies are solidcore's own boxes, cylinders and linear sweeps (and booleans of them).
use std::collections::BTreeMap;
use std::f64::consts::PI;
use std::sync::mpsc;
use std::time::Duration;
use solidcore::curve::curve::Curve;
use solidcore::curve::ellipse::Ellipse;
use solidcore::geom::axis2_placement::Axis2Placement;
use solidcore::geom::extent1d::Extent1d;
use solidcore::geom::vector3d::Vec3;
use solidcore::primitive::api_curves::create_line_segment;
use solidcore::sweep::api_brep::create_linear_sweep;
use super::offset::{draft, mark_moved, moved_faces, offset_faces, offset_sheet, shell, thicken};
use super::{Body, area, bounds, counts, make_box, make_cylinder, make_sphere, run, to_arrays, volume};
use crate::export::native;
use crate::{BooleanOp, Error};
/// A case longer than this is a hang.
const TIMEOUT: Duration = Duration::from_secs(300);
/// How near a pick point must be.
const TOL: f64 = 0.01;
#[derive(Clone, Copy, Debug)]
enum Shape {
Box([f64; 3], [f64; 3]),
Cylinder([f64; 3], f64, f64),
Sphere([f64; 3], f64),
/// A frustum about +Z: base centre, base radius, top radius, height.
Frustum([f64; 3], f64, f64, f64),
/// A sheet: the line from the first point to the second swept along `dir` by `dist`.
LineSheet([f64; 3], [f64; 3], [f64; 3], f64),
/// A sheet: the arc about +Z through `center` of `radius` from 0 to `degrees`, swept along
/// +Z by `height`.
ArcSheet([f64; 3], f64, f64, f64),
/// Rig case shell/4711: a 60 x 40 x 10 block less an r3 through hole at (22.5, 7) and a
/// 10 x 10 notch on the front edge from x = 25 to 35.
Notched,
}
fn v3(p: [f64; 3]) -> Vec3 {
Vec3::new(p[0], p[1], p[2])
}
impl Shape {
fn core(self) -> Body {
match self {
Shape::Box(o, s) => make_box(o, s).unwrap(),
Shape::Cylinder(o, r, h) => make_cylinder(o, r, h).unwrap(),
Shape::Sphere(o, r) => make_sphere(o, r).unwrap(),
Shape::Frustum(o, a, b, h) => super::make_cone(o, a, b, h).unwrap(),
Shape::LineSheet(a, b, dir, dist) => run("sheet", |k| {
let c = Curve::from(create_line_segment(v3(a), v3(b))?);
create_linear_sweep(k, &[Some(&c)], v3(dir), dist, false)
})
.map(Body::new)
.unwrap(),
Shape::ArcSheet(o, r, degrees, h) => run("sheet", |k| {
let frame = Axis2Placement::new(v3(o), Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0));
let c = Curve::from(Ellipse::create_canonical_circle(k, &frame, r, Some(&Extent1d::from_min_max(0.0, degrees)), None)?);
create_linear_sweep(k, &[Some(&c)], Vec3::new(0.0, 0.0, 1.0), h, false)
})
.map(Body::new)
.unwrap(),
Shape::Notched => {
let block = make_box([0.0; 3], [60.0, 40.0, 10.0]).unwrap();
let pin = make_cylinder([22.5, 7.0, -5.0], 3.0, 20.0).unwrap();
let notch = make_box([25.0, -5.0, -5.0], [10.0, 10.0, 20.0]).unwrap();
let a = super::boolean::boolean(&block, &pin, BooleanOp::Subtract).unwrap().unwrap();
super::boolean::boolean(&a, &notch, BooleanOp::Subtract).unwrap().unwrap()
}
}
}
}
#[derive(Clone, Debug)]
enum Op {
/// `Brep::shell`: open faces, thickness.
Shell(Shape, Vec<[f64; 3]>, f64),
/// `Brep::offset_faces`: faces, distance.
OffsetFaces(Shape, Vec<[f64; 3]>, f64),
/// `Brep::thicken`: thickness, symmetric.
Thicken(Shape, f64, bool),
/// `Brep::offset_sheet`: distance.
OffsetSheet(Shape, f64),
/// `Brep::draft`: faces, neutral origin, neutral normal, pull, angle.
Draft(Shape, Vec<[f64; 3]>, [f64; 3], [f64; 3], [f64; 3], f64),
}
/// What a case must give.
#[derive(Clone, Debug)]
enum Expect {
/// A solid of this volume (relative 1e-9).
Volume(f64),
/// A solid of this volume whose bounds along z are these.
Slab(f64, f64, f64),
/// A solid of this volume (relative 1e-9) whose bounds are these.
Solid(f64, [f64; 3], [f64; 3]),
/// A sheet of this area (relative 1e-9) whose bounds are these.
Sheet(f64, [f64; 3], [f64; 3]),
/// This error.
Fails(Error),
}
struct Case {
op: Op,
expect: Expect,
}
/// A 30 x 20 x 10 block from the origin.
const BLOCK: Shape = Shape::Box([0.0; 3], [30.0, 20.0, 10.0]);
const ABC: f64 = 6000.0;
/// A point on its top face.
const TOP: [f64; 3] = [15.0, 10.0, 10.0];
/// A point on its front face (y = 0).
const FRONT: [f64; 3] = [15.0, 0.0, 5.0];
/// The z axis: a neutral plane's normal and a pull.
const Z: [f64; 3] = [0.0, 0.0, 1.0];
/// A 30 x 20 sheet in z = 0: the x axis from 0 to 30 swept 20 along y.
const PLATE: Shape = Shape::LineSheet([0.0; 3], [30.0, 0.0, 0.0], [0.0, 1.0, 0.0], 20.0);
/// A sphere of radius 10 about the origin.
const BALL: Shape = Shape::Sphere([0.0; 3], 10.0);
/// A quarter of a cylinder of radius 5 about the z axis, 10 high.
const QUARTER: Shape = Shape::ArcSheet([0.0; 3], 5.0, 90.0, 10.0);
fn case(name: &str) -> Case {
let c = |op, expect| Case { op, expect };
match name {
// abc - (a - 2t)(b - 2t)(c - t), t = 2.
"shell_box_open_top" => c(Op::Shell(BLOCK, vec![TOP], -2.0), Expect::Volume(ABC - 26.0 * 16.0 * 8.0)),
// A void: abc - (a - 2t)(b - 2t)(c - 2t).
"shell_box_closed" => c(Op::Shell(BLOCK, vec![], -2.0), Expect::Volume(ABC - 26.0 * 16.0 * 6.0)),
// Closed and outward, the convex edges extended: (a + 2t)(b + 2t)(c + 2t) - abc, t = 1.
"shell_box_closed_outward" => c(Op::Shell(BLOCK, vec![], 1.0), Expect::Volume(32.0 * 22.0 * 12.0 - ABC)),
// Outward, the convex edges extended: (a + 2t)(b + 2t)(c + t) - abc, t = 1.
"shell_box_outward" => c(Op::Shell(BLOCK, vec![TOP], 1.0), Expect::Volume(32.0 * 22.0 * 11.0 - ABC)),
// r = 5, h = 10, open at the top, t = 1: π r² h - π (r - t)² (h - t).
"shell_cylinder_open_top" => {
c(Op::Shell(Shape::Cylinder([0.0; 3], 5.0, 10.0), vec![[0.0, 2.0, 10.0]], -1.0), Expect::Volume(PI * 25.0 * 10.0 - PI * 16.0 * 9.0))
}
// A wall of 15 inside a block 20 wide leaves no cavity.
"shell_too_thick" => c(Op::Shell(BLOCK, vec![TOP], -15.0), Expect::Fails(Error::NoRoom)),
// The hole's offset (r 3 + 2) is tangent to the front's (y = 2): the walls meet along a
// line, which bounds no solid.
"shell_walls_touch" => c(Op::Shell(Shape::Notched, vec![[50.0, 30.0, 0.0]], -2.0), Expect::Fails(Error::WallsTouch)),
"shell_pick_near_nothing" => c(Op::Shell(BLOCK, vec![TOP, [100.0; 3]], -2.0), Expect::Fails(Error::NoMatch { point: 1 })),
// The top front edge's midpoint is as near the top as the front.
"shell_pick_ambiguous" => c(Op::Shell(BLOCK, vec![[15.0, 0.0, 10.0]], -2.0), Expect::Fails(Error::Ambiguous { point: 0 })),
// A plate of 30 x 20 thickened 2: 1200, 2 thick along z; symmetric, from -1 to 1.
"thicken_plate" => c(Op::Thicken(PLATE, 2.0, false), Expect::Slab(1200.0, 0.0, 2.0)),
"thicken_plate_symmetric" => c(Op::Thicken(PLATE, 2.0, true), Expect::Slab(1200.0, -1.0, 1.0)),
"thicken_a_solid" => c(Op::Thicken(BLOCK, 2.0, false), Expect::Fails(Error::Argument)),
// The plate moved 3 along its normal (+z: the x axis swept along y): still 600, at z = 3.
"offset_sheet_plate" => c(Op::OffsetSheet(PLATE, 3.0), Expect::Sheet(600.0, [0.0, 0.0, 3.0], [30.0, 20.0, 3.0])),
// A sphere's one face closes on itself (tangency-labs/smlib fix #17): r = 10 moved out 2 is
// the ball of radius 12, (4/3) π 12³; moved in 10, to its centre, nothing is left.
"offset_sheet_a_sphere" => c(Op::OffsetSheet(BALL, 2.0), Expect::Volume(4.0 / 3.0 * PI * 12.0f64.powi(3))),
"offset_sheet_a_sphere_to_its_centre" => c(Op::OffsetSheet(BALL, -10.0), Expect::Fails(Error::Empty)),
// The quarter cylinder moved 2 along its normal (outward: the arc's tangent swept along
// z): radius 7, area (π/2) 7 · 10.
"offset_sheet_quarter_cylinder" => c(Op::OffsetSheet(QUARTER, 2.0), Expect::Sheet(PI / 2.0 * 7.0 * 10.0, [0.0, 0.0, 0.0], [7.0, 7.0, 10.0])),
// A closed sheet offsets as an open one (tangency-labs/smlib fix #17, develop 9d0fb564):
// each face moved out 1 along its normal, the convex edges extended, gives the block
// (a + 2d)(b + 2d)(c + 2d) of six planes, from -d to (a, b, c) + d.
"offset_sheet_a_solid" => c(Op::OffsetSheet(BLOCK, 1.0), Expect::Solid(32.0 * 22.0 * 12.0, [-1.0, -1.0, -1.0], [31.0, 21.0, 11.0])),
// The top moved out 2: abc + d·ab.
"offset_faces_top_out" => c(Op::OffsetFaces(BLOCK, vec![TOP], 2.0), Expect::Volume(ABC + 2.0 * 600.0)),
// The top moved in 10, as deep as the block: nothing left behind it.
"offset_faces_through" => c(Op::OffsetFaces(BLOCK, vec![TOP], -10.0), Expect::Fails(Error::NoRoom)),
// The four sides drafted θ = 5° about the bottom (z = 0), pulled along +z, leaning in:
// the section at height z is (a - 2 z tanθ)(b - 2 z tanθ), so
// V = abh - (a + b) h² tanθ + (4/3) h³ tan²θ.
"draft_box_sides" => {
let s = (5.0f64).to_radians().tan();
let (a, b, h) = (30.0, 20.0, 10.0);
let sides = vec![[15.0, 0.0, 5.0], [30.0, 10.0, 5.0], [15.0, 20.0, 5.0], [0.0, 10.0, 5.0]];
c(
Op::Draft(BLOCK, sides, [0.0; 3], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], 5f64.to_radians()),
Expect::Volume(a * b * h - (a + b) * h * h * s + 4.0 / 3.0 * h * h * h * s * s),
)
}
// The front (y = 0) alone drafted θ = 5° about the bottom: a wedge of a h² tanθ / 2
// comes off along its length.
"draft_one_side" => {
let s = (5.0f64).to_radians().tan();
c(Op::Draft(BLOCK, vec![FRONT], [0.0; 3], Z, Z, 5f64.to_radians()), Expect::Volume(ABC - 30.0 * 100.0 * s / 2.0))
}
// A negative angle leans it out: the same wedge goes on.
"draft_one_side_out" => {
let s = (5.0f64).to_radians().tan();
c(Op::Draft(BLOCK, vec![FRONT], [0.0; 3], Z, Z, -5f64.to_radians()), Expect::Volume(ABC + 30.0 * 100.0 * s / 2.0))
}
// Hinged at the top (z = 10) and leaning in going up, the face leans out below the
// hinge: the wedge goes on.
"draft_one_side_hinged_at_the_top" => {
let s = (5.0f64).to_radians().tan();
c(Op::Draft(BLOCK, vec![FRONT], [0.0, 0.0, 10.0], Z, Z, 5f64.to_radians()), Expect::Volume(ABC + 30.0 * 100.0 * s / 2.0))
}
// A cylinder along the pull drafts to a cone (tangency-labs/smlib fix #27, develop
// 9d0fb564): R = 5, h = 10, θ = 0.1 about the bottom, leaning in: a frustum of radii R
// and r = R - h tanθ (a cone and its two caps): V = π h (R² + R r + r²) / 3.
"draft_a_curved_face" => {
let (big, h) = (5.0, 10.0);
let small = big - h * 0.1f64.tan();
c(
Op::Draft(Shape::Cylinder([0.0; 3], big, h), vec![[5.0, 0.0, 5.0]], [0.0; 3], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], 0.1),
Expect::Volume(PI * h * (big * big + big * small + small * small) / 3.0),
)
}
// A cone r 10 at its base to 8 at its top, 20 tall, drafted 5° about its base turns its
// generators 5° more (fork d51a723): r 10 + 20 tan(atan(-0.1) - 5°) at the top.
"draft_a_cone_about_the_pull" => {
let (big, h, a) = (10.0, 20.0, 5f64.to_radians());
let small = big + h * ((-0.1f64).atan() - a).tan();
c(
Op::Draft(Shape::Frustum([0.0; 3], big, 8.0, h), vec![[0.0, 9.0, 10.0]], [0.0; 3], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], a),
Expect::Volume(PI * h * (big * big + big * small + small * small) / 3.0),
)
}
// The top is square to the pull, not along it.
"draft_a_face_across_the_pull" => {
c(Op::Draft(BLOCK, vec![TOP], [0.0; 3], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], 0.1), Expect::Fails(Error::Unsupported))
}
_ => panic!("{name}"),
}
}
/// The tolerance a case picks with: the ambiguous point lies on its two faces.
fn tolerance(name: &str) -> f64 {
if name == "shell_pick_ambiguous" { 1.0 } else { TOL }
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs()
}
#[derive(Debug, PartialEq)]
struct Measured {
/// The volume of a solid, the area of a sheet.
size: f64,
bounds: ([f64; 3], [f64; 3]),
counts: [usize; 3],
kinds: BTreeMap<String, usize>,
}
fn kinds(rec: &solidcraft_geom::brep::Brep) -> BTreeMap<String, usize> {
let mut out = BTreeMap::new();
for f in &rec.faces {
let v = serde_json::to_value(&f.surface).unwrap();
*out.entry(v["kind"].as_str().unwrap_or("?").to_owned()).or_default() += 1;
}
out
}
fn is_sheet(e: &Expect) -> bool {
matches!(e, Expect::Sheet(..))
}
fn on_core(name: &str, c: &Case) -> crate::Result<Body> {
let tol = tolerance(name);
match &c.op {
Op::Shell(s, points, t) => shell(&s.core(), points, tol, *t),
Op::OffsetFaces(s, points, d) => offset_faces(&s.core(), points, tol, *d),
Op::Thicken(s, t, sym) => thicken(&s.core(), *t, *sym),
Op::OffsetSheet(s, d) => offset_sheet(&s.core(), *d),
Op::Draft(s, points, o, n, pull, angle) => draft(&s.core(), points, tol, *o, *n, *pull, *angle),
}
}
/// Runs `f` on its own thread: a hang fails the test instead of stopping the run.
fn timed<T: Send + 'static>(name: &str, f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = mpsc::channel();
let worker = std::thread::spawn(move || {
let _ = tx.send(f());
});
match rx.recv_timeout(TIMEOUT) {
Ok(v) => v,
Err(mpsc::RecvTimeoutError::Disconnected) => match worker.join() {
Err(p) => std::panic::resume_unwind(p),
Ok(()) => panic!("{name}: no result"),
},
Err(mpsc::RecvTimeoutError::Timeout) => panic!("{name}: solidcore hung (no result after {TIMEOUT:?})"),
}
}
type Got = std::result::Result<Measured, Error>;
fn near(a: [f64; 3], b: [f64; 3]) -> bool {
a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6)
}
/// Checks a kernel's result against the case's closed form; what it measured, or its error.
fn judge(name: &str, who: &str, expect: &Expect, got: Got) -> Got {
match (expect, &got) {
(Expect::Fails(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"),
(Expect::Fails(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"),
(_, Err(g)) => panic!("{name}: {who} failed: {g}"),
(Expect::Volume(v), Ok(m)) => assert!(rel(m.size, *v) < 1e-9, "{name}: {who} volume {} vs analytic {v}", m.size),
(Expect::Slab(v, lo, hi), Ok(m)) => {
assert!(rel(m.size, *v) < 1e-9, "{name}: {who} volume {} vs analytic {v}", m.size);
let (a, b) = (m.bounds.0[2], m.bounds.1[2]);
assert!((a - lo).abs() < 1e-6 && (b - hi).abs() < 1e-6, "{name}: {who} z from {a} to {b}, expected {lo} to {hi}");
}
(Expect::Solid(v, lo, hi), Ok(m)) => {
assert!(rel(m.size, *v) < 1e-9, "{name}: {who} volume {} vs analytic {v}", m.size);
assert!(near(m.bounds.0, *lo) && near(m.bounds.1, *hi), "{name}: {who} bounds {:?}, expected {lo:?} {hi:?}", m.bounds);
}
(Expect::Sheet(s, lo, hi), Ok(m)) => {
assert!(rel(m.size, *s) < 1e-9, "{name}: {who} area {} vs analytic {s}", m.size);
assert!(near(m.bounds.0, *lo) && near(m.bounds.1, *hi), "{name}: {who} bounds {:?}, expected {lo:?} {hi:?}", m.bounds);
}
}
got
}
fn solidcore_case(name: &'static str) -> Got {
timed(name, move || {
let c = case(name);
let sheet = is_sheet(&c.expect);
let got = on_core(name, &c).map(|body| {
let size = if sheet { area(&body, 1e-8) } else { volume(&body, 1e-8) }.unwrap_or_else(|e| panic!("{name}: size: {e}"));
let n = counts(&body).unwrap();
let (rec, _) = native(&to_arrays(&body).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
let bx = bounds(&body).unwrap();
Measured { size, bounds: bx, counts: n, kinds: kinds(&rec) }
});
judge(name, "solidcore", &c.expect, got)
})
}
macro_rules! cases {
($($m:ident $(: $ign:meta)?),* $(,)?) => {$(
mod $m {
#[test]
$(#[$ign])?
fn solidcore_matches_the_closed_form() {
let _ = super::solidcore_case(stringify!($m));
}
}
)*};
}
cases!(
shell_box_open_top,
shell_box_closed,
shell_box_closed_outward,
shell_box_outward,
shell_cylinder_open_top,
shell_too_thick,
shell_walls_touch,
shell_pick_near_nothing,
shell_pick_ambiguous,
thicken_plate,
thicken_plate_symmetric,
thicken_a_solid,
offset_sheet_plate,
offset_sheet_quarter_cylinder,
offset_sheet_a_solid,
offset_sheet_a_sphere,
offset_sheet_a_sphere_to_its_centre,
offset_faces_top_out,
offset_faces_through,
draft_box_sides,
draft_one_side,
draft_one_side_out,
draft_one_side_hinged_at_the_top,
draft_a_curved_face,
draft_a_cone_about_the_pull,
draft_a_face_across_the_pull,
);
/// The capi's argument checks, before any kernel call.
#[test]
fn hostile_arguments_are_refused() {
let b = BLOCK.core();
for t in [0.0, f64::NAN, -1e7, f64::INFINITY] {
assert_eq!(shell(&b, &[TOP], TOL, t).err(), Some(Error::Argument), "thickness {t}");
assert_eq!(thicken(&b, t, false).err(), Some(Error::Argument), "thickness {t}");
assert_eq!(offset_sheet(&b, t).err(), Some(Error::Argument), "distance {t}");
assert_eq!(offset_faces(&b, &[TOP], TOL, t).err(), Some(Error::Argument), "distance {t}");
}
assert_eq!(shell(&b, &[[f64::NAN; 3]], TOL, -1.0).err(), Some(Error::Argument));
assert_eq!(shell(&b, &[TOP], 0.0, -1.0).err(), Some(Error::Argument));
assert_eq!(offset_faces(&b, &[], TOL, 1.0).err(), Some(Error::Argument));
let z = [0.0, 0.0, 1.0];
assert_eq!(draft(&b, &[TOP], TOL, [0.0; 3], [0.0; 3], z, 0.1).err(), Some(Error::Argument), "no neutral normal");
assert_eq!(draft(&b, &[TOP], TOL, [0.0; 3], z, z, 0.0).err(), Some(Error::Argument), "no angle");
assert_eq!(draft(&b, &[TOP], TOL, [0.0; 3], z, z, PI / 2.0).err(), Some(Error::Argument), "a right angle");
assert_eq!(draft(&b, &[TOP], TOL, [f64::NAN; 3], z, z, 0.1).err(), Some(Error::Argument));
}
/// The body offset is never changed.
#[test]
fn the_body_stays_as_it_was() {
let r = timed("operand", || {
let b = BLOCK.core();
let _ = shell(&b, &[TOP], TOL, -2.0);
(counts(&b).unwrap(), volume(&b, 1e-8).unwrap())
});
assert_eq!(r.0, [6, 12, 8]);
assert!(rel(r.1, ABC) < 1e-12, "{}", r.1);
}
/// The distance from `p` to each face of `b` (`SmApiFaceClosestPoint`), under `identity`.
fn face_distances(
k: &mut solidcore::kernel::Kernel,
b: &mut solidcore::topology::Brep,
identity: &solidcore::query::brep::BrepIdentity<'_>,
p: Vec3,
) -> solidcore::Result<Vec<(f64, Vec3)>> {
use solidcore::geom::object::Shape as Sh;
use solidcore::query::api_queries::face_closest_point_in_shape;
use solidcore::query::brep::LiveBrepObjects;
use solidcore::topology::BrepId;
let one = BrepId::from_index(0);
let mut out = Vec::new();
for f in solidcore::topology::brep::p1::faces(b, None)? {
let shape = Sh::new(k, &(one, &*b), &[], &[(one, f)], &[], &[])?;
let objects = LiveBrepObjects::new(b, identity, None)?;
let c = face_closest_point_in_shape(k, &shape, &objects, (one, f), p)?;
out.push((c.distance, c.point));
}
Ok(out)
}
/// The closed form for (15, 10, 20) above the 30 x 20 x 10 box at the origin, face by face of
/// `create_box`: bottom 20, top 10, the x sides sqrt(15^2 + 10^2), the y sides sqrt(10^2 + 10^2).
fn box_face_distances_are_exact(what: &str, d: &[(f64, Vec3)]) {
let want = [20.0, 10.0, 325f64.sqrt(), 325f64.sqrt(), 200f64.sqrt(), 200f64.sqrt()];
assert_eq!(d.len(), want.len(), "{what}: faces");
for (i, (&(got, _), want)) in d.iter().zip(want).enumerate() {
assert!((got - want).abs() < 1e-9, "{what}: face {i} at {got}, want {want}");
}
let top = d[1].1;
assert!((top.x - 15.0).abs() < 1e-9 && (top.y - 10.0).abs() < 1e-9 && (top.z - 10.0).abs() < 1e-9, "{what}: top {top:?}");
}
/// Picking faces on a solidcore box: on the body itself, on the working copy every operation
/// makes (`brep_copy`), and by solidcore's recommended usage, each face's distance is the closed
/// form and the top face is picked.
#[test]
fn faces_are_picked_on_the_box_and_on_its_working_copy() {
use solidcore::query::brep::{BrepIdentity, BrepQuery};
let p = Vec3::new(15.0, 10.0, 20.0);
let on_top = [[15.0, 10.0, 10.0]];
run("pick", |k| {
let mut b = solidcore::primitive::api_primitives::create_box(k, Vec3::new(0.0, 0.0, 0.0), 30.0, 20.0, 10.0)?;
let mut w = solidcore::topology::brep::p3::brep_copy(k, &b)?;
let mut faces = Vec::new();
for (what, x) in [("the body", &mut b), ("its copy", &mut w)] {
let query = BrepQuery::of_brep(x);
let identity = query.effective_identity();
box_face_distances_are_exact(what, &face_distances(k, x, &identity, p)?);
let picked = super::offset::pick_faces(k, x, &on_top, TOL)?;
faces.push(picked.ok());
}
assert!(faces[0].as_ref().is_some_and(|f| f.len() == 1), "the body: {faces:?}");
assert_eq!(faces[0], faces[1], "the body and its copy pick the same face");
// `BrepIdentity::of_brep` honours a copy's owner keys since solidcore#1445: the body
// and its copy both resolve.
for (what, x) in [("of_brep on the body", &mut b), ("of_brep on its copy", &mut w)] {
let proven = BrepIdentity::of_brep(x);
box_face_distances_are_exact(what, &face_distances(k, x, &proven.identity(), p)?);
}
Ok(())
})
.unwrap();
}
/// A pick skips an entity whose query returns an SMLib status, as the capi skips any status
/// other than `SM_SUCCESS`; any other solidcore error fails the pick.
#[test]
fn a_solidcore_error_in_a_pick_is_an_error() {
use super::answer;
use super::blend::pick;
use solidcore::SolidError;
let all = [0usize, 1];
let points = [[0.0; 3]];
let status = pick(&all, &points, 1.0, 1e-6, |i, _| answer(if i == 0 { Err(SolidError::sm_err("q")) } else { Ok(0.5) }));
assert_eq!(status.unwrap().unwrap().0, vec![1], "a status skips the entity");
let other = pick(&all, &points, 1.0, 1e-6, |i, _| answer(if i == 0 { Err(SolidError::invalid_input("q", "owner")) } else { Ok(0.5) }));
assert!(matches!(other, Err(SolidError::InvalidInput { .. })), "{other:?}");
let unported = pick(&all, &points, 1.0, 1e-6, |i, _| answer(if i == 0 { Err(SolidError::unported("Sm::X", "u")) } else { Ok(0.5) }));
assert!(matches!(unported, Err(SolidError::Unported { .. })), "{unported:?}");
}
/// The capi's `moved_faces` reading: no mark, no move (SMLIB_ERR_NO_EFFECT); a mark is read once,
/// and gone.
#[test]
fn a_draft_with_no_marked_face_left_has_no_effect() {
let body = make_box([0.0; 3], [10.0; 3]).unwrap();
super::with_body("marks", &body, |_, w| {
assert!(!moved_faces(w)?, "no face marked");
let f = w.face_list()[0];
mark_moved(w, &[f])?;
assert!(moved_faces(w)?, "a face marked");
assert!(!moved_faces(w)?, "the mark removed");
Ok(())
})
.unwrap();
}
/// A draft leaves no mark of its own on what it returns, on a plane or on a cylinder turned cone.
#[test]
fn a_draft_leaves_no_mark() {
let z = [0.0, 0.0, 1.0];
let bx = make_box([0.0; 3], [20.0, 30.0, 10.0]).unwrap();
let plane = draft(&bx, &[[10.0, 0.0, 5.0]], TOL, [0.0; 3], z, z, 0.1).unwrap();
let cyl = make_cylinder([0.0; 3], 5.0, 10.0).unwrap();
let cone = draft(&cyl, &[[5.0, 0.0, 5.0]], TOL, [0.0; 3], z, z, 0.1).unwrap();
for b in [plane, cone] {
super::with_body("marks", &b, |_, w| {
assert!(!moved_faces(w)?);
Ok(())
})
.unwrap();
}
}
/// A stepped block, 40 x 30 x 10 with its half x < 20 raised to 20 (rig case
/// shell/cov-replace-face-step-onto-the-higher-face, the fork's `offset_faces_close_a_step_wall`):
/// a top moved onto the other's plane closes the step wall between them to nothing, and the tops
/// are one face of a 6-faced box.
#[test]
fn offset_faces_close_a_step_wall() {
use super::boolean::boolean;
let stepped = boolean(&make_box([0.0; 3], [40.0, 30.0, 10.0]).unwrap(), &make_box([0.0; 3], [20.0, 30.0, 20.0]).unwrap(), BooleanOp::Union)
.unwrap()
.unwrap();
assert_eq!(counts(&stepped).unwrap()[0], 8);
for (at, d, v, a) in [([30.0, 15.0, 10.0], 10.0, 24000.0, 5200.0), ([10.0, 15.0, 20.0], -10.0, 12000.0, 3800.0)] {
let r = offset_faces(&stepped, &[at], 0.1, d).unwrap();
let (got, n, area) = (volume(&r, 1e-8).unwrap(), counts(&r).unwrap(), super::area(&r, 1e-8).unwrap());
assert_eq!(n[0], 6, "d {d}");
assert!(rel(got, v) < 1e-9 && rel(area, a) < 1e-9, "d {d}: V {got} A {area}");
}
}
/// Rig case shell/4492's rounded box: four mitred rounds, r 3, come off a 40 x 30 x 10 box's top
/// together and follow it. From height 0 the outline is the 34 x 24 top moved out by the round's
/// w(z) = sqrt(9 - (z - axis)^2), or 3 below the axis. With the fork's values at +19, and bodies
/// whose rounds go all the way down from -7 and are cut short from -7.5 (solidcore#2027). At -9
/// solidcore refuses to put the rounds back (solidcore#2020, open): SolidCraft says it does not
/// make that move.
#[test]
fn mitred_rounds_follow_a_moved_top() {
use super::blend::{Corners, blend};
use crate::Blend;
let edges = [[20.0, 0.0, 10.0], [20.0, 30.0, 10.0], [0.0, 15.0, 10.0], [40.0, 15.0, 10.0]];
let top = [20.0, 15.0, 10.0];
let rounded = blend(&make_box([0.0; 3], [40.0, 30.0, 10.0]).unwrap(), Blend::Round, &edges, &[3.0; 4], TOL, &[], &[], Corners::Filled).unwrap();
let analytic = |d: f64| {
let (height, axis, n) = (10.0 + d, 7.0 + d, 20000);
(0..n)
.map(|i| {
let z = height * (f64::from(i) + 0.5) / f64::from(n);
let w = if z < axis { 3.0 } else { (9.0 - (z - axis) * (z - axis)).max(0.0).sqrt() };
(34.0 + 2.0 * w) * (24.0 + 2.0 * w) * height / f64::from(n)
})
.sum::<f64>()
};
let moves = [
(19.0, [10, 20, 12]),
(1.0, [10, 20, 12]),
(-1.0, [10, 20, 12]),
(-6.9, [10, 20, 12]),
(-7.0, [6, 12, 8]),
(-7.5, [6, 12, 8]),
(-8.0, [6, 12, 8]),
(-9.5, [6, 12, 8]),
];
for (d, n) in moves {
let moved = offset_faces(&rounded, &[top], 0.1, d).unwrap();
let v = volume(&moved, 1e-8).unwrap();
assert_eq!(counts(&moved).unwrap(), n, "d {d}");
assert!(rel(v, analytic(d)) < 1e-6, "d {d}: {v} vs {}", analytic(d));
}
assert!(matches!(offset_faces(&rounded, &[top], 0.1, -9.0), Err(Error::Unsupported)));
}
/// `moved_faces`: drafts so small they leave a plane or cylinder where it was still moved a face
/// (solidcore's mark stays on it), and an angle that brings a wall onto the plane of the wall above
/// it leaves the two faces apart: every draft here builds, none is "no effect".
#[test]
fn a_draft_moves_a_face() {
use crate::sweeps::{Curve, DraftCorner};
let z = [0.0, 0.0, 1.0];
let plate = make_box([0.0; 3], [40.0, 40.0, 10.0]).unwrap();
let rod = make_cylinder([20.0, 20.0, -5.0], 5.0, 30.0).unwrap();
let holed = super::boolean::boolean(&plate, &rod, BooleanOp::Subtract).unwrap().unwrap();
let pinned = super::boolean::boolean(&plate, &rod, BooleanOp::Union).unwrap().unwrap();
let t = 5f64.to_radians().tan();
// A wall x = 20 up to z = 10, then a wall leaning in 5 degrees: the lower wall turned 5
// degrees about z = 10 lies on the upper one's plane.
let p = [[0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [20.0, 0.0, 10.0], [20.0 - 10.0 * t, 0.0, 20.0], [0.0, 0.0, 20.0]];
let profile: Vec<Curve> = (0..5).map(|i| Curve::Line { start: p[i], end: p[(i + 1) % 5] }).collect();
let leaning = super::sweep::extrude(&profile, [0.0, 1.0, 0.0], 10.0, 0.0, DraftCorner::default(), true).unwrap();
let five = 5f64.to_radians();
let cases: [(&str, &Body, [f64; 3], [f64; 3], f64); 7] = [
("tiny on a plane", &plate, [20.0, 0.0, 5.0], [0.0; 3], 1e-14),
("tiny on a hole", &holed, [25.0, 20.0, 5.0], [0.0, 0.0, 5.0], 1e-14),
("tiny on a pin", &pinned, [25.0, 20.0, 20.0], [0.0, 0.0, 5.0], -1e-14),
("small on a hole", &holed, [25.0, 20.0, 5.0], [0.0, 0.0, 5.0], 1e-6),
("onto the wall above", &leaning, [20.0, 5.0, 5.0], [0.0, 0.0, 10.0], five),
("away from the wall above", &leaning, [20.0, 5.0, 5.0], [0.0, 0.0, 10.0], -five),
("hinged far off", &plate, [20.0, 0.0, 5.0], [0.0, 0.0, 1000.0], five),
];
for (name, b, at, origin, angle) in cases {
let r = draft(b, &[at], TOL, origin, z, z, angle).unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(volume(&r, 1e-8).unwrap() > 0.0, "{name}");
}
}
/// The tip cut off a triangular prism, drafted about a plane 100 mm off so that the whole face goes
/// out past where its neighbours meet: its edges vanish, `SM_ERR_OFFSET_EDGE_VANISHES` in SMLib,
/// which the capi's `status` answers as `SMLIB_ERR_UNSUPPORTED`.
#[test]
fn a_draft_past_the_neighbours_meeting_is_unsupported() {
use crate::sweeps::{Curve, DraftCorner};
let p = [[0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [11.0, 18.0, 0.0], [9.0, 18.0, 0.0]];
let profile: Vec<Curve> = (0..4).map(|i| Curve::Line { start: p[i], end: p[(i + 1) % 4] }).collect();
let prism = super::sweep::extrude(&profile, [0.0, 0.0, 1.0], 10.0, 0.0, DraftCorner::default(), true).unwrap();
let (tip, z, origin, angle) = ([[10.0, 18.0, 5.0]], [0.0, 0.0, 1.0], [0.0, 0.0, -100.0], (-2f64).to_radians());
assert_eq!(draft(&prism, &tip, TOL, origin, z, z, angle).err(), Some(Error::Unsupported));
}
/// A flat L of two faces thickened by 2 with mitred walls: 2 x 10 x (20 + 22).
#[test]
fn an_l_sheet_thickens() {
use crate::sweeps::{Curve, DraftCorner};
let l = [Curve::Line { start: [0.0; 3], end: [20.0, 0.0, 0.0] }, Curve::Line { start: [20.0, 0.0, 0.0], end: [20.0, 20.0, 0.0] }];
let sheet = super::sweep::extrude(&l, [0.0, 0.0, 1.0], 10.0, 0.0, DraftCorner::default(), false).unwrap();
let core = thicken(&sheet, 2.0, false).unwrap();
assert!(rel(volume(&core, 1e-8).unwrap(), 840.0) < 1e-9);
}
+513
View File
@@ -0,0 +1,513 @@
//! Split Face and Split Body on solidcore: the C API's composition, ported from the fork's
//! `capi/src/smlib_split.cpp` (`smlib_split_faces`, `smlib_split_body`, `cut_face`,
//! `plane_covers`, `slice`, `both_sides`, `smlib_split_body_by_sheet`) and the tool surfaces of `capi/src/smlib_ssi.cpp`
//! (`smlib_surface_plane`, `_cylinder`, `_cone`, `_sphere`, `_torus`, `_nurbs`).
//!
//! As there, every operation works on copies of its body (`SmApiBrepCopy`). A face is cut where
//! a tool meets it the way SMLib's plane cutter cuts a subset of faces: the face's surface is
//! intersected with the tool over the face's UV domain (`CreatePlanarSectionCurves` for a plane
//! reaching over the face, `GlobalSurfaceIntersect` otherwise) and the untrimmed curves go to
//! `SmBrep::MergeCurvesOnSurface` with the tool as the intersecting surface. Unlike the plane
//! cutter, a failed merge fails the call. A body is split by slicing (`SM_BO_SLICE`) copies of it
//! with a one-face sheet on the tool surface, once with the surface turned over; or with a sheet
//! of several faces as it is, its surfaces first turned to agree.
//!
//! A tool is a [`crate::ssi::Surface`]; solidcore's own surface is made from its description
//! ([`Spec`]), as `smlib_ssi.cpp` makes SMLib's, over the surface's natural UV domain.
use std::sync::PoisonError;
use solidcore::EntityRef;
use solidcore::boolean::api_brep::boolean_with_options;
use solidcore::boolean::merge::MergeOptions;
use solidcore::geom::a_object::BooleanOperation;
use solidcore::geom::axis2_placement::Axis2Placement;
use solidcore::geom::extent2d::Extent2d;
use solidcore::geom::object::ObjectRef;
use solidcore::geom::tol::{USE_DEFAULT, approx_tol3d};
use solidcore::geom::vector2d::Vec2;
use solidcore::geom::vector3d::{Point3, Vec3};
use solidcore::kernel::Kernel;
use solidcore::nurbs::b_spline_curve::p1::KnotType;
use solidcore::nurbs::b_spline_surface::p1::{BSplineSurface, BSplineSurfaceForm, CanonicalSurfaceData};
use solidcore::nurbs::math::CPoint;
use solidcore::query::brep::BrepFaceServices;
use solidcore::query::face::p1::calculate_bounding_box;
use solidcore::surface::cone::p1::Cone;
use solidcore::surface::plane::p1::Plane;
use solidcore::surface::sphere::{Sphere, SphereOptions};
use solidcore::surface::surface::Surface;
use solidcore::surface::surface::p2::TsectCurveType;
use solidcore::surface::torus::{Torus, TorusOptions};
use solidcore::topology::brep::p1 as brep_p1;
use solidcore::topology::brep::p2::{is_manifold_solid, is_sheet};
use solidcore::topology::brep::p3::{new_brep, with_kernel_callbacks};
use solidcore::topology::brep::p8::create_face_from_surface;
use solidcore::topology::brep::p9::orient_sheet_surfaces;
use solidcore::topology::brep::p10::merge_curves_on_surface_through_surface;
use solidcore::topology::brep::planar_section::face_planar_section_curves;
use solidcore::topology::brep_cache::CacheObject;
use solidcore::topology::edge::NotifyEnv;
use solidcore::topology::face::p2 as face_p2;
use solidcore::topology::{Brep, EdgeId, FaceId};
use super::blend::status;
use super::sweep::build_faces;
use super::{Body, run};
use crate::ssi::Spec;
use crate::{Error, Result};
use solidcore::topology::brep::p3::brep_copy;
/// `SMLIB_SPLIT_MAX_FACES`, `SMLIB_SPLIT_MAX_TOOLS`.
pub(crate) const MAX_FACES: usize = 100_000;
pub(crate) const MAX_TOOLS: usize = 4096;
/// `SM_ERR`.
const SM_ERR: i32 = 1001;
/// The capi's statuses as SolidCraft errors: an operation's own result.
type Out<T> = std::result::Result<T, Error>;
/// A tool surface as solidcore holds it: the surface over its natural UV domain (`smlib_surface`'s
/// `s` and `domain`), and for a plane its point and unit normal (`IsPlanar`).
pub(crate) struct Tool {
pub(super) surface: Surface,
pub(super) domain: Extent2d,
plane: Option<(Point3, Vec3)>,
}
fn v3(p: [f64; 3]) -> Vec3 {
Vec3::new(p[0], p[1], p[2])
}
/// The runner's `unit_cross`.
fn unit_cross(a: Vec3, b: Vec3) -> Vec3 {
let c = a.cross(b);
let l = c.length();
Vec3::new(c.x / l, c.y / l, c.z / l)
}
/// `[k0, k0, k1, ...]` as unique values and their multiplicities (`unique_knots`).
fn unique_knots(k: &[f64]) -> (Vec<f64>, Vec<usize>) {
let (mut values, mut mults): (Vec<f64>, Vec<usize>) = (Vec::new(), Vec::new());
for &x in k {
match (values.last(), mults.last_mut()) {
(Some(&last), Some(m)) if last == x => *m += 1,
_ => {
values.push(x);
mults.push(1);
}
}
}
(values, mults)
}
/// `clamped`: the first and the last knot each p + 1 times.
fn clamped(k: &[f64], p: usize) -> bool {
match (k.first(), k.get(p), k.last(), k.len().checked_sub(1 + p).and_then(|i| k.get(i))) {
(Some(a), Some(b), Some(c), Some(d)) => a == b && c == d,
_ => false,
}
}
/// The tool's surface as `smlib_ssi.cpp`'s constructors make it, then its natural UV domain
/// (`construct`: finite and not empty, else `SM_ERR_INVALID_INPUT`).
pub(super) fn tool(k: &Kernel, spec: &Spec) -> solidcore::Result<Out<Tool>> {
let mut plane = None;
let surface = match spec {
Spec::Plane { origin, xaxis, yaxis, u, v } => {
let dom = Extent2d::from_bounds(u[0], v[0], u[1], v[1]);
plane = Some((v3(*origin), unit_cross(v3(*xaxis), v3(*yaxis))));
Surface::from(Plane::new(v3(*origin), v3(*xaxis), v3(*yaxis), Vec2::new(1.0, 1.0), &dom))
}
Spec::Cylinder { origin, xaxis, yaxis, radius, angle_deg, height } => {
let (x, y) = (v3(*xaxis), v3(*yaxis));
let o = v3(*origin) + unit_cross(x, y) * height[0];
Surface::from(Cone::new_cylinder_bounded(o, x, y, *radius, angle_deg[0], angle_deg[1], height[1] - height[0], false, false, false))
}
Spec::Cone { apex, xaxis, yaxis, half_angle_deg, height } => {
let (x, y) = (v3(*xaxis), v3(*yaxis));
let (h0, h1) = (height[0], height[1]);
let t = half_angle_deg.to_radians().tan();
let o = v3(*apex) + unit_cross(x, y) * h0;
let pl = Axis2Placement::new(o, x, y);
Surface::from(Cone::create_canonical(k, &pl, h0 * t, h1 * t, h1 - h0, false, false)?)
}
Spec::Sphere { center, xaxis, yaxis, radius, lon_deg, lat_deg } => {
let anal = Extent2d::from_bounds(lon_deg[0], lat_deg[0], lon_deg[1], lat_deg[1]);
let opts = SphereOptions { swap_uv: false, inside_out: false, circle_nurb: None };
Surface::from(Sphere::new(k, v3(*center), v3(*xaxis), v3(*yaxis), anal, *radius, opts)?)
}
Spec::Torus { center, xaxis, yaxis, major_radius, minor_radius, major_deg, minor_deg } => {
let anal = Extent2d::from_bounds(major_deg[0], minor_deg[0], major_deg[1], minor_deg[1]);
Surface::from(Torus::new(k, v3(*center), v3(*xaxis), v3(*yaxis), anal, *major_radius, *minor_radius, TorusOptions::default())?)
}
Spec::Nurbs { degree, nu, nv, knots_u, knots_v, points, weights } => {
let [p, q] = *degree;
let w = |i: usize| weights.as_ref().and_then(|w| w.get(i).copied()).unwrap_or(1.0);
if !clamped(knots_u, p) || !clamped(knots_v, q) {
// SMLib's component constructor clamps the surface to its domain.
let (ku, mu) = unique_knots(knots_u);
let (kv, mv) = unique_knots(knots_v);
let wts: Vec<f64> = (0..points.len()).map(w).collect();
let pts: Vec<Point3> = points.iter().map(|p| v3(*p)).collect();
let data = CanonicalSurfaceData {
u_degree: p,
v_degree: q,
control_points: &pts,
form: BSplineSurfaceForm::Unspecified,
u_multiplicities: &mu,
v_multiplicities: &mv,
u_knots: &ku,
v_knots: &kv,
knot_type: KnotType::Unspecified,
weights: Some(&wts),
uv_domain: None,
};
Surface::from(BSplineSurface::create_canonical(&data)?)
} else {
// The runner's way: the homogeneous net filled directly.
let mut pw = Vec::with_capacity(*nu);
for i in 0..*nu {
let mut row = Vec::with_capacity(*nv);
for j in 0..*nv {
let at = i * nv + j;
let (Some(pt), wt) = (points.get(at), w(at)) else {
return Ok(Err(Error::Argument));
};
row.push(CPoint::new(pt[0] * wt, pt[1] * wt, pt[2] * wt, wt));
}
pw.push(row);
}
let (n, m) = (nu.saturating_sub(1), nv.saturating_sub(1));
let (r, s) = (knots_u.len().saturating_sub(1), knots_v.len().saturating_sub(1));
Surface::from(BSplineSurface::from_data(n, m, p, q, r, s, &pw, knots_u, knots_v)?)
}
}
};
let domain = surface.natural_uv_domain_with_nurb(k)?;
let (lo, hi) = (domain.min(), domain.max());
if !(lo.x.is_finite() && lo.y.is_finite() && hi.x.is_finite() && hi.y.is_finite() && lo.x < hi.x && lo.y < hi.y) {
return Ok(Err(Error::Status(1007)));
}
Ok(Ok(Tool { surface, domain, plane }))
}
/// `plane_covers`: whether the plane `tool` reaches over all of `bx` (each corner's foot on the
/// plane lies in the tool's parameter box): then its section with a face in the box is the
/// infinite plane's.
fn plane_covers(k: &Kernel, tool: &Tool, bx: &solidcore::geom::extent3d::Extent3d, tol: f64) -> bool {
let (lo, hi) = (tool.domain.min(), tool.domain.max());
let at = |u: f64, v: f64| tool.surface.evaluate_point(k, Vec2::new(u, v)).ok();
let (Some(o), Some(pu), Some(pv)) = (at(lo.x, lo.y), at(hi.x, lo.y), at(lo.x, hi.y)) else {
return false;
};
// The plane is affine in (u, v): P = o + (u - u0) X + (v - v0) Y.
let x = (pu - o) / (hi.x - lo.x);
let y = (pv - o) / (hi.y - lo.y);
let (a, b, c) = (x.dot(x), x.dot(y), y.dot(y));
let det = a * c - b * b;
if !(det.abs() > 0.0) {
return false;
}
let (m, mm) = (bx.min(), bx.max());
(0..8).all(|kk| {
let q = Vec3::new(if kk & 1 != 0 { mm.x } else { m.x }, if kk & 2 != 0 { mm.y } else { m.y }, if kk & 4 != 0 { mm.z } else { m.z });
let r = q - o;
let (g0, g1) = (x.dot(r), y.dot(r));
let u = lo.x + (c * g0 - b * g1) / det;
let v = lo.y + (a * g1 - b * g0) / det;
let (su, sv) = (tol / a.sqrt(), tol / c.sqrt());
u >= lo.x - su && u <= hi.x + su && v >= lo.y - sv && v <= hi.y + sv
})
}
/// The name of each edge curve a merge makes (`SmApiCut`'s).
fn curve_ref(e: EdgeId, _type_code: i64) -> ObjectRef {
solidcore::topology::loopuse::notify_ref(EntityRef::Edge(e))
}
/// `cut_face`: cuts `face` where `tool` meets it; the faces it was split into besides itself.
fn cut_face(k: &mut Kernel, b: &mut Brep, env: &mut NotifyEnv<'_>, face: FaceId, tool: &Tool, tol: f64) -> solidcore::Result<Out<Vec<FaceId>>> {
let Some(surface) = face_p2::surface(b, face)? else {
return Ok(Err(Error::Status(SM_ERR)));
};
let surface = surface.clone_for_evaluation()?;
let domain = face_p2::uv_domain(b, face)?;
let approx = approx_tol3d(k, approx_tol3d(k, tol) / 4.0);
let bx = match calculate_bounding_box(k, &mut *b, face, false) {
Ok(bx) => bx,
Err(e) => return Ok(Err(status(e))),
};
let merge =
|k: &mut Kernel, b: &mut Brep, env: &mut NotifyEnv<'_>, mut c3: Vec<_>, uv: Option<Vec<_>>| match merge_curves_on_surface_through_surface(
k,
b,
env,
&curve_ref,
face,
tol,
&mut c3,
uv.as_deref(),
Some(tool.surface.clone()),
) {
Ok((made, _)) => Ok(Ok(made)),
Err(e) => Ok(Err(status(e))),
};
// A plane reaching over the face sections it exactly (SmPlaneCutter's way); a plane strip,
// like any other tool, is intersected over its own domain.
if let Some((at, normal)) = tool.plane
&& plane_covers(k, tool, &bx, tol)
{
// As SmPlaneCutter: a face far from the plane is not sectioned.
if bx.distance_to_plane(at, normal).abs() > tol {
return Ok(Ok(Vec::new()));
}
let section = match face_planar_section_curves(k, b, face, &surface, &domain, at, normal, Some(approx), None) {
Ok(s) => s,
Err(e) => return Ok(Err(status(e))),
};
if section.curves_3d.is_empty() {
return Ok(Ok(Vec::new()));
}
let uv = (section.uv_curves.len() == section.curves_3d.len()).then_some(section.uv_curves);
return merge(k, b, env, section.curves_3d, uv);
}
// `face->GetSurface()` reaches its face's trimmed cache: the solve runs through the B-rep's
// face services, as solidcore's own face intersections do (a face-owned surface has no cache
// of its own).
let mut services = BrepFaceServices::new(&mut *b);
let found = match surface.global_surface_intersect_with_context(
k,
[Some(CacheObject::Surface(face)), None],
&mut services,
&domain,
&tool.surface,
&tool.domain,
[true, true],
Some(approx),
None,
) {
Ok(f) => f,
Err(e) => return Ok(Err(status(e))),
};
// A single point of contact cuts nothing.
let (mut c3, mut uv1) = (Vec::new(), Vec::new());
for (i, c) in found.curves_3d.into_iter().enumerate() {
if found.curve_types.get(i) == Some(&TsectCurveType::Touching) {
continue;
}
c3.push(c);
uv1.push(found.surface1_uv_curves.get(i).cloned().flatten());
}
if c3.is_empty() {
return Ok(Ok(Vec::new()));
}
let all_uv = uv1.iter().all(Option::is_some);
merge(k, b, env, c3, all_uv.then_some(uv1))
}
/// `smlib_split_faces`: a copy of `body` with faces `faces` (indices in its face order, distinct)
/// cut where each tool meets them, the tools in turn, each cutting the pieces the earlier ones
/// left; and for each face how many faces it became besides itself. `None` when no face is split
/// (`SMLIB_ERR_NO_CUT`).
pub(crate) fn split_faces(body: &Body, faces: &[usize], tools: &[&Spec]) -> Result<Option<(Body, Vec<usize>)>> {
if faces.is_empty() || faces.len() > MAX_FACES || tools.is_empty() || tools.len() > MAX_TOOLS {
return Err(Error::Argument);
}
if body.0.is_poisoned() {
return Err(Error::Internal("smlib_split_faces: the body was left unfinished by an earlier panic".to_owned()));
}
let r = run("smlib_split_faces", |k| {
let mut a = {
let g = body.0.lock().unwrap_or_else(PoisonError::into_inner);
super::dump::input(k, "brep", &g);
let count = brep_p1::faces(&g, None)?.len();
let mut seen = std::collections::HashSet::new();
if faces.iter().any(|&f| f >= count || !seen.insert(f)) {
return Ok(Err(Error::Argument));
}
brep_copy(k, &g)?
};
let all = brep_p1::faces(&a, None)?;
// Each chosen face and, as tools cut it, its pieces.
let mut pieces: Vec<Vec<FaceId>> = Vec::with_capacity(faces.len());
for &f in faces {
let Some(&face) = all.get(f) else { return Ok(Err(Error::Status(SM_ERR))) };
pieces.push(vec![face]);
}
let mut made = vec![0usize; faces.len()];
let mut built = Vec::with_capacity(tools.len());
for spec in tools {
match tool(k, spec)? {
Ok(t) => built.push(t),
Err(e) => return Ok(Err(e)),
}
}
let editing = a.editing_enabled();
let doing = k.context.doing_boolean();
a.set_editing_enabled(true);
k.context.set_doing_boolean(true);
let tol = a.tolerance();
// The edits notify through the kernel's callbacks with the body's own attributes, so
// the history tags follow each cut (as SMLib's objects notify their attributes).
let cut = with_kernel_callbacks(k, &mut a, |k, a, env| {
let mut total = 0usize;
for t in &built {
for (i, list) in pieces.iter_mut().enumerate() {
let now = list.clone();
for f in now {
let split = match cut_face(k, a, env, f, t, tol)? {
Ok(s) => s,
Err(e) => return Ok(Err(e)),
};
if let Some(m) = made.get_mut(i) {
*m += split.len();
}
total += split.len();
list.extend(split);
}
}
}
Ok(Ok(total))
});
a.set_editing_enabled(editing);
k.context.set_doing_boolean(doing);
a.refresh_cached_topology();
// The cut's new edges own their curves under the kernel's identities (it records them),
// not the copy's Notify ones, and the check reads the owners by the body's naming.
a.set_naming(solidcore::topology::NamingScheme::Caller);
Ok(match cut? {
Err(e) => Err(e),
Ok(0) => Ok(None),
Ok(_) => Ok(Some((a, made))),
})
})??;
Ok(r.map(|(b, made)| (Body::new(b), made)))
}
/// `slice`: a copy of `body` sliced by `sheet` (SMLib's slice reads the side from the surfaces, not
/// the faces' uses). `None` when nothing is left.
fn slice(k: &mut Kernel, body: &Body, sheet: Brep) -> solidcore::Result<Out<Option<Brep>>> {
let a = {
let g = body.0.lock().unwrap_or_else(PoisonError::into_inner);
brep_copy(k, &g)?
};
let options = MergeOptions::new(false, false, false, false, false);
let r = match boolean_with_options(k, a, sheet, BooleanOperation::Slice, options) {
Ok(r) => r.result,
Err(e) => return Ok(Err(status(e))),
};
Ok(Ok((!r.face_list().is_empty()).then_some(r)))
}
/// `both_sides`: both parts, `side` making each side's sheet (`true`: turned over, for the back).
/// A body that is no manifold solid is [`Error::NotSolid`]; a side left empty is `None`
/// (`SMLIB_ERR_NO_CUT`), and with `whole` so is a part that is no manifold solid: `SM_BO_SLICE`
/// with a sheet that only nicks a region behaves as a difference, leaving the sheet's piece as a
/// slit in the body and a lamina on the other side, which is no cut.
fn both_sides(
k: &mut Kernel,
body: &Body,
whole: bool,
mut side: impl FnMut(&mut Kernel, bool) -> solidcore::Result<Out<Brep>>,
) -> solidcore::Result<Out<Option<(Brep, Brep)>>> {
let solid = {
let g = body.0.lock().unwrap_or_else(PoisonError::into_inner);
is_manifold_solid(&g)?
};
if !solid {
return Ok(Err(Error::NotSolid));
}
let mut parts = [None, None];
for (part, flip) in parts.iter_mut().zip([true, false]) {
let sheet = match side(k, flip)? {
Ok(s) => s,
Err(e) => return Ok(Err(e)),
};
*part = match slice(k, body, sheet)? {
Ok(p) => p,
Err(e) => return Ok(Err(e)),
};
}
let [Some(back), Some(front)] = parts else { return Ok(Ok(None)) };
if whole && !(is_manifold_solid(&back)? && is_manifold_solid(&front)?) {
return Ok(Ok(None));
}
Ok(Ok(Some((back, front))))
}
/// One side of `smlib_split_body`: a one-face sheet on the tool's surface, turned over with `flip`.
fn side(k: &mut Kernel, body: &Body, spec: &Spec, flip: bool) -> solidcore::Result<Out<Brep>> {
let tolerance = body.0.lock().unwrap_or_else(PoisonError::into_inner).tolerance();
let t = match tool(k, spec)? {
Ok(t) => t,
Err(e) => return Ok(Err(e)),
};
let mut surface = t.surface.copy(k)?;
if flip && let Err(e) = surface.swap_uv(k) {
return Ok(Err(status(e)));
}
let mut sheet = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
sheet.set_tolerance(tolerance);
let natural = surface.natural_uv_domain_with_nurb(k)?;
if let Err(e) = build_faces(&mut sheet, |s, fenv| create_face_from_surface(k, s, Some(surface), &natural, fenv)) {
return Ok(Err(status(e)));
}
sheet.refresh_cached_topology();
Ok(Ok(sheet))
}
/// `smlib_split_body`: the manifold solid `body` cut in two by the tool: the part behind the
/// surface (its normal points away) and the part in front. A side left empty is `None`
/// (`SMLIB_ERR_NO_CUT`); a body that is no manifold solid is [`Error::NotSolid`].
pub(crate) fn split_body(body: &Body, spec: &Spec) -> Result<Option<(Body, Body)>> {
if body.0.is_poisoned() {
return Err(Error::Internal("smlib_split_body: the body was left unfinished by an earlier panic".to_owned()));
}
let r = run("smlib_split_body", |k| {
super::dump::input(k, "brep", &body.0.lock().unwrap_or_else(PoisonError::into_inner));
both_sides(k, body, false, |k, flip| side(k, body, spec, flip))
})??;
Ok(r.map(|(b, f)| (Body::new(b), Body::new(f))))
}
/// `smlib_split_body_by_sheet`: the manifold solid `body` cut in two by `sheet` as it is (one or
/// more faces joined edge to edge), its surfaces turned to agree with face `face`'s
/// (`SmBrep::OrientSheetSurfaces`), for the back all turned over: the part behind that normal and
/// the part in front. A sheet that does not divide the body is `None` (`SMLIB_ERR_NO_CUT`). A
/// `sheet` that is no sheet, or a `face` out of range, is [`Error::Argument`].
pub(crate) fn split_body_by_sheet(body: &Body, sheet: &Body, face: usize) -> Result<Option<(Body, Body)>> {
if std::ptr::eq(body, sheet) {
return Err(Error::Argument);
}
if body.0.is_poisoned() || sheet.0.is_poisoned() {
return Err(Error::Internal("smlib_split_body_by_sheet: a body was left unfinished by an earlier panic".to_owned()));
}
let r = run("smlib_split_body_by_sheet", |k| {
super::dump::input(k, "brep", &body.0.lock().unwrap_or_else(PoisonError::into_inner));
{
let g = sheet.0.lock().unwrap_or_else(PoisonError::into_inner);
super::dump::input(k, "sheet", &g);
if !is_sheet(k, &g)? || face >= g.face_list().len() {
return Ok(Err(Error::Argument));
}
}
both_sides(k, body, true, |k, flip| {
let mut copy = {
let g = sheet.0.lock().unwrap_or_else(PoisonError::into_inner);
brep_copy(k, &g)?
};
let Some(&f) = brep_p1::faces(&copy, None)?.get(face) else { return Ok(Err(Error::Argument)) };
// The UV swaps notify as SMLib's objects do: through the kernel's callbacks, with
// the sheet's own attributes, so its faces' tags see each swap.
let turned = with_kernel_callbacks(k, &mut copy, |k, b, env| orient_sheet_surfaces(k, b, Some(f), !flip, env));
copy.refresh_cached_topology();
Ok(match turned {
Ok(()) => Ok(copy),
Err(e) => Err(status(e)),
})
})
})??;
Ok(r.map(|(b, f)| (Body::new(b), Body::new(f))))
}
+315
View File
@@ -0,0 +1,315 @@
//! Split Face and Split Body on solidcore, against closed forms: face, edge and vertex counts,
//! volumes, how many faces each chosen face became, and every result exports to a record that
//! validates.
//!
//! The body is a 10 mm cube [0, 10]^3 on solidcore; the tools are described as
//! [`crate::ssi::Surface`] describes them.
use super::split::{split_body, split_body_by_sheet, split_faces};
use super::{Body, area, counts, make_box, to_arrays, volume};
use crate::Error;
use crate::export::native;
use crate::ssi::Spec;
const X: [f64; 3] = [1.0, 0.0, 0.0];
const Y: [f64; 3] = [0.0, 1.0, 0.0];
const Z: [f64; 3] = [0.0, 0.0, 1.0];
fn cube() -> Body {
make_box([0.0; 3], [10.0; 3]).unwrap()
}
/// The plane x = `at` over u (along y) and v (along z) in `[-1, 11]`: it reaches over the cube.
fn plane_x(at: f64) -> Spec {
Spec::Plane { origin: [at, 0.0, 0.0], xaxis: Y, yaxis: Z, u: [-1.0, 11.0], v: [-1.0, 11.0] }
}
/// The cylinder of radius 3 about the vertical line through (5, 5), from z = -1 to 11.
fn cylinder() -> Spec {
Spec::Cylinder { origin: [5.0, 5.0, 0.0], xaxis: X, yaxis: Y, radius: 3.0, angle_deg: [0.0, 360.0], height: [-1.0, 11.0] }
}
#[derive(Clone, Debug)]
enum Op {
/// Split the faces (all six when empty) by the tools.
Faces(Vec<usize>, Vec<Spec>),
Body(Spec),
}
#[derive(Clone, Debug, PartialEq)]
enum Expect {
/// Volume, counts, and the total of faces made.
Split(f64, [usize; 3], usize),
/// The two sides' volumes and counts.
Halves(f64, [usize; 3], f64, [usize; 3]),
NoCut,
}
fn case(name: &str) -> (Op, Expect) {
let all: Vec<usize> = (0..6).collect();
match name {
// Four faces cross x = 5, each cut in two; the four edges along x are cut at it.
"split_faces_by_a_plane" => (Op::Faces(all, vec![plane_x(5.0)]), Expect::Split(1000.0, [10, 20, 12], 4)),
// The cylinder meets the top and the bottom in a circle each: a disc and a holed face.
"split_faces_by_a_cylinder" => (Op::Faces(all, vec![cylinder()]), Expect::Split(1000.0, [8, 14, 10], 2)),
// Two planes in turn, the second cutting the first's pieces.
"split_faces_by_two_planes" => (Op::Faces(all, vec![plane_x(3.0), plane_x(7.0)]), Expect::Split(1000.0, [14, 28, 16], 8)),
"split_faces_missed_by_the_tool" => (Op::Faces(all, vec![plane_x(20.0)]), Expect::NoCut),
"split_body_by_a_plane" => (Op::Body(plane_x(3.0)), Expect::Halves(300.0, [6, 12, 8], 700.0, [6, 12, 8])),
// A tool that misses leaves both parts the whole body.
"split_body_missed_by_the_tool" => (Op::Body(plane_x(20.0)), Expect::Halves(1000.0, [6, 12, 8], 1000.0, [6, 12, 8])),
_ => panic!("no case {name}"),
}
}
#[derive(Debug, PartialEq)]
enum Measured {
Split { volume: f64, counts: [usize; 3], made: Vec<usize> },
Halves { volumes: [f64; 2], counts: [[usize; 3]; 2] },
NoCut,
}
type Got = std::result::Result<Measured, Error>;
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs()
}
fn valid(name: &str, b: &Body) {
let (rec, _) = native(&to_arrays(b).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
}
fn judge(name: &str, who: &str, got: Got) -> Got {
let (_, expect) = case(name);
match (&expect, &got) {
(_, Err(g)) => panic!("{name}: {who} failed: {g}"),
(Expect::NoCut, Ok(m)) => assert_eq!(*m, Measured::NoCut, "{name}: {who}"),
(Expect::Split(v, n, total), Ok(Measured::Split { volume, counts, made })) => {
assert!(rel(*volume, *v) < 1e-9, "{name}: {who} volume {volume} vs analytic {v}");
assert_eq!(counts, n, "{name}: {who} counts");
assert_eq!(made.iter().sum::<usize>(), *total, "{name}: {who} faces made {made:?}");
}
(Expect::Halves(v0, n0, v1, n1), Ok(Measured::Halves { volumes, counts })) => {
assert!(rel(volumes[0], *v0) < 1e-9 && rel(volumes[1], *v1) < 1e-9, "{name}: {who} volumes {volumes:?} vs {v0} {v1}");
assert_eq!(counts, &[*n0, *n1], "{name}: {who} counts");
}
(e, Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"),
}
got
}
fn solidcore_case(name: &str) -> Got {
let (op, _) = case(name);
let got = match &op {
Op::Faces(faces, tools) => {
let tools: Vec<&Spec> = tools.iter().collect();
split_faces(&cube(), faces, &tools).map(|r| match r {
None => Measured::NoCut,
Some((b, made)) => {
valid(name, &b);
Measured::Split { volume: volume(&b, 1e-8).unwrap(), counts: counts(&b).unwrap(), made }
}
})
}
Op::Body(tool) => split_body(&cube(), tool).map(|r| match r {
None => Measured::NoCut,
Some((b, f)) => {
valid(name, &b);
valid(name, &f);
Measured::Halves {
volumes: [volume(&b, 1e-8).unwrap(), volume(&f, 1e-8).unwrap()],
counts: [counts(&b).unwrap(), counts(&f).unwrap()],
}
}
}),
};
judge(name, "solidcore", got)
}
macro_rules! cases {
($($m:ident $(: $ign:meta)?),* $(,)?) => {$(
mod $m {
#[test]
$(#[$ign])?
fn solidcore_matches_the_closed_form() {
let _ = super::solidcore_case(stringify!($m));
}
}
)*};
}
cases!(
split_faces_by_a_plane,
split_faces_by_a_cylinder,
split_faces_by_two_planes,
split_faces_missed_by_the_tool,
split_body_by_a_plane,
split_body_missed_by_the_tool,
);
/// The capi's argument checks, before any kernel call.
#[test]
fn hostile_arguments_are_refused() {
let b = cube();
let p = plane_x(5.0);
assert_eq!(split_faces(&b, &[], &[&p]).err(), Some(Error::Argument), "no face");
assert_eq!(split_faces(&b, &[0], &[]).err(), Some(Error::Argument), "no tool");
assert_eq!(split_faces(&b, &[0, 0], &[&p]).err(), Some(Error::Argument), "a face twice");
assert_eq!(split_faces(&b, &[6], &[&p]).err(), Some(Error::Argument), "no face 6");
assert_eq!(split_faces(&b, &[usize::MAX], &[&p]).err(), Some(Error::Argument), "no face usize::MAX");
let many = vec![&p; super::split::MAX_TOOLS + 1];
assert_eq!(split_faces(&b, &[0], &many).err(), Some(Error::Argument), "too many tools");
let faces: Vec<usize> = (0..=super::split::MAX_FACES).collect();
assert_eq!(split_faces(&b, &faces, &[&p]).err(), Some(Error::Argument), "too many faces");
}
/// A sheet is no solid to split in two (`SMLIB_ERR_NOT_SOLID`).
#[test]
fn a_sheet_is_not_split_into_two_bodies() {
use solidcore::curve::curve::Curve;
use solidcore::geom::vector3d::Vec3;
use solidcore::primitive::api_curves::create_line_segment;
use solidcore::sweep::api_brep::create_linear_sweep;
let sheet = super::run("sheet", |k| {
let c = Curve::from(create_line_segment(Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0))?);
create_linear_sweep(k, &[Some(&c)], Vec3::new(0.0, 1.0, 0.0), 10.0, false)
})
.map(Body::new)
.unwrap();
assert!(rel(area(&sheet, 1e-8).unwrap(), 100.0) < 1e-9);
assert_eq!(split_body(&sheet, &plane_x(5.0)).err(), Some(Error::NotSolid));
}
/// Faces of the box -20..5 x -20..20 x -20..5 copied off it into a sheet (`CopyFaces` with merge,
/// as the capi's test): the top z = 5 and, with `side`, the side x = 5 too.
fn lid(side: bool) -> Body {
use solidcore::EntityRef;
use solidcore::geom::tol::USE_DEFAULT;
use solidcore::query::api_queries::face_internal_point;
use solidcore::topology::brep::p1::faces;
use solidcore::topology::brep::p2::ArenaNotifyState;
use solidcore::topology::brep::p3::new_brep;
use solidcore::topology::brep::p7::copy_faces;
let slab = make_box([-20.0; 3], [25.0, 40.0, 25.0]).unwrap();
super::run("sheet", |k| {
let mut whole = solidcore::topology::brep::p3::brep_copy(k, &slab.0.lock().unwrap())?;
let mut picked = Vec::new();
for f in faces(&whole, None)? {
let p = face_internal_point(k, &mut whole, f)?;
if (p.z - 5.0).abs() < 1e-9 || (side && (p.x - 5.0).abs() < 1e-9) {
picked.push(f);
}
}
assert_eq!(picked.len(), if side { 2 } else { 1 });
let mut w = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
w.set_tolerance(whole.tolerance());
{
let mut state = ArenaNotifyState::new(&w);
let mut env = state.env(None);
let key_of = env.key_of;
let this_face = |f| key_of(EntityRef::Face(f));
copy_faces(k, &mut whole, &picked, &mut w, true, &this_face, &mut env)?;
}
w.refresh_cached_topology();
Ok(w)
})
.map(Body::new)
.unwrap()
}
/// The capi's test of `smlib_split_body_by_sheet` (fork 2fc375f): a cube 0..10 cut by an L of two
/// faces: the corner x < 5, z < 5 (250) on one side, the rest (750) on the other, whichever face
/// the sides are read from; on C++ too.
#[test]
fn split_body_by_an_l_sheet() {
let (c, sheet) = (cube(), lid(true));
for face in 0..2 {
let (b, f) = split_body_by_sheet(&c, &sheet, face).unwrap().expect("the sheet divides the cube");
valid("L sheet", &b);
valid("L sheet", &f);
let (vb, vf) = (volume(&b, 1e-8).unwrap(), volume(&f, 1e-8).unwrap());
assert!(rel(vb.min(vf), 250.0) < 1e-9 && rel(vb.max(vf), 750.0) < 1e-9, "face {face}: {vb} + {vf}");
}
}
/// What `smlib_split_body_by_sheet` refuses: a body that is no sheet, a face out of range, the
/// sheet as its own body; a face that ends inside the cube only nicks it, which is no cut.
#[test]
fn split_by_a_sheet_refuses() {
let (c, sheet) = (cube(), lid(true));
assert_eq!(split_body_by_sheet(&c, &make_box([-20.0; 3], [25.0, 40.0, 25.0]).unwrap(), 0).err(), Some(Error::Argument));
assert_eq!(split_body_by_sheet(&c, &sheet, 2).err(), Some(Error::Argument));
assert_eq!(split_body_by_sheet(&c, &c, 0).err(), Some(Error::Argument));
assert!(split_body_by_sheet(&c, &lid(false), 0).unwrap().is_none());
assert_eq!(split_body_by_sheet(&sheet, &lid(false), 0).err(), Some(Error::NotSolid));
}
/// The body split is never changed.
#[test]
fn the_body_stays_as_it_was() {
let b = cube();
let _ = split_faces(&b, &[0, 1, 2, 3, 4, 5], &[&plane_x(5.0)]).unwrap();
let _ = split_body(&b, &plane_x(5.0)).unwrap();
assert_eq!(counts(&b).unwrap(), [6, 12, 8]);
assert!(rel(volume(&b, 1e-8).unwrap(), 1000.0) < 1e-12);
}
/// A one-face sheet on a B-spline surface (as SolidCraft's extrude of a sketch spline makes it:
/// the cubic through (0, 0), (13, -8), (27, 8), (40, 0) in x and z, straight along y over
/// [0, 40]) cut by four plane strips standing on the square [12, 28]^2: the strips meet the
/// face inside its bounds, so the face's surface is intersected through its face's trimmed
/// cache. The square comes out as a face of its own, the sheet holed by it.
#[test]
fn a_spline_sheet_is_cut_by_strips_inside_it() {
use solidcore::geom::tol::USE_DEFAULT;
use solidcore::geom::vector3d::Vec3;
use solidcore::nurbs::b_spline_curve::p1::KnotType;
use solidcore::nurbs::b_spline_surface::p1::{BSplineSurface, BSplineSurfaceForm, CanonicalSurfaceData};
use solidcore::surface::surface::Surface;
use solidcore::topology::brep::p3::new_brep;
use solidcore::topology::brep::p8::create_face_from_surface;
// Control points u-major: the curve's four, each at y = 0 and y = 40.
let curve = [[0.0, 0.0], [13.0, -8.0], [27.0, 8.0], [40.0, 0.0]];
let pts: Vec<Vec3> = curve.iter().flat_map(|[x, z]| [Vec3::new(*x, 0.0, *z), Vec3::new(*x, 40.0, *z)]).collect();
let sheet = super::run("sheet", |k| {
let surface = Surface::from(BSplineSurface::create_canonical(&CanonicalSurfaceData {
u_degree: 3,
v_degree: 1,
control_points: &pts,
form: BSplineSurfaceForm::Unspecified,
u_multiplicities: &[4, 4],
v_multiplicities: &[2, 2],
u_knots: &[0.0, 1.0],
v_knots: &[0.0, 40.0],
knot_type: KnotType::Unspecified,
weights: None,
uv_domain: None,
})?);
let mut b = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
let natural = surface.natural_uv_domain_with_nurb(k)?;
super::sweep::build_faces(&mut b, |s, fenv| create_face_from_surface(k, s, Some(surface), &natural, fenv))?;
b.refresh_cached_topology();
Ok(b)
})
.map(Body::new)
.unwrap();
assert_eq!(counts(&sheet).unwrap(), [1, 4, 4]);
let whole = area(&sheet, 1e-8).unwrap();
// The strip standing on the line from `o` along `d` (16 long), reaching past the sheet in z.
let strip = |o: [f64; 3], d: [f64; 3]| Spec::Plane { origin: o, xaxis: Z, yaxis: d, u: [-60.0, 60.0], v: [0.0, 16.0] };
let tools = [
strip([12.0, 12.0, 0.0], X),
strip([28.0, 12.0, 0.0], Y),
strip([28.0, 28.0, 0.0], [-1.0, 0.0, 0.0]),
strip([12.0, 28.0, 0.0], [0.0, -1.0, 0.0]),
];
let tools: Vec<&Spec> = tools.iter().collect();
let (cut, made) = split_faces(&sheet, &[0], &tools).unwrap().expect("the strips cut the sheet");
valid("spline sheet", &cut);
// The square's face and the holed sheet: four edges and vertices round each.
assert_eq!(counts(&cut).unwrap(), [2, 8, 8]);
assert_eq!(made, [1]);
// Cutting moves no surface: the pieces cover the sheet.
assert!(rel(area(&cut, 1e-8).unwrap(), whole) < 1e-9);
}
+120
View File
@@ -0,0 +1,120 @@
//! Surface/surface intersection on solidcore (`smlib_ssi.cpp`'s `smlib_ssi`, `smlib_surface_domain`,
//! `smlib_surface_evaluate`), on the surfaces [`crate::core::split`] makes from a [`Spec`]:
//! `SmSurface::GlobalSurfaceIntersect` and `SmAdvSurfaceIntersector`, whose results are read as the
//! capi reads them.
use std::borrow::Cow;
use solidcore::curve::curve::Curve;
use solidcore::geom::vector2d::Point2;
use solidcore::intersect::surface_intersector::p5::do_intersection;
use solidcore::kernel::Kernel;
use solidcore::nurbs::b_spline_curve::BSplineCurve;
use solidcore::surface::surface::p2::{SurfaceIntersection, TsectCurveType};
use super::convert::bspline_curve_part;
use super::run;
use super::split::{Tool, tool};
use crate::ssi::{CurveType, Method, Nurbs, Spec, SsiCurve, SsiPoint, SsiResult, curve2, curve3};
use crate::{Error, Result};
/// The surface of `spec` with its natural UV domain (`construct`).
pub(crate) fn surface(spec: &Spec) -> Result<Tool> {
run("smlib_surface", |k| tool(k, spec))?
}
/// `[u0, u1, v0, v1]`.
pub(crate) fn domain(s: &Tool) -> [f64; 4] {
let (lo, hi) = (s.domain.min(), s.domain.max());
[lo.x, hi.x, lo.y, hi.y]
}
/// The surface's point at (u, v): `smlib_surface_evaluate`, a non-finite point being `SM_ERR`.
pub(crate) fn evaluate(s: &Tool, u: f64, v: f64) -> Result<[f64; 3]> {
if !u.is_finite() || !v.is_finite() {
return Err(Error::Argument);
}
let p = run("smlib_surface_evaluate", |k| s.surface.evaluate_point(k, Point2::new(u, v)))?;
let r = [p.x, p.y, p.z];
if r.iter().all(|x| x.is_finite()) { Ok(r) } else { Err(Error::Solidcore("smlib_surface_evaluate: not a finite point".into())) }
}
fn curve_type(t: TsectCurveType) -> CurveType {
match t {
TsectCurveType::Unknown => CurveType::Unknown,
TsectCurveType::Touching => CurveType::Touching,
TsectCurveType::Crossing => CurveType::Crossing,
TsectCurveType::Tangent => CurveType::Tangent,
TsectCurveType::Coincident => CurveType::Coincident,
TsectCurveType::NearTangent => CurveType::NearTangent,
TsectCurveType::RegionBoundary => CurveType::RegionBoundary,
}
}
/// The capi's `nurbs`: `c` as its B-spline, control points made cartesian in `dim` coordinates;
/// `None` for a curve that is not one, which is never approximated here.
fn nurbs(k: &Kernel, c: &Curve, dim: usize) -> Result<Option<Nurbs>> {
let Some(part): Option<Cow<'_, BSplineCurve>> = bspline_curve_part(k, c)? else { return Ok(None) };
let Some(n) = part.gw_nurb() else { return Ok(None) };
let mut out = Nurbs { points: Vec::with_capacity(n.cpts().len() * dim), weights: Vec::with_capacity(n.cpts().len()), knots: n.knots().to_vec() };
for p in n.cpts() {
let w = if p.w != solidcore::nurbs::math::NOW { p.w } else { 1.0 };
let q = [p.x / w, p.y / w, p.z / w];
out.points.extend_from_slice(q.get(..dim).unwrap_or(&q));
out.weights.push(w);
}
Ok(Some(out))
}
/// The capi's `start`: where `c` starts, NaN where there is no curve or it can't be evaluated.
fn start(k: &Kernel, c: Option<&Curve>) -> [f64; 3] {
c.and_then(|c| c.evaluate_point(k, c.natural_interval().min()).ok()).map_or([f64::NAN; 3], |p| [p.x, p.y, p.z])
}
fn read(k: &Kernel, r: &SurfaceIntersection) -> Result<SsiResult> {
let mut out = SsiResult::default();
for (i, c3) in r.curves_3d.iter().enumerate() {
let (ua, ub) = (r.surface1_uv_curves.get(i).and_then(Option::as_ref), r.surface2_uv_curves.get(i).and_then(Option::as_ref));
let ty = r.curve_types.get(i).copied();
let ivl = c3.natural_interval();
let uv = |c: Option<&Curve>| -> Result<_> { Ok(c.map(|c| nurbs(k, c, 2)).transpose()?.flatten().map(curve2)) };
out.curves.push(SsiCurve {
curve_type: ty.map_or(CurveType::None, curve_type),
deviation: r.deviations.get(i).copied().unwrap_or(f64::NAN),
interval: [ivl.min(), ivl.max()],
// A curve that is no B-spline is the one thing the result can't hold.
curve: curve3(nurbs(k, c3, 3)?.ok_or(Error::Unsupported)?),
uv_a: uv(ua)?,
uv_b: uv(ub)?,
});
if ty == Some(TsectCurveType::Touching) {
let (a, b) = (start(k, ua), start(k, ub));
out.points.push(SsiPoint { xyz: start(k, Some(c3)), uv_a: [a[0], a[1]], uv_b: [b[0], b[1]] });
}
}
Ok(out)
}
/// `smlib_ssi`: `a` and `b` within their domains, at approximation tolerance `approx_tol` (<= 0:
/// the kernel's default).
pub(crate) fn intersect(a: &Tool, b: &Tool, method: Method, approx_tol: f64) -> Result<SsiResult> {
if !approx_tol.is_finite() {
return Err(Error::Argument);
}
let tol = (approx_tol > 0.0).then_some(approx_tol);
let edges = [true, true];
run("smlib_ssi", |k| {
let found = match method {
Method::Global => a.surface.global_surface_intersect(k, &a.domain, &b.surface, &b.domain, edges, tol, None),
Method::Advanced => do_intersection(k, &a.surface, &a.domain, &b.surface, &b.domain, edges, tol, None),
};
// An SMLib status is an answer, as the C API returns it.
match found {
Ok(found) => Ok(read(k, &found)),
Err(e) => match e.sm_status() {
Some(status) => Ok(Err(Error::Status(status))),
None => Err(e),
},
}
})?
}
File diff suppressed because it is too large Load Diff
+613
View File
@@ -0,0 +1,613 @@
//! Extrusions, revolutions, sweeps, lofts and planar faces on solidcore, against closed forms:
//! every result exports to a record that validates.
//!
//! The profiles are built straight into solidcore curves from the curve lists.
use std::collections::BTreeMap;
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use std::sync::mpsc;
use std::time::Duration;
use super::sweep::{extrude, face_section, loft, loft_to_point, planar_faces, project_curves, revolve, screw_sweep, sweep_placed, sweep_stations};
use super::{Body, area, bounds, counts, make_box, make_cylinder, to_arrays, volume};
use crate::Error;
use crate::export::native;
use crate::sweeps::{Affine, CornerRefusal, Curve, DraftCorner, Ends, SweepFrame};
/// A case longer than this is a hang.
const TIMEOUT: Duration = Duration::from_secs(300);
const Z: [f64; 3] = [0.0, 0.0, 1.0];
fn line(a: [f64; 3], b: [f64; 3]) -> Curve {
Curve::Line { start: a, end: b }
}
/// The closed polygon through `pts`.
fn polygon(pts: &[[f64; 3]]) -> Vec<Curve> {
(0..pts.len()).map(|i| line(pts[i], pts[(i + 1) % pts.len()])).collect()
}
/// The square of side `s` from `(x, y)` in the plane z = `z`.
fn square(x: f64, y: f64, s: f64, z: f64) -> Vec<Curve> {
polygon(&[[x, y, z], [x + s, y, z], [x + s, y + s, z], [x, y + s, z]])
}
/// The circle of `radius` about `axis` through `center`, from `reference`.
fn circle(center: [f64; 3], axis: [f64; 3], reference: [f64; 3], radius: f64) -> Curve {
Curve::Arc { center, axis, reference, radius, start: 0.0, end: TAU }
}
/// An arc of radius 5 about the z axis from `reference`, from `start` to `end`.
fn arc5(reference: [f64; 3], start: f64, end: f64) -> Curve {
Curve::Arc { center: [0.0; 3], axis: Z, reference, radius: 5.0, start, end }
}
/// The circle of radius 5 about the z axis as two half arcs in one frame.
fn halves() -> Vec<Curve> {
vec![arc5([1.0, 0.0, 0.0], 0.0, PI), arc5([1.0, 0.0, 0.0], PI, TAU)]
}
/// The circle of radius 5 about the z axis as a quarter from +x and three quarters from +y.
fn two_frames() -> Vec<Curve> {
vec![arc5([1.0, 0.0, 0.0], 0.0, FRAC_PI_2), arc5([0.0, 1.0, 0.0], 0.0, 3.0 * FRAC_PI_2)]
}
/// The upper half disc of radius 5 about the z axis, its arc in two quarters.
fn half_disc() -> Vec<Curve> {
vec![arc5([1.0, 0.0, 0.0], 0.0, FRAC_PI_2), arc5([1.0, 0.0, 0.0], FRAC_PI_2, PI), line([-5.0, 0.0, 0.0], [5.0, 0.0, 0.0])]
}
/// The rectangle from x = 5 to 8 and z = 0 to 4 in the plane y = 0: area 12, centroid at x 6.5.
fn ring_section() -> Vec<Curve> {
polygon(&[[5.0, 0.0, 0.0], [8.0, 0.0, 0.0], [8.0, 0.0, 4.0], [5.0, 0.0, 4.0]])
}
fn translate(t: [f64; 3]) -> Affine {
Affine { m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], t }
}
fn scale_up(s: f64, z: f64) -> Affine {
Affine { m: [[s, 0.0, 0.0], [0.0, s, 0.0], [0.0, 0.0, 1.0]], t: [0.0, 0.0, z] }
}
#[derive(Clone, Debug)]
enum Op {
PlanarFaces(Vec<Curve>),
Extrude(Vec<Curve>, [f64; 3], f64, f64, DraftCorner, bool),
Revolve(Vec<Curve>, [f64; 3], [f64; 3], f64, bool),
Sweep(Vec<Curve>, Vec<Curve>, SweepFrame, bool),
Loft(Vec<Vec<Curve>>, u32, bool),
LoftToPoint(Vec<Curve>, [f64; 3]),
Stations(Vec<Curve>, Vec<Affine>, Ends),
Screw(Vec<Curve>, [f64; 3], [f64; 3], f64, f64, f64),
/// A screw sweep with a radial law.
ScrewRadial(Vec<Curve>, [f64; 3], [f64; 3], f64, f64, Vec<(f64, f64)>, f64),
}
/// What a case must give.
#[derive(Clone, Debug)]
enum Expect {
/// A solid of this volume (relative 1e-9).
Volume(f64),
/// A solid of this volume within this relative error (walls approximated within a tolerance).
About(f64, f64),
/// A sheet of this area (relative 1e-9).
Sheet(f64),
/// A drafted corner refused for this reason, wherever.
Corner(CornerRefusal),
}
struct Case {
op: Op,
expect: Expect,
}
/// The frustum of a pyramid or cone: h/3 (A1 + A2 + sqrt(A1 A2)).
fn frustum(a1: f64, a2: f64, h: f64) -> f64 {
h / 3.0 * (a1 + a2 + (a1 * a2).sqrt())
}
fn case(name: &str) -> Case {
let c = |op, expect| Case { op, expect };
let rect = polygon(&[[0.0, 0.0, 0.0], [30.0, 0.0, 0.0], [30.0, 20.0, 0.0], [0.0, 20.0, 0.0]]);
let theta = 5f64.to_radians();
let disc = || vec![circle([0.0; 3], Z, [1.0, 0.0, 0.0], 5.0)];
match name {
// A square of 10 with a hole of radius 2: 100 - 4π.
"planar_faces_with_a_hole" => {
let mut loops = square(0.0, 0.0, 10.0, 0.0);
loops.push(circle([5.0, 5.0, 0.0], Z, [1.0, 0.0, 0.0], 2.0));
c(Op::PlanarFaces(loops), Expect::Sheet(100.0 - 4.0 * PI))
}
// V = A h.
"extrude_rectangle" => c(Op::Extrude(rect, Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(6000.0)),
// V = π r² h.
"extrude_circle" => c(Op::Extrude(disc(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(250.0 * PI)),
// A circle drawn in two halves (split where another sketch curve ends on it), the
// pieces in the circle's own frame: one wall, as the whole circle sweeps (smlib 7dee48f).
"extrude_circle_in_two_halves" => c(Op::Extrude(halves(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(250.0 * PI)),
// The same circle as a quarter and three quarters each in its own frame: one wall still,
// its seam in the middle of the longer piece.
"extrude_circle_in_two_frames" => c(Op::Extrude(two_frames(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(250.0 * PI)),
// A half disc whose arc is drawn in two quarters, closed by its diameter: the quarters
// sweep one wall. V = π r² h / 2.
"extrude_half_disc_split_arc" => c(Op::Extrude(half_disc(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(125.0 * PI)),
// Leaning in by θ, the walls' offsets overlap at the corners and are trimmed where they
// cross, under every rule: the frustum from side 20 to 20 - 2 h tanθ.
"extrude_square_drafted_round" => {
let top = 20.0 - 2.0 * 10.0 * theta.tan();
c(Op::Extrude(square(0.0, 0.0, 20.0, 0.0), Z, 10.0, theta, DraftCorner::Round, true), Expect::Volume(frustum(400.0, top * top, 10.0)))
}
"extrude_square_drafted_curvature" => {
let top = 20.0 - 2.0 * 10.0 * theta.tan();
c(Op::Extrude(square(0.0, 0.0, 20.0, 0.0), Z, 10.0, theta, DraftCorner::Curvature, true), Expect::Volume(frustum(400.0, top * top, 10.0)))
}
// No corner: the cone frustum from radius 5 to 5 - h tanθ.
"extrude_circle_drafted" => {
let r2 = 5.0 - 10.0 * theta.tan();
c(Op::Extrude(disc(), Z, 10.0, theta, DraftCorner::default(), true), Expect::Volume(frustum(PI * 25.0, PI * r2 * r2, 10.0)))
}
// A strip 2 wide leaning in 10° over 10: the long walls meet across the short ones
// (10 tan 10° > 1) before the far end.
"extrude_draft_wall_closes" => c(
Op::Extrude(
polygon(&[[0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [20.0, 2.0, 0.0], [0.0, 2.0, 0.0]]),
Z,
10.0,
10f64.to_radians(),
DraftCorner::Curvature,
true,
),
Expect::Corner(CornerRefusal::WallCloses),
),
// Pappus: A · θ x̄ = 12 · θ · 6.5.
"revolve_rectangle_full" => c(Op::Revolve(ring_section(), [0.0; 3], Z, TAU, true), Expect::Volume(12.0 * TAU * 6.5)),
"revolve_rectangle_quarter" => c(Op::Revolve(ring_section(), [0.0; 3], Z, FRAC_PI_2, true), Expect::Volume(12.0 * FRAC_PI_2 * 6.5)),
// π r² L, along two lines end to end.
"sweep_circle_straight" => c(
Op::Sweep(
vec![circle([0.0; 3], Z, [1.0, 0.0, 0.0], 2.0)],
vec![line([0.0; 3], [0.0, 0.0, 6.0]), line([0.0, 0.0, 6.0], [0.0, 0.0, 10.0])],
SweepFrame::Follow,
true,
),
Expect::Volume(PI * 4.0 * 10.0),
),
// A quarter of a torus: π r² · R θ.
"sweep_circle_along_an_arc" => c(
Op::Sweep(
vec![circle([10.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], 1.0)],
vec![Curve::Arc { center: [0.0; 3], axis: Z, reference: [1.0, 0.0, 0.0], radius: 10.0, start: 0.0, end: FRAC_PI_2 }],
SweepFrame::Follow,
true,
),
Expect::Volume(PI * 10.0 * FRAC_PI_2),
),
// A bent path (a line, then a quarter arc) needs the curve sweep: π r² times the path's
// length, 10 + 10 π/2, within the walls' fit.
"sweep_along_a_bent_path" => c(
Op::Sweep(
vec![circle([0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0)],
vec![
line([0.0; 3], [10.0, 0.0, 0.0]),
Curve::Arc { center: [10.0, 10.0, 0.0], axis: Z, reference: [0.0, -1.0, 0.0], radius: 10.0, start: 0.0, end: FRAC_PI_2 },
],
SweepFrame::Follow,
true,
),
Expect::About(PI * (10.0 + 10.0 * FRAC_PI_2), 1e-4),
),
// A 4 x 3 rectangle round a closed 60 x 40 rounded rectangle (corner radius 8), from the
// middle of its bottom line: no caps, the walls closing on each other. Pappus: A L, with
// A = 12 and L = 2 (44 + 24) + 4 (π/2) 8.
"sweep_round_a_closed_path" => {
let arc = |c: [f64; 3], r: [f64; 3]| Curve::Arc { center: c, axis: Z, reference: r, radius: 8.0, start: 0.0, end: FRAC_PI_2 };
let path = vec![
line([0.0, -20.0, 0.0], [22.0, -20.0, 0.0]),
arc([22.0, -12.0, 0.0], [0.0, -1.0, 0.0]),
line([30.0, -12.0, 0.0], [30.0, 12.0, 0.0]),
arc([22.0, 12.0, 0.0], [1.0, 0.0, 0.0]),
line([22.0, 20.0, 0.0], [-22.0, 20.0, 0.0]),
arc([-22.0, 12.0, 0.0], [0.0, 1.0, 0.0]),
line([-30.0, 12.0, 0.0], [-30.0, -12.0, 0.0]),
arc([-22.0, -12.0, 0.0], [-1.0, 0.0, 0.0]),
line([-22.0, -20.0, 0.0], [0.0, -20.0, 0.0]),
];
c(
Op::Sweep(polygon(&[[0.0, -22.0, -1.5], [0.0, -18.0, -1.5], [0.0, -18.0, 1.5], [0.0, -22.0, 1.5]]), path, SweepFrame::Follow, true),
Expect::About(12.0 * (2.0 * (44.0 + 24.0) + 16.0 * PI), 1e-4),
)
}
// The frustum of a square pyramid: side 20 to 10 over 10.
"loft_two_squares" => {
c(Op::Loft(vec![square(0.0, 0.0, 20.0, 0.0), square(5.0, 5.0, 10.0, 10.0)], 1, true), Expect::Volume(frustum(400.0, 100.0, 10.0)))
}
// The cone frustum: radius 5 to 3 over 10.
"loft_two_circles" => c(
Op::Loft(vec![disc(), vec![circle([0.0, 0.0, 10.0], Z, [1.0, 0.0, 0.0], 3.0)]], 1, true),
Expect::Volume(frustum(25.0 * PI, 9.0 * PI, 10.0)),
),
// A h / 3.
"loft_to_point_pyramid" => c(Op::LoftToPoint(square(0.0, 0.0, 10.0, 0.0), [5.0, 5.0, 6.0]), Expect::Volume(200.0)),
// A square of 2 carried up 10: 40.
"sweep_stations_prism" => c(
Op::Stations(
square(-1.0, -1.0, 2.0, 0.0),
vec![translate([0.0; 3]), translate([0.0, 0.0, 5.0]), translate([0.0, 0.0, 10.0])],
Ends::Capped,
),
Expect::Volume(40.0),
),
// Scaled by s = 1 + z/10 on the way: ∫ (2 s)² dz = 4 · 10 · (2³ - 1) / 3.
"sweep_stations_scaled" => c(
Op::Stations(square(-1.0, -1.0, 2.0, 0.0), vec![scale_up(1.0, 0.0), scale_up(1.5, 5.0), scale_up(2.0, 10.0)], Ends::Capped),
Expect::Volume(4.0 * 10.0 * 7.0 / 3.0),
),
// A square thread 1 x 1 from radius 5 to 6, pitch 2, two turns: Pappus along the screw
// motion (its rise lies in the profile's plane), A · 2π x̄ · turns, the walls within the
// tolerance of the screw surfaces.
"screw_sweep_square_thread" => c(
Op::Screw(polygon(&[[5.0, 0.0, 0.0], [6.0, 0.0, 0.0], [6.0, 0.0, 1.0], [5.0, 0.0, 1.0]]), [0.0; 3], Z, 2.0, 2.0, 1e-5),
Expect::About(TAU * 5.5 * 2.0, 1e-5),
),
// The same thread moved out by 0 to 1 over its two turns, linearly: Pappus with the
// centroid at 5.5 + t/2, A · 2π · turns · (5.5 + 1/2).
"screw_sweep_radial_taper" => c(
Op::ScrewRadial(
polygon(&[[5.0, 0.0, 0.0], [6.0, 0.0, 0.0], [6.0, 0.0, 1.0], [5.0, 0.0, 1.0]]),
[0.0; 3],
Z,
2.0,
2.0,
vec![(0.0, 0.0), (2.0, 1.0)],
1e-5,
),
Expect::About(TAU * 2.0 * 6.0, 1e-5),
),
_ => panic!("{name}"),
}
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs()
}
#[derive(Debug, PartialEq)]
struct Measured {
/// The volume of a solid, the area of a sheet.
size: f64,
counts: [usize; 3],
kinds: BTreeMap<String, usize>,
}
fn kinds(rec: &solidcraft_geom::brep::Brep) -> BTreeMap<String, usize> {
let mut out = BTreeMap::new();
for f in &rec.faces {
let v = serde_json::to_value(&f.surface).unwrap();
*out.entry(v["kind"].as_str().unwrap_or("?").to_owned()).or_default() += 1;
}
out
}
fn on_core(op: &Op) -> crate::Result<Body> {
match op {
Op::PlanarFaces(l) => planar_faces(l),
Op::Extrude(p, d, dist, draft, corner, cap) => extrude(p, *d, *dist, *draft, *corner, *cap),
Op::Revolve(p, o, a, angle, cap) => revolve(p, *o, *a, *angle, *cap),
Op::Sweep(p, path, frame, cap) => sweep_placed(p, path, *frame, *cap).map(|(b, _)| b),
Op::Loft(s, d, cap) => loft(s, *d, *cap),
Op::LoftToPoint(s, apex) => loft_to_point(s, *apex),
Op::Stations(p, s, e) => sweep_stations(p, s, *e),
Op::Screw(p, o, a, pitch, turns, tol) => screw_sweep(p, *o, *a, *pitch, *turns, None, *tol),
Op::ScrewRadial(p, o, a, pitch, turns, law, tol) => screw_sweep(p, *o, *a, *pitch, *turns, Some(law), *tol),
}
}
/// Runs `f` on its own thread: a hang fails the test instead of stopping the run.
fn timed<T: Send + 'static>(name: &str, f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = mpsc::channel();
let worker = std::thread::spawn(move || {
let _ = tx.send(f());
});
match rx.recv_timeout(TIMEOUT) {
Ok(v) => v,
Err(mpsc::RecvTimeoutError::Disconnected) => match worker.join() {
Err(p) => std::panic::resume_unwind(p),
Ok(()) => panic!("{name}: no result"),
},
Err(mpsc::RecvTimeoutError::Timeout) => panic!("{name}: solidcore hung (no result after {TIMEOUT:?})"),
}
}
type Got = std::result::Result<Measured, Error>;
/// Checks a kernel's result against the case's closed form; what it measured, or its error.
fn judge(name: &str, who: &str, expect: &Expect, got: Got) -> Got {
match (expect, &got) {
(Expect::Corner(why), Err(g)) => {
assert!(
matches!(g, Error::Corner { why: w, at } if w == why && at.iter().all(|x| x.is_finite())),
"{name}: {who} failed otherwise: {g:?}"
)
}
(Expect::Corner(_), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {expect:?}"),
(_, Err(g)) => panic!("{name}: {who} failed: {g}"),
(Expect::Volume(v) | Expect::Sheet(v), Ok(m)) => assert!(rel(m.size, *v) < 1e-9, "{name}: {who} {} vs analytic {v}", m.size),
(Expect::About(v, within), Ok(m)) => assert!(rel(m.size, *v) < *within, "{name}: {who} {} vs analytic {v}", m.size),
}
got
}
fn solidcore_case(name: &'static str) -> Got {
timed(name, move || {
let c = case(name);
let sheet = matches!(c.expect, Expect::Sheet(_));
let got = on_core(&c.op).map(|body| {
let size = if sheet { area(&body, 1e-8) } else { volume(&body, 1e-8) }.unwrap_or_else(|e| panic!("{name}: size: {e}"));
let n = counts(&body).unwrap();
let (rec, _) = native(&to_arrays(&body).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
bounds(&body).unwrap();
Measured { size, counts: n, kinds: kinds(&rec) }
});
judge(name, "solidcore", &c.expect, got)
})
}
macro_rules! cases {
($($m:ident $([$pign:meta])? $(: $ign:meta)?),* $(,)?) => {$(
mod $m {
#[test]
$(#[$ign])?
fn solidcore_matches_the_closed_form() {
let _ = super::solidcore_case(stringify!($m));
}
}
)*};
}
// `: ignore = …` cases fail on solidcore.
cases!(
planar_faces_with_a_hole,
extrude_rectangle,
extrude_circle,
extrude_circle_in_two_halves,
extrude_circle_in_two_frames,
extrude_half_disc_split_arc,
extrude_square_drafted_round,
extrude_square_drafted_curvature,
extrude_circle_drafted,
extrude_draft_wall_closes,
revolve_rectangle_full,
revolve_rectangle_quarter,
sweep_circle_straight,
sweep_circle_along_an_arc,
sweep_along_a_bent_path,
sweep_round_a_closed_path,
loft_two_squares,
loft_two_circles,
loft_to_point_pyramid,
sweep_stations_prism,
sweep_stations_scaled,
screw_sweep_square_thread,
screw_sweep_radial_taper,
);
/// Arcs of a profile on one circle sweep one wall face (tangency-labs/smlib 7dee48f): a circle
/// in two pieces extrudes to the faces, edges and vertices of the whole circle (two caps and one
/// cylinder: 3 faces, 3 edges with the seam, 2 vertices) and the area 2πr² + 2πrh; a half disc
/// whose arc is in two quarters to its diameter's plane wall and one cylinder (4 faces, 6 edges,
/// 4 vertices) and the area πr² + πrh + 2rh.
#[test]
fn arcs_on_one_circle_sweep_one_wall() {
let cases = [
("extrude_circle_in_two_halves", [3, 3, 2], 50.0 * PI + 100.0 * PI, true),
("extrude_circle_in_two_frames", [3, 3, 2], 50.0 * PI + 100.0 * PI, true),
// solidcore sweeps a partial arc, merged or drawn whole, to a surface of extrusion (a
// rational spline in the record) where SMLib made a cylinder: one wall still.
("extrude_half_disc_split_arc", [4, 6, 4], 25.0 * PI + 50.0 * PI + 100.0, false),
];
let walls = |kinds: &BTreeMap<String, usize>| kinds.iter().filter(|(k, _)| !k.starts_with("plane")).map(|(_, n)| *n).sum::<usize>();
for (name, want_counts, want_area, cylinder_on_core) in cases {
let (counts_got, kinds_got, area_got) = timed(name, move || {
let body = on_core(&case(name).op).unwrap_or_else(|e| panic!("{name}: {e}"));
let (rec, _) = native(&to_arrays(&body).unwrap()).unwrap();
(counts(&body).unwrap(), kinds(&rec), area(&body, 1e-8).unwrap())
});
assert_eq!(counts_got, want_counts, "{name}: solidcore faces, edges, vertices");
assert_eq!(walls(&kinds_got), 1, "{name}: solidcore curved walls {kinds_got:?}");
if cylinder_on_core {
assert_eq!(kinds_got.get("cylinder"), Some(&1), "{name}: solidcore walls {kinds_got:?}");
}
assert!(rel(area_got, want_area) < 1e-9, "{name}: solidcore area {area_got} vs analytic {want_area}");
}
}
fn near(a: [f64; 3], b: [f64; 3]) -> bool {
a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-9)
}
fn same_motion(a: &Affine, b: &Affine) -> bool {
a.m.iter().zip(&b.m).all(|(x, y)| near(*x, *y)) && near(a.t, b.t)
}
/// The motions of a quarter turn about the z axis: by 0, π/4 and π/2.
fn quarter_turn() -> [Affine; 3] {
[0.0, FRAC_PI_2 / 2.0, FRAC_PI_2].map(|a: f64| {
let (c, s) = (a.cos(), a.sin());
Affine { m: [[c, s, 0.0], [-s, c, 0.0], [0.0, 0.0, 1.0]], t: [0.0; 3] }
})
}
fn straight_up() -> (Vec<Curve>, Vec<Curve>) {
(vec![circle([0.0; 3], Z, [1.0, 0.0, 0.0], 2.0)], vec![line([0.0; 3], [0.0, 0.0, 10.0])])
}
fn round_z() -> (Vec<Curve>, Vec<Curve>) {
(
vec![circle([10.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], 1.0)],
vec![Curve::Arc { center: [0.0; 3], axis: Z, reference: [1.0, 0.0, 0.0], radius: 10.0, start: 0.0, end: FRAC_PI_2 }],
)
}
fn check_motions(placed: &[Affine; 3], want: &[Affine; 3]) {
assert!(placed.iter().zip(want).all(|(a, b)| same_motion(a, b)), "{placed:?}, expected {want:?}");
}
/// `sweep_placed`: along a straight path the profile only moves, by none, half and all of the
/// path; along an arc about z it turns with it about the arc's centre, by none, half and all of
/// the arc's angle.
#[test]
fn sweep_placed_reports_the_motions() {
let (placed, turned) = timed("placed", || {
let (p, up) = straight_up();
let (q, arc) = round_z();
(sweep_placed(&p, &up, SweepFrame::Follow, true).unwrap().1, sweep_placed(&q, &arc, SweepFrame::Follow, true).unwrap().1)
});
check_motions(&placed, &[0.0, 5.0, 10.0].map(|z| translate([0.0, 0.0, z])));
check_motions(&turned, &quarter_turn());
}
/// A diagonal 10 above a 10 x 10 x 10 box's top, projected down onto the top: the same diagonal
/// at z = 10.
#[test]
fn project_a_line_onto_a_face() {
let got = timed("project", || {
let b = make_box([0.0; 3], [10.0; 3]).unwrap();
project_curves(&b, [5.0, 5.0, 10.0], 0.01, &[line([1.0, 1.0, 20.0], [9.0, 9.0, 20.0])], [0.0, 0.0, -1.0], 1e-6)
})
.unwrap();
assert_eq!(got.len(), 1, "{got:?}");
let Some((Curve::Nurbs { points, .. }, _)) = got.first() else { panic!("{got:?}") };
let (a, b) = (points[0], points[points.len() - 1]);
let ends = [[a[0], a[1], a[2]], [b[0], b[1], b[2]]];
let want = [[1.0, 1.0, 10.0], [9.0, 9.0, 10.0]];
assert!((near(ends[0], want[0]) && near(ends[1], want[1])) || (near(ends[0], want[1]) && near(ends[1], want[0])), "{ends:?}");
}
/// A point of a section curve: the rational B-spline at `u` (de Boor on the weighted points).
fn at(c: &Curve, u: f64) -> [f64; 3] {
let Curve::Nurbs { degree, points, knots } = c else { panic!("{c:?}") };
let p = *degree;
let span = (p..points.len()).rev().find(|&i| knots[i] <= u).unwrap_or(p);
let mut d: Vec<[f64; 4]> = (0..=p).map(|j| points[span - p + j]).map(|[x, y, z, w]| [x * w, y * w, z * w, w]).collect();
for r in 1..=p {
for j in (r..=p).rev() {
let i = span - p + j;
let den = knots[i + p + 1 - r] - knots[i];
let a = if den > 0.0 { (u - knots[i]) / den } else { 0.0 };
let prev = d[j - 1];
for (x, q) in d[j].iter_mut().zip(prev) {
*x = (1.0 - a) * q + a * *x;
}
}
}
let [x, y, z, w] = d[p];
[x / w, y / w, z / w]
}
/// `n + 1` points along each section curve over its interval.
fn samples(got: &[(Curve, [f64; 2])], n: usize) -> Vec<Vec<[f64; 3]>> {
got.iter().map(|(c, [a, b])| (0..=n).map(|i| at(c, a + (b - a) * i as f64 / n as f64)).collect()).collect()
}
/// The length of the section curves, from chords.
fn length(pts: &[Vec<[f64; 3]>]) -> f64 {
pts.iter().flat_map(|p| p.windows(2)).map(|w| (0..3).map(|i| (w[1][i] - w[0][i]).powi(2)).sum::<f64>().sqrt()).sum()
}
/// A loft from a 10 x 10 square at z = 0 to a 6 x 6 one at z = 10 (degree 1): its front wall
/// runs from y = 0 up to y = 2, so the plane z = 5 cuts it in the line from (9, 1, 5) to
/// (1, 1, 5).
fn square_frustum() -> Vec<Vec<Curve>> {
vec![square(0.0, 0.0, 10.0, 0.0), square(2.0, 2.0, 6.0, 10.0)]
}
#[test]
fn a_lofted_wall_section_is_the_line_across_it() {
let got = timed("section", || {
let b = loft(&square_frustum(), 1, true).unwrap();
face_section(&b, [5.0, 1.0, 5.0], 0.01, [0.0, 0.0, 5.0], [0.0, 0.0, 1.0], 1e-6)
})
.unwrap();
assert_eq!(got.len(), 1, "{got:?}");
let p = samples(&got, 8);
let ends = [p[0][0], p[0][8]];
let want = [[9.0, 1.0, 5.0], [1.0, 1.0, 5.0]];
assert!((near(ends[0], want[0]) && near(ends[1], want[1])) || (near(ends[0], want[1]) && near(ends[1], want[0])), "{ends:?}");
assert!(p[0].iter().all(|q| (q[1] - 1.0).abs() < 1e-9 && (q[2] - 5.0).abs() < 1e-9), "{p:?}");
}
/// The plane z = 3 through the side of a cylinder of radius 5 and height 10 on the xy plane: the
/// circle of radius 5 at z = 3, 2π·5 long.
#[test]
fn a_cylinder_side_section_is_its_circle() {
let got = timed("section", || {
let b = make_cylinder([0.0; 3], 5.0, 10.0).unwrap();
face_section(&b, [5.0, 0.0, 5.0], 0.01, [0.0, 0.0, 3.0], [0.0, 0.0, 1.0], 1e-7)
})
.unwrap();
let p = samples(&got, 2000);
for q in p.iter().flatten() {
assert!(((q[0] * q[0] + q[1] * q[1]).sqrt() - 5.0).abs() < 1e-6 && (q[2] - 3.0).abs() < 1e-9, "{q:?}");
}
let len = length(&p);
assert!((len - TAU * 5.0).abs() < 1e-4, "{len} against {}", TAU * 5.0);
}
/// The plane x = 5 through the top of a 10 mm box: the line (5, 0, 10)-(5, 10, 10) across it.
#[test]
fn a_box_top_section_is_the_line_across_it() {
let got = timed("section", || {
let b = make_box([0.0; 3], [10.0; 3]).unwrap();
face_section(&b, [5.0, 5.0, 10.0], 0.01, [5.0, 5.0, 5.0], [1.0, 0.0, 0.0], 1e-7)
})
.unwrap();
let p = samples(&got, 8);
assert!(p.iter().flatten().all(|q| (q[0] - 5.0).abs() < 1e-9 && (q[2] - 10.0).abs() < 1e-9), "{p:?}");
let len = length(&p);
assert!((len - 10.0).abs() < 1e-6, "{len} against 10");
}
/// The capi's argument checks, before any kernel call.
#[test]
fn hostile_arguments_are_refused() {
let sq = square(0.0, 0.0, 1.0, 0.0);
let d = DraftCorner::default();
for dist in [0.0, -1.0, f64::NAN, 1e10] {
assert_eq!(extrude(&sq, Z, dist, 0.0, d, true).err(), Some(Error::Argument), "distance {dist}");
}
assert_eq!(extrude(&sq, Z, 1.0, FRAC_PI_2, d, true).err(), Some(Error::Argument), "a right-angled draft");
assert_eq!(extrude(&sq, [0.0; 3], 1.0, 0.0, d, true).err(), Some(Error::Argument), "no direction");
assert_eq!(extrude(&[], Z, 1.0, 0.0, d, true).err(), Some(Error::Argument), "no profile");
assert_eq!(extrude(&[line([0.0; 3], [0.0; 3])], Z, 1.0, 0.0, d, true).err(), Some(Error::Argument), "a point");
assert_eq!(extrude(&[line([f64::NAN; 3], [1.0; 3])], Z, 1.0, 0.0, d, true).err(), Some(Error::Argument));
// A drafted profile is one loop, square to the direction.
assert_eq!(extrude(&sq[..3], Z, 1.0, 0.1, d, true).err(), Some(Error::Argument), "an open profile");
assert_eq!(extrude(&sq, [1.0, 0.0, 0.0], 1.0, 0.1, d, true).err(), Some(Error::Argument), "along the profile");
let bad_arc = Curve::Arc { center: [0.0; 3], axis: Z, reference: Z, radius: 1.0, start: 0.0, end: 1.0 };
assert_eq!(extrude(&[bad_arc], Z, 1.0, 0.0, d, false).err(), Some(Error::Argument), "reference along the axis");
let bad_nurbs = Curve::Nurbs { degree: 1, points: vec![[0.0; 4], [1.0, 0.0, 0.0, 1.0]], knots: vec![0.0, 0.0, 1.0, 1.0] };
assert_eq!(extrude(&[bad_nurbs], Z, 1.0, 0.0, d, false).err(), Some(Error::Argument), "a zero weight");
let unclamped = Curve::Nurbs { degree: 1, points: vec![[0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]], knots: vec![0.0, 0.5, 1.0, 1.0] };
assert_eq!(extrude(&[unclamped], Z, 1.0, 0.0, d, false).err(), Some(Error::Argument), "unclamped knots");
assert_eq!(revolve(&sq, [0.0; 3], Z, 0.0, true).err(), Some(Error::Argument), "no angle");
assert_eq!(revolve(&sq, [0.0; 3], Z, 7.0, true).err(), Some(Error::Argument), "more than a turn");
assert_eq!(revolve(&sq, [f64::INFINITY; 3], Z, 1.0, true).err(), Some(Error::Argument));
assert_eq!(loft(std::slice::from_ref(&sq), 1, true).err(), Some(Error::Argument), "one section");
assert_eq!(loft(&[sq.clone(), sq.clone()], 4, true).err(), Some(Error::Argument), "degree 4");
assert_eq!(loft_to_point(&sq, [0.5, 0.5, 0.0]).err(), Some(Error::Argument), "apex in the base's plane");
assert_eq!(loft_to_point(&[circle([0.0; 3], Z, [1.0, 0.0, 0.0], 1.0)], Z).err(), Some(Error::Argument), "not lines");
let flat = Affine { m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]], t: [0.0; 3] };
assert_eq!(sweep_stations(&sq, &[translate([0.0; 3]), flat], Ends::Capped).err(), Some(Error::Argument), "a singular map");
assert_eq!(sweep_stations(&sq, &[translate([0.0; 3])], Ends::Capped).err(), Some(Error::Argument), "one station");
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 0.0, 1.0, None, 1e-3).err(), Some(Error::Argument), "no pitch");
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 300.0, None, 1e-3).err(), Some(Error::Argument), "too many turns");
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 1.0, Some(&[(0.5, 0.0), (0.2, 0.0)]), 1e-3).err(), Some(Error::Argument), "a law going back");
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 1.0, Some(&[]), 1e-3).err(), Some(Error::Argument), "an empty law");
let b = make_box([0.0; 3], [1.0; 3]).unwrap();
assert_eq!(project_curves(&b, [0.5; 3], 0.01, &sq, [0.0; 3], 1e-6).err(), Some(Error::Argument), "no direction");
assert_eq!(project_curves(&b, [0.5; 3], 0.0, &sq, Z, 1e-6).err(), Some(Error::Argument), "no pick tolerance");
assert_eq!(face_section(&b, [0.5; 3], 0.01, [0.5; 3], [0.0; 3], 1e-6).err(), Some(Error::Argument), "no plane normal");
}
+32
View File
@@ -0,0 +1,32 @@
//! Copies and affine transforms on solidcore: the C API's `smlib_brep_copy` and
//! `smlib_brep_transform`, ported from the fork's `capi/src/smlib_c.cpp`.
//!
//! As there, a transform works on a copy of its body (`SmApiBrepCopy`), which
//! `SmBrep::Transform(double[4][4], bScaleTol)` (solidcore's `transform_matrix`, solidcore#1570)
//! then moves in place, tolerances scaling with the model. The matrix is the capi's: rows 0-2 the
//! images of x, y and z, row 3 the translation (row-vector convention, `p' = p·A + t`); the
//! fourth column is not read.
use super::Body;
use super::blend::{on_copy, status};
use crate::{Error, Result};
use solidcore::topology::brep::mirror::transform_matrix;
/// `smlib_brep_copy`: `SmApiBrepCopy`.
pub(crate) fn copy(body: &Body) -> Result<Body> {
on_copy("SmApiBrepCopy", body, |_, b| Ok(Ok(b)))
}
/// `smlib_brep_transform`: `SmBrep::Transform(matrix, TRUE)` on a copy of `body`. Every entry of
/// the matrix must be finite (`SMLIB_ERR_ARG`).
pub(crate) fn transform(body: &Body, matrix: &[[f64; 4]; 4]) -> Result<Body> {
if !matrix.iter().flatten().all(|x| x.is_finite()) {
return Err(Error::Argument);
}
on_copy("SmBrep::Transform", body, |k, mut b| {
Ok(match transform_matrix(k, &mut b, matrix, true) {
Ok(()) => Ok(b),
Err(e) => Err(status(e)),
})
})
}
+240
View File
@@ -0,0 +1,240 @@
//! Copies and affine transforms on solidcore, against closed forms: every result keeps its counts,
//! its volume scales by the matrix's |det|, its bounds are the moved box's, and it exports to a
//! record that validates.
use std::collections::BTreeMap;
use std::f64::consts::PI;
use super::transform::{copy, transform};
use super::{Body, bounds, counts, make_box, make_cylinder, to_arrays, volume};
use crate::Error;
use crate::export::native;
/// The identity, as the capi reads a matrix: rows 0-2 the images of x, y and z, row 3 the move.
const ID: [[f64; 4]; 4] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
#[derive(Clone, Copy, Debug)]
enum Shape {
/// A box from its lower corner.
Box([f64; 3], [f64; 3]),
/// A cylinder about +Z from its base centre.
Cylinder([f64; 3], f64, f64),
}
impl Shape {
fn core(self) -> Body {
match self {
Shape::Box(o, s) => make_box(o, s).unwrap(),
Shape::Cylinder(o, r, h) => make_cylinder(o, r, h).unwrap(),
}
}
fn volume(self) -> f64 {
match self {
Shape::Box(_, s) => s[0] * s[1] * s[2],
Shape::Cylinder(_, r, h) => PI * r * r * h,
}
}
fn counts(self) -> [usize; 3] {
match self {
Shape::Box(..) => [6, 12, 8],
Shape::Cylinder(..) => [3, 3, 2],
}
}
/// The corners of its bounding box.
fn corners(self) -> Vec<[f64; 3]> {
let (lo, hi) = match self {
Shape::Box(o, s) => (o, [o[0] + s[0], o[1] + s[1], o[2] + s[2]]),
Shape::Cylinder(o, r, h) => ([o[0] - r, o[1] - r, o[2]], [o[0] + r, o[1] + r, o[2] + h]),
};
(0..8)
.map(|k| [if k & 1 == 0 { lo[0] } else { hi[0] }, if k & 2 == 0 { lo[1] } else { hi[1] }, if k & 4 == 0 { lo[2] } else { hi[2] }])
.collect()
}
}
/// `p·A + t`.
fn apply(m: &[[f64; 4]; 4], p: [f64; 3]) -> [f64; 3] {
[0, 1, 2].map(|j| p[0] * m[0][j] + p[1] * m[1][j] + p[2] * m[2][j] + m[3][j])
}
fn det(m: &[[f64; 4]; 4]) -> f64 {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
}
fn rotation_z(a: f64) -> [[f64; 4]; 4] {
let (s, c) = a.sin_cos();
[[c, s, 0.0, 0.0], [-s, c, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]
}
struct Case {
shape: Shape,
matrix: [[f64; 4]; 4],
/// `None`: the closed form (volume times |det|, the moved box, the same counts).
fails: Option<Error>,
}
const BOX: Shape = Shape::Box([1.0, 2.0, 3.0], [10.0, 20.0, 30.0]);
const CYL: Shape = Shape::Cylinder([0.0, 0.0, 0.0], 5.0, 12.0);
fn case(name: &str) -> Case {
let ok = |shape, matrix| Case { shape, matrix, fails: None };
let mut moved = ID;
moved[3] = [5.0, -7.0, 11.0, 1.0];
match name {
"identity" => ok(BOX, ID),
"translation" => ok(BOX, moved),
"rotation_about_z" => ok(BOX, rotation_z(PI / 2.0)),
"uniform_scale" => ok(BOX, [[2.0, 0.0, 0.0, 0.0], [0.0, 2.0, 0.0, 0.0], [0.0, 0.0, 2.0, 0.0], [1.0, 1.0, 1.0, 1.0]]),
"mirror_in_x" => ok(BOX, [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0; 4]]),
"non_uniform_scale" => ok(BOX, [[2.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.5, 0.0], [0.0; 4]]),
"shear" => ok(BOX, [[1.0, 0.5, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [2.0, 3.0, 4.0, 1.0]]),
"cylinder_moved" => ok(CYL, moved),
"cylinder_rotated" => ok(CYL, rotation_z(0.3)),
"cylinder_mirrored" => ok(CYL, [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0; 4]]),
"cylinder_scaled" => ok(CYL, [[3.0, 0.0, 0.0, 0.0], [0.0, 3.0, 0.0, 0.0], [0.0, 0.0, 3.0, 0.0], [0.0; 4]]),
_ => panic!("no case {name}"),
}
}
#[derive(Debug, PartialEq)]
struct Measured {
volume: f64,
bounds: ([f64; 3], [f64; 3]),
counts: [usize; 3],
kinds: BTreeMap<String, usize>,
}
type Got = std::result::Result<Measured, Error>;
fn kinds(rec: &solidcraft_geom::brep::Brep) -> BTreeMap<String, usize> {
let mut out = BTreeMap::new();
for f in &rec.faces {
let v = serde_json::to_value(&f.surface).unwrap();
*out.entry(v["kind"].as_str().unwrap_or("?").to_owned()).or_default() += 1;
}
out
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs()
}
/// The moved shape's bounding box: a box's moved corners bound it exactly; a cylinder's only for
/// the matrices here (a rotation about its axis, a move, a mirror in z, a uniform scale), whose
/// moved circle's box is the moved square's turned back to the axes.
fn expected_bounds(c: &Case) -> ([f64; 3], [f64; 3]) {
let pts: Vec<[f64; 3]> = match c.shape {
Shape::Cylinder(o, r, h) => {
let s = (c.matrix[0][0] * c.matrix[0][0] + c.matrix[0][1] * c.matrix[0][1]).sqrt();
let centre = [apply(&c.matrix, o), apply(&c.matrix, [o[0], o[1], o[2] + h])];
centre.iter().flat_map(|p| [[p[0] - r * s, p[1] - r * s, p[2]], [p[0] + r * s, p[1] + r * s, p[2]]]).collect()
}
s => s.corners().into_iter().map(|p| apply(&c.matrix, p)).collect(),
};
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
for p in pts {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
(lo, hi)
}
fn judge(name: &str, who: &str, got: Got) -> Got {
let c = case(name);
match (&c.fails, &got) {
(Some(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"),
(Some(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"),
(None, Err(g)) => panic!("{name}: {who} failed: {g}"),
(None, Ok(m)) => {
let v = c.shape.volume() * det(&c.matrix).abs();
assert!(rel(m.volume, v) < 1e-9, "{name}: {who} volume {} vs analytic {v}", m.volume);
assert_eq!(m.counts, c.shape.counts(), "{name}: {who} counts");
let (lo, hi) = expected_bounds(&c);
let near = |a: [f64; 3], b: [f64; 3]| a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6);
assert!(near(m.bounds.0, lo) && near(m.bounds.1, hi), "{name}: {who} bounds {:?}, expected {lo:?} {hi:?}", m.bounds);
}
}
got
}
fn solidcore_case(name: &str) -> Got {
let c = case(name);
let got = transform(&c.shape.core(), &c.matrix).map(|b| {
let (rec, _) = native(&to_arrays(&b).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
Measured { volume: volume(&b, 1e-8).unwrap(), bounds: bounds(&b).unwrap(), counts: counts(&b).unwrap(), kinds: kinds(&rec) }
});
judge(name, "solidcore", got)
}
macro_rules! cases {
($($m:ident $(: $ign:meta)?),* $(,)?) => {$(
mod $m {
#[test]
$(#[$ign])?
fn solidcore_matches_the_closed_form() {
let _ = super::solidcore_case(stringify!($m));
}
}
)*};
}
cases!(
identity,
translation,
rotation_about_z,
uniform_scale,
mirror_in_x,
non_uniform_scale,
shear,
cylinder_moved,
cylinder_rotated,
cylinder_mirrored,
cylinder_scaled,
);
/// The capi's argument check, before any kernel call: every entry finite.
#[test]
fn hostile_matrices_are_refused() {
let b = BOX.core();
for (i, j) in [(0, 0), (1, 2), (3, 1), (2, 3)] {
for x in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut m = ID;
m[i][j] = x;
assert_eq!(transform(&b, &m).err(), Some(Error::Argument), "m[{i}][{j}] = {x}");
}
}
}
/// A singular or huge matrix never panics: it gives a body or an error.
#[test]
fn degenerate_matrices_do_not_crash() {
let b = BOX.core();
let zero = [[0.0; 4]; 4];
let flat = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0; 4]];
let huge = [[1e300, 0.0, 0.0, 0.0], [0.0, 1e300, 0.0, 0.0], [0.0, 0.0, 1e300, 0.0], [0.0; 4]];
for m in [zero, flat, huge] {
let _ = transform(&b, &m);
}
}
/// The body moved, and the body copied, are never changed.
#[test]
fn the_body_stays_as_it_was() {
let b = BOX.core();
let moved = transform(&b, &rotation_z(1.0)).unwrap();
let c = copy(&b).unwrap();
drop(moved);
for x in [&b, &c] {
assert_eq!(counts(x).unwrap(), [6, 12, 8]);
assert!(rel(volume(x, 1e-8).unwrap(), 6000.0) < 1e-12);
assert_eq!(bounds(x).unwrap(), ([1.0, 2.0, 3.0], [11.0, 22.0, 33.0]));
}
}
+862
View File
@@ -0,0 +1,862 @@
//! Untrim and extend on solidcore: the C API's `smlib_untrim_face` and `smlib_extend_sheet`,
//! ported from the fork's `capi/src/smlib_untrim.cpp` (`face_at`, `loop_distance`,
//! `copy_attributes`, `analytic`, `span_of`, `pieces_of`, `is_disc`, `rebuild_hole`) and
//! `capi/src/smlib_extend.cpp` (`on_side`, `eval`, `nearest_edge`, `is_boundary`, `side_edge`,
//! `across`, `cone_continued`, `same_patch`).
//!
//! A face restored towards its surface. Picked inner loops (holes) are taken away with
//! `SmFace::DeleteInnerLoop` on a copy where they are lamina; the outer boundary goes back to a
//! parameter box of the surface (`SmBrep::CreateFaceFromSurface`), and the holes kept are rebuilt
//! in that face from their own edges' curves and intervals (`MakeVertexLoop`, `MakeEdgeInFace`:
//! closing a loop splits off the face inside it, which is deleted). The new face and the rebuilt
//! edges take the attributes of the face and edges they stand for, so the history tags carry
//! through.
//!
//! A sheet is extended by SMLib's extension of its surface (`CreateExtendedSurface`: its end
//! spans continued, or a G1 or G2 run-off), the distance asked of it corrected until the
//! extension measures the distance across the side's middle; `CopyAndAddAnalytics` gives the
//! result back its analytic kind, and a cone or cylinder continued naturally stays the analytic
//! widened. The new face and each of its sides take the attributes of the face and side they
//! continue.
//!
//! An untrim of a face out of its body (`in_place` false) copies the face into a body of its own
//! through `SmBrep::CopyFaces` with the merge (solidcore#1872), which keeps its attributes.
use std::sync::PoisonError;
use solidcore::EntityRef;
use solidcore::curve::curve::Curve;
use solidcore::geom::extent2d::Extent2d;
use solidcore::geom::geom_utility::p2::ContinuityType;
use solidcore::geom::object::Shape;
use solidcore::geom::tol::USE_DEFAULT;
use solidcore::geom::vector2d::Point2;
use solidcore::geom::vector3d::{Point3, Vec3};
use solidcore::kernel::Kernel;
use solidcore::nurbs::b_spline_surface::p1::SurfParamType;
use solidcore::nurbs::b_spline_surface_n_lib::p2::EvaluateFlags;
use solidcore::nurbs::b_spline_surface_n_lib::p2::OrientType;
use solidcore::query::api_queries::{edge_closest_point, face_closest_point_in_shape, face_internal_point};
use solidcore::query::brep::{BrepQuery, LiveBrepObjects};
use solidcore::surface::cone::p1::Cone;
use solidcore::surface::surface::Surface;
use solidcore::surface::surface::SurfaceKind;
use solidcore::topology::brep::p1 as brep_p1;
use solidcore::topology::brep::p2::{CopyNotifyState, copy_source_ref};
use solidcore::topology::brep::p3::{DeleteFaceOptions, delete_face, new_brep, with_kernel_callbacks};
use solidcore::topology::brep::p5::{EdgeInFace, make_edge_in_face};
use solidcore::topology::brep::p7::copy_faces_from;
use solidcore::topology::brep::p8::create_face_from_surface;
use solidcore::topology::brep::p9::make_vertex_loop;
use solidcore::topology::edgeuse::p1 as eu_p1;
use solidcore::topology::edgeuse::p3::get_or_create_uv_trim_curve;
use solidcore::topology::face::{p1 as face_p1, p2 as face_p2, p4 as face_p4};
use solidcore::topology::loopuse::{loop_edges, loop_loopuses, notify_ref};
use solidcore::topology::topology_traverser::Marks;
use solidcore::topology::{Brep, BrepId, EdgeId, FaceId, LoopId, edge};
use super::blend::{p3, status};
use super::sweep::build_faces;
use super::{Body, answer, run};
use crate::trim::Continuation;
use crate::{Error, Result};
use solidcore::topology::brep::p3::brep_copy;
/// `MAX_HOLE_POINTS`.
const MAX_HOLE_POINTS: usize = 10_000;
/// `SMLIB_ERR_EXCEPTION`'s stand-in: a rebuilt hole that did not close as the capi expects.
fn hole_refused() -> Error {
Error::Exception
}
/// The capi's statuses as SolidCraft errors: an operation's own result.
type Out<T> = std::result::Result<T, Error>;
const ONE: BrepId = BrepId::from_index(0);
/// `face_at`: the face of `b` nearest `p`, within `tol` (`SmApiFaceClosestPoint`; a face whose
/// query fails is skipped).
fn face_at(k: &mut Kernel, b: &mut Brep, p: Point3, tol: f64) -> solidcore::Result<Option<FaceId>> {
let all = brep_p1::faces(b, None)?;
let mut shapes = Vec::with_capacity(all.len());
for &f in &all {
shapes.push(answer(Shape::new(k, &(ONE, &*b), &[], &[(ONE, f)], &[], &[]))?);
}
let query = BrepQuery::of_brep(b);
let identity = query.effective_identity();
let objects = LiveBrepObjects::new(b, &identity, None)?;
let (mut best, mut d0) = (None, f64::INFINITY);
for (&f, shape) in all.iter().zip(&shapes) {
let Some(shape) = shape else { continue };
if let Some(c) = answer(face_closest_point_in_shape(k, shape, &objects, (ONE, f), p))?
&& c.distance.is_finite()
&& c.distance < d0
{
d0 = c.distance;
best = Some(f);
}
}
Ok(best.filter(|_| d0 <= tol))
}
/// `loop_distance`: the distance from `p` to the nearest edge of loop `l`.
fn loop_distance(k: &Kernel, b: &Brep, l: LoopId, p: Point3) -> solidcore::Result<f64> {
let mut best = f64::INFINITY;
for e in loop_edges(b, l)?.into_iter().flatten() {
if let Some(c) = answer(edge_closest_point(k, b, e, p))?
&& c.closest.distance.is_finite()
{
best = best.min(c.closest.distance);
}
}
Ok(best)
}
/// `copy_attributes`: `to` (in `into`) takes a copy of each attribute of `from` (in `src`).
fn copy_attributes(src: &Brep, from: EntityRef, into: &mut Brep, to: EntityRef) -> solidcore::Result<()> {
let copies: Vec<_> = src.entity_attributes(from).into_iter().map(|(_, a)| a.make_copy()).collect();
for a in copies {
into.add_attribute(to, a)?;
}
Ok(())
}
/// [`copy_attributes`] within one body.
fn copy_attributes_within(b: &mut Brep, from: EntityRef, to: EntityRef) -> solidcore::Result<()> {
let copies: Vec<_> = b.entity_attributes(from).into_iter().map(|(_, a)| a.make_copy()).collect();
for a in copies {
b.add_attribute(to, a)?;
}
Ok(())
}
/// `span_of`: the box of the face's outer loop in the surface's parameters, the union of the
/// tight boxes of its edgeuses' UV trim curves (made where the face has none attached yet).
fn span_of(k: &Kernel, b: &mut Brep, outer: LoopId) -> solidcore::Result<Option<Extent2d>> {
let (lu, _) = loop_loopuses(b, outer)?;
let eus = eu_p1::loopuse_edgeuses(b, lu)?;
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
let mut any = false;
for eu in eus {
let fetched = get_or_create_uv_trim_curve(k, b, eu, false, false, true)?;
if fetched.status.is_err() {
return Ok(None);
}
let (Some(uv), _) = eu_p1::uv_trim_curve(b, eu)? else { return Ok(None) };
let Some(e) = answer(uv.calculate_tight_bounding_box(k, &uv.natural_interval()))? else { return Ok(None) };
let (mn, mx) = (e.min(), e.max());
lo = [lo[0].min(mn.x), lo[1].min(mn.y)];
hi = [hi[0].max(mx.x), hi[1].max(mx.y)];
any = true;
}
if !any || !(hi[0] > lo[0]) || !(hi[1] > lo[1]) || !(lo[0] + lo[1] + hi[0] + hi[1]).is_finite() {
return Ok(None);
}
Ok(Some(Extent2d::from_bounds(lo[0], lo[1], hi[0], hi[1])))
}
/// One edge of a hole to rebuild: its edge, how the loop runs along it, its ends as the loop
/// runs, its curve and interval (`Piece`).
struct Piece {
edge: EdgeId,
orient: OrientType,
from: Point3,
to: Point3,
curve: Curve,
interval: solidcore::geom::extent1d::Extent1d,
}
/// `pieces_of`; `None` where the capi gives up.
fn pieces_of(b: &Brep, l: LoopId) -> solidcore::Result<Option<Vec<Piece>>> {
let (lu, _) = loop_loopuses(b, l)?;
let mut out = Vec::new();
for eu in eu_p1::loopuse_edgeuses(b, lu)? {
let Some(e) = eu_p1::edge(b, eu)? else { return Ok(None) };
let ed = b.edge(e)?;
let Some(curve) = ed.curve.clone() else { return Ok(None) };
let (Some(v0), Some(v1)) = edge::start_end_vertices(b, e)? else { return Ok(None) };
let same = eu_p1::orientation(b, eu)? == OrientType::Same;
let (p0, p1) = (b.vertex(v0)?.point, b.vertex(v1)?.point);
let (from, to) = if same { (p0, p1) } else { (p1, p0) };
out.push(Piece { edge: e, orient: if same { OrientType::Same } else { OrientType::Opposite }, from, to, curve, interval: ed.interval });
}
Ok((!out.is_empty()).then_some(out))
}
/// `is_disc`: whether `f` is bounded by one loop of exactly the edges `made`.
fn is_disc(b: &Brep, f: FaceId, made: &[EdgeId]) -> solidcore::Result<bool> {
let loops = face_p2::loops(b, f, false, None)?;
let [l] = loops.as_slice() else { return Ok(false) };
let edges: Vec<EdgeId> = loop_edges(b, *l)?.into_iter().flatten().collect();
Ok(edges.len() == made.len() && edges.iter().all(|e| made.contains(e)))
}
/// `rebuild_hole`: the hole `pieces` (edges of `src`) rebuilt in `face` of `r`: vertices and
/// edges made in the face, the face the closed loop splits off inside it deleted. `face` is the
/// face that keeps the rest.
fn rebuild_hole(k: &mut Kernel, src: Option<&Brep>, r: &mut Brep, face: &mut FaceId, pieces: &[Piece]) -> solidcore::Result<Out<()>> {
let Some(first_piece) = pieces.first() else { return Ok(Err(hole_refused())) };
let editing = r.editing_enabled();
r.set_editing_enabled(true);
let tol = r.tolerance();
// Each edit notifies through the kernel's callbacks with the body's own attributes
// (`MakeEdgeInFace` does so itself), so the history tags follow.
let vertex_loop = |k: &mut Kernel, r: &mut Brep, face: FaceId, at: Point3| {
with_kernel_callbacks(k, r, |_, r, env| make_vertex_loop(r, face, at, OrientType::Opposite, env))
};
let done = (|| -> solidcore::Result<Out<()>> {
let (_, first) = match vertex_loop(k, r, *face, first_piece.from) {
Ok(v) => v,
Err(e) => return Ok(Err(status(e))),
};
let mut at = first;
let mut made = Vec::with_capacity(pieces.len());
let mut split = None;
for (i, p) in pieces.iter().enumerate() {
let last = i + 1 == pieces.len();
let next = if last {
first
} else {
match vertex_loop(k, r, *face, p.to) {
Ok((_, v)) => v,
Err(e) => return Ok(Err(status(e))),
}
};
let curve = p.curve.copy(k)?;
let mut out = EdgeInFace::default();
if let Err(e) = make_edge_in_face(k, r, true, Some(*face), Some(at), Some(next), curve, None, p.interval, p.orient, tol, &mut out, None) {
return Ok(Err(status(e)));
}
let Some(e) = out.edge else { return Ok(Err(Error::Empty)) };
match src {
Some(s) => copy_attributes(s, EntityRef::Edge(p.edge), r, EntityRef::Edge(e))?,
None => copy_attributes_within(r, EntityRef::Edge(p.edge), EntityRef::Edge(e))?,
}
made.push(e);
if let Some(nf) = out.face {
if !last || split.is_some() {
return Ok(Err(hole_refused()));
}
split = Some(nf);
}
at = next;
}
let Some(split) = split else { return Ok(Err(hole_refused())) };
// One of the two faces is the disc inside the hole: it goes, its edges stay on the other.
let disc = if is_disc(r, split, &made)? {
split
} else if is_disc(r, *face, &made)? {
*face
} else {
return Ok(Err(hole_refused()));
};
let rest = if disc == split { *face } else { split };
let deleted = with_kernel_callbacks(k, r, |k, r, env| {
let mut marks = Marks::default();
let options = DeleteFaceOptions { delete_edges_vertices: false, ..DeleteFaceOptions::default() };
delete_face(k, r, env, &mut marks, disc, options)
});
if let Err(e) = deleted {
return Ok(Err(status(e)));
}
*face = rest;
Ok(Ok(()))
})();
r.set_editing_enabled(editing);
r.refresh_cached_topology();
done
}
/// The orientation of `f`'s upward faceuse.
fn upward(b: &Brep, f: FaceId) -> solidcore::Result<Option<OrientType>> {
Ok(match face_p2::upward_faceuse(b, f)? {
Some(fu) => Some(b.faceuse(fu)?.orientation),
None => None,
})
}
/// `smlib_untrim_face`: the face of `body` nearest `point` (within `tol`) restored towards its
/// surface: the holes nearest `holes` (all of them for `None`) taken away and, with `outer`, its
/// outer boundary back on the surface's parameter box. The face alone, or with `in_place` the
/// whole body with it replaced.
pub(crate) fn untrim_face(body: &Body, point: [f64; 3], tol: f64, holes: Option<&[[f64; 3]]>, outer: bool, in_place: bool) -> Result<Body> {
let n_holes = holes.map(<[_]>::len);
if !point.iter().all(|x| x.is_finite())
|| !tol.is_finite()
|| !(tol > 0.0)
|| n_holes.is_some_and(|n| n > MAX_HOLE_POINTS)
|| holes.is_some_and(|h| !h.iter().flatten().all(|x| x.is_finite()))
|| (n_holes == Some(0) && !outer)
{
return Err(Error::Argument);
}
if body.0.is_poisoned() {
return Err(Error::Internal("smlib_untrim_face: the body was left unfinished by an earlier panic".to_owned()));
}
let at = p3(point);
run("smlib_untrim_face", |k| {
let mut whole = {
let g = body.0.lock().unwrap_or_else(PoisonError::into_inner);
super::dump::input(k, "brep", &g);
brep_copy(k, &g)?
};
// The face to untrim, in a body of its own (its hole edges lamina there) or in the copy
// of the whole body.
let (mut w, f) = if in_place {
let f = face_at(k, &mut whole, at, tol)?;
(whole, f)
} else {
let Some(src) = face_at(k, &mut whole, at, tol)? else { return Ok(Err(Error::NoMatch { point: 0 })) };
let mut w = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
w.set_tolerance(whole.tolerance());
// Through SmMerge, with both bodies' attributes in the notifications, so the copy
// carries its source's history tags (`SM_NO_COPY`).
let copied = {
let mut state = CopyNotifyState::with_attributes_of(&mut whole, &mut w)?;
let this_face = |f: FaceId| notify_ref(EntityRef::Face(f));
let copied = copy_faces_from(k, &mut whole, &[src], &mut w, true, &copy_source_ref, &this_face, &mut state.env(None));
state.restore_attributes(&mut whole, &mut w)?;
copied
};
let copied = match copied {
Ok((c, _)) => c,
Err(e) => return Ok(Err(status(e))),
};
// The capi's `copied.GetSize() != 1`: `SMLIB_ERR_EMPTY`.
let [f] = copied.as_slice() else { return Ok(Err(Error::Empty)) };
w.refresh_cached_topology();
(w, Some(*f))
};
let Some(f) = f else { return Ok(Err(Error::NoMatch { point: 0 })) };
let loops = face_p2::loops(&w, f, false, None)?;
if loops.is_empty() {
return Ok(Err(Error::Unsupported));
}
let inner = loops.get(1..).unwrap_or_default();
// The holes taken away: all of them, or the one nearest each point.
let mut removed: Vec<LoopId> = Vec::new();
match holes {
None => removed.extend_from_slice(inner),
Some(points) => {
for p in points {
let p = p3(*p);
let (mut best, mut d0) = (None, f64::INFINITY);
for &l in inner {
let d = loop_distance(k, &w, l, p)?;
if d < d0 {
d0 = d;
best = Some(l);
}
}
let Some(best) = best.filter(|_| d0 <= tol) else { return Ok(Err(Error::NoMatch { point: 0 })) };
if !removed.contains(&best) {
removed.push(best);
}
}
}
}
if !outer {
for &l in &removed {
// A hole whose edges another face of the body uses is no hole of a sheet.
let deleted = face_p1::delete_inner_loop_with_kernel_callbacks(k, &mut w, f, l);
if deleted.is_err() {
return Ok(Err(Error::Unsupported));
}
}
w.refresh_cached_topology();
return Ok(Ok(w));
}
// The outer boundary: the surface's parameter box (a free-form surface's own, an
// analytic one's the span of the face).
let Some(surf) = face_p2::surface(&w, f)?.cloned() else { return Ok(Err(Error::Unsupported)) };
let mut bx = surf.natural_uv_domain_with_nurb(k)?;
if !matches!(surf.kind(), SurfaceKind::BSpline(_)) {
let Some(outer_loop) = loops.first() else { return Ok(Err(Error::Unsupported)) };
match span_of(k, &mut w, *outer_loop)? {
Some(s) => bx = s,
None => return Ok(Err(Error::Unsupported)),
}
}
let mut kept = Vec::new();
for &l in inner {
if removed.contains(&l) {
continue;
}
match pieces_of(&w, l)? {
Some(p) => kept.push(p),
None => return Ok(Err(Error::Unsupported)),
}
}
let copy = surf.copy(k)?;
let was = upward(&w, f)?;
let mut r = if in_place {
None
} else {
let mut r = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
r.set_tolerance(w.tolerance());
Some(r)
};
let target: &mut Brep = match r.as_mut() {
Some(r) => r,
None => &mut w,
};
let mut nf = match build_faces(target, |t, fenv| create_face_from_surface(k, t, Some(copy), &bx, fenv)) {
Ok(nf) => nf,
Err(e) => return Ok(Err(status(e))),
};
target.refresh_cached_topology();
let _ = target;
for hole in &kept {
let done = match r.as_mut() {
Some(r) => rebuild_hole(k, Some(&w), r, &mut nf, hole)?,
None => rebuild_hole(k, None, &mut w, &mut nf, hole)?,
};
if let Err(e) = done {
return Ok(Err(e));
}
}
match r.as_mut() {
Some(r) => copy_attributes(&w, EntityRef::Face(f), r, EntityRef::Face(nf))?,
None => copy_attributes_within(&mut w, EntityRef::Face(f), EntityRef::Face(nf))?,
}
// The face faces the way the old one did.
let target: &mut Brep = match r.as_mut() {
Some(r) => r,
None => &mut w,
};
let now = upward(target, nf)?;
if let (Some(a), Some(b)) = (was, now)
&& a != b
&& let Err(e) = face_p1::flip_face_orientation(target, nf)
{
return Ok(Err(status(e)));
}
if let Some(r) = r {
return Ok(Ok(r));
}
// The old face goes, with the edges and vertices only it used: an edit of the brep,
// which SMLib allows only in edit mode.
let editing = w.editing_enabled();
w.set_editing_enabled(true);
let deleted = with_kernel_callbacks(k, &mut w, |k, w, env| {
let mut marks = Marks::default();
let options = DeleteFaceOptions { delete_edges_vertices: true, ..DeleteFaceOptions::default() };
delete_face(k, w, env, &mut marks, f, options)
});
w.set_editing_enabled(editing);
w.refresh_cached_topology();
if let Err(e) = deleted {
return Ok(Err(status(e)));
}
Ok(Ok(w))
})?
.map(Body::new)
}
/// One side of a face's parameter box (`Side`): which, whether it is a u = const line (the face
/// grows in u), and whether it is at the box's max.
#[derive(Clone, Copy)]
struct Side {
which: SurfParamType,
u: bool,
high: bool,
}
/// `SIDES`.
const SIDES: [Side; 4] = [
Side { which: SurfParamType::UMin, u: true, high: false },
Side { which: SurfParamType::UMax, u: true, high: true },
Side { which: SurfParamType::VMin, u: false, high: false },
Side { which: SurfParamType::VMax, u: false, high: true },
];
/// `SAMPLES`.
const SAMPLES: usize = 9;
/// `on_side`: the point of side `s` of `dom` at `t` in [0, 1] along it, in UV.
fn on_side(dom: &Extent2d, s: Side, t: f64) -> Point2 {
let (lo, hi) = (dom.min(), dom.max());
if s.u {
Point2::new(if s.high { hi.x } else { lo.x }, lo.y + t * (hi.y - lo.y))
} else {
Point2::new(lo.x + t * (hi.x - lo.x), if s.high { hi.y } else { lo.y })
}
}
/// `eval`: the point at `uv` with its u and v derivatives; `None` where it does not evaluate or
/// is not finite.
fn eval(k: &Kernel, surf: &Surface, uv: Point2) -> Option<(Vec3, Vec3, Vec3)> {
let d = surf.evaluate(k, uv, 1, 1, EvaluateFlags::default()).ok()?;
let p = *d.first()?.first()?;
let du = *d.get(1)?.first()?;
let dv = *d.first()?.get(1)?;
(p.length().is_finite() && du.length().is_finite() && dv.length().is_finite()).then_some((p, du, dv))
}
fn point_at(k: &Kernel, surf: &Surface, uv: Point2) -> Option<Vec3> {
eval(k, surf, uv).map(|(p, _, _)| p)
}
/// `edge_distance`.
fn edge_distance(k: &Kernel, b: &Brep, e: EdgeId, p: Vec3) -> f64 {
match edge_closest_point(k, b, e, p) {
Ok(c) if c.closest.distance.is_finite() => c.closest.distance,
_ => f64::INFINITY,
}
}
/// `nearest_edge`: the distance to the nearest of `edges`, with that edge.
fn nearest_edge(k: &Kernel, b: &Brep, edges: &[EdgeId], p: Vec3) -> (f64, Option<EdgeId>) {
let (mut best, mut which) = (f64::INFINITY, None);
for &e in edges {
let d = edge_distance(k, b, e, p);
if d < best {
best = d;
which = Some(e);
}
}
(best, which)
}
/// `is_boundary`: whether side `s` of the face's box is a side of the face (its samples on the
/// face's edges), and whether it collapses to a point (a pole).
fn is_boundary(k: &Kernel, b: &Brep, surf: &Surface, dom: &Extent2d, s: Side, edges: &[EdgeId], tol: f64) -> (bool, bool) {
let (Some(a), Some(z), Some(m)) =
(point_at(k, surf, on_side(dom, s, 0.0)), point_at(k, surf, on_side(dom, s, 1.0)), point_at(k, surf, on_side(dom, s, 0.5)))
else {
return (false, false);
};
if (a - z).length() <= tol && (a - m).length() <= tol {
return (true, true);
}
let all = (0..=SAMPLES).all(|i| match point_at(k, surf, on_side(dom, s, i as f64 / SAMPLES as f64)) {
Some(p) => nearest_edge(k, b, edges, p).0 <= tol,
None => false,
});
(all, false)
}
/// `side_edge`: the edge of `edges` side `s` of `dom` runs along (nearest the side's middle), or
/// `None` for a side collapsed to a point.
fn side_edge(k: &Kernel, b: &Brep, surf: &Surface, dom: &Extent2d, s: Side, edges: &[EdgeId], on: f64) -> Option<EdgeId> {
let a = point_at(k, surf, on_side(dom, s, 0.0))?;
let z = point_at(k, surf, on_side(dom, s, 1.0))?;
let m = point_at(k, surf, on_side(dom, s, 0.5))?;
if (a - z).length() <= on && (a - m).length() <= on {
return None;
}
let (d, e) = nearest_edge(k, b, edges, m);
e.filter(|_| d <= on)
}
/// `across`: how far `ext` (the face's surface extended past side `s` of its box `dom`, on the
/// same parameters) runs past that side, measured across it through the side's middle, by
/// 16-point Gauss-Legendre quadrature on 64 pieces. Negative when it can't be evaluated.
fn across(k: &Kernel, ext: &Surface, dom: &Extent2d, s: Side) -> f64 {
const X: [f64; 8] = [
0.0950125098376374,
0.2816035507792589,
0.4580167776572274,
0.6178762444026438,
0.755404408355003,
0.8656312023878318,
0.9445750230732326,
0.9894009349916499,
];
const W: [f64; 8] = [
0.1894506104550685,
0.1826034150449236,
0.1691565193950025,
0.1495959888165767,
0.1246289712555339,
0.0951585116824928,
0.0622535239386479,
0.0271524594117541,
];
const PIECES: usize = 64;
let Ok(bx) = ext.natural_uv_domain_with_nurb(k) else { return -1.0 };
let mid = on_side(dom, s, 0.5);
let from = if s.u { mid.x } else { mid.y };
let to = match (s.u, s.high) {
(true, true) => bx.max().x,
(true, false) => bx.min().x,
(false, true) => bx.max().y,
(false, false) => bx.min().y,
};
let h = (to - from) / PIECES as f64;
let mut length = 0.0;
for piece in 0..PIECES {
let c = from + (piece as f64 + 0.5) * h;
for i in 0..16 {
let (x, w) = if i < 8 { (-X[i], W[i]) } else { (X[i - 8], W[i - 8]) };
let t = c + 0.5 * h * x;
let uv = if s.u { Point2::new(t, mid.y) } else { Point2::new(mid.x, t) };
let Some((_, du, dv)) = eval(k, ext, uv) else { return -1.0 };
length += 0.5 * h.abs() * w * if s.u { du } else { dv }.length();
}
}
length
}
/// `cone_continued`: a cone's or cylinder's face continued naturally `distance` past side `s` of
/// its box `dom`, as the analytic itself over a wider STEP domain (degrees round the axis,
/// height along it). `None` where the cone can't be widened so (more than a turn, or past the
/// apex).
fn cone_continued(k: &Kernel, surf: &Surface, dom: &Extent2d, s: Side, distance: f64) -> Option<Surface> {
let SurfaceKind::Cone(cone) = surf.kind() else { return None };
let mut cone: Cone = cone.as_ref().clone();
let step = |uv: Point2| cone.convert_uv_from_nurbs_to_step(k, uv).ok();
let (a, z, m) = (step(on_side(dom, s, 0.0))?, step(on_side(dom, s, 1.0))?, step(on_side(dom, s, 0.5))?);
let (lo, hi) = (step(dom.min())?, step(dom.max())?);
let semi = cone.canonical().semi_angle_deg;
let mut bx = [[lo.x.min(hi.x), lo.x.max(hi.x)], [lo.y.min(hi.y), lo.y.max(hi.y)]];
let eps = 1e-9 * (1.0 + m.x.abs() + m.y.abs());
// The STEP coordinate constant along the side is the one the face grows in.
let c = if (a.x - z.x).abs() <= eps {
0
} else if (a.y - z.y).abs() <= eps {
1
} else {
return None;
};
let at = if c == 0 { m.x } else { m.y };
let row = bx.get_mut(c)?;
let end = if (at - row[1]).abs() <= (at - row[0]).abs() { 1 } else { 0 };
let by = if c == 0 {
let r = cone.radius(m.y);
if !(r > 0.0) {
return None;
}
(distance / r).to_degrees()
} else {
distance * semi.to_radians().cos()
};
if end == 1 {
row[1] += by;
} else {
row[0] -= by;
}
if bx[0][1] - bx[0][0] > 360.0 + 1e-9 {
return None;
}
let wider = Extent2d::from_bounds(bx[0][0], bx[1][0], bx[0][1], bx[1][1]);
cone.adjust_step_uv_domain(k, &wider).ok()?;
Some(Surface::from(cone))
}
/// `same_patch`: whether `p` and `q` span the same patch: their boxes' corners the same points,
/// in any order, within `tol`.
fn same_patch(k: &Kernel, p: &Surface, q: &Surface, tol: f64) -> bool {
let (Ok(bp), Ok(bq)) = (p.natural_uv_domain_with_nurb(k), q.natural_uv_domain_with_nurb(k)) else { return false };
let corners = |b: &Extent2d| {
let (lo, hi) = (b.min(), b.max());
[lo, Point2::new(hi.x, lo.y), hi, Point2::new(lo.x, hi.y)]
};
corners(&bp).into_iter().all(|u| {
let Some(x) = point_at(k, p, u) else { return false };
corners(&bq).into_iter().any(|v| point_at(k, q, v).is_some_and(|y| (x - y).length() <= tol))
})
}
/// `extended_by`: the surface `surf` (whose face is its whole box `dom`) extended past side `s` as
/// `ct` says, so that it runs `distance` past that side measured across it ([`across`]), within
/// 1e-9 of it. `CreateExtendedSurface` takes its distance as an estimate, and how far it then runs
/// is increasing in what it is asked: a bracketed solve on that (each ask scaled by the distance
/// over what it gave until two asks fall either side, then Illinois false position). An extension
/// that moves the face's side off its parameters, or a target not met, is [`Error::NotExact`],
/// never the surface as it came out.
fn extended_by(k: &Kernel, surf: &Surface, dom: &Extent2d, s: Side, ct: ContinuityType, distance: f64, model_tol: f64) -> Result<Surface> {
const BRACKET_TRIES: usize = 40;
const SOLVE_TRIES: usize = 100;
let tol = 1e-9 * distance;
// How far asking `ask` runs, and the surface it makes.
let try_ask = |ask: f64| -> Result<(Surface, f64)> {
let tried = surf.create_extended_surface_dir(k, s.which, ask, ct).map_err(status)?;
// The face's side stays where it was on the extended surface's parameters.
let mid = on_side(dom, s, 0.5);
let stays = matches!((point_at(k, surf, mid), point_at(k, &tried, mid)), (Some(was), Some(now)) if (was - now).length() <= model_tol);
if !stays {
return Err(Error::NotExact);
}
let got = across(k, &tried, dom, s);
if got.is_finite() && got > 0.0 { Ok((tried, got)) } else { Err(Error::NotExact) }
};
// Asks that run short of and past `distance`, by f = got - distance.
let (mut short, mut past) = (None, None);
let mut ask = distance;
for _ in 0..BRACKET_TRIES {
let (made, got) = try_ask(ask)?;
let f = got - distance;
if f.abs() <= tol {
return Ok(made);
}
if f < 0.0 {
short = Some((ask, f));
} else {
past = Some((ask, f));
}
if short.is_some() && past.is_some() {
break;
}
ask *= distance / got;
}
let (Some((mut lo, mut flo)), Some((mut hi, mut fhi))) = (short, past) else { return Err(Error::NotExact) };
// Illinois: false position, halving the value at the end that stays when the same end moves
// twice running.
#[derive(PartialEq)]
enum Moved {
Neither,
Lo,
Hi,
}
let mut moved = Moved::Neither;
for _ in 0..SOLVE_TRIES {
let x = hi - fhi * (hi - lo) / (fhi - flo);
if !(x > lo.min(hi) && x < lo.max(hi)) {
break;
}
let (made, got) = try_ask(x)?;
let f = got - distance;
if f.abs() <= tol {
return Ok(made);
}
if f < 0.0 {
(lo, flo) = (x, f);
if moved == Moved::Lo {
fhi *= 0.5;
}
moved = Moved::Lo;
} else {
(hi, fhi) = (x, f);
if moved == Moved::Hi {
flo *= 0.5;
}
moved = Moved::Hi;
}
}
Err(Error::NotExact)
}
/// `smlib_extend_sheet`: the one-face sheet `sheet`, whose face is its surface's whole parameter
/// box, continued `distance` past the side its edge nearest `point` (within `tol`) runs along,
/// as `how` says. A sheet of another shape is [`Error::Unsupported`]; an extension that cannot be
/// made `distance` long across the side (within 1e-9 of it) is [`Error::NotExact`].
pub(crate) fn extend_sheet(sheet: &Body, point: [f64; 3], tol: f64, distance: f64, how: Continuation) -> Result<Body> {
if !point.iter().all(|x| x.is_finite()) || !tol.is_finite() || !(tol > 0.0) || !distance.is_finite() || !(distance > 0.0) {
return Err(Error::Argument);
}
if sheet.0.is_poisoned() {
return Err(Error::Internal("smlib_extend_sheet: the body was left unfinished by an earlier panic".to_owned()));
}
run("smlib_extend_sheet", |k| {
let b = {
let g = sheet.0.lock().unwrap_or_else(PoisonError::into_inner);
super::dump::input(k, "sheet", &g);
brep_copy(k, &g)?
};
let faces = brep_p1::faces(&b, None)?;
let [f] = faces.as_slice() else { return Ok(Err(Error::Unsupported)) };
let f = *f;
let Some(surf) = face_p2::surface(&b, f)?.cloned() else { return Ok(Err(Error::Unsupported)) };
if face_p2::loops(&b, f, false, None)?.len() != 1 {
return Ok(Err(Error::Unsupported));
}
let edges = face_p4::edges(&b, f, None, false, None)?;
let (d, picked) = nearest_edge(k, &b, &edges, p3(point));
let Some(picked) = picked.filter(|_| d <= tol) else { return Ok(Err(Error::NoMatch { point: 0 })) };
let model_tol = b.tolerance().max(1e-9);
let on = 10.0 * model_tol;
let dom = face_p2::uv_domain(&b, f)?;
// Every side of the box is a side of the face (the face is its whole box), and the picked
// edge is all of one side.
let mut grow = None;
for s in SIDES {
let (boundary, collapsed) = is_boundary(k, &b, &surf, &dom, s, &edges, on);
if !boundary {
return Ok(Err(Error::Unsupported));
}
if collapsed {
continue;
}
let all = (0..=SAMPLES).all(|i| match point_at(k, &surf, on_side(&dom, s, i as f64 / SAMPLES as f64)) {
Some(p) => edge_distance(k, &b, picked, p) <= on,
None => false,
});
if all {
grow = Some(s);
}
}
let Some(grow) = grow else { return Ok(Err(Error::Unsupported)) };
// SMLib extends the whole surface; the face must be all of it.
let natural = surf.natural_uv_domain_with_nurb(k)?;
let (nl, nh, dl, dh) = (natural.min(), natural.max(), dom.min(), dom.max());
let ptol = 1e-9 * 1f64.max((nh.x - nl.x).max(nh.y - nl.y));
if (nl.x - dl.x).abs() > ptol || (nl.y - dl.y).abs() > ptol || (nh.x - dh.x).abs() > ptol || (nh.y - dh.y).abs() > ptol {
return Ok(Err(Error::Unsupported));
}
let ct = match how {
Continuation::Tangent => ContinuityType::G1,
Continuation::Curvature => ContinuityType::G1G2,
Continuation::Natural => ContinuityType::CInfinity,
};
// CreateExtendedSurface takes its distance as an estimate (a natural parabola falls 2.5%
// short, a G2 run-off of a circle runs more than twice as far): the distance asked of it
// is solved for until the extension measures `distance` across the side's middle.
let extended = match extended_by(k, &surf, &dom, grow, ct, distance, model_tol) {
Ok(e) => e,
Err(e) => return Ok(Err(e)),
};
// A cone or cylinder continued naturally stays one, as the analytic widened, else SMLib
// gives it back its analytic kind where it can.
let grown = match how {
Continuation::Natural => cone_continued(k, &surf, &dom, grow, distance).filter(|g| same_patch(k, g, &extended, 10.0 * model_tol)),
_ => None,
};
let grown = match grown {
Some(g) => g,
None => match extended.copy_and_add_analytics(k) {
Ok(g) => g,
Err(e) => return Ok(Err(status(e))),
},
};
let bx = grown.natural_uv_domain_with_nurb(k)?;
let mut r = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
r.set_tolerance(b.tolerance());
let grown_eval = grown.copy(k)?;
let nf = match build_faces(&mut r, |t, fenv| create_face_from_surface(k, t, Some(grown), &bx, fenv)) {
Ok(nf) => nf,
Err(e) => return Ok(Err(status(e))),
};
r.refresh_cached_topology();
// The face and each side stand for the face and side they continue.
copy_attributes(&b, EntityRef::Face(f), &mut r, EntityRef::Face(nf))?;
let made = face_p4::edges(&r, nf, None, false, None)?;
let new_on = 10.0 * r.tolerance().max(1e-9);
for s in SIDES {
let from = side_edge(k, &b, &surf, &dom, s, &edges, on);
let to = side_edge(k, &r, &grown_eval, &bx, s, &made, new_on);
if let (Some(from), Some(to)) = (from, to) {
copy_attributes(&b, EntityRef::Edge(from), &mut r, EntityRef::Edge(to))?;
}
}
// The new face faces the way the old one did.
if let (Some(was), Some(now)) = (upward(&b, f)?, upward(&r, nf)?)
&& was != now
&& let Err(e) = face_p1::flip_face_orientation(&mut r, nf)
{
return Ok(Err(status(e)));
}
Ok(Ok(r))
})?
.map(Body::new)
}
/// A point inside each face, in the body's face order (`smlib_brep_face_points`:
/// `SmFace::CalculateAnInternalPoint`).
pub(crate) fn face_points(body: &Body) -> Result<Vec<[f64; 3]>> {
super::with_body("smlib_brep_face_points", body, |k, b| {
let faces = b.face_list().to_vec();
faces.into_iter().map(|f| face_internal_point(k, b, f).map(|p| [p.x, p.y, p.z])).collect()
})
}
+313
View File
@@ -0,0 +1,313 @@
//! Untrim and extend on solidcore, against closed forms: areas and counts, and every result exports
//! to a record that validates.
//!
//! An untrim's body is a 20 x 20 x 5 plate with a 3 mm through hole at (10, 10), made on
//! solidcore; its top face is a square with a round hole. An extend's is a one-face sheet that is
//! its surface's whole parameter box: a 10 mm square, or a quarter of a cylinder of radius 10 and
//! height 10.
use std::f64::consts::{FRAC_1_SQRT_2, PI};
use super::trim::{extend_sheet, untrim_face};
use super::{Body, area, counts, make_box, make_cylinder, to_arrays};
use crate::export::native;
use crate::trim::Continuation;
use crate::{BooleanOp, Error};
const TOL: f64 = 0.01;
/// A point on the top face, away from the hole.
const TOP: [f64; 3] = [2.0, 2.0, 5.0];
/// A point on the hole's rim on the top face.
const RIM: [f64; 3] = [13.0, 10.0, 5.0];
/// The plate's top as a sheet of its own: a 20 mm square at z = 0 with a 3 mm hole at (10, 10).
fn holed_sheet() -> Body {
use crate::sweeps::Curve;
let line = |a: [f64; 3], b: [f64; 3]| Curve::Line { start: a, end: b };
let (a, b, c, d) = ([0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [20.0, 20.0, 0.0], [0.0, 20.0, 0.0]);
let hole = Curve::Arc { center: [10.0, 10.0, 0.0], axis: [0.0, 0.0, 1.0], reference: [1.0, 0.0, 0.0], radius: 3.0, start: 0.0, end: 2.0 * PI };
super::sweep::planar_faces(&[line(a, b), line(b, c), line(c, d), line(d, a), hole]).unwrap()
}
/// On the sheet: a point away from the hole, and one on the hole's rim.
const SHEET: [f64; 3] = [2.0, 2.0, 0.0];
const SHEET_RIM: [f64; 3] = [13.0, 10.0, 0.0];
#[derive(Clone, Copy, Debug)]
enum Input {
Plate,
Sheet,
}
impl Input {
fn core(self) -> Body {
match self {
Input::Plate => plate(),
Input::Sheet => holed_sheet(),
}
}
}
fn plate() -> Body {
let block = make_box([0.0; 3], [20.0, 20.0, 5.0]).unwrap();
let pin = make_cylinder([10.0, 10.0, -1.0], 3.0, 7.0).unwrap();
super::boolean::boolean(&block, &pin, BooleanOp::Subtract).unwrap().unwrap()
}
#[derive(Clone, Copy, Debug)]
struct Case {
input: Input,
point: [f64; 3],
/// `None`: every hole.
holes: Option<&'static [[f64; 3]]>,
outer: bool,
in_place: bool,
}
#[derive(Clone, Debug, PartialEq)]
enum Expect {
/// A sheet: its area and face count.
Sheet(f64, usize),
Fails(Error),
}
fn case(name: &str) -> (Case, Expect) {
let c = |input, point, holes, outer, in_place| Case { input, point, holes, outer, in_place };
let square = 400.0;
let holed = 400.0 - 9.0 * PI;
let (plate, sheet) = (Input::Plate, Input::Sheet);
match name {
// The plate's top face, alone.
"every_hole_taken_away" => (c(plate, TOP, None, false, false), Expect::Sheet(square, 1)),
"the_hole_at_a_point_taken_away" => (c(plate, TOP, Some(&[RIM]), false, false), Expect::Sheet(square, 1)),
// The outer boundary back on the plane's box of the face's span, the hole kept.
"outer_boundary_back_hole_kept" => (c(plate, TOP, Some(&[]), true, false), Expect::Sheet(holed, 1)),
"outer_boundary_back_holes_gone" => (c(plate, TOP, None, true, false), Expect::Sheet(square, 1)),
// In the solid, the hole's edges are the bore's too: no hole of a sheet.
"a_hole_of_a_solid_in_place" => (c(plate, TOP, None, false, true), Expect::Fails(Error::Unsupported)),
"a_point_near_no_face" => (c(plate, [50.0, 50.0, 50.0], None, false, false), Expect::Fails(Error::NoMatch { point: 0 })),
// The holed sheet, in place.
"sheet_every_hole_taken_away" => (c(sheet, SHEET, None, false, true), Expect::Sheet(square, 1)),
"sheet_the_hole_at_a_point_taken_away" => (c(sheet, SHEET, Some(&[SHEET_RIM]), false, true), Expect::Sheet(square, 1)),
"sheet_outer_boundary_back_hole_kept" => (c(sheet, SHEET, Some(&[]), true, true), Expect::Sheet(holed, 1)),
"sheet_outer_boundary_back_holes_gone" => (c(sheet, SHEET, None, true, true), Expect::Sheet(square, 1)),
"sheet_a_point_near_no_hole" => (c(sheet, SHEET, Some(&[SHEET]), false, true), Expect::Fails(Error::NoMatch { point: 0 })),
_ => panic!("no case {name}"),
}
}
#[derive(Debug, PartialEq)]
struct Measured {
area: f64,
counts: [usize; 3],
}
type Got = std::result::Result<Measured, Error>;
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs()
}
fn judge(name: &str, who: &str, got: Got) -> Got {
let (_, expect) = case(name);
match (&expect, &got) {
(Expect::Fails(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"),
(Expect::Fails(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"),
(_, Err(g)) => panic!("{name}: {who} failed: {g}"),
(Expect::Sheet(a, faces), Ok(m)) => {
assert!(rel(m.area, *a) < 1e-9, "{name}: {who} area {} vs analytic {a}", m.area);
assert_eq!(m.counts[0], *faces, "{name}: {who} faces");
}
}
got
}
fn solidcore_case(name: &str) -> Got {
let (c, _) = case(name);
let got = untrim_face(&c.input.core(), c.point, TOL, c.holes, c.outer, c.in_place).map(|b| {
let (rec, _) = native(&to_arrays(&b).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
Measured { area: area(&b, 1e-8).unwrap(), counts: counts(&b).unwrap() }
});
judge(name, "solidcore", got)
}
macro_rules! cases {
($($m:ident $(: $ign:meta)?),* $(,)?) => {$(
mod $m {
#[test]
$(#[$ign])?
fn solidcore_matches_the_closed_form() {
let _ = super::solidcore_case(stringify!($m));
}
}
)*};
}
cases!(
every_hole_taken_away,
the_hole_at_a_point_taken_away,
outer_boundary_back_hole_kept,
outer_boundary_back_holes_gone,
a_hole_of_a_solid_in_place,
a_point_near_no_face,
sheet_every_hole_taken_away,
sheet_the_hole_at_a_point_taken_away,
sheet_outer_boundary_back_hole_kept,
sheet_outer_boundary_back_holes_gone,
sheet_a_point_near_no_hole,
);
/// The capi's argument checks, before any kernel call.
#[test]
fn hostile_arguments_are_refused() {
let b = make_box([0.0; 3], [10.0; 3]).unwrap();
let p = [5.0, 5.0, 10.0];
assert_eq!(untrim_face(&b, [f64::NAN, 0.0, 0.0], TOL, None, true, false).err(), Some(Error::Argument));
for t in [0.0, -1.0, f64::NAN, f64::INFINITY] {
assert_eq!(untrim_face(&b, p, t, None, true, false).err(), Some(Error::Argument), "tolerance {t}");
}
assert_eq!(untrim_face(&b, p, TOL, Some(&[]), false, false).err(), Some(Error::Argument), "nothing to do");
assert_eq!(untrim_face(&b, p, TOL, Some(&[[f64::INFINITY; 3]]), false, false).err(), Some(Error::Argument));
let many = vec![p; 10_001];
assert_eq!(untrim_face(&b, p, TOL, Some(&many), false, false).err(), Some(Error::Argument));
}
/// The body untrimmed is never changed.
#[test]
fn the_body_stays_as_it_was() {
let b = plate();
let was = counts(&b).unwrap();
let _ = untrim_face(&b, TOP, TOL, None, true, false);
let _ = untrim_face(&b, TOP, TOL, None, false, true);
assert_eq!(counts(&b).unwrap(), was);
}
/// A one-face sheet on `surface` over `domain`, the face its whole box.
fn whole_face(surface: solidcore::surface::surface::Surface) -> Body {
use solidcore::topology::brep::p3::new_brep;
use solidcore::topology::brep::p8::create_face_from_surface;
super::run("sheet", |k| {
let mut b = new_brep(k, solidcore::geom::tol::USE_DEFAULT, solidcore::geom::tol::USE_DEFAULT)?;
let domain = surface.natural_uv_domain_with_nurb(k)?;
super::sweep::build_faces(&mut b, |b, fenv| create_face_from_surface(k, b, Some(surface), &domain, fenv))?;
b.refresh_cached_topology();
Ok(b)
})
.map(Body::new)
.unwrap()
}
#[derive(Clone, Copy, Debug)]
enum Sheet {
/// [0, 10]^2 at z = 0.
Square,
/// Radius 10 about +Z, from 0 to 90 degrees, z from 0 to 10.
QuarterCylinder,
}
impl Sheet {
fn core(self) -> Body {
use solidcore::geom::extent2d::Extent2d;
use solidcore::geom::vector2d::Vec2;
use solidcore::geom::vector3d::Vec3;
use solidcore::surface::cone::p1::Cone;
use solidcore::surface::plane::p1::Plane;
use solidcore::surface::surface::Surface;
let (o, x, y) = (Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0));
match self {
Sheet::Square => whole_face(Surface::from(Plane::new(o, x, y, Vec2::new(1.0, 1.0), &Extent2d::from_bounds(0.0, 0.0, 10.0, 10.0)))),
Sheet::QuarterCylinder => whole_face(Surface::from(Cone::new_cylinder_bounded(o, x, y, 10.0, 0.0, 90.0, 10.0, false, false, false))),
}
}
}
fn extend_case(name: &str) -> (Sheet, [f64; 3], f64, Continuation, Expect) {
let quarter = 5.0 * PI;
match name {
"extend_a_square" => (Sheet::Square, [10.0, 5.0, 0.0], 5.0, Continuation::Natural, Expect::Sheet(150.0, 1)),
"extend_a_square_tangent" => (Sheet::Square, [5.0, 10.0, 0.0], 5.0, Continuation::Tangent, Expect::Sheet(150.0, 1)),
"extend_a_square_curvature" => (Sheet::Square, [0.0, 5.0, 0.0], 2.0, Continuation::Curvature, Expect::Sheet(120.0, 1)),
"extend_a_cylinder_along_its_axis" => {
(Sheet::QuarterCylinder, [FRAC_1_SQRT_2 * 10.0, FRAC_1_SQRT_2 * 10.0, 10.0], 5.0, Continuation::Natural, Expect::Sheet(quarter * 15.0, 1))
}
"extend_a_cylinder_round_its_axis" => {
(Sheet::QuarterCylinder, [10.0, 0.0, 5.0], 5.0, Continuation::Natural, Expect::Sheet(10.0 * (quarter + 5.0), 1))
}
// Run-offs 5 long across the surface whatever their curvature: the strip stays its section
// swept 10 along z. A circle's G2 run-off runs about 2.3 times what it is asked and more
// than doubles with it (fork 0f7b209), so the ask must be solved for.
"extend_a_cylinder_tangent_run_off" => {
(Sheet::QuarterCylinder, [10.0, 0.0, 5.0], 5.0, Continuation::Tangent, Expect::Sheet(10.0 * (quarter + 5.0), 1))
}
"extend_a_cylinder_curvature_run_off" => {
(Sheet::QuarterCylinder, [10.0, 0.0, 5.0], 5.0, Continuation::Curvature, Expect::Sheet(10.0 * (quarter + 5.0), 1))
}
"extend_picks_near_no_edge" => (Sheet::Square, [50.0, 50.0, 0.0], 5.0, Continuation::Natural, Expect::Fails(Error::NoMatch { point: 0 })),
_ => panic!("no case {name}"),
}
}
fn extend_judge(name: &str, who: &str, got: Got) -> Got {
let (.., expect) = extend_case(name);
match (&expect, &got) {
(Expect::Fails(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"),
(Expect::Fails(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"),
(_, Err(g)) => panic!("{name}: {who} failed: {g}"),
(Expect::Sheet(a, faces), Ok(m)) => {
assert!(rel(m.area, *a) < 1e-6, "{name}: {who} area {} vs analytic {a}", m.area);
assert_eq!(m.counts[0], *faces, "{name}: {who} faces");
}
}
got
}
fn extend_solidcore(name: &str) -> Got {
let (sheet, point, distance, how, _) = extend_case(name);
let got = extend_sheet(&sheet.core(), point, TOL, distance, how).map(|b| {
let (rec, _) = native(&to_arrays(&b).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
Measured { area: area(&b, 1e-8).unwrap(), counts: counts(&b).unwrap() }
});
extend_judge(name, "solidcore", got)
}
macro_rules! extend_cases {
($($m:ident $(: $ign:meta)?),* $(,)?) => {$(
mod $m {
#[test]
$(#[$ign])?
fn solidcore_matches_the_closed_form() {
let _ = super::extend_solidcore(stringify!($m));
}
}
)*};
}
extend_cases!(
extend_a_square,
extend_a_square_tangent,
extend_a_square_curvature,
extend_a_cylinder_along_its_axis,
extend_a_cylinder_round_its_axis,
extend_a_cylinder_tangent_run_off,
extend_a_cylinder_curvature_run_off,
extend_picks_near_no_edge,
);
/// The capi's argument checks for an extend, before any kernel call, and a body of more than
/// one face.
#[test]
fn hostile_extend_arguments_are_refused() {
let s = Sheet::Square.core();
let p = [10.0, 5.0, 0.0];
assert_eq!(extend_sheet(&s, [f64::NAN; 3], TOL, 5.0, Continuation::Natural).err(), Some(Error::Argument));
for t in [0.0, -1.0, f64::NAN, f64::INFINITY] {
assert_eq!(extend_sheet(&s, p, t, 5.0, Continuation::Natural).err(), Some(Error::Argument), "tolerance {t}");
assert_eq!(extend_sheet(&s, p, TOL, t, Continuation::Natural).err(), Some(Error::Argument), "distance {t}");
}
let cube = make_box([0.0; 3], [10.0; 3]).unwrap();
assert_eq!(extend_sheet(&cube, p, TOL, 5.0, Continuation::Natural).err(), Some(Error::Unsupported));
// A face that is not its surface's whole box: a square with a hole.
assert_eq!(extend_sheet(&holed_sheet(), [20.0, 5.0, 0.0], TOL, 5.0, Continuation::Natural).err(), Some(Error::Unsupported));
}
+10 -107
View File
@@ -45,45 +45,33 @@ use solidcraft_geom::brep as b;
use crate::flat::{self, *};
use crate::history::{Order, Piece};
use crate::import::Arrays;
use crate::{Brep, Result, error, ffi, init};
use crate::{Brep, Result};
impl Brep {
/// A cylinder about +Z: base centre, radius and height (mm, > 0).
pub fn make_cylinder(origin: [f64; 3], radius: f64, height: f64) -> Result<Brep> {
init()?;
ffi::make_cylinder(&origin, radius, height).map(Brep).map_err(error)
crate::core::make_cylinder(origin, radius, height).map(Brep::made_core)
}
/// A sphere: centre and radius (mm, > 0).
pub fn make_sphere(center: [f64; 3], radius: f64) -> Result<Brep> {
init()?;
ffi::make_sphere(&center, radius).map(Brep).map_err(error)
crate::core::make_sphere(center, radius).map(Brep::made_core)
}
/// A cone or frustum about +Z: base centre, base radius (> 0), top radius (≥ 0, not the base
/// radius) and height (> 0), in mm.
pub fn make_cone(origin: [f64; 3], base_radius: f64, top_radius: f64, height: f64) -> Result<Brep> {
init()?;
ffi::make_cone(&origin, base_radius, top_radius, height).map(Brep).map_err(error)
crate::core::make_cone(origin, base_radius, top_radius, height).map(Brep::made_core)
}
/// A ring torus about +Z: centre, major radius > minor radius > 0 (mm).
pub fn make_torus(center: [f64; 3], major_radius: f64, minor_radius: f64) -> Result<Brep> {
init()?;
ffi::make_torus(&center, major_radius, minor_radius).map(Brep).map_err(error)
crate::core::make_torus(center, major_radius, minor_radius).map(Brep::made_core)
}
/// The flat arrays of this body that the mapping reads ([`Brep::export`]), copied out.
/// The flat arrays of this body that the mapping reads.
pub fn arrays(&self) -> Result<Arrays> {
let f = self.export()?;
let mut out = Arrays::default();
for &id in I64_IDS {
out.ints.insert(id, f.get_i64(id)?);
}
for &id in F64_IDS {
out.reals.insert(id, f.get_f64(id)?);
}
Ok(out)
crate::core::to_arrays(self.core())
}
/// This body as a schema 4 B-rep, validated.
@@ -150,92 +138,6 @@ fn unsupported(what: impl std::fmt::Display) -> ExportError {
// ---- The arrays (ids and kinds from `crate::flat`, as in capi/include/smlib.h) ------------
/// The int64 arrays this mapping reads.
const I64_IDS: &[i32] = &[
REGION_SHELL_COUNT,
REGION_TYPE,
SHELL_FACEUSE_COUNT,
SHELL_WIRE_EDGE_COUNT,
SHELL_POINT_TYPE,
FACEUSE_FACE,
FACEUSE_ORIENTATION,
FACE_LOOP_COUNT,
FACE_SURFACE_TYPE,
LOOP_EDGEUSE_COUNT,
LOOP_VERTEX,
EDGEUSE_EDGE,
EDGEUSE_ORIENTATION,
EDGEUSE_NEXT_RADIAL,
EDGEUSE_RADIAL_ENTRY,
EDGEUSE_NURBS_VERTEX_COUNT,
EDGEUSE_NURBS_ORDER,
EDGE_CURVE_TYPE,
EDGE_VERTICES,
EDGE_NURBS_VERTEX_COUNT,
EDGE_NURBS_ORDER,
FACE_NURBS_U_VERTEX_COUNT,
FACE_NURBS_V_VERTEX_COUNT,
FACE_NURBS_U_ORDER,
FACE_NURBS_V_ORDER,
FACE_NURBS_FORM,
EDGE_NURBS_FORM,
FACE_DEFINITION_REAL_COUNT,
];
/// The double arrays this mapping reads.
const F64_IDS: &[i32] = &[
TOLERANCE,
EXTENT,
FACE_RANGE,
EDGE_RANGE,
VERTEX_POSITION,
EDGE_NURBS_CVS,
EDGE_NURBS_KNOTS,
EDGE_NURBS_WEIGHTS,
EDGE_CIRCLE_CENTER,
EDGE_CIRCLE_AXIS,
EDGE_CIRCLE_REF_DIRECTION,
EDGE_CIRCLE_RADIUS,
EDGE_LINE_ORIGIN,
EDGE_LINE_DIRECTION,
EDGE_ELLIPSE_CENTER,
EDGE_ELLIPSE_AXIS,
EDGE_ELLIPSE_REF_DIRECTION,
EDGE_ELLIPSE_X_RADIUS,
EDGE_ELLIPSE_Y_RADIUS,
EDGEUSE_NURBS_CVS,
EDGEUSE_NURBS_KNOTS,
EDGEUSE_NURBS_WEIGHTS,
FACE_NURBS_CVS,
FACE_NURBS_U_KNOTS,
FACE_NURBS_V_KNOTS,
FACE_NURBS_WEIGHTS,
FACE_SPHERE_CENTER,
FACE_SPHERE_AXIS,
FACE_SPHERE_REF_DIRECTION,
FACE_SPHERE_RADIUS,
FACE_PLANE_ORIGIN,
FACE_PLANE_AXIS,
FACE_PLANE_REF_DIRECTION,
FACE_CYLINDER_ORIGIN,
FACE_CYLINDER_AXIS,
FACE_CYLINDER_REF_DIRECTION,
FACE_CYLINDER_RADIUS,
FACE_CONE_ORIGIN,
FACE_CONE_AXIS,
FACE_CONE_REF_DIRECTION,
FACE_CONE_RADIUS,
FACE_CONE_SEMI_ANGLE,
FACE_TORUS_ORIGIN,
FACE_TORUS_AXIS,
FACE_TORUS_REF_DIRECTION,
FACE_TORUS_MAJOR_RADIUS,
FACE_TORUS_MINOR_RADIUS,
EDGE_TOLERANCE,
VERTEX_TOLERANCE,
FACE_DEFINITION_REALS,
];
// The kind values as the arrays hold them (int64); a local item shadows the glob import.
const SURFACE_NURBS: i64 = flat::SURFACE_NURBS as i64;
const SURFACE_PLANE: i64 = flat::SURFACE_PLANE as i64;
@@ -384,8 +286,9 @@ fn surfaces(flat: View, n_faces: usize) -> Res<Vec<(b::Surface, Option<b::Surfac
v3(flat.f(FACE_CONE_REF_DIRECTION), i, "cone")?,
);
let (radius, half) = (at(flat.f(FACE_CONE_RADIUS), i, "cone")?, at(flat.f(FACE_CONE_SEMI_ANGLE), i, "cone")?);
// SMLib's cone of semi-angle 0 is its cylinder.
if half == 0.0 {
// SMLib's cone of semi-angle 0 is its cylinder, and one within rounding of it (a thread's
// cone of 1e-13 comes out of the trims) is too: the portable record refuses it as a cone.
if half.abs() < 1e-12 {
b::Surface::Cylinder { frame: f, radius }
} else {
b::Surface::Cone { frame: f, radius_at_origin: radius, half_angle: half }
+29 -40
View File
@@ -1,7 +1,5 @@
#[cfg(feature = "native")]
use super::*;
#[cfg(feature = "native")]
mod native_tests {
use super::*;
use crate::{CheckLevel, MEASURE_ACCURACY};
@@ -55,65 +53,46 @@ mod native_tests {
}
}
/// A 20 mm cube with every edge rounded r 2: its eight corners are SMLib's ball patches
/// (`SmBallPatch`, flat kind 6). The record keeps each as a ball patch on the sphere of
/// radius 2 about the corner pulled in by 2 along each axis, its net the NURBS form, and
/// every point of the net on that sphere. Imported back, the patches are kind 6 again with
/// the same definitions, bit for bit, and the body keeps its closed-form volume.
/// A 20 mm cube with every edge rounded r 2: each of its eight corners is a sphere of radius 2
/// about the corner pulled in by 2 along each axis. The record keeps each corner on that
/// sphere; imported back, the body is valid and keeps its closed-form volume.
#[test]
fn a_rounded_cubes_ball_patches_go_round_with_their_spheres() {
fn a_rounded_cubes_corners_go_round_on_their_spheres() {
let (a, r) = (20.0, 2.0);
let h = a / 2.0;
let mids: Vec<[f64; 3]> = [(0.0, 0.0), (a, 0.0), (0.0, a), (a, a)].iter().flat_map(|&(p, q)| [[h, p, q], [p, h, q], [p, q, h]]).collect();
let f = Brep::make_box([0.0; 3], [a; 3]).unwrap().fillet(&mids, 0.1, r).unwrap();
let record = f.to_native().unwrap();
record.validate().unwrap();
let patches: Vec<(&b::Face, [f64; 3], f64)> = record
let corners: Vec<([f64; 3], f64)> = record
.faces
.iter()
.filter_map(|face| match face.surface {
b::Surface::BallPatch { center, radius } => Some((face, center, radius)),
.filter_map(|face| match &face.surface {
b::Surface::Sphere { frame, radius } => Some((frame.origin, *radius)),
b::Surface::BallPatch { center, radius } => Some((*center, *radius)),
_ => None,
})
.collect();
assert_eq!(patches.len(), 8, "one ball patch per corner");
let mut centers: Vec<[f64; 3]> = patches.iter().map(|p| p.1).collect();
centers.sort_by(|p, q| p.partial_cmp(q).unwrap());
assert_eq!(corners.len(), 8, "one sphere per corner");
// Each corner's sphere is about the corner pulled in by r along each axis: one sphere each.
let inset = [r, a - r];
let want: Vec<[f64; 3]> = (0..8).map(|i| [inset[i >> 2], inset[(i >> 1) & 1], inset[i & 1]]).collect();
for (got, want) in centers.iter().zip(&want) {
assert!((0..3).all(|k| (got[k] - want[k]).abs() < 1e-9), "centre {got:?}, the corner's {want:?}");
for i in 0..8 {
let want = [inset[i >> 2], inset[(i >> 1) & 1], inset[i & 1]];
let at = corners.iter().filter(|(c, _)| (0..3).all(|k| (c[k] - want[k]).abs() < 1e-9)).count();
assert_eq!(at, 1, "spheres about {want:?}: {corners:?}");
}
for (face, c, radius) in &patches {
for (_, radius) in &corners {
assert!((radius - r).abs() < 1e-12, "radius {radius}");
assert!(matches!(face.nurbs, Some(b::Surface::RationalSpline { .. })), "a ball patch's form is its rational net");
let eval = b::eval::SurfaceEval::new(face.parameters()).unwrap();
let [[u0, u1], [v0, v1]] = face.domain;
for (s, t) in [(0.25, 0.25), (0.5, 0.5), (0.75, 0.3), (0.1, 0.9)] {
let q = eval.point([u0 + s * (u1 - u0), v0 + t * (v1 - v0)]).unwrap();
let d = ((q.x - c[0]).powi(2) + (q.y - c[1]).powi(2) + (q.z - c[2]).powi(2)).sqrt();
assert!((d - r).abs() < 1e-12 * a, "a point of the patch {} off its sphere", d - r);
}
}
let back = Brep::from_record(&record, true).unwrap();
let check = back.check(CheckLevel::All).unwrap();
assert!(check.is_valid(), "{check}");
let arrays = back.arrays().unwrap();
let kinds = arrays.ints.get(&crate::flat::FACE_SURFACE_TYPE).cloned().unwrap_or_default();
assert_eq!(kinds.iter().filter(|k| **k == i64::from(crate::flat::SURFACE_BALL_PATCH)).count(), 8);
// The record numbers faces in canonical order, not SMLib's: the same definitions, each bit
// for bit, in another order.
let defs = |b: &Brep| {
let reals = b.arrays().unwrap().reals.get(&crate::flat::FACE_DEFINITION_REALS).cloned().unwrap_or_default();
let mut d: Vec<[u64; 4]> = reals.chunks(4).map(|c| [0, 1, 2, 3].map(|k| c[k].to_bits())).collect();
d.sort_unstable();
d
};
assert_eq!(defs(&back), defs(&f), "the definitions come back bit for bit");
let b = a - 2.0 * r;
let want = b.powi(3) + 6.0 * b * b * r + 3.0 * PI * r * r * b + 4.0 / 3.0 * PI * r.powi(3);
let got = back.mass_properties(MEASURE_ACCURACY).unwrap().volume;
assert!((got - want).abs() < 1e-6 * want, "volume {got} vs {want}");
for (name, body) in [("as built", &f), ("through the record", &back)] {
let got = body.mass_properties(MEASURE_ACCURACY).unwrap().volume;
assert!((got - want).abs() < 1e-6 * want, "{name}: volume {got} vs {want}");
}
}
/// A tagged body's own history, renumbered with its export's order on both sides, is the
@@ -174,6 +153,16 @@ mod native_tests {
assert!((half.unwrap() + 0.5f64.atan()).abs() < 1e-12);
}
/// A cone whose half angle is within rounding of zero (a52's thread bosses: 1e-13) is exported as
/// a cylinder; as a cone the portable record's validation refuses it.
#[test]
fn a_cone_of_rounding_angle_is_exported_as_a_cylinder() {
let near = Brep::make_cone([0.0; 3], 4.0, 4.0 - 1e-12, 10.0).unwrap().to_native().unwrap();
assert!(near.faces.iter().any(|f| matches!(f.surface, b::Surface::Cylinder { .. })));
assert!(!near.faces.iter().any(|f| matches!(f.surface, b::Surface::Cone { .. })));
near.validate().unwrap();
}
/// A hole tangent to the block's outline from inside: the material touches itself along the
/// tangent line, where SMLib's one shell uses the edge four times. The record has two copies
/// of that edge, each bounding one wedge of material (a face of the side and the half of the
-405
View File
@@ -1,405 +0,0 @@
//! The C API of `capi/include/smlib.h` (tangency-labs/smlib), declared by hand. The only place in
//! the crate with `unsafe`: every function here is called through the safe wrappers in the crate
//! root, which check their arguments and own the handles.
#![allow(unsafe_code)]
#[cfg(feature = "native")]
use std::ffi::{c_char, c_void};
/// `SMLIB_ABI_VERSION` this crate was written against.
pub const ABI_VERSION: i32 = 6;
pub const OK: i32 = 0;
pub const ERR_EXCEPTION: i32 = -1000;
pub const ERR_BUFFER: i32 = -1001;
pub const ERR_ARG: i32 = -1002;
pub const ERR_EMPTY: i32 = -1003;
/// `smlib_brep`, opaque.
#[repr(C)]
pub struct RawBrep {
_private: [u8; 0],
}
#[cfg(feature = "native")]
pub type LogFn = extern "C" fn(user: *mut c_void, level: i32, line: *const c_char);
#[cfg(feature = "native")]
unsafe extern "C" {
pub fn smlib_abi_version() -> i32;
pub fn smlib_init() -> i32;
pub fn smlib_set_log(log: Option<LogFn>, user: *mut c_void);
pub fn smlib_set_dump_dir(dir: *const c_char) -> i32;
pub fn smlib_current_call(buf: *mut c_char, cap: i64) -> i64;
pub fn smlib_brep_read(path: *const c_char, ascii: i32, out: *mut *mut RawBrep) -> i32;
pub fn smlib_brep_write(brep: *const RawBrep, path: *const c_char, ascii: i32) -> i32;
pub fn smlib_brep_free(brep: *mut RawBrep);
pub fn smlib_brep_copy(brep: *const RawBrep, out: *mut *mut RawBrep) -> i32;
pub fn smlib_brep_transform(brep: *const RawBrep, matrix: *const [f64; 4], out: *mut *mut RawBrep) -> i32;
pub fn smlib_make_box(origin: *const f64, size: *const f64, out: *mut *mut RawBrep) -> i32;
pub fn smlib_brep_volume(brep: *const RawBrep, relative_accuracy: f64, out: *mut f64) -> i32;
pub fn smlib_brep_counts(brep: *const RawBrep, out: *mut i64) -> i32;
}
/// Returned by every call when the crate was built without the `native` feature.
pub const NOT_BUILT: i32 = -2000;
/// An owned `smlib_brep`. Only made here, from a pointer the C API returned; freed once on drop.
#[cfg_attr(not(feature = "native"), allow(dead_code))]
pub struct Handle(std::ptr::NonNull<RawBrep>);
// SMLib serialises every call behind its own lock, and a handle is only ever used through it.
unsafe impl Send for Handle {}
unsafe impl Sync for Handle {}
impl Drop for Handle {
fn drop(&mut self) {
#[cfg(feature = "native")]
unsafe {
smlib_brep_free(self.0.as_ptr())
};
}
}
impl Handle {
#[cfg_attr(not(feature = "native"), allow(dead_code))]
fn raw(&self) -> *const RawBrep {
self.0.as_ptr()
}
}
/// Runs a call that returns a new B-rep through `out`.
#[cfg(feature = "native")]
fn new_brep(f: impl FnOnce(*mut *mut RawBrep) -> i32) -> Result<Handle, i32> {
let mut out: *mut RawBrep = std::ptr::null_mut();
let s = f(&mut out);
match (s, std::ptr::NonNull::new(out)) {
(OK, Some(p)) => Ok(Handle(p)),
(OK, None) => Err(ERR_EMPTY),
(s, _) => Err(s),
}
}
#[cfg(feature = "native")]
mod calls {
use super::*;
use std::ffi::CStr;
pub fn abi_version() -> i32 {
unsafe { smlib_abi_version() }
}
pub fn init() -> i32 {
unsafe { smlib_init() }
}
/// Routes SMLib's log lines to `sink` (`None`: stops them).
pub fn set_log(sink: Option<fn(i32, String)>) {
let log: Option<LogFn> = match sink {
Some(sink) => {
// The first sink stays: init sets it once, and lines keep a single destination.
let _ = SINK.set(sink);
Some(trampoline)
}
None => None,
};
unsafe { smlib_set_log(log, std::ptr::null_mut()) }
}
pub fn set_dump_dir(dir: Option<&CStr>) -> i32 {
unsafe { smlib_set_dump_dir(dir.map_or(std::ptr::null(), CStr::as_ptr)) }
}
pub fn current_call(buf: &mut [u8]) -> usize {
let cap = i64::try_from(buf.len()).unwrap_or(i64::MAX);
let n = unsafe { smlib_current_call(buf.as_mut_ptr().cast(), cap) };
usize::try_from(n).unwrap_or(0).min(buf.len().saturating_sub(1))
}
pub fn read(path: &CStr, ascii: bool) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_brep_read(path.as_ptr(), i32::from(ascii), out) })
}
pub fn write(b: &Handle, path: &CStr, ascii: bool) -> i32 {
unsafe { smlib_brep_write(b.raw(), path.as_ptr(), i32::from(ascii)) }
}
pub fn copy(b: &Handle) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_brep_copy(b.raw(), out) })
}
pub fn transform(b: &Handle, matrix: &[[f64; 4]; 4]) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_brep_transform(b.raw(), matrix.as_ptr(), out) })
}
pub fn make_box(origin: &[f64; 3], size: &[f64; 3]) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_make_box(origin.as_ptr(), size.as_ptr(), out) })
}
pub fn volume(b: &Handle, relative_accuracy: f64) -> Result<f64, i32> {
let mut v = 0.0;
match unsafe { smlib_brep_volume(b.raw(), relative_accuracy, &mut v) } {
OK => Ok(v),
s => Err(s),
}
}
pub fn counts(b: &Handle) -> Result<[i64; 3], i32> {
let mut c = [0i64; 3];
match unsafe { smlib_brep_counts(b.raw(), c.as_mut_ptr()) } {
OK => Ok(c),
s => Err(s),
}
}
}
/// The same calls when SMLib is not built: each one fails with [`NOT_BUILT`].
#[cfg(not(feature = "native"))]
mod calls {
use super::*;
use std::ffi::CStr;
pub fn abi_version() -> i32 {
NOT_BUILT
}
pub fn init() -> i32 {
NOT_BUILT
}
pub fn set_log(_: Option<fn(i32, String)>) {}
pub fn set_dump_dir(_: Option<&CStr>) -> i32 {
NOT_BUILT
}
pub fn current_call(_: &mut [u8]) -> usize {
0
}
pub fn read(_: &CStr, _: bool) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
pub fn write(_: &Handle, _: &CStr, _: bool) -> i32 {
NOT_BUILT
}
pub fn copy(_: &Handle) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
pub fn transform(_: &Handle, _: &[[f64; 4]; 4]) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
pub fn make_box(_: &[f64; 3], _: &[f64; 3]) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
pub fn volume(_: &Handle, _: f64) -> Result<f64, i32> {
Err(NOT_BUILT)
}
pub fn counts(_: &Handle) -> Result<[i64; 3], i32> {
Err(NOT_BUILT)
}
}
pub use calls::*;
pub mod check;
pub mod flat;
pub mod history;
pub mod measure;
pub mod ops;
pub mod sheets;
pub mod split;
pub mod ssi;
pub mod sweeps;
pub mod trim;
/// Where [`set_log`] sends SMLib's log lines.
#[cfg(feature = "native")]
static SINK: std::sync::OnceLock<fn(i32, String)> = std::sync::OnceLock::new();
/// The log callback the C API calls: `line` is NUL-terminated and valid for the call's duration
/// (smlib.h), so it is read here, inside the one module that touches raw pointers.
#[cfg(feature = "native")]
extern "C" fn trampoline(_: *mut c_void, level: i32, line: *const c_char) {
let text = if line.is_null() { String::new() } else { unsafe { std::ffi::CStr::from_ptr(line) }.to_string_lossy().into_owned() };
if let Some(sink) = SINK.get() {
sink(level, text);
}
}
// ---- Primitives (smlib_primitives.cpp) -----------------------------------------------------
#[cfg(feature = "native")]
unsafe extern "C" {
fn smlib_make_cylinder(origin: *const f64, radius: f64, height: f64, out: *mut *mut RawBrep) -> i32;
fn smlib_make_sphere(center: *const f64, radius: f64, out: *mut *mut RawBrep) -> i32;
fn smlib_make_cone(origin: *const f64, base_radius: f64, top_radius: f64, height: f64, out: *mut *mut RawBrep) -> i32;
fn smlib_make_torus(center: *const f64, major_radius: f64, minor_radius: f64, out: *mut *mut RawBrep) -> i32;
}
#[cfg(feature = "native")]
mod primitive_calls {
use super::*;
pub fn make_cylinder(origin: &[f64; 3], radius: f64, height: f64) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_make_cylinder(origin.as_ptr(), radius, height, out) })
}
pub fn make_sphere(center: &[f64; 3], radius: f64) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_make_sphere(center.as_ptr(), radius, out) })
}
pub fn make_cone(origin: &[f64; 3], base_radius: f64, top_radius: f64, height: f64) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_make_cone(origin.as_ptr(), base_radius, top_radius, height, out) })
}
pub fn make_torus(center: &[f64; 3], major_radius: f64, minor_radius: f64) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_make_torus(center.as_ptr(), major_radius, minor_radius, out) })
}
}
/// The same calls when SMLib is not built: each one fails with [`NOT_BUILT`].
#[cfg(not(feature = "native"))]
mod primitive_calls {
use super::*;
pub fn make_cylinder(_: &[f64; 3], _: f64, _: f64) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
pub fn make_sphere(_: &[f64; 3], _: f64) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
pub fn make_cone(_: &[f64; 3], _: f64, _: f64, _: f64) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
pub fn make_torus(_: &[f64; 3], _: f64, _: f64) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
}
pub use primitive_calls::*;
// ---- WASI calls (wasm32-unknown-unknown) -------------------------------------------------------
/// In a browser build SMLib is compiled against wasi-sdk's C library (build.rs), which calls a few
/// WASI functions. The page has no WASI host, so they are defined here, where wasm-ld finds them
/// before it would leave them as imports. There is no file system or environment: those calls fail
/// with WASI's own error codes. The clocks are the page's (the C API times every call). What
/// C writes to stdout or stderr goes to the `log` crate (target `smlib`).
#[cfg(all(feature = "native", target_arch = "wasm32", target_os = "unknown"))]
mod wasi {
const SUCCESS: i32 = 0;
const BADF: i32 = 8;
const INVAL: i32 = 28;
const NOSYS: i32 = 52;
const NOTCAPABLE: i32 = 76;
/// One `__wasi_ciovec_t`: a buffer and its length.
#[repr(C)]
struct Ciovec {
buf: *const u8,
len: usize,
}
#[unsafe(no_mangle)]
extern "C" fn __imported_wasi_snapshot_preview1_fd_write(fd: i32, iovs: *const Ciovec, n: usize, written: *mut usize) -> i32 {
if !(1..=2).contains(&fd) {
return BADF;
}
let mut text = Vec::new();
for i in 0..n {
// libc passes `n` valid iovecs, each a valid buffer, for the call's duration.
let v = unsafe { &*iovs.add(i) };
if !v.buf.is_null() {
text.extend_from_slice(unsafe { std::slice::from_raw_parts(v.buf, v.len) });
}
}
for line in String::from_utf8_lossy(&text).lines().filter(|l| !l.trim().is_empty()) {
log::debug!(target: "smlib", "{line}");
}
if !written.is_null() {
unsafe { *written = text.len() };
}
SUCCESS
}
#[unsafe(no_mangle)]
extern "C" fn __imported_wasi_snapshot_preview1_environ_sizes_get(count: *mut usize, size: *mut usize) -> i32 {
if !count.is_null() && !size.is_null() {
unsafe {
*count = 0;
*size = 0;
}
}
SUCCESS
}
#[unsafe(no_mangle)]
extern "C" fn __imported_wasi_snapshot_preview1_environ_get(_: *mut *mut u8, _: *mut u8) -> i32 {
SUCCESS
}
/// Clock 0 is the wall clock; the others (monotonic, process and thread time) all count from
/// the first call, through the page's monotonic clock.
#[unsafe(no_mangle)]
extern "C" fn __imported_wasi_snapshot_preview1_clock_time_get(id: i32, _: i64, time: *mut u64) -> i32 {
static START: std::sync::OnceLock<web_time::Instant> = std::sync::OnceLock::new();
let d = match id {
0 => match web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH) {
Ok(d) => d,
Err(_) => return NOSYS,
},
1..=3 => START.get_or_init(web_time::Instant::now).elapsed(),
_ => return INVAL,
};
if time.is_null() {
return INVAL;
}
unsafe { *time = u64::try_from(d.as_nanos()).unwrap_or(u64::MAX) };
SUCCESS
}
/// No preopened directories: libc stops looking at the first descriptor that is not one.
#[unsafe(no_mangle)]
extern "C" fn __imported_wasi_snapshot_preview1_fd_prestat_get(_: i32, _: *mut u8) -> i32 {
BADF
}
#[unsafe(no_mangle)]
extern "C" fn __imported_wasi_snapshot_preview1_fd_prestat_dir_name(_: i32, _: *mut u8, _: usize) -> i32 {
BADF
}
#[unsafe(no_mangle)]
extern "C" fn __imported_wasi_snapshot_preview1_fd_fdstat_get(_: i32, _: *mut u8) -> i32 {
BADF
}
#[unsafe(no_mangle)]
extern "C" fn __imported_wasi_snapshot_preview1_fd_fdstat_set_flags(_: i32, _: i32) -> i32 {
BADF
}
#[unsafe(no_mangle)]
extern "C" fn __imported_wasi_snapshot_preview1_fd_close(_: i32) -> i32 {
BADF
}
#[unsafe(no_mangle)]
extern "C" fn __imported_wasi_snapshot_preview1_fd_read(_: i32, _: *const Ciovec, _: usize, _: *mut usize) -> i32 {
BADF
}
#[unsafe(no_mangle)]
extern "C" fn __imported_wasi_snapshot_preview1_fd_seek(_: i32, _: i64, _: i32, _: *mut u64) -> i32 {
BADF
}
#[unsafe(no_mangle)]
extern "C" fn __imported_wasi_snapshot_preview1_fd_renumber(_: i32, _: i32) -> i32 {
BADF
}
#[unsafe(no_mangle)]
extern "C" fn __imported_wasi_snapshot_preview1_path_open(
_: i32,
_: i32,
_: *const u8,
_: usize,
_: i32,
_: i64,
_: i64,
_: i32,
_: *mut i32,
) -> i32 {
NOTCAPABLE
}
/// C's `exit` and `abort`: the C API catches everything, so this is a kernel defect; stop the
/// module as a native build would stop the process.
#[unsafe(no_mangle)]
extern "C" fn __imported_wasi_snapshot_preview1_proc_exit(_: i32) -> ! {
std::arch::wasm32::unreachable()
}
}
-36
View File
@@ -1,36 +0,0 @@
//! `smlib_brep_check` (the fork's `capi/src/smlib_check.cpp`), declared by hand.
//! Part of the FFI boundary, like its parent: called only through [`crate::check`].
#![allow(unsafe_code)]
use super::Handle;
#[cfg(not(feature = "native"))]
use super::NOT_BUILT;
#[cfg(feature = "native")]
use super::{OK, RawBrep};
#[cfg(feature = "native")]
use std::ffi::c_char;
#[cfg(feature = "native")]
unsafe extern "C" {
pub fn smlib_brep_check(brep: *const RawBrep, level: i32, buf: *mut c_char, cap: i64, out_len: *mut i64, out_failures: *mut i64) -> i32;
}
/// Runs the check into `buf`: the report's length and the failure count, or a status
/// (`ERR_BUFFER` with the needed length when `buf` is too small).
#[cfg(feature = "native")]
pub fn check(b: &Handle, level: i32, buf: &mut [u8]) -> Result<(usize, i64), (i32, usize)> {
let cap = i64::try_from(buf.len()).unwrap_or(i64::MAX);
let (mut len, mut failures) = (0i64, 0i64);
let s = unsafe { smlib_brep_check(b.raw(), level, buf.as_mut_ptr().cast(), cap, &mut len, &mut failures) };
let len = usize::try_from(len).unwrap_or(0);
match s {
OK => Ok((len.min(buf.len()), failures)),
s => Err((s, len)),
}
}
#[cfg(not(feature = "native"))]
pub fn check(_: &Handle, _: i32, _: &mut [u8]) -> Result<(usize, i64), (i32, usize)> {
Err((NOT_BUILT, 0))
}
-134
View File
@@ -1,134 +0,0 @@
//! The flat-array block of `capi/include/smlib.h`, declared by hand. Part of the FFI boundary,
//! like its parent: called only through [`crate::flat`].
#![allow(unsafe_code)]
use super::Handle;
#[cfg(feature = "native")]
use super::{OK, RawBrep, new_brep};
/// `smlib_flat`, opaque.
#[repr(C)]
pub struct RawFlat {
_private: [u8; 0],
}
#[cfg(feature = "native")]
unsafe extern "C" {
fn smlib_flat_new() -> *mut RawFlat;
fn smlib_flat_free(flat: *mut RawFlat);
fn smlib_brep_export(brep: *const RawBrep, out: *mut *mut RawFlat) -> i32;
fn smlib_brep_import(flat: *const RawFlat, out: *mut *mut RawBrep) -> i32;
fn smlib_flat_get_i64(flat: *const RawFlat, id: i32, data: *mut *const i64, len: *mut i64) -> i32;
fn smlib_flat_get_f64(flat: *const RawFlat, id: i32, data: *mut *const f64, len: *mut i64) -> i32;
fn smlib_flat_set_i64(flat: *mut RawFlat, id: i32, data: *const i64, len: i64) -> i32;
fn smlib_flat_set_f64(flat: *mut RawFlat, id: i32, data: *const f64, len: i64) -> i32;
fn smlib_brep_is_manifold_solid(brep: *const RawBrep, out: *mut i32) -> i32;
}
/// An owned `smlib_flat`, freed once on drop.
#[cfg_attr(not(feature = "native"), allow(dead_code))]
pub struct FlatHandle(std::ptr::NonNull<RawFlat>);
// Only ever used through the C API, which serialises every call.
unsafe impl Send for FlatHandle {}
unsafe impl Sync for FlatHandle {}
impl Drop for FlatHandle {
fn drop(&mut self) {
#[cfg(feature = "native")]
unsafe {
smlib_flat_free(self.0.as_ptr())
};
}
}
#[cfg(feature = "native")]
mod calls {
use super::*;
use crate::ffi::{ERR_ARG, ERR_EMPTY, ERR_EXCEPTION};
pub fn new() -> Result<FlatHandle, i32> {
std::ptr::NonNull::new(unsafe { smlib_flat_new() }).map(FlatHandle).ok_or(ERR_EXCEPTION)
}
pub fn export(b: &Handle) -> Result<FlatHandle, i32> {
let mut out: *mut RawFlat = std::ptr::null_mut();
match (unsafe { smlib_brep_export(b.raw(), &mut out) }, std::ptr::NonNull::new(out)) {
(OK, Some(p)) => Ok(FlatHandle(p)),
(OK, None) => Err(ERR_EMPTY),
(s, _) => Err(s),
}
}
pub fn import(f: &FlatHandle) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_brep_import(f.0.as_ptr(), out) })
}
/// Copies the array out: the C side's buffer lives only as long as the arrays are unchanged.
fn copy<T: Copy>(s: i32, data: *const T, len: i64) -> Result<Vec<T>, i32> {
if s != OK {
return Err(s);
}
let n = usize::try_from(len).map_err(|_| ERR_ARG)?;
if n == 0 || data.is_null() {
return Ok(Vec::new());
}
Ok(unsafe { std::slice::from_raw_parts(data, n) }.to_vec())
}
pub fn get_i64(f: &FlatHandle, id: i32) -> Result<Vec<i64>, i32> {
let (mut data, mut len) = (std::ptr::null(), 0i64);
let s = unsafe { smlib_flat_get_i64(f.0.as_ptr(), id, &mut data, &mut len) };
copy(s, data, len)
}
pub fn get_f64(f: &FlatHandle, id: i32) -> Result<Vec<f64>, i32> {
let (mut data, mut len) = (std::ptr::null(), 0i64);
let s = unsafe { smlib_flat_get_f64(f.0.as_ptr(), id, &mut data, &mut len) };
copy(s, data, len)
}
pub fn set_i64(f: &mut FlatHandle, id: i32, v: &[i64]) -> i32 {
let Ok(n) = i64::try_from(v.len()) else { return ERR_ARG };
unsafe { smlib_flat_set_i64(f.0.as_ptr(), id, v.as_ptr(), n) }
}
pub fn set_f64(f: &mut FlatHandle, id: i32, v: &[f64]) -> i32 {
let Ok(n) = i64::try_from(v.len()) else { return ERR_ARG };
unsafe { smlib_flat_set_f64(f.0.as_ptr(), id, v.as_ptr(), n) }
}
pub fn is_manifold_solid(b: &Handle) -> Result<bool, i32> {
let mut out = 0i32;
match unsafe { smlib_brep_is_manifold_solid(b.raw(), &mut out) } {
OK => Ok(out != 0),
s => Err(s),
}
}
}
/// The same calls when SMLib is not built: each one fails with `NOT_BUILT`.
#[cfg(not(feature = "native"))]
mod calls {
use super::*;
use crate::ffi::NOT_BUILT;
pub fn new() -> Result<FlatHandle, i32> {
Err(NOT_BUILT)
}
pub fn export(_: &Handle) -> Result<FlatHandle, i32> {
Err(NOT_BUILT)
}
pub fn import(_: &FlatHandle) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
pub fn get_i64(_: &FlatHandle, _: i32) -> Result<Vec<i64>, i32> {
Err(NOT_BUILT)
}
pub fn get_f64(_: &FlatHandle, _: i32) -> Result<Vec<f64>, i32> {
Err(NOT_BUILT)
}
pub fn set_i64(_: &mut FlatHandle, _: i32, _: &[i64]) -> i32 {
NOT_BUILT
}
pub fn set_f64(_: &mut FlatHandle, _: i32, _: &[f64]) -> i32 {
NOT_BUILT
}
pub fn is_manifold_solid(_: &Handle) -> Result<bool, i32> {
Err(NOT_BUILT)
}
}
pub use calls::*;
-53
View File
@@ -1,53 +0,0 @@
//! The history block of `capi/include/smlib.h` (`smlib_history_tag`, `smlib_history_read`),
//! declared by hand. Part of the FFI boundary, like its parent: called only through
//! [`crate::history`].
#![allow(unsafe_code)]
use super::Handle;
#[cfg(feature = "native")]
use super::{ERR_BUFFER, OK, RawBrep, new_brep};
/// `SMLIB_HISTORY_VERSION` this crate reads.
pub const VERSION: i64 = 1;
#[cfg(feature = "native")]
unsafe extern "C" {
fn smlib_history_tag(brep: *const RawBrep, operand: i32, out: *mut *mut RawBrep) -> i32;
fn smlib_history_read(result: *const RawBrep, buf: *mut i64, cap: i64, out_len: *mut i64) -> i32;
}
#[cfg(feature = "native")]
pub fn tag(b: &Handle, operand: i32) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_history_tag(b.raw(), operand, out) })
}
/// The result's history records, sized by asking first.
#[cfg(feature = "native")]
pub fn read(b: &Handle) -> Result<Vec<i64>, i32> {
let mut len = 0i64;
let mut buf: Vec<i64> = Vec::new();
loop {
let cap = i64::try_from(buf.len()).map_err(|_| ERR_BUFFER)?;
let ptr = if buf.is_empty() { std::ptr::null_mut() } else { buf.as_mut_ptr() };
match unsafe { smlib_history_read(b.raw(), ptr, cap, &mut len) } {
OK => {
buf.truncate(usize::try_from(len).map_err(|_| ERR_BUFFER)?);
return Ok(buf);
}
// Asked again with room for what it said; a second shortfall would mean the body
// changed, which a handle never does.
ERR_BUFFER if buf.is_empty() => buf.resize(usize::try_from(len).map_err(|_| ERR_BUFFER)?, 0),
s => return Err(s),
}
}
}
#[cfg(not(feature = "native"))]
pub fn tag(_: &Handle, _: i32) -> Result<Handle, i32> {
Err(super::NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn read(_: &Handle) -> Result<Vec<i64>, i32> {
Err(super::NOT_BUILT)
}
-95
View File
@@ -1,95 +0,0 @@
//! `smlib_brep_mass_properties`, `smlib_brep_area`, `smlib_brep_bounds`,
//! `smlib_brep_edge_face_distance` and `smlib_brep_classify_point` (the fork's `capi/src/smlib_measure.cpp`), declared by
//! hand. Part of the FFI boundary, like its parent: called only through [`crate::measure`].
#![allow(unsafe_code)]
use super::Handle;
#[cfg(not(feature = "native"))]
use super::NOT_BUILT;
#[cfg(feature = "native")]
use super::{OK, RawBrep};
#[cfg(feature = "native")]
unsafe extern "C" {
pub fn smlib_brep_mass_properties(brep: *const RawBrep, relative_accuracy: f64, out: *mut f64) -> i32;
pub fn smlib_brep_area(brep: *const RawBrep, relative_accuracy: f64, out: *mut f64) -> i32;
pub fn smlib_brep_bounds(brep: *const RawBrep, lo: *mut f64, hi: *mut f64) -> i32;
pub fn smlib_brep_edge_face_distance(brep: *const RawBrep, edge: i64, face: i64, out: *mut f64) -> i32;
pub fn smlib_brep_classify_point(brep: *const RawBrep, x: f64, y: f64, z: f64, out: *mut i32) -> i32;
}
/// `SMLIB_POINT_OUTSIDE`, `SMLIB_POINT_INSIDE` and `SMLIB_POINT_ON_BOUNDARY`.
pub const POINT_OUTSIDE: i32 = 0;
pub const POINT_INSIDE: i32 = 1;
pub const POINT_ON_BOUNDARY: i32 = 2;
#[cfg(feature = "native")]
pub fn mass_properties(b: &Handle, relative_accuracy: f64) -> Result<[f64; 11], i32> {
let mut m = [0.0; 11];
match unsafe { smlib_brep_mass_properties(b.raw(), relative_accuracy, m.as_mut_ptr()) } {
OK => Ok(m),
s => Err(s),
}
}
#[cfg(feature = "native")]
pub fn area(b: &Handle, relative_accuracy: f64) -> Result<f64, i32> {
let mut a = 0.0;
match unsafe { smlib_brep_area(b.raw(), relative_accuracy, &mut a) } {
OK => Ok(a),
s => Err(s),
}
}
#[cfg(feature = "native")]
pub fn bounds(b: &Handle) -> Result<([f64; 3], [f64; 3]), i32> {
let (mut lo, mut hi) = ([0.0; 3], [0.0; 3]);
match unsafe { smlib_brep_bounds(b.raw(), lo.as_mut_ptr(), hi.as_mut_ptr()) } {
OK => Ok((lo, hi)),
s => Err(s),
}
}
#[cfg(feature = "native")]
pub fn edge_face_distance(b: &Handle, edge: i64, face: i64) -> Result<f64, i32> {
let mut d = 0.0;
match unsafe { smlib_brep_edge_face_distance(b.raw(), edge, face, &mut d) } {
OK => Ok(d),
s => Err(s),
}
}
#[cfg(feature = "native")]
pub fn classify_point(b: &Handle, p: [f64; 3]) -> Result<i32, i32> {
let mut c = -1;
match unsafe { smlib_brep_classify_point(b.raw(), p[0], p[1], p[2], &mut c) } {
OK => Ok(c),
s => Err(s),
}
}
#[cfg(not(feature = "native"))]
pub fn classify_point(_: &Handle, _: [f64; 3]) -> Result<i32, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn edge_face_distance(_: &Handle, _: i64, _: i64) -> Result<f64, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn bounds(_: &Handle) -> Result<([f64; 3], [f64; 3]), i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn mass_properties(_: &Handle, _: f64) -> Result<[f64; 11], i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn area(_: &Handle, _: f64) -> Result<f64, i32> {
Err(NOT_BUILT)
}
-563
View File
@@ -1,563 +0,0 @@
//! The operations block of `capi/include/smlib.h` (`smlib_boolean`, `smlib_boolean_union_all`, `smlib_boolean2d`, `smlib_fillet`,
//! `smlib_chamfer`, `smlib_blend`, `smlib_blend_dropping`, `smlib_blend_setback`, `smlib_blend_setback_open`, `smlib_blend_fit_size`, `smlib_fillet_variable`, `smlib_chamfer_sides`, `smlib_shell`, `smlib_offset_faces`,
//! `smlib_thicken`, `smlib_draft`, `smlib_merge_faces`, `smlib_brep_solid_count`, `smlib_imprint`; the fork's `capi/src/smlib_ops.cpp`), declared by
//! hand. Part of the FFI boundary, like its parent: called only through
//! [`crate::ops`].
#![allow(unsafe_code)]
use super::Handle;
#[cfg(not(feature = "native"))]
use super::NOT_BUILT;
#[cfg(feature = "native")]
use super::{ERR_ARG, ERR_BUFFER, OK, RawBrep, new_brep};
pub const ERR_NOT_SOLID: i32 = -1100;
pub const ERR_PICK_NONE: i32 = -1101;
pub const ERR_PICK_AMBIGUOUS: i32 = -1102;
pub const ERR_NO_ROOM: i32 = -1103;
pub const ERR_NO_CUT: i32 = -1104;
pub const ERR_UNSUPPORTED: i32 = -1105;
pub const ERR_NO_EFFECT: i32 = -1107;
pub const ERR_WALLS_TOUCH: i32 = -1110;
pub const ERR_CORNER_UNSUPPORTED: i32 = -1111;
pub const BOOLEAN_UNION: i32 = 0;
pub const BOOLEAN_SUBTRACT: i32 = 1;
pub const BOOLEAN_INTERSECT: i32 = 2;
pub const BLEND_ROUND: i32 = 0;
pub const BLEND_CURVATURE: i32 = 1;
pub const BLEND_CHORD: i32 = 2;
pub const BLEND_CHAMFER: i32 = 3;
pub const FIT_SIZE: i32 = 0;
pub const FIT_SETBACK: i32 = 1;
pub const FIT_SCALE: i32 = 2;
pub const FIT_SETBACK_AT_LEAST: i32 = 3;
/// Which selection operation: the C functions share one signature.
#[derive(Clone, Copy)]
pub enum Pick {
Fillet,
Chamfer,
Shell,
OffsetFaces,
}
/// A failed selection operation: the status, and the index of the point that selected nothing
/// or several things (only for `ERR_PICK_*`).
pub type PickError = (i32, Option<usize>);
/// A failed blend with corner setbacks: the status, the index of the edge point that selected
/// nothing or several edges (only for `ERR_PICK_*`), and the index of the corner point whose
/// corner SMLib refused to set back (only for `ERR_UNSUPPORTED`).
pub type SetbackError = (i32, Option<usize>, Option<usize>);
/// The [`SetbackError`] of status `s`, given what the C call wrote to its failed indices.
#[cfg(feature = "native")]
fn setback_error(s: i32, failed: i64, failed_corner: i64) -> SetbackError {
let at = (s == ERR_PICK_NONE || s == ERR_PICK_AMBIGUOUS).then(|| usize::try_from(failed).ok()).flatten();
let corner = (s == ERR_UNSUPPORTED).then(|| usize::try_from(failed_corner).ok()).flatten();
(s, at, corner)
}
#[cfg(feature = "native")]
type PickFn = unsafe extern "C" fn(*const RawBrep, *const f64, i64, f64, f64, *mut *mut RawBrep, *mut i64) -> i32;
#[cfg(feature = "native")]
unsafe extern "C" {
fn smlib_boolean(a: *const RawBrep, b: *const RawBrep, op: i32, out: *mut *mut RawBrep) -> i32;
fn smlib_boolean_union_all(breps: *const *const RawBrep, n: i64, out: *mut *mut RawBrep) -> i32;
fn smlib_boolean2d(a: *const RawBrep, b: *const RawBrep, op: i32, out: *mut *mut RawBrep) -> i32;
fn smlib_brep_solid_count(brep: *const RawBrep, out: *mut i64) -> i32;
fn smlib_imprint(body: *const RawBrep, tool: *const RawBrep, out: *mut *mut RawBrep) -> i32;
fn smlib_fillet(
brep: *const RawBrep,
points: *const f64,
n_points: i64,
tolerance: f64,
radius: f64,
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
) -> i32;
fn smlib_chamfer(
brep: *const RawBrep,
points: *const f64,
n_points: i64,
tolerance: f64,
distance: f64,
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
) -> i32;
fn smlib_blend(
brep: *const RawBrep,
kind: i32,
points: *const f64,
sizes: *const f64,
n_points: i64,
tolerance: f64,
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
) -> i32;
fn smlib_blend_fit_size(out_size: *mut f64, out_what: *mut i32) -> i32;
fn smlib_blend_dropping(
brep: *const RawBrep,
kind: i32,
points: *const f64,
sizes: *const f64,
n_points: i64,
tolerance: f64,
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
out_dropped: *mut i64,
) -> i32;
fn smlib_blend_setback(
brep: *const RawBrep,
kind: i32,
points: *const f64,
sizes: *const f64,
n_points: i64,
corners: *const f64,
setbacks: *const f64,
n_corners: i64,
tolerance: f64,
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
out_failed_corner: *mut i64,
) -> i32;
fn smlib_blend_setback_open(
brep: *const RawBrep,
kind: i32,
points: *const f64,
sizes: *const f64,
n_points: i64,
corners: *const f64,
setbacks: *const f64,
n_corners: i64,
tolerance: f64,
out: *mut *mut RawBrep,
holes: *mut i64,
cap: i64,
out_len: *mut i64,
out_failed_point: *mut i64,
out_failed_corner: *mut i64,
) -> i32;
fn smlib_fillet_variable(
brep: *const RawBrep,
point: *const f64,
tolerance: f64,
start: *const f64,
degree: i32,
knots: *const f64,
n_knots: i64,
radii: *const f64,
n_radii: i64,
out: *mut *mut RawBrep,
) -> i32;
fn smlib_chamfer_sides(
brep: *const RawBrep,
points: *const f64,
n_points: i64,
tolerance: f64,
distance: f64,
second: f64,
second_is_angle: i32,
first: *const f64,
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
) -> i32;
fn smlib_shell(
brep: *const RawBrep,
points: *const f64,
n_points: i64,
tolerance: f64,
thickness: f64,
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
) -> i32;
fn smlib_offset_faces(
brep: *const RawBrep,
points: *const f64,
n_points: i64,
tolerance: f64,
distance: f64,
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
) -> i32;
fn smlib_thicken(brep: *const RawBrep, thickness: f64, symmetric: i32, out: *mut *mut RawBrep) -> i32;
fn smlib_merge_faces(
brep: *const RawBrep,
keep: *const f64,
n_keep: i64,
tolerance: f64,
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
) -> i32;
fn smlib_draft(
brep: *const RawBrep,
points: *const f64,
n_points: i64,
tolerance: f64,
neutral_origin: *const f64,
neutral_normal: *const f64,
pull: *const f64,
angle: f64,
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
) -> i32;
}
#[cfg(feature = "native")]
pub fn boolean(a: &Handle, b: &Handle, op: i32) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_boolean(a.raw(), b.raw(), op, out) })
}
#[cfg(feature = "native")]
pub fn boolean_union_all(breps: &[&Handle]) -> Result<Handle, i32> {
let raw: Vec<*const RawBrep> = breps.iter().map(|h| h.raw()).collect();
let n = i64::try_from(raw.len()).map_err(|_| ERR_ARG)?;
new_brep(|out| unsafe { smlib_boolean_union_all(raw.as_ptr(), n, out) })
}
#[cfg(feature = "native")]
pub fn boolean2d(a: &Handle, b: &Handle, op: i32) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_boolean2d(a.raw(), b.raw(), op, out) })
}
#[cfg(feature = "native")]
pub fn imprint(body: &Handle, tool: &Handle) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_imprint(body.raw(), tool.raw(), out) })
}
#[cfg(feature = "native")]
pub fn solid_count(b: &Handle) -> Result<i64, i32> {
let mut n = 0i64;
match unsafe { smlib_brep_solid_count(b.raw(), &mut n) } {
OK => Ok(n),
s => Err(s),
}
}
/// Runs a selection operation; `points` are passed as the flat x, y, z array the C API reads.
#[cfg(feature = "native")]
pub fn pick_op(kind: Pick, b: &Handle, points: &[[f64; 3]], tolerance: f64, size: f64) -> Result<Handle, PickError> {
let n = i64::try_from(points.len()).map_err(|_| (ERR_ARG, None))?;
let f: PickFn = match kind {
Pick::Fillet => smlib_fillet,
Pick::Chamfer => smlib_chamfer,
Pick::Shell => smlib_shell,
Pick::OffsetFaces => smlib_offset_faces,
};
let flat = points.as_ptr().cast::<f64>();
let mut failed = -1i64;
new_brep(|out| unsafe { f(b.raw(), flat, n, tolerance, size, out, &mut failed) }).map_err(|s| {
let at = (s == ERR_PICK_NONE || s == ERR_PICK_AMBIGUOUS).then(|| usize::try_from(failed).ok()).flatten();
(s, at)
})
}
/// `smlib_blend_fit_size`: the size that builds, and what it sizes (`FIT_*`), for the last blend
/// call SMLib refused as too large or a setback as too short; `None` where it names none.
#[cfg(feature = "native")]
pub fn blend_fit_size() -> Option<(f64, i32)> {
let (mut size, mut what) = (0.0f64, -1i32);
(unsafe { smlib_blend_fit_size(&mut size, &mut what) } == OK).then_some((size, what))
}
#[cfg(not(feature = "native"))]
pub fn blend_fit_size() -> Option<(f64, i32)> {
None
}
#[cfg(feature = "native")]
pub fn thicken(b: &Handle, thickness: f64, symmetric: bool) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_thicken(b.raw(), thickness, i32::from(symmetric), out) })
}
#[cfg(not(feature = "native"))]
pub fn thicken(_: &Handle, _: f64, _: bool) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
/// `smlib_draft`: the faces under `points` tilted by `angle` radians about their lines on the
/// neutral plane (`origin`, `normal`), leaning in along `pull`.
#[cfg(feature = "native")]
pub fn draft(
b: &Handle,
points: &[[f64; 3]],
tolerance: f64,
origin: &[f64; 3],
normal: &[f64; 3],
pull: &[f64; 3],
angle: f64,
) -> Result<Handle, PickError> {
let n = i64::try_from(points.len()).map_err(|_| (ERR_ARG, None))?;
let flat = points.as_ptr().cast::<f64>();
let mut failed = -1i64;
new_brep(|out| unsafe { smlib_draft(b.raw(), flat, n, tolerance, origin.as_ptr(), normal.as_ptr(), pull.as_ptr(), angle, out, &mut failed) })
.map_err(|s| {
let at = (s == ERR_PICK_NONE || s == ERR_PICK_AMBIGUOUS).then(|| usize::try_from(failed).ok()).flatten();
(s, at)
})
}
#[cfg(not(feature = "native"))]
pub fn draft(_: &Handle, _: &[[f64; 3]], _: f64, _: &[f64; 3], _: &[f64; 3], _: &[f64; 3], _: f64) -> Result<Handle, PickError> {
Err((NOT_BUILT, None))
}
/// `smlib_merge_faces`: the body's topological edges and vertices removed, the edges under
/// `keep` kept.
#[cfg(feature = "native")]
pub fn merge_faces(b: &Handle, keep: &[[f64; 3]], tolerance: f64) -> Result<Handle, PickError> {
let n = i64::try_from(keep.len()).map_err(|_| (ERR_ARG, None))?;
let flat = keep.as_ptr().cast::<f64>();
let mut failed = -1i64;
new_brep(|out| unsafe { smlib_merge_faces(b.raw(), flat, n, tolerance, out, &mut failed) }).map_err(|s| {
let at = (s == ERR_PICK_NONE || s == ERR_PICK_AMBIGUOUS).then(|| usize::try_from(failed).ok()).flatten();
(s, at)
})
}
#[cfg(not(feature = "native"))]
pub fn merge_faces(_: &Handle, _: &[[f64; 3]], _: f64) -> Result<Handle, PickError> {
Err((NOT_BUILT, None))
}
/// `smlib_blend`: `sizes` holds one size per point (the caller checks the lengths match).
#[cfg(feature = "native")]
pub fn blend(kind: i32, b: &Handle, points: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<Handle, PickError> {
let n = i64::try_from(points.len()).map_err(|_| (ERR_ARG, None))?;
if sizes.len() != points.len() {
return Err((ERR_ARG, None));
}
let flat = points.as_ptr().cast::<f64>();
let mut failed = -1i64;
new_brep(|out| unsafe { smlib_blend(b.raw(), kind, flat, sizes.as_ptr(), n, tolerance, out, &mut failed) }).map_err(|s| {
let at = (s == ERR_PICK_NONE || s == ERR_PICK_AMBIGUOUS).then(|| usize::try_from(failed).ok()).flatten();
(s, at)
})
}
/// `smlib_blend_setback`: `sizes` holds one size per point and `setbacks` one distance per
/// corner (the caller checks the lengths match). A failed pick names its point; a corner that
/// picks no vertex or several comes back with no index.
#[cfg(feature = "native")]
pub fn blend_setback(
kind: i32,
b: &Handle,
points: &[[f64; 3]],
sizes: &[f64],
corners: &[[f64; 3]],
setbacks: &[f64],
tolerance: f64,
) -> Result<Handle, SetbackError> {
let n = i64::try_from(points.len()).map_err(|_| (ERR_ARG, None, None))?;
let nc = i64::try_from(corners.len()).map_err(|_| (ERR_ARG, None, None))?;
if sizes.len() != points.len() || setbacks.len() != corners.len() {
return Err((ERR_ARG, None, None));
}
let flat = points.as_ptr().cast::<f64>();
let flat_corners = corners.as_ptr().cast::<f64>();
let (mut failed, mut failed_corner) = (-1i64, -1i64);
new_brep(|out| unsafe {
smlib_blend_setback(
b.raw(),
kind,
flat,
sizes.as_ptr(),
n,
flat_corners,
setbacks.as_ptr(),
nc,
tolerance,
out,
&mut failed,
&mut failed_corner,
)
})
.map_err(|s| setback_error(s, failed, failed_corner))
}
#[cfg(not(feature = "native"))]
pub fn blend_setback(_: i32, _: &Handle, _: &[[f64; 3]], _: &[f64], _: &[[f64; 3]], _: &[f64], _: f64) -> Result<Handle, SetbackError> {
Err((NOT_BUILT, None, None))
}
/// `smlib_blend_setback_open`: the sheet and the holes' values as the C function writes them
/// (per corner: n, then n (edge, face, forward) triples). The buffer starts at room for 64
/// edges a corner and is grown once to the length a first call names.
#[cfg(feature = "native")]
pub fn blend_setback_open(
kind: i32,
b: &Handle,
points: &[[f64; 3]],
sizes: &[f64],
corners: &[[f64; 3]],
setbacks: &[f64],
tolerance: f64,
) -> Result<(Handle, Vec<i64>), SetbackError> {
let n = i64::try_from(points.len()).map_err(|_| (ERR_ARG, None, None))?;
let nc = i64::try_from(corners.len()).map_err(|_| (ERR_ARG, None, None))?;
if sizes.len() != points.len() || setbacks.len() != corners.len() {
return Err((ERR_ARG, None, None));
}
let flat = points.as_ptr().cast::<f64>();
let flat_corners = corners.as_ptr().cast::<f64>();
let mut holes = vec![0i64; corners.len().saturating_mul(1 + 3 * 64)];
for _ in 0..2 {
let cap = i64::try_from(holes.len()).map_err(|_| (ERR_ARG, None, None))?;
let (mut failed, mut failed_corner, mut len) = (-1i64, -1i64, -1i64);
let made = new_brep(|out| unsafe {
smlib_blend_setback_open(
b.raw(),
kind,
flat,
sizes.as_ptr(),
n,
flat_corners,
setbacks.as_ptr(),
nc,
tolerance,
out,
holes.as_mut_ptr(),
cap,
&mut len,
&mut failed,
&mut failed_corner,
)
});
match made {
Ok(h) => {
holes.truncate(usize::try_from(len).map_err(|_| (ERR_BUFFER, None, None))?);
return Ok((h, holes));
}
Err(ERR_BUFFER) if len > cap => holes.resize(usize::try_from(len).map_err(|_| (ERR_BUFFER, None, None))?, 0),
Err(s) => return Err(setback_error(s, failed, failed_corner)),
}
}
Err((ERR_BUFFER, None, None))
}
#[cfg(not(feature = "native"))]
pub fn blend_setback_open(
_: i32,
_: &Handle,
_: &[[f64; 3]],
_: &[f64],
_: &[[f64; 3]],
_: &[f64],
_: f64,
) -> Result<(Handle, Vec<i64>), SetbackError> {
Err((NOT_BUILT, None, None))
}
/// `smlib_fillet_variable`.
#[cfg(feature = "native")]
pub fn fillet_variable(
b: &Handle,
point: &[f64; 3],
tolerance: f64,
start: &[f64; 3],
degree: i32,
knots: &[f64],
radii: &[f64],
) -> Result<Handle, i32> {
let nk = i64::try_from(knots.len()).map_err(|_| ERR_ARG)?;
let nr = i64::try_from(radii.len()).map_err(|_| ERR_ARG)?;
new_brep(|out| unsafe {
smlib_fillet_variable(b.raw(), point.as_ptr(), tolerance, start.as_ptr(), degree, knots.as_ptr(), nk, radii.as_ptr(), nr, out)
})
}
/// `smlib_chamfer_sides`.
#[cfg(feature = "native")]
pub fn chamfer_sides(
b: &Handle,
points: &[[f64; 3]],
tolerance: f64,
distance: f64,
second: f64,
angle: bool,
first: &[f64; 3],
) -> Result<Handle, PickError> {
let n = i64::try_from(points.len()).map_err(|_| (ERR_ARG, None))?;
let flat = points.as_ptr().cast::<f64>();
let mut failed = -1i64;
new_brep(|out| unsafe { smlib_chamfer_sides(b.raw(), flat, n, tolerance, distance, second, i32::from(angle), first.as_ptr(), out, &mut failed) })
.map_err(|s| {
let at = (s == ERR_PICK_NONE || s == ERR_PICK_AMBIGUOUS).then(|| usize::try_from(failed).ok()).flatten();
(s, at)
})
}
#[cfg(not(feature = "native"))]
pub fn chamfer_sides(_: &Handle, _: &[[f64; 3]], _: f64, _: f64, _: f64, _: bool, _: &[f64; 3]) -> Result<Handle, PickError> {
Err((NOT_BUILT, None))
}
/// `smlib_blend_dropping`: `smlib_blend`, with whether each point picked an edge a larger,
/// overflowing round removed whole (and so not rounded).
#[cfg(feature = "native")]
pub fn blend_dropping(kind: i32, b: &Handle, points: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<(Handle, Vec<bool>), PickError> {
let n = i64::try_from(points.len()).map_err(|_| (ERR_ARG, None))?;
if sizes.len() != points.len() {
return Err((ERR_ARG, None));
}
let flat = points.as_ptr().cast::<f64>();
let mut failed = -1i64;
let mut dropped = vec![0i64; points.len()];
let out =
new_brep(|out| unsafe { smlib_blend_dropping(b.raw(), kind, flat, sizes.as_ptr(), n, tolerance, out, &mut failed, dropped.as_mut_ptr()) })
.map_err(|s| {
let at = (s == ERR_PICK_NONE || s == ERR_PICK_AMBIGUOUS).then(|| usize::try_from(failed).ok()).flatten();
(s, at)
})?;
Ok((out, dropped.iter().map(|d| *d != 0).collect()))
}
#[cfg(not(feature = "native"))]
pub fn blend_dropping(_: i32, _: &Handle, _: &[[f64; 3]], _: &[f64], _: f64) -> Result<(Handle, Vec<bool>), PickError> {
Err((NOT_BUILT, None))
}
#[cfg(not(feature = "native"))]
pub fn blend(_: i32, _: &Handle, _: &[[f64; 3]], _: &[f64], _: f64) -> Result<Handle, PickError> {
Err((NOT_BUILT, None))
}
#[cfg(not(feature = "native"))]
pub fn fillet_variable(_: &Handle, _: &[f64; 3], _: f64, _: &[f64; 3], _: i32, _: &[f64], _: &[f64]) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn boolean(_: &Handle, _: &Handle, _: i32) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn boolean_union_all(_: &[&Handle]) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn boolean2d(_: &Handle, _: &Handle, _: i32) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn imprint(_: &Handle, _: &Handle) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn solid_count(_: &Handle) -> Result<i64, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn pick_op(_: Pick, _: &Handle, _: &[[f64; 3]], _: f64, _: f64) -> Result<Handle, PickError> {
Err((NOT_BUILT, None))
}
-40
View File
@@ -1,40 +0,0 @@
//! The sheets block of `capi/include/smlib.h` (`smlib_stitch`, `smlib_offset_sheet`; the fork's
//! `capi/src/smlib_sheets.cpp`), declared by hand. Part of the FFI boundary, like its parent:
//! called only through [`crate::sheets`].
#![allow(unsafe_code)]
use super::Handle;
#[cfg(not(feature = "native"))]
use super::NOT_BUILT;
#[cfg(feature = "native")]
use super::{RawBrep, new_brep};
#[cfg(feature = "native")]
unsafe extern "C" {
fn smlib_stitch(brep: *const RawBrep, tolerance: f64, out_closed: *mut i32, out: *mut *mut RawBrep) -> i32;
fn smlib_offset_sheet(sheet: *const RawBrep, distance: f64, out: *mut *mut RawBrep) -> i32;
}
/// The stitched body and whether it closed into a solid.
#[cfg(feature = "native")]
pub fn stitch(b: &Handle, tolerance: f64) -> Result<(Handle, bool), i32> {
let mut closed = 0i32;
let h = new_brep(|out| unsafe { smlib_stitch(b.raw(), tolerance, &mut closed, out) })?;
Ok((h, closed != 0))
}
#[cfg(feature = "native")]
pub fn offset_sheet(b: &Handle, distance: f64) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_offset_sheet(b.raw(), distance, out) })
}
#[cfg(not(feature = "native"))]
pub fn stitch(_: &Handle, _: f64) -> Result<(Handle, bool), i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn offset_sheet(_: &Handle, _: f64) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
-96
View File
@@ -1,96 +0,0 @@
//! The split block of `capi/include/smlib.h` (`smlib_split_faces`, `smlib_split_body`,
//! `smlib_split_body_by_sheet`; the
//! fork's `capi/src/smlib_split.cpp`), declared by hand. Part of the FFI boundary, like its
//! parent: called only through [`crate::split`].
#![allow(unsafe_code)]
use super::Handle;
#[cfg(not(feature = "native"))]
use super::NOT_BUILT;
use super::ssi::SurfaceHandle;
#[cfg(feature = "native")]
use super::{ERR_ARG, ERR_EMPTY, OK, RawBrep, new_brep, ssi::RawSurface};
/// `SMLIB_SPLIT_MAX_FACES`, `SMLIB_SPLIT_MAX_TOOLS`.
pub const MAX_FACES: usize = 100_000;
pub const MAX_TOOLS: usize = 4096;
#[cfg(feature = "native")]
unsafe extern "C" {
fn smlib_split_faces(
body: *const RawBrep,
faces: *const i64,
n_faces: i64,
tools: *const *const RawSurface,
n_tools: i64,
out: *mut *mut RawBrep,
made: *mut i64,
) -> i32;
fn smlib_split_body(body: *const RawBrep, tool: *const RawSurface, back: *mut *mut RawBrep, front: *mut *mut RawBrep) -> i32;
fn smlib_split_body_by_sheet(body: *const RawBrep, sheet: *const RawBrep, face: i64, back: *mut *mut RawBrep, front: *mut *mut RawBrep) -> i32;
}
/// `body` with `faces` cut by `tools` in turn, and per face how many faces it became besides
/// itself.
#[cfg(feature = "native")]
pub fn split_faces(body: &Handle, faces: &[i64], tools: &[&SurfaceHandle]) -> Result<(Handle, Vec<i64>), i32> {
if faces.is_empty() || faces.len() > MAX_FACES || tools.is_empty() || tools.len() > MAX_TOOLS {
return Err(ERR_ARG);
}
let raw: Vec<*const RawSurface> = tools.iter().map(|t| t.raw()).collect();
let mut made = vec![0i64; faces.len()];
let n = i64::try_from(faces.len()).map_err(|_| ERR_ARG)?;
let m = i64::try_from(raw.len()).map_err(|_| ERR_ARG)?;
// SAFETY: the arrays outlive the call and hold `n` and `m` entries; the handles are live.
let h = new_brep(|out| unsafe { smlib_split_faces(body.raw(), faces.as_ptr(), n, raw.as_ptr(), m, out, made.as_mut_ptr()) })?;
Ok((h, made))
}
/// The parts of `body` behind and in front of `tool`.
#[cfg(feature = "native")]
pub fn split_body(body: &Handle, tool: &SurfaceHandle) -> Result<(Handle, Handle), i32> {
// SAFETY: the handles are live; the out-pointers are written only on success.
two_parts(|back, front| unsafe { smlib_split_body(body.raw(), tool.raw(), back, front) })
}
/// The parts of `body` behind and in front of `sheet`, its sides those of its face `face`.
#[cfg(feature = "native")]
pub fn split_body_by_sheet(body: &Handle, sheet: &Handle, face: i64) -> Result<(Handle, Handle), i32> {
// SAFETY: the handles are live; the out-pointers are written only on success.
two_parts(|back, front| unsafe { smlib_split_body_by_sheet(body.raw(), sheet.raw(), face, back, front) })
}
/// The two bodies a call writing a back and a front part gives, owned.
#[cfg(feature = "native")]
fn two_parts(call: impl FnOnce(*mut *mut RawBrep, *mut *mut RawBrep) -> i32) -> Result<(Handle, Handle), i32> {
let mut back: *mut RawBrep = std::ptr::null_mut();
let mut front: *mut RawBrep = std::ptr::null_mut();
let s = call(&mut back, &mut front);
let (b, f) = (std::ptr::NonNull::new(back), std::ptr::NonNull::new(front));
match (s, b, f) {
(OK, Some(b), Some(f)) => Ok((Handle(b), Handle(f))),
(OK, b, f) => {
// Free whichever came back alone.
drop(b.map(Handle));
drop(f.map(Handle));
Err(ERR_EMPTY)
}
(s, _, _) => Err(s),
}
}
#[cfg(not(feature = "native"))]
pub fn split_faces(_: &Handle, _: &[i64], _: &[&SurfaceHandle]) -> Result<(Handle, Vec<i64>), i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn split_body(_: &Handle, _: &SurfaceHandle) -> Result<(Handle, Handle), i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn split_body_by_sheet(_: &Handle, _: &Handle, _: i64) -> Result<(Handle, Handle), i32> {
Err(NOT_BUILT)
}
-378
View File
@@ -1,378 +0,0 @@
//! The surface/surface intersection block of `capi/include/smlib.h` (`smlib_surface_*`,
//! `smlib_ssi*`), declared by hand. Part of the FFI boundary, like its parent: called only
//! through [`crate::ssi`].
#![allow(unsafe_code)]
/// `smlib_surface`, opaque.
#[repr(C)]
pub struct RawSurface {
_private: [u8; 0],
}
/// `smlib_ssi_result`, opaque.
#[repr(C)]
pub struct RawResult {
_private: [u8; 0],
}
#[cfg(feature = "native")]
unsafe extern "C" {
fn smlib_surface_plane(origin: *const f64, xaxis: *const f64, yaxis: *const f64, u: *const f64, v: *const f64, out: *mut *mut RawSurface) -> i32;
fn smlib_surface_cylinder(
origin: *const f64,
xaxis: *const f64,
yaxis: *const f64,
radius: f64,
angle_deg: *const f64,
height: *const f64,
out: *mut *mut RawSurface,
) -> i32;
fn smlib_surface_cone(
apex: *const f64,
xaxis: *const f64,
yaxis: *const f64,
half_angle_deg: f64,
height: *const f64,
out: *mut *mut RawSurface,
) -> i32;
fn smlib_surface_sphere(
center: *const f64,
xaxis: *const f64,
yaxis: *const f64,
radius: f64,
lon_deg: *const f64,
lat_deg: *const f64,
out: *mut *mut RawSurface,
) -> i32;
fn smlib_surface_torus(
center: *const f64,
xaxis: *const f64,
yaxis: *const f64,
major_radius: f64,
minor_radius: f64,
major_deg: *const f64,
minor_deg: *const f64,
out: *mut *mut RawSurface,
) -> i32;
fn smlib_surface_nurbs(
degree_u: i32,
degree_v: i32,
nu: i32,
nv: i32,
knots_u: *const f64,
n_knots_u: i64,
knots_v: *const f64,
n_knots_v: i64,
points: *const f64,
weights: *const f64,
out: *mut *mut RawSurface,
) -> i32;
fn smlib_surface_free(s: *mut RawSurface);
fn smlib_surface_domain(s: *const RawSurface, out: *mut f64) -> i32;
fn smlib_surface_evaluate(s: *const RawSurface, u: f64, v: f64, out: *mut f64) -> i32;
fn smlib_ssi(a: *const RawSurface, b: *const RawSurface, method: i32, approx_tol: f64, out: *mut *mut RawResult) -> i32;
fn smlib_ssi_result_free(r: *mut RawResult);
fn smlib_ssi_curve_count(r: *const RawResult, out: *mut i32) -> i32;
fn smlib_ssi_curve_info(r: *const RawResult, i: i32, ty: *mut i32, flags: *mut i32, deviation: *mut f64, interval: *mut f64) -> i32;
fn smlib_ssi_curve_nurbs(
r: *const RawResult,
i: i32,
which: i32,
degree: *mut i32,
n_points: *mut i64,
n_knots: *mut i64,
cap_points: i64,
points: *mut f64,
weights: *mut f64,
cap_knots: i64,
knots: *mut f64,
) -> i32;
fn smlib_ssi_point_count(r: *const RawResult, out: *mut i32) -> i32;
fn smlib_ssi_point(r: *const RawResult, i: i32, xyz: *mut f64, uv_a: *mut f64, uv_b: *mut f64) -> i32;
}
/// An owned `smlib_surface`, freed once on drop.
#[cfg_attr(not(feature = "native"), allow(dead_code))]
pub struct SurfaceHandle(std::ptr::NonNull<RawSurface>);
/// An owned `smlib_ssi_result`, freed once on drop.
#[cfg_attr(not(feature = "native"), allow(dead_code))]
pub struct ResultHandle(std::ptr::NonNull<RawResult>);
// Only ever used through the C API, which serialises every call.
unsafe impl Send for SurfaceHandle {}
unsafe impl Sync for SurfaceHandle {}
unsafe impl Send for ResultHandle {}
unsafe impl Sync for ResultHandle {}
impl SurfaceHandle {
#[cfg_attr(not(feature = "native"), allow(dead_code))]
pub(crate) fn raw(&self) -> *const RawSurface {
self.0.as_ptr()
}
}
impl Drop for SurfaceHandle {
fn drop(&mut self) {
#[cfg(feature = "native")]
unsafe {
smlib_surface_free(self.0.as_ptr())
};
}
}
impl Drop for ResultHandle {
fn drop(&mut self) {
#[cfg(feature = "native")]
unsafe {
smlib_ssi_result_free(self.0.as_ptr())
};
}
}
/// One curve's `smlib_ssi_curve_info`.
#[derive(Clone, Copy, Debug)]
pub struct CurveInfo {
pub ty: i32,
pub deviation: f64,
pub interval: [f64; 2],
}
/// `smlib_ssi_curve_nurbs` `which`: the 3D curve, the UV curve on a, on b.
pub const CURVE_3D: i32 = 0;
pub const CURVE_UV_A: i32 = 1;
pub const CURVE_UV_B: i32 = 2;
/// One B-spline as `smlib_ssi_curve_nurbs` gives it: cartesian control points, `dim` values each,
/// one weight each, and every knot (as many as points + degree + 1).
#[derive(Clone, Debug, Default)]
pub struct Nurbs {
pub points: Vec<f64>,
pub weights: Vec<f64>,
pub knots: Vec<f64>,
}
#[cfg(feature = "native")]
mod calls {
use super::*;
use crate::ffi::{ERR_ARG, ERR_EMPTY, OK};
fn new_surface(f: impl FnOnce(*mut *mut RawSurface) -> i32) -> Result<SurfaceHandle, i32> {
let mut out: *mut RawSurface = std::ptr::null_mut();
match (f(&mut out), std::ptr::NonNull::new(out)) {
(OK, Some(p)) => Ok(SurfaceHandle(p)),
(OK, None) => Err(ERR_EMPTY),
(s, _) => Err(s),
}
}
pub fn plane(origin: &[f64; 3], xaxis: &[f64; 3], yaxis: &[f64; 3], u: &[f64; 2], v: &[f64; 2]) -> Result<SurfaceHandle, i32> {
new_surface(|out| unsafe { smlib_surface_plane(origin.as_ptr(), xaxis.as_ptr(), yaxis.as_ptr(), u.as_ptr(), v.as_ptr(), out) })
}
pub fn cylinder(
origin: &[f64; 3],
xaxis: &[f64; 3],
yaxis: &[f64; 3],
radius: f64,
angle_deg: &[f64; 2],
height: &[f64; 2],
) -> Result<SurfaceHandle, i32> {
new_surface(|out| unsafe {
smlib_surface_cylinder(origin.as_ptr(), xaxis.as_ptr(), yaxis.as_ptr(), radius, angle_deg.as_ptr(), height.as_ptr(), out)
})
}
pub fn cone(apex: &[f64; 3], xaxis: &[f64; 3], yaxis: &[f64; 3], half_angle_deg: f64, height: &[f64; 2]) -> Result<SurfaceHandle, i32> {
new_surface(|out| unsafe { smlib_surface_cone(apex.as_ptr(), xaxis.as_ptr(), yaxis.as_ptr(), half_angle_deg, height.as_ptr(), out) })
}
pub fn sphere(
center: &[f64; 3],
xaxis: &[f64; 3],
yaxis: &[f64; 3],
radius: f64,
lon_deg: &[f64; 2],
lat_deg: &[f64; 2],
) -> Result<SurfaceHandle, i32> {
new_surface(|out| unsafe {
smlib_surface_sphere(center.as_ptr(), xaxis.as_ptr(), yaxis.as_ptr(), radius, lon_deg.as_ptr(), lat_deg.as_ptr(), out)
})
}
pub fn torus(
center: &[f64; 3],
xaxis: &[f64; 3],
yaxis: &[f64; 3],
major_radius: f64,
minor_radius: f64,
major_deg: &[f64; 2],
minor_deg: &[f64; 2],
) -> Result<SurfaceHandle, i32> {
new_surface(|out| unsafe {
smlib_surface_torus(
center.as_ptr(),
xaxis.as_ptr(),
yaxis.as_ptr(),
major_radius,
minor_radius,
major_deg.as_ptr(),
minor_deg.as_ptr(),
out,
)
})
}
/// `points` holds `nu * nv` points (3 values each), `weights` `nu * nv` or none; the C side
/// checks every count against the degrees and sizes.
pub fn nurbs(
degree: [i32; 2],
nu: i32,
nv: i32,
knots_u: &[f64],
knots_v: &[f64],
points: &[f64],
weights: Option<&[f64]>,
) -> Result<SurfaceHandle, i32> {
let n = i64::from(nu).checked_mul(i64::from(nv)).ok_or(ERR_ARG)?;
let fits = |len: usize, per: i64| n.checked_mul(per).is_some_and(|need| i64::try_from(len).is_ok_and(|l| l == need));
if !fits(points.len(), 3) || weights.is_some_and(|w| !fits(w.len(), 1)) {
return Err(ERR_ARG);
}
let ku = i64::try_from(knots_u.len()).map_err(|_| ERR_ARG)?;
let kv = i64::try_from(knots_v.len()).map_err(|_| ERR_ARG)?;
let w = weights.map_or(std::ptr::null(), <[f64]>::as_ptr);
new_surface(|out| unsafe {
smlib_surface_nurbs(degree[0], degree[1], nu, nv, knots_u.as_ptr(), ku, knots_v.as_ptr(), kv, points.as_ptr(), w, out)
})
}
pub fn domain(s: &SurfaceHandle) -> Result<[f64; 4], i32> {
let mut d = [0.0; 4];
match unsafe { smlib_surface_domain(s.0.as_ptr(), d.as_mut_ptr()) } {
OK => Ok(d),
st => Err(st),
}
}
pub fn evaluate(s: &SurfaceHandle, u: f64, v: f64) -> Result<[f64; 3], i32> {
let mut p = [0.0; 3];
match unsafe { smlib_surface_evaluate(s.0.as_ptr(), u, v, p.as_mut_ptr()) } {
OK => Ok(p),
st => Err(st),
}
}
pub fn ssi(a: &SurfaceHandle, b: &SurfaceHandle, method: i32, approx_tol: f64) -> Result<ResultHandle, i32> {
let mut out: *mut RawResult = std::ptr::null_mut();
match (unsafe { smlib_ssi(a.0.as_ptr(), b.0.as_ptr(), method, approx_tol, &mut out) }, std::ptr::NonNull::new(out)) {
(OK, Some(p)) => Ok(ResultHandle(p)),
(OK, None) => Err(ERR_EMPTY),
(s, _) => Err(s),
}
}
pub fn curve_count(r: &ResultHandle) -> Result<i32, i32> {
let mut n = 0;
match unsafe { smlib_ssi_curve_count(r.0.as_ptr(), &mut n) } {
OK => Ok(n),
s => Err(s),
}
}
pub fn curve_info(r: &ResultHandle, i: i32) -> Result<CurveInfo, i32> {
let mut c = CurveInfo { ty: 0, deviation: 0.0, interval: [0.0; 2] };
match unsafe { smlib_ssi_curve_info(r.0.as_ptr(), i, &mut c.ty, std::ptr::null_mut(), &mut c.deviation, c.interval.as_mut_ptr()) } {
OK => Ok(c),
s => Err(s),
}
}
/// Curve `i`'s `which` B-spline, `dim` coordinates per control point: the counts first, then
/// the arrays.
pub fn curve_nurbs(r: &ResultHandle, i: i32, which: i32, dim: usize) -> Result<Nurbs, i32> {
let (mut degree, mut np, mut nk) = (0i32, 0i64, 0i64);
let null = std::ptr::null_mut();
let st = unsafe { smlib_ssi_curve_nurbs(r.0.as_ptr(), i, which, &mut degree, &mut np, &mut nk, 0, null, null, 0, null) };
if st != OK {
return Err(st);
}
let (n, k) = (usize::try_from(np).map_err(|_| ERR_ARG)?, usize::try_from(nk).map_err(|_| ERR_ARG)?);
let degree = usize::try_from(degree).map_err(|_| ERR_ARG)?;
if !(n > degree && k == n + degree + 1) {
return Err(ERR_ARG);
}
let mut c = Nurbs { points: vec![0.0; n.checked_mul(dim).ok_or(ERR_ARG)?], weights: vec![0.0; n], knots: vec![0.0; k] };
let st = unsafe {
smlib_ssi_curve_nurbs(
r.0.as_ptr(),
i,
which,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
np,
c.points.as_mut_ptr(),
c.weights.as_mut_ptr(),
nk,
c.knots.as_mut_ptr(),
)
};
if st == OK { Ok(c) } else { Err(st) }
}
pub fn point_count(r: &ResultHandle) -> Result<i32, i32> {
let mut n = 0;
match unsafe { smlib_ssi_point_count(r.0.as_ptr(), &mut n) } {
OK => Ok(n),
s => Err(s),
}
}
pub fn point(r: &ResultHandle, i: i32) -> Result<([f64; 3], [f64; 2], [f64; 2]), i32> {
let (mut p, mut a, mut b) = ([0.0; 3], [0.0; 2], [0.0; 2]);
match unsafe { smlib_ssi_point(r.0.as_ptr(), i, p.as_mut_ptr(), a.as_mut_ptr(), b.as_mut_ptr()) } {
OK => Ok((p, a, b)),
s => Err(s),
}
}
}
/// The same calls when SMLib is not built: each one fails with `NOT_BUILT`.
#[cfg(not(feature = "native"))]
mod calls {
use super::*;
use crate::ffi::NOT_BUILT;
pub fn plane(_: &[f64; 3], _: &[f64; 3], _: &[f64; 3], _: &[f64; 2], _: &[f64; 2]) -> Result<SurfaceHandle, i32> {
Err(NOT_BUILT)
}
pub fn cylinder(_: &[f64; 3], _: &[f64; 3], _: &[f64; 3], _: f64, _: &[f64; 2], _: &[f64; 2]) -> Result<SurfaceHandle, i32> {
Err(NOT_BUILT)
}
pub fn cone(_: &[f64; 3], _: &[f64; 3], _: &[f64; 3], _: f64, _: &[f64; 2]) -> Result<SurfaceHandle, i32> {
Err(NOT_BUILT)
}
pub fn sphere(_: &[f64; 3], _: &[f64; 3], _: &[f64; 3], _: f64, _: &[f64; 2], _: &[f64; 2]) -> Result<SurfaceHandle, i32> {
Err(NOT_BUILT)
}
pub fn torus(_: &[f64; 3], _: &[f64; 3], _: &[f64; 3], _: f64, _: f64, _: &[f64; 2], _: &[f64; 2]) -> Result<SurfaceHandle, i32> {
Err(NOT_BUILT)
}
pub fn nurbs(_: [i32; 2], _: i32, _: i32, _: &[f64], _: &[f64], _: &[f64], _: Option<&[f64]>) -> Result<SurfaceHandle, i32> {
Err(NOT_BUILT)
}
pub fn domain(_: &SurfaceHandle) -> Result<[f64; 4], i32> {
Err(NOT_BUILT)
}
pub fn evaluate(_: &SurfaceHandle, _: f64, _: f64) -> Result<[f64; 3], i32> {
Err(NOT_BUILT)
}
pub fn ssi(_: &SurfaceHandle, _: &SurfaceHandle, _: i32, _: f64) -> Result<ResultHandle, i32> {
Err(NOT_BUILT)
}
pub fn curve_count(_: &ResultHandle) -> Result<i32, i32> {
Err(NOT_BUILT)
}
pub fn curve_info(_: &ResultHandle, _: i32) -> Result<CurveInfo, i32> {
Err(NOT_BUILT)
}
pub fn curve_nurbs(_: &ResultHandle, _: i32, _: i32, _: usize) -> Result<Nurbs, i32> {
Err(NOT_BUILT)
}
pub fn point_count(_: &ResultHandle) -> Result<i32, i32> {
Err(NOT_BUILT)
}
pub fn point(_: &ResultHandle, _: i32) -> Result<([f64; 3], [f64; 2], [f64; 2]), i32> {
Err(NOT_BUILT)
}
}
pub use calls::*;
-431
View File
@@ -1,431 +0,0 @@
//! The sweeps block of `capi/include/smlib.h` (`smlib_extrude`, `smlib_revolve`, `smlib_sweep`,
//! `smlib_sweep_placed`, `smlib_project_curves`, `smlib_face_section` (with `smlib_curves_*`), `smlib_loft`, `smlib_loft_to_point`, `smlib_screw_sweep`, `smlib_screw_sweep_radial`,
//! `smlib_sweep_stations`, `smlib_planar_faces`; the fork's
//! `capi/src/smlib_sweeps.cpp`), declared by hand.
//! Part of the FFI boundary, like its parent: called only through [`crate::sweeps`], which builds
//! the curve lists.
#![allow(unsafe_code)]
use super::Handle;
#[cfg(not(feature = "native"))]
use super::NOT_BUILT;
#[cfg(feature = "native")]
use super::{ERR_ARG, RawBrep, new_brep};
pub const CURVE_LINE: i64 = 1;
pub const CURVE_ARC: i64 = 2;
pub const CURVE_NURBS: i64 = 3;
pub const CURVE_MAX_CURVES: usize = 16_384;
pub const CURVE_MAX_DEGREE: usize = 25;
pub const CURVE_MAX_POINTS: usize = 100_000;
/// How a drafted extrusion closes a sharp corner (`SMLIB_DRAFT_CORNER_*`).
pub const DRAFT_CORNER_CURVATURE: i32 = 0;
pub const DRAFT_CORNER_TANGENT: i32 = 1;
pub const DRAFT_CORNER_ROUND: i32 = 2;
/// The continued curves do not meet at the corner (`SMLIB_ERR_CORNER_APART`).
pub const ERR_CORNER_APART: i32 = -1108;
/// A wall closes before the far end (`SMLIB_ERR_WALL_CLOSES`).
pub const ERR_WALL_CLOSES: i32 = -1109;
pub const SWEEP_FOLLOW: i32 = 0;
pub const SWEEP_TRANSLATE: i32 = 1;
pub const ENDS_OPEN: i32 = 0;
pub const ENDS_CAPPED: i32 = 1;
pub const ENDS_JOINED: i32 = 2;
pub const SWEEP_MAX_STATIONS: usize = 4096;
pub const SWEEP_MAX_STATION_CURVES: usize = 400_000;
/// A curve list as the C API reads it: kinds (with a NURBS curve's degree and point count) and
/// each curve's numbers.
#[cfg(feature = "native")]
pub struct List<'a> {
kinds: &'a [i64],
reals: &'a [f64],
}
/// Without SMLib nothing reads the list, so it carries nothing.
#[cfg(not(feature = "native"))]
pub struct List<'a>(std::marker::PhantomData<&'a ()>);
impl<'a> List<'a> {
#[cfg(feature = "native")]
pub fn new(kinds: &'a [i64], reals: &'a [f64]) -> Self {
List { kinds, reals }
}
#[cfg(not(feature = "native"))]
pub fn new(_: &'a [i64], _: &'a [f64]) -> Self {
List(std::marker::PhantomData)
}
}
#[cfg(feature = "native")]
unsafe extern "C" {
fn smlib_planar_faces(kinds: *const i64, n_kinds: i64, reals: *const f64, n_reals: i64, out: *mut *mut RawBrep) -> i32;
fn smlib_extrude(
kinds: *const i64,
n_kinds: i64,
reals: *const f64,
n_reals: i64,
direction: *const f64,
distance: f64,
draft: f64,
corner: i32,
cap: i32,
out: *mut *mut RawBrep,
out_corner: *mut f64,
) -> i32;
fn smlib_revolve(
kinds: *const i64,
n_kinds: i64,
reals: *const f64,
n_reals: i64,
origin: *const f64,
direction: *const f64,
angle: f64,
cap: i32,
out: *mut *mut RawBrep,
) -> i32;
fn smlib_sweep(
kinds: *const i64,
n_kinds: i64,
reals: *const f64,
n_reals: i64,
path_kinds: *const i64,
n_path_kinds: i64,
path_reals: *const f64,
n_path_reals: i64,
frame: i32,
cap: i32,
out: *mut *mut RawBrep,
) -> i32;
fn smlib_sweep_placed(
kinds: *const i64,
n_kinds: i64,
reals: *const f64,
n_reals: i64,
path_kinds: *const i64,
n_path_kinds: i64,
path_reals: *const f64,
n_path_reals: i64,
frame: i32,
cap: i32,
out: *mut *mut RawBrep,
placed: *mut f64,
) -> i32;
fn smlib_project_curves(
brep: *const RawBrep,
face_point: *const f64,
pick_tolerance: f64,
kinds: *const i64,
n_kinds: i64,
reals: *const f64,
n_reals: i64,
direction: *const f64,
tolerance: f64,
out: *mut *mut RawCurves,
) -> i32;
fn smlib_face_section(
brep: *const RawBrep,
face_point: *const f64,
pick_tolerance: f64,
plane_point: *const f64,
plane_normal: *const f64,
tolerance: f64,
out: *mut *mut RawCurves,
) -> i32;
fn smlib_curves_size(curves: *const RawCurves, n_kinds: *mut i64, n_reals: *mut i64, n_curves: *mut i64) -> i32;
fn smlib_curves_read(
curves: *const RawCurves,
kinds: *mut i64,
n_kinds: i64,
reals: *mut f64,
n_reals: i64,
intervals: *mut f64,
n_intervals: i64,
) -> i32;
fn smlib_curves_free(curves: *mut RawCurves);
fn smlib_loft(
kinds: *const i64,
n_kinds: i64,
reals: *const f64,
n_reals: i64,
section_sizes: *const i64,
n_sections: i64,
degree: i32,
cap: i32,
out: *mut *mut RawBrep,
) -> i32;
fn smlib_loft_to_point(kinds: *const i64, n_kinds: i64, reals: *const f64, n_reals: i64, apex: *const f64, out: *mut *mut RawBrep) -> i32;
fn smlib_screw_sweep(
kinds: *const i64,
n_kinds: i64,
reals: *const f64,
n_reals: i64,
origin: *const f64,
direction: *const f64,
pitch: f64,
turns: f64,
tolerance: f64,
out: *mut *mut RawBrep,
) -> i32;
fn smlib_screw_sweep_radial(
kinds: *const i64,
n_kinds: i64,
reals: *const f64,
n_reals: i64,
origin: *const f64,
direction: *const f64,
pitch: f64,
turns: f64,
law: *const f64,
n_law: i64,
tolerance: f64,
out: *mut *mut RawBrep,
) -> i32;
fn smlib_sweep_stations(
kinds: *const i64,
n_kinds: i64,
reals: *const f64,
n_reals: i64,
maps: *const f64,
n_stations: i64,
ends: i32,
out: *mut *mut RawBrep,
) -> i32;
}
/// The list's arrays and lengths as the C API takes them.
#[cfg(feature = "native")]
fn raw(l: &List) -> Result<(*const i64, i64, *const f64, i64), i32> {
let nk = i64::try_from(l.kinds.len()).map_err(|_| ERR_ARG)?;
let nr = i64::try_from(l.reals.len()).map_err(|_| ERR_ARG)?;
Ok((l.kinds.as_ptr(), nk, l.reals.as_ptr(), nr))
}
#[cfg(feature = "native")]
pub fn planar_faces(loops: &List) -> Result<Handle, i32> {
let (k, nk, r, nr) = raw(loops)?;
new_brep(|out| unsafe { smlib_planar_faces(k, nk, r, nr, out) })
}
#[cfg(feature = "native")]
pub fn extrude(profile: &List, direction: &[f64; 3], distance: f64, draft: f64, corner: i32, cap: bool, at: &mut [f64; 3]) -> Result<Handle, i32> {
let (k, nk, r, nr) = raw(profile)?;
new_brep(|out| unsafe { smlib_extrude(k, nk, r, nr, direction.as_ptr(), distance, draft, corner, i32::from(cap), out, at.as_mut_ptr()) })
}
#[cfg(feature = "native")]
pub fn revolve(profile: &List, origin: &[f64; 3], direction: &[f64; 3], angle: f64, cap: bool) -> Result<Handle, i32> {
let (k, nk, r, nr) = raw(profile)?;
new_brep(|out| unsafe { smlib_revolve(k, nk, r, nr, origin.as_ptr(), direction.as_ptr(), angle, i32::from(cap), out) })
}
#[cfg(feature = "native")]
pub fn sweep(profile: &List, path: &List, frame: i32, cap: bool) -> Result<Handle, i32> {
let (k, nk, r, nr) = raw(profile)?;
let (pk, npk, pr, npr) = raw(path)?;
new_brep(|out| unsafe { smlib_sweep(k, nk, r, nr, pk, npk, pr, npr, frame, i32::from(cap), out) })
}
/// [`sweep`], with the three rigid motions placing the profile (12 numbers each: a matrix by
/// columns, then a translation) written to `placed`.
#[cfg(feature = "native")]
pub fn sweep_placed(profile: &List, path: &List, frame: i32, cap: bool, placed: &mut [f64; 36]) -> Result<Handle, i32> {
let (k, nk, r, nr) = raw(profile)?;
let (pk, npk, pr, npr) = raw(path)?;
let at = placed.as_mut_ptr();
new_brep(|out| unsafe { smlib_sweep_placed(k, nk, r, nr, pk, npk, pr, npr, frame, i32::from(cap), out, at) })
}
/// The C API's `smlib_curves`.
#[cfg(feature = "native")]
#[repr(C)]
pub struct RawCurves {
_private: [u8; 0],
}
/// Curves a call made, read out of the C API's `smlib_curves`: a curve list (kinds, reals) and
/// two interval numbers per curve.
pub struct OutCurves {
pub kinds: Vec<i64>,
pub reals: Vec<f64>,
pub intervals: Vec<f64>,
}
/// `smlib_project_curves`: the curve list projected along `direction` onto the face of `brep`
/// nearest `face_point`.
#[cfg(feature = "native")]
pub fn project_curves(
brep: &Handle,
face_point: &[f64; 3],
pick_tolerance: f64,
curves: &List,
direction: &[f64; 3],
tolerance: f64,
) -> Result<OutCurves, i32> {
let (k, nk, r, nr) = raw(curves)?;
take_curves(|out| unsafe {
smlib_project_curves(brep.raw(), face_point.as_ptr(), pick_tolerance, k, nk, r, nr, direction.as_ptr(), tolerance, out)
})
}
/// `smlib_face_section`: the face of `brep` at `face_point` cut by a plane.
#[cfg(feature = "native")]
pub fn face_section(
brep: &Handle,
face_point: &[f64; 3],
pick_tolerance: f64,
plane_point: &[f64; 3],
plane_normal: &[f64; 3],
tolerance: f64,
) -> Result<OutCurves, i32> {
take_curves(|out| unsafe {
smlib_face_section(brep.raw(), face_point.as_ptr(), pick_tolerance, plane_point.as_ptr(), plane_normal.as_ptr(), tolerance, out)
})
}
/// Runs a call that makes an `smlib_curves`, reads it out and frees it.
#[cfg(feature = "native")]
fn take_curves(call: impl FnOnce(*mut *mut RawCurves) -> i32) -> Result<OutCurves, i32> {
let mut out: *mut RawCurves = std::ptr::null_mut();
let s = call(&mut out);
if s != super::OK {
return Err(s);
}
if out.is_null() {
return Err(super::ERR_EMPTY);
}
let read = || -> Result<OutCurves, i32> {
let (mut nk, mut nr, mut nc) = (0i64, 0i64, 0i64);
let s = unsafe { smlib_curves_size(out, &mut nk, &mut nr, &mut nc) };
if s != super::OK {
return Err(s);
}
let len = |n: i64| usize::try_from(n).map_err(|_| ERR_ARG);
let ni = nc.checked_mul(2).ok_or(ERR_ARG)?;
let mut c = OutCurves { kinds: vec![0; len(nk)?], reals: vec![0.0; len(nr)?], intervals: vec![0.0; len(ni)?] };
let s = unsafe { smlib_curves_read(out, c.kinds.as_mut_ptr(), nk, c.reals.as_mut_ptr(), nr, c.intervals.as_mut_ptr(), ni) };
if s != super::OK {
return Err(s);
}
Ok(c)
};
let c = read();
unsafe { smlib_curves_free(out) };
c
}
#[cfg(feature = "native")]
pub fn loft(sections: &List, sizes: &[i64], degree: i32, cap: bool) -> Result<Handle, i32> {
let (k, nk, r, nr) = raw(sections)?;
let ns = i64::try_from(sizes.len()).map_err(|_| ERR_ARG)?;
new_brep(|out| unsafe { smlib_loft(k, nk, r, nr, sizes.as_ptr(), ns, degree, i32::from(cap), out) })
}
#[cfg(feature = "native")]
pub fn loft_to_point(section: &List, apex: &[f64; 3]) -> Result<Handle, i32> {
let (k, nk, r, nr) = raw(section)?;
new_brep(|out| unsafe { smlib_loft_to_point(k, nk, r, nr, apex.as_ptr(), out) })
}
#[cfg(feature = "native")]
pub fn screw_sweep(profile: &List, origin: &[f64; 3], direction: &[f64; 3], pitch: f64, turns: f64, tolerance: f64) -> Result<Handle, i32> {
let (k, nk, r, nr) = raw(profile)?;
new_brep(|out| unsafe { smlib_screw_sweep(k, nk, r, nr, origin.as_ptr(), direction.as_ptr(), pitch, turns, tolerance, out) })
}
/// `law` holds (turn, offset) pairs.
#[cfg(feature = "native")]
pub fn screw_sweep_radial(
profile: &List,
origin: &[f64; 3],
direction: &[f64; 3],
pitch: f64,
turns: f64,
law: &[f64],
tolerance: f64,
) -> Result<Handle, i32> {
let (k, nk, r, nr) = raw(profile)?;
if !law.len().is_multiple_of(2) {
return Err(ERR_ARG);
}
let n = i64::try_from(law.len() / 2).map_err(|_| ERR_ARG)?;
new_brep(|out| unsafe {
smlib_screw_sweep_radial(k, nk, r, nr, origin.as_ptr(), direction.as_ptr(), pitch, turns, law.as_ptr(), n, tolerance, out)
})
}
/// `maps` holds 12 numbers per station (a matrix by columns, then a translation).
#[cfg(feature = "native")]
pub fn sweep_stations(profile: &List, maps: &[f64], ends: i32) -> Result<Handle, i32> {
let (k, nk, r, nr) = raw(profile)?;
if !maps.len().is_multiple_of(12) {
return Err(ERR_ARG);
}
let n = i64::try_from(maps.len() / 12).map_err(|_| ERR_ARG)?;
new_brep(|out| unsafe { smlib_sweep_stations(k, nk, r, nr, maps.as_ptr(), n, ends, out) })
}
#[cfg(not(feature = "native"))]
pub fn planar_faces(_: &List) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn extrude(_: &List, _: &[f64; 3], _: f64, _: f64, _: i32, _: bool, _: &mut [f64; 3]) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn revolve(_: &List, _: &[f64; 3], _: &[f64; 3], _: f64, _: bool) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn sweep(_: &List, _: &List, _: i32, _: bool) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn sweep_placed(_: &List, _: &List, _: i32, _: bool, _: &mut [f64; 36]) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn project_curves(_: &Handle, _: &[f64; 3], _: f64, _: &List, _: &[f64; 3], _: f64) -> Result<OutCurves, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn face_section(_: &Handle, _: &[f64; 3], _: f64, _: &[f64; 3], _: &[f64; 3], _: f64) -> Result<OutCurves, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn loft(_: &List, _: &[i64], _: i32, _: bool) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn loft_to_point(_: &List, _: &[f64; 3]) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn screw_sweep(_: &List, _: &[f64; 3], _: &[f64; 3], _: f64, _: f64, _: f64) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn screw_sweep_radial(_: &List, _: &[f64; 3], _: &[f64; 3], _: f64, _: f64, _: &[f64], _: f64) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn sweep_stations(_: &List, _: &[f64], _: i32) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
-72
View File
@@ -1,72 +0,0 @@
//! The untrim and extend blocks of `capi/include/smlib.h` (`smlib_untrim_face`,
//! `smlib_extend_sheet`; the fork's `capi/src/smlib_untrim.cpp` and `smlib_extend.cpp`) and
//! `smlib_face_points` (`smlib_imprint.cpp`, the pieces a trim chooses from), declared by hand. Part of the FFI boundary, like its parent: called only through [`crate::trim`].
#![allow(unsafe_code)]
use super::Handle;
#[cfg(not(feature = "native"))]
use super::NOT_BUILT;
#[cfg(feature = "native")]
use super::{RawBrep, new_brep};
/// The answer is not exact in the input's geometry (`SMLIB_ERR_NOT_EXACT`).
pub const ERR_NOT_EXACT: i32 = -1106;
pub const EXTEND_NATURAL: i32 = 0;
pub const EXTEND_TANGENT: i32 = 1;
pub const EXTEND_CURVATURE: i32 = 2;
#[cfg(feature = "native")]
unsafe extern "C" {
fn smlib_untrim_face(
body: *const RawBrep,
point: *const f64,
tol: f64,
holes: *const f64,
n_holes: i64,
outer: i32,
in_place: i32,
out: *mut *mut RawBrep,
) -> i32;
fn smlib_face_points(body: *const RawBrep, out: *mut f64, n_faces: i64) -> i32;
fn smlib_extend_sheet(sheet: *const RawBrep, point: *const f64, tol: f64, distance: f64, mode: i32, out: *mut *mut RawBrep) -> i32;
}
#[cfg(feature = "native")]
pub fn extend_sheet(sheet: &Handle, point: &[f64; 3], tol: f64, distance: f64, mode: i32) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_extend_sheet(sheet.raw(), point.as_ptr(), tol, distance, mode, out) })
}
#[cfg(not(feature = "native"))]
pub fn extend_sheet(_: &Handle, _: &[f64; 3], _: f64, _: f64, _: i32) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
/// `holes`: `None` for every hole.
#[cfg(feature = "native")]
pub fn untrim_face(body: &Handle, point: &[f64; 3], tol: f64, holes: Option<&[[f64; 3]]>, outer: bool, in_place: bool) -> Result<Handle, i32> {
let (ptr, n) = match holes {
Some(h) => (h.as_ptr().cast::<f64>(), i64::try_from(h.len()).map_err(|_| super::ERR_ARG)?),
None => (std::ptr::null(), -1),
};
new_brep(|out| unsafe { smlib_untrim_face(body.raw(), point.as_ptr(), tol, ptr, n, i32::from(outer), i32::from(in_place), out) })
}
#[cfg(not(feature = "native"))]
pub fn untrim_face(_: &Handle, _: &[f64; 3], _: f64, _: Option<&[[f64; 3]]>, _: bool, _: bool) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(feature = "native")]
pub fn face_points(body: &Handle, n_faces: usize) -> Result<Vec<[f64; 3]>, i32> {
let n = i64::try_from(n_faces).map_err(|_| super::ERR_ARG)?;
let mut out = vec![[0.0f64; 3]; n_faces];
let s = unsafe { smlib_face_points(body.raw(), out.as_mut_ptr().cast::<f64>(), n) };
if s == super::OK { Ok(out) } else { Err(s) }
}
#[cfg(not(feature = "native"))]
pub fn face_points(_: &Handle, _: usize) -> Result<Vec<[f64; 3]>, i32> {
Err(NOT_BUILT)
}
+5 -57
View File
@@ -1,8 +1,8 @@
//! SMLib's flat B-rep arrays (`capi/include/smlib.h`, "Flat B-rep arrays"): a B-rep as plain
//! int64 and double arrays laid out like a member of USD's BrepArray schema, read from SMLib with
//! [`Brep::export`](crate::Brep::export) and built into SMLib with [`Flat::import`].
//! int64 and double arrays laid out like a member of USD's BrepArray schema, which solidcore's body
//! is converted to and from ([`crate::import::Arrays`]).
use crate::{Brep, Result, error, ffi, init};
use crate::{Brep, Result};
// Array ids and kind values, as in smlib.h.
pub const REGION_SHELL_COUNT: i32 = 1;
@@ -39,6 +39,7 @@ pub const FACE_NURBS_V_ORDER: i32 = 31;
pub const SOURCE: i32 = 32;
pub const FACE_NURBS_FORM: i32 = 33;
pub const EDGE_NURBS_FORM: i32 = 34;
pub const WIRE_EDGE_NURBS_FORM: i32 = 35;
/// Per face whose kind carries a definition (a ball patch), in face order: how many reals of
/// `FACE_DEFINITION_REALS` are its definition (a ball patch: 4).
pub const FACE_DEFINITION_REAL_COUNT: i32 = 36;
@@ -128,62 +129,9 @@ pub const CURVE_LINE: u8 = 1;
pub const CURVE_CIRCLE: u8 = 2;
pub const CURVE_ELLIPSE: u8 = 3;
/// One B-rep's flat arrays, owned on the C side.
pub struct Flat(ffi::flat::FlatHandle);
impl std::fmt::Debug for Flat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("smlib::Flat")
}
}
impl Flat {
/// An empty set of arrays.
pub fn new() -> Result<Flat> {
init()?;
ffi::flat::new().map(Flat).map_err(error)
}
/// The arrays of `a` (see [`crate::import::arrays`]).
pub fn from_arrays(a: &crate::import::Arrays) -> Result<Flat> {
let mut f = Flat::new()?;
for (id, v) in &a.ints {
f.set_i64(*id, v)?;
}
for (id, v) in &a.reals {
f.set_f64(*id, v)?;
}
Ok(f)
}
pub fn get_i64(&self, id: i32) -> Result<Vec<i64>> {
ffi::flat::get_i64(&self.0, id).map_err(error)
}
pub fn get_f64(&self, id: i32) -> Result<Vec<f64>> {
ffi::flat::get_f64(&self.0, id).map_err(error)
}
pub fn set_i64(&mut self, id: i32, v: &[i64]) -> Result<()> {
crate::check(ffi::flat::set_i64(&mut self.0, id, v))
}
pub fn set_f64(&mut self, id: i32, v: &[f64]) -> Result<()> {
crate::check(ffi::flat::set_f64(&mut self.0, id, v))
}
/// The SMLib B-rep these arrays describe (checked on the C side before SMLib sees them).
pub fn import(&self) -> Result<Brep> {
init()?;
ffi::flat::import(&self.0).map(Brep).map_err(error)
}
}
impl Brep {
/// This body as flat arrays.
pub fn export(&self) -> Result<Flat> {
ffi::flat::export(&self.0).map(Flat).map_err(error)
}
/// Whether SMLib finds this body one closed, manifold solid.
pub fn is_manifold_solid(&self) -> Result<bool> {
ffi::flat::is_manifold_solid(&self.0).map_err(error)
crate::core::is_manifold_solid(self.core())
}
}
+3 -57
View File
@@ -14,7 +14,7 @@ use std::collections::BTreeSet;
use solidcraft_geom::brep::history::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, Failed, History, Side};
use crate::{Brep, Error, Result, error, ffi};
use crate::{Brep, Result, core};
/// A result face as SMLib reports it: how it relates to its origins, and the (operand, face)
/// pairs it came from, sorted.
@@ -68,53 +68,17 @@ impl Brep {
/// SMLib's order, which the copy keeps). Run an operation on tagged copies, then read its
/// result's [`Brep::history`].
pub fn tagged(&self, operand: usize) -> Result<Brep> {
let operand = i32::try_from(operand).map_err(|_| Error::Argument)?;
ffi::history::tag(&self.0, operand).map(Brep).map_err(error)
core::history::tag(self.core(), operand).map(Brep::made_core)
}
/// The history of this body, made by an operation from tagged operands: each face's and
/// edge's origins among them. Faces and edges no tag reached have none.
pub fn history(&self) -> Result<Raw> {
let v = ffi::history::read(&self.0).map_err(error)?;
Raw::decode(&v).ok_or(Error::Exception)
core::history::read(self.core())
}
}
impl Raw {
/// Decodes `smlib_history_read`'s records; `None` where they don't read as its layout.
fn decode(v: &[i64]) -> Option<Raw> {
let mut r = Reader(v.iter());
if r.next()? != usize::try_from(ffi::history::VERSION).ok()? {
return None;
}
let (nf, ne) = (r.next()?, r.next()?);
// Every entry takes at least two values: no more entries than values.
if nf.saturating_add(ne) > v.len() {
return None;
}
let mut faces = Vec::with_capacity(nf);
for _ in 0..nf {
let change = match r.next()? {
0 => Change::Kept,
1 => Change::Modified,
2 => Change::Split,
3 => Change::Merged,
4 => Change::Generated,
_ => return None,
};
let n = r.next()?;
faces.push(RawFace { change, origins: r.pairs(n)? });
}
let mut edges = Vec::with_capacity(ne);
for _ in 0..ne {
let m = r.next()?;
let bounds = (0..m).map(|_| r.next()).collect::<Option<Vec<_>>>()?;
let n = r.next()?;
edges.push(RawEdge { faces: bounds, origins: r.pairs(n)? });
}
r.0.next().is_none().then_some(Raw { faces, edges })
}
/// As a [`History`] over `operands.len()` operands, every index renumbered into canonical
/// order: operand k's through `operands[k]`, the result's through `result`. An edge with no
/// origin is where its two faces meet; one bounding other than two faces, or a face new to
@@ -258,23 +222,5 @@ pub fn blends(h: &mut History, operand: &[EdgeProv], picked: &[usize], taken_up:
}
}
/// Reads non-negative values off the records.
struct Reader<'a>(std::slice::Iter<'a, i64>);
impl Reader<'_> {
fn next(&mut self) -> Option<usize> {
self.0.next().and_then(|x| usize::try_from(*x).ok())
}
/// `n` pairs; `None` past the end (so a huge `n` costs nothing).
fn pairs(&mut self, n: usize) -> Option<Vec<(usize, usize)>> {
let mut v = Vec::with_capacity(n.min(self.0.len() / 2));
for _ in 0..n {
v.push((self.next()?, self.next()?));
}
Some(v)
}
}
#[cfg(test)]
mod tests;

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