Compare commits

...

46 Commits

Author SHA1 Message Date
jyviko a59c14678f licences test: the bundled third-party text is checked for wgpu and egui, not truck
Truck is gone from SolidCraft's dependencies, and main's regenerated
THIRD-PARTY-LICENSES.txt no longer lists it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 19:34:09 -05:00
jyviko b4a3a9baae Wave 45/45: smlib export test: a rounded cube's corners checked on solidcore's spheres (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360).

- b2fd08e85 jyviko: Profiler: no smlib category left in its tests and help
- d49a7eac0 jyviko: Known-failing tests: the convex chamfer that would grow the part passes
- d55fedd56 jyviko: smlib export test: a rounded cube's corners checked on solidcore's spheres
- 017a2a607 jyviko: Known-failing tests: the fillet into an earlier round passes on solidcore

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 cc3e954f45 Wave 44/45: Kernel ladder: the golden recorded on solidcore
1 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360).

- f70f55eff jyviko: Kernel ladder: the golden recorded on solidcore

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 c5ccb9201d Wave 43/45: smlib: rustfmt after the C++ kernel's removal (and 1 more)
2 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360).

- 19db2d7c0 jyviko: Docs and comments: solidcore is the kernel
- f1ee045de jyviko: smlib: rustfmt after the C++ kernel's removal

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 53211ab6ff Wave 42/45: Rig and CI: one kernel, solidcore
1 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360).

- 7d7203f9e jyviko: Rig and CI: one 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 49d9ec2be7 Wave 41/45: smlib crate: the C++ SMLib kernel and its FFI removed
1 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360).

- aa87a149f jyviko: smlib crate: the C++ SMLib kernel and its FFI removed

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 39807359f0 Wave 40/45: Kernel choice removed: solidcore is the only kernel
1 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360).

- fa1b4a9b9 jyviko: Kernel choice removed: solidcore is the only kernel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 17:56:23 -05:00
jyviko 28e983e191 Wave 39/45: diagnostics.kernel takes a typed contract, as main's other diagnostics commands do
1 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360).

- 7ad1d3e12 jyviko: diagnostics.kernel takes a typed contract, as main's other diagnostics commands do

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
brett19 f582b1f39e Wave 38/45: CI: build without the C++ SMLib kernel except where a job needs it (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- b8fa0d8aa brett19: Known failing on solidcore after the f916a8d sync; blend_fit in the entry-point guard
- 5a93e6c4d brett19: Make the C++ SMLib kernel opt-in at build time (feature smlib, off by default)
- 29a148abd brett19: CI: build without the C++ SMLib kernel except where a job needs it
- e30cfd2ac brett19: Tests and the rig for a default build without the C++ kernel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 12:30:22 -07:00
brett19 a025d2de9b Wave 37/45: Tabbed plate: the rim and one corner arc round together (solidcore#2223) (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 4c1eed960 brett19: Tabbed plate: the rim and one corner arc round together (solidcore#2223)
- dd00ff989 brett19: Native wire fixture: a box edge round's cylinder keeps its own frame
- 584075662 brett19: Known failing on solidcore after the smlib merge: SMLib 5e8c9c1's new blend (solidcore#2273) and main's new tests
- 8af1fdf5a brett19: 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_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 03:04:25 -07:00
brett19 a89ea233ee Wave 36/45: solidcore: a curvature fillet is the equal-area Gⁿ section; mixed wedges refused (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- f9fc68167 brett19: solidcore: a curvature fillet is the equal-area Gⁿ section; mixed wedges refused
- d52cf6b14 brett19: Known failures at solidcore adf26ddbc: curvature and variable rounds built
- 5ec92b0ba brett19: Move the solidcore pin to 2dbd88b6e
- 92618ac4c brett19: Turn on solidcore's new fixes at 2dbd88b6e

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-01 19:14:46 -07:00
brett19 a239ff50c9 Wave 35/45: solidcore: a variable round is built again, as the union of its balls (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- e30e983c3 brett19: Move the solidcore pin to 4d3ce6264
- c8351719e brett19: Move the solidcore pin to adf26ddbc
- d33b6eb30 brett19: Turn on fillet_gn_section_generator
- 3b4adeb73 brett19: solidcore: a variable round is built again, as the union of its balls

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-01 18:32:30 -07:00
brett19 96a8820f56 Wave 34/45: Known failures on solidcore after the merge: the refused curvature and variable rounds, develop's new cases (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 9f64cce54 brett19: solidcore: a variable-radius fillet is refused until solidcore builds the union of its balls
- bd4dc081d brett19: Known failures on solidcore after the merge: the refused curvature and variable rounds, develop's new cases
- c6fa7e563 brett19: Move the solidcore pin to c9a06dc7c
- de9a654db brett19: Turn on solidcore's new fixes at c9a06dc7c

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-01 14:22:24 -07:00
brett19 2b85a39c9c Wave 33/45: solidcore: a curvature fillet is refused until solidcore has the equal-area Gⁿ section (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 02de15f90 brett19: Move the solidcore pin to 9fb13f145
- 09b646005 brett19: Turn on solidcore's new fixes at 9fb13f145
- c494a18f9 brett19: Rig: the reimported peg's rounds fail as the peg's do
- e9ac0e606 brett19: solidcore: a curvature fillet is refused until solidcore has the equal-area Gⁿ section

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-01 11:47:31 -07:00
brett19 d0fbd1f60a Wave 32/45: Known failures after the new fixes: four tests and a rig case pass (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- a1b4119c3 brett19: Turn on geometric_g2_continuity: 2992's slowdown is gone
- f2ce343a0 brett19: Known failures after the new fixes: four tests and a rig case pass
- b57c24f7f brett19: Known-failing tests: the tabbed plate cites solidcore#2223
- 0aff35705 brett19: Merge fix: solidcore reads planar sheets without SMLib, so their edge reader is always built

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-01 07:33:59 -07:00
brett19 7240165c31 Wave 31/45: Turn on solidcore's new fixes at c931c9641 (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- fb851d9c2 brett19: Known-failing tests: main's four tests that fail on solidcore as on C++
- 6de10a7fb brett19: Model cache test: say why it reaches the C++ kernel
- 84bca5969 brett19: Move the solidcore pin to c931c9641
- e1b76bdfc brett19: Turn on solidcore's new fixes at c931c9641

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-01 03:52:10 -07:00
brett19 3f0e9b87fa Wave 30/45: Model cache: a saved model is its kernel's (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 15c5cd5d5 brett19: Known-failing tests: cite the solidcore issues now filed
- 462738b6a brett19: Known failing after the smlib merge: the r5 test-peg corner on solidcore, main's C++ test lines
- 4a98a1f8a brett19: Model cache: a saved model is its kernel's
- 9c2388ef8 brett19: Geometry kernel strings in Vietnamese, now a complete catalog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-01 01:25:18 -07:00
brett19 4b6bbe9d72 Wave 29/45: One kernel flag: solidcore, the default, or smlib; routed is gone
1 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- afb55d806 brett19: One kernel flag: solidcore, the default, or smlib; routed is gone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-31 21:48:04 -07:00
brett19 7032e6c760 Wave 28/45: Rig: --route is gone; a run has one kernel (and 1 more)
2 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 3908128a8 brett19: Known-failing tests: refreshed from the default run at solidcore de85a3e00
- cd94f78f3 brett19: Rig: --route is gone; a run has one kernel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-31 21:48:04 -07:00
brett19 3ddd8c7179 Wave 27/45: Rig: solidcore known-failing lists at pin de85a3e00 (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 75229f2b9 brett19: Offset tests: a sphere moved to its centre leaves nothing again (solidcore#2092 fixed by #2096)
- 6221b7f4b brett19: Rig: solidcore known-failing lists at pin de85a3e00
- b04e363e7 brett19: Rig: #316's renamed shell cases pass on solidcore, off its known-failing list
- c786db6bf brett19: A blend's pick measures close edges on their curves, not their polylines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-31 17:46:08 -07:00
brett19 6a3d422a83 Wave 26/45: Chamfer: second offsets through SetFilletParameters (solidcore#2095) (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 197cd4f43 brett19: Booleans: the copies no longer take an index
- 0f843c295 brett19: Pin solidcore at main de85a3e00 (#2080, #2081, #2086, #2095, #2096, #2097, #2102, #2103)
- 0b48548af brett19: Turn on solidcore's new fixes and take the rational-span flag off HELD_BACK
- d0c95f0dc brett19: Chamfer: second offsets through SetFilletParameters (solidcore#2095)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-31 15:57:25 -07:00
brett19 8fa2488a00 Wave 25/45: Working copies by solidcore's brep_copy, not our own key domains (#2047 fixed by #2055) (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 1bf9c8665 brett19: HELD_BACK: geometric_g2_continuity is a fourfold slowdown on 2992, not a hang
- 126a281bb brett19: Pin solidcore at main c093bc5a3 (#2054, #2060, #2061, #2065, #2066, #2068, #2069, #2072, #2076)
- 5d59d42d8 brett19: Turn on gap_samples_non_bspline_v_stride, gap_array_append_updates_longest, imprint_skips_collapsed_definition
- 1c90e60aa brett19: Working copies by solidcore's brep_copy, not our own key domains (#2047 fixed by #2055)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-31 14:08:19 -07:00
brett19 8f7a8f693c Wave 24/45: Offset tests: both kernels refuse a top moved -9; the sphere to its centre waits on solidcore (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 32b26888c brett19: Turn on offset_keep_cavity_regions and offset_four_face_vertex_split
- 38849d7b0 brett19: Hold geometric_g2_continuity: with the rational-span flag off, 2992 and 3368 don't end
- 5d7dca1a9 brett19: Offset tests: both kernels refuse a top moved -9; the sphere to its centre waits on solidcore
- da4db59a3 brett19: HELD_BACK: geometric_g2_continuity's reason is 2992 alone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-31 12:10:13 -07:00
brett19 91ca23359e Wave 23/45: Every solidcore fix on unless it appears broken (brett19's rule) (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 2b1a63d3f brett19: Pin solidcore at main 2898e1197 (#2053)
- 9337aff4c brett19: Every solidcore fix on unless it appears broken (brett19's rule)
- 2cd566aae brett19: Hold bspline_trim_keeps_rational_end_spans: it fails 3078's check
- 9df34988c brett19: 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_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-31 11:00:12 -07:00
brett19 41700f928e Wave 22/45: A round mitred to one that overflows goes first with it (fork 9ba2b12); flags() and warnings (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- eff6416d4 brett19: Pin solidcore at main f75e9d2e8 (#2023, #2024, #2034-#2040, #2043-#2045)
- 15e3a61da brett19: Pin 8: decide the new solidcore flags against the fork at 26cc451
- 6a0166853 brett19: A round mitred to one that overflows goes first with it (fork 9ba2b12); flags() and warnings
- d4eb66f25 brett19: Turn on offset_splits_corner_vertices (fork 2e1bb8c's behaviour)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-31 10:18:03 -07:00
brett19 905c02afc2 Wave 21/45: boolean2d through boolean_2d, typed edge-vanishes, typed put-back refusal (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 8ad306e69 brett19: boolean2d through boolean_2d, typed edge-vanishes, typed put-back refusal
- b6285e525 brett19: Overflowing rounds go first again (solidcore#1980)
- 2647b12e5 brett19: Hold back fillet_refuse_crossing_rails: not in the fork
- 0b627d637 brett19: 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_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-31 08:31:30 -07:00
brett19 0cb1031f2c Wave 20/45: Pin 7: decide the new solidcore flags against the fork (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 83487c4d9 brett19: Pin solidcore at main 9f2c980b3 (#2004, #2005, #2006, #2008)
- aca0b57ee brett19: Turn on plane_recognition_origin_after_axes
- 5c0279153 brett19: Pin solidcore at main c369f8801 (#1977, #1996, #2000, #2016, #2017, #2018, #2027 and more)
- 14b1d1956 brett19: Pin 7: decide the new solidcore flags against the fork

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-31 06:48:40 -07:00
brett19 b6078cbe68 Wave 19/45: Offsets go through solidcore's simple calls; ignored tests cite solidcore issues (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- ec3a21778 Brett Lawson: smlib: a blend on solidcore has a history, as on the C++ kernel
- 03470c3df brett19: Pin solidcore at main 9dde25e97 (#1988, #1997, #1985, #1940, #1974, #1975, #1976)
- f01b87757 brett19: Audit the held flags against the fork's source; tabbed plate and mitred rounds parity
- 9b73dfe74 brett19: Offsets go through solidcore's simple calls; ignored tests cite solidcore issues

Co-authored-by: Brett Lawson <brett@Ubuntu-resolute-latest-amd64-base.zst>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-31 03:01:40 -07:00
Brett Lawson 39e9916cbc Wave 18/45: smlib: solidcore's four new fork-carried opt-ins are on; the trimmed face tight box waits (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- b0568524b Brett Lawson: smlib: solidcore's four new fork-carried opt-ins are on; the trimmed face tight box waits
- a2d5971be Brett Lawson: smlib: the quarter-cylinder sheet offset and the half-disc extrude match the C++ kernel on solidcore
- 509209838 Brett Lawson: solidcore: move the pin to afcee131a (main, with solidcore#1594, #1674, #1595)
- c9fcf82a5 Brett Lawson: smlib: solidcore's four new fork-carried opt-ins are on; the fillet trim status waits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-31 00:24:30 +01:00
Brett Lawson c324acdf29 Wave 17/45: smlib: an extruded profile's arcs on one circle sweep one wall face on solidcore (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 9a901d136 Brett Lawson: smlib: tag history on solidcore is one application Tag per object, with the capi's handlers
- fecd4bee3 Brett Lawson: smlib: an extruded profile's arcs on one circle sweep one wall face on solidcore
- 5f7a21157 Brett Lawson: smlib: the half-disc extrude's C++ comparison cites solidcore#1477
- f64857f95 Brett Lawson: solidcore: move the pin to 15e72dff9 (main, with solidcore#1752, #1495, #1796)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-30 21:44:01 +01:00
Brett Lawson b6cd0d495e Wave 16/45: smlib: history on solidcore: a face whose edge is only cut by a vertex is Kept (fork 1c7accd) (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 63864b298 Brett Lawson: smlib: solidcore's five new fork-carried opt-ins are on; the trim end-span opt-in waits
- 494a08de1 Brett Lawson: smlib: history on solidcore: a face whose edge is only cut by a vertex is Kept (fork 1c7accd)
- ccfa37a9b Brett Lawson: solidcore: move the pin to bba8d8798 (main, with solidcore#1747 application attribute handlers)
- 8d47118ff Brett Lawson: smlib: solidcore's three new fork-carried opt-ins are on; the blend-curve revolve opt-in waits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-30 20:15:56 +01:00
Brett Lawson 8c597703f8 Wave 15/45: smlib: a face section on solidcore cuts planes, cylinders, cones and spheres too (solidcore#1769) (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- cf2a15ace Brett Lawson: smlib: solidcore's four fork-carried opt-ins are on; only the trim base v opt-in waits
- c3f5bb40b Brett Lawson: smlib: a draft on solidcore keeps the body's tags and face history
- cd25e9e8f Brett Lawson: smlib: a face section on solidcore cuts planes, cylinders, cones and spheres too (solidcore#1769)
- 9c1f8904e Brett Lawson: solidcore: move the pin to 43ed10e54 (main, with solidcore#1650, fixing #1631)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-30 18:17:38 +01:00
Brett Lawson 7a761d9436 Wave 14/45: smlib: solidcore's tangent-face edge check and horn-torus revolve opt-ins are on (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 5314031ba Brett Lawson: smlib: solidcore's tangent-face edge check and horn-torus revolve opt-ins are on
- 81088bf0c Brett Lawson: smlib: a sweep along a bent path is tested on solidcore (solidcore#1628 is fixed)
- a6b280467 Brett Lawson: solidcore: move the pin to 3569b5d73 (main, with solidcore#1665, #1681, #1614, #1677)
- 0bed45d82 Brett Lawson: smlib: solidcore's planar thicken side walls opt-in is on; four opt-ins wait for the fork

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-30 13:55:13 +01:00
Brett Lawson 2c5a94cd1d Wave 13/45: 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 (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 2d84f8a17 Brett Lawson: 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
- 0877278d6 Brett Lawson: solidcore: move the pin to 2b81c033 (main, with solidcore#1640, #1623, #1655, #1673, #1668, #1662)
- 97a475d8d Brett Lawson: 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
- f4eec76b4 Brett Lawson: solidcore: move the pin to dca3135cc (main, with solidcore#1630, #1632, #1649, #1664)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-30 11:52:02 +01:00
Brett Lawson 7df316ccce Wave 12/45: smlib: a face's planar section runs on solidcore, as the capi's smlib_face_section (solidcore#1534) (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 3346f4172 Brett Lawson: 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
- 271f0a39e Brett Lawson: smlib: a face's planar section runs on solidcore, as the capi's smlib_face_section (solidcore#1534)
- 436fae56a Brett Lawson: smlib: drafting a cylinder's side runs on solidcore (solidcore#1606, fixed by #1611)
- 39adb66ad Brett Lawson: 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_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-30 09:00:08 +01:00
Brett Lawson 0710897b63 Wave 11/45: smlib: a sweep along lines and arcs goes piece by piece on solidcore, as the capi does (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 97d79e3b8 Brett Lawson: smlib: the solidcore tag test names what still holds it back
- 6a3558a96 Brett Lawson: smlib: a sweep along lines and arcs goes piece by piece on solidcore, as the capi does
- 1feba5af9 Brett Lawson: smlib: the bent-path sweep's ignore cites solidcore#1628
- 9ceddd9d5 Brett Lawson: 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)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-30 06:56:18 +01:00
Brett Lawson 3ff9e634bc Wave 10/45: smlib: curvature-continuous rounds and corners left open on solidcore (solidcore#1438, #1439) (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- c8adedaec Brett Lawson: 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)
- 744f7574d Brett Lawson: smlib: drafted extrudes keep their corners and curve sweeps keep the profile where it lies on solidcore (solidcore#1449, #1450)
- dafb25b4b Brett Lawson: smlib: the body check reads a Caller body's recorded keys on solidcore (solidcore#1584)
- 2ad118666 Brett Lawson: smlib: curvature-continuous rounds and corners left open on solidcore (solidcore#1438, #1439)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-30 04:41:50 +01:00
Brett Lawson 1db07a82f8 Wave 9/45: smlib: an offset or other non-NURBS face surface exports through ApproximateSurface on solidcore (solidcore#1441), and the new fixes are on (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- a69016f21 Brett Lawson: solidcore: move the pin to e4379b45 (main, with solidcore#1573: the #1024 rail-through-boundary-vertex test, plus the CreateCurveApproxPoints and PolyBrepCopy ports)
- 2308fa5be Brett Lawson: solidcore: move the pin to ddfb3895 (main, with solidcore#1569 and #1533: the 3x2 mixed corner at a concave unfilleted edge, and SmSurface::ApproximateSurface)
- f23aa76d3 Brett Lawson: smlib: an offset or other non-NURBS face surface exports through ApproximateSurface on solidcore (solidcore#1441), and the new fixes are on
- d4cec2ca9 Brett Lawson: 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_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-30 04:25:21 +01:00
Brett Lawson d3dc45681a Wave 8/45: smlib: a call answers None exactly when solidcore's error stands for an SMLib status (SolidError::sm_status, solidcore#1452) (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 0d3c46dad Brett Lawson: solidcore: move the pin to 6a068272 (main, with solidcore#1419 WP4: the level-2 AssertValid tests and SmSurfOfExtrusion::AssertValid, #1578)
- 41e902eaa Brett Lawson: solidcore: move the pin to 0ae36782 (main, with solidcore#1452: SolidError::sm_status and a port-invariant variant)
- c13d760fa Brett Lawson: smlib: a copy queried under the arena's identities is a port-side invariant (solidcore#1452)
- 76c755508 Brett Lawson: smlib: a call answers None exactly when solidcore's error stands for an SMLib status (SolidError::sm_status, solidcore#1452)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-29 23:03:37 +01:00
Brett Lawson 824615e119 Wave 7/45: solidcore: move the pin to 7ca6ce7a (main, with solidcore PR #1427 SmBrep::LocalOperation, #1474 Nx1 corner, and fixes since 3c17ddb1) (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 376bba92c Brett Lawson: 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)
- c191634b5 Brett Lawson: solidcore: move the pin to 7ca6ce7a (main, with solidcore PR #1427 SmBrep::LocalOperation, #1474 Nx1 corner, and fixes since 3c17ddb1)
- 9c7c139bd Brett Lawson: 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)
- 22a1c20b1 Brett Lawson: 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)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-29 21:23:31 +01:00
Brett Lawson 97f5208c2c Wave 6/45: smlib: the curved primitives' measures, round trips and two sweep volumes run on solidcore (solidcore#1435 landed) (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 53da86734 Brett Lawson: solidcore: move the pin to 2a47645e (main, with solidcore#1435: precise properties split at the surface's knots)
- cb79c32ee Brett Lawson: smlib: the curved primitives' measures, round trips and two sweep volumes run on solidcore (solidcore#1435 landed)
- 6e5722af9 Brett Lawson: 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)
- 9c7e54be4 Brett Lawson: solidcore: move the pin to 43ca3745 (main, with solidcore#1473: SmShell::IsInnerShellOf, and #1419's level-0 helpers)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-29 09:40:20 +01:00
Brett Lawson 3d5e874943 Wave 5/45: solidcore: move the pin to 19d87ca7 (main, with solidcore#1442: walls that meet are refused as WALLS_TOUCH) (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- d35dcf022 Brett Lawson: solidcore: move the pin to 19d87ca7 (main, with solidcore#1442: walls that meet are refused as WALLS_TOUCH)
- 8493abc90 Brett Lawson: smlib: shell_walls_touch runs on solidcore (solidcore#1442 landed)
- 30d6331a5 Brett Lawson: solidcore: move the pin to 19587445 (main, with solidcore#1453: an offset edge between two analytic faces is their exact line or conic)
- f44e08128 Brett Lawson: 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_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-29 05:39:42 +01:00
Brett Lawson 0106365e64 Wave 4/45: smlib: circle, cylinder and torus NURBS forms come into solidcore (solidcore#1432, #1433) (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- f62d6ee6d Brett Lawson: solidcore: move the pin to 4cd55082 (main, with Ellipse::is_nurb_curve_circle, solidcore#1432)
- c11640214 Brett Lawson: solidcore: move the pin to f020a066 (main, with IsNurbSurfaceCylinder / IsNurbSurfaceTorus, solidcore#1433)
- c9b2c18eb Brett Lawson: smlib: circle, cylinder and torus NURBS forms come into solidcore (solidcore#1432, #1433)
- 04c2af035 Brett Lawson: solidcore: move the pin to 3546c9bc (main, with solidcore#1437 and #1445)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-29 03:44:11 +01:00
Brett Lawson 091357ea58 Wave 3/45: Sweep cases name their solidcore issues (#1449 draft corners, #1450 curve-sweep placement, #1451 planar sections) (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- d6b3bf27b Brett Lawson: Shell walls-touch case waits on solidcore#1442 (port fork a759622)
- 29a1ac5a6 Brett Lawson: Sweep cases name their solidcore issues (#1449 draft corners, #1450 curve-sweep placement, #1451 planar sections)
- 4f1ad5cc6 Brett Lawson: Cylinder shell case waits on solidcore#1453 (port fork fddc366 + 98ab1b0)
- c9aa5116d Brett Lawson: solidcore: move the pin to 9b76ac13 (main, with SmApiBrepMaterialCensus, solidcore#1436)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-29 02:55:39 +01:00
Brett Lawson 2f7f0c679c Wave 2/45: solidcore backend: every UpstreamFixes flag on, and the ignored cases name their solidcore issues (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 86e4226ae Brett Lawson: solidcore backend: every UpstreamFixes flag on, and the ignored cases name their solidcore issues
- ce5de8bcf Brett Lawson: Sphere measurement cases wait on solidcore#1437 (trim_boundary_seam_wrap at the poles)
- 5140556c7 Brett Lawson: Blend refusals name their solidcore issues (#960, #1438, #1439, #430)
- 263f34850 Brett Lawson: 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_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-29 00:31:16 +01:00
Brett Lawson bc4741b05a Wave 1/45: Kernel: a solidcore backend behind crates/smlib, routed per operation (and 3 more)
4 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360). Its changes conflict with main as it is, so this wave keeps the previous files; the next wave that merges cleanly holds them.

- 21eba6a38 Brett Lawson: claim: solidcore backend for develop
- 8c12073be Brett Lawson: Kernel: a solidcore backend behind crates/smlib, routed per operation
- 12ace4973 Brett Lawson: diagnostics.kernel: read and set which kernel answers, and see the routing table
- 976acfd76 Brett Lawson: 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_01DtL5M41G8Nua7HvuFPZkmJ
2026-10-28 23:18:10 +01:00
144 changed files with 16026 additions and 5902 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
@@ -811,6 +811,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)
@@ -826,6 +835,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"]
-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 -1
View File
@@ -20,7 +20,7 @@ mod tests {
#[test]
fn the_licences_are_built_in() {
assert!(super::LICENSE_MIT.contains("MIT License") && super::LICENSE_APACHE.contains("Apache License"));
assert!(super::THIRD_PARTY.contains("truck") && super::THIRD_PARTY.contains("egui"));
assert!(super::THIRD_PARTY.contains("wgpu") && super::THIRD_PARTY.contains("egui"));
assert!(super::all().contains("NOTICE"));
}
}
+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;
+22 -39
View File
@@ -1,49 +1,32 @@
use super::*;
use crate::Error;
use solidcraft_geom::brep::history::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, History};
fn face(operand: usize, face: usize) -> FaceOrigin {
FaceOrigin::Face { operand, face }
}
/// Two boxes joined across a face, as `smlib_history_read` lays it out: face 0 is operand 0's
/// face 2 kept, face 1 operand 1's face 4 trimmed, face 2 both boxes' coplanar faces 0 merged.
/// Edge 0 is new between faces 0 and 1, edge 1 is operand 0's edge 7, edge 2 joins edge 3 of
/// each box and bounds faces 1 and 2.
const RECORDS: &[i64] = &[
1, 3, 3, //
0, 1, 0, 2, //
1, 1, 1, 4, //
3, 2, 0, 0, 1, 0, //
2, 0, 1, 0, //
2, 0, 2, 1, 0, 7, //
2, 1, 2, 2, 0, 3, 1, 3,
];
#[test]
fn decodes_the_c_layout() {
let raw = Raw::decode(RECORDS).unwrap();
assert_eq!(raw.faces.len(), 3);
assert_eq!(raw.faces[2], RawFace { change: Change::Merged, origins: vec![(0, 0), (1, 0)] });
assert_eq!(raw.edges[0], RawEdge { faces: vec![0, 1], origins: vec![] });
assert_eq!(raw.edges[2].origins, vec![(0, 3), (1, 3)]);
}
#[test]
fn hostile_records_are_refused() {
// Wrong version, truncated, trailing values, counts far past the data, an unknown change.
assert_eq!(Raw::decode(&[2, 0, 0]), None);
assert_eq!(Raw::decode(&RECORDS[..RECORDS.len() - 1]), None);
assert_eq!(Raw::decode(&[RECORDS, &[0]].concat()), None);
assert_eq!(Raw::decode(&[1, i64::MAX, 0]), None);
assert_eq!(Raw::decode(&[1, 1, 0, 0, i64::MAX]), None);
assert_eq!(Raw::decode(&[1, 1, 0, 9, 0]), None);
assert_eq!(Raw::decode(&[1, 1, 0, 0, -1]), None);
assert_eq!(Raw::decode(&[]), None);
/// Two boxes joined across a face: face 0 is operand 0's face 2 kept, face 1 operand 1's face 4
/// trimmed, face 2 both boxes' coplanar faces 0 merged. Edge 0 is new between faces 0 and 1, edge
/// 1 is operand 0's edge 7, edge 2 joins edge 3 of each box and bounds faces 1 and 2.
fn joined() -> Raw {
Raw {
faces: vec![
RawFace { change: Change::Kept, origins: vec![(0, 2)] },
RawFace { change: Change::Modified, origins: vec![(1, 4)] },
RawFace { change: Change::Merged, origins: vec![(0, 0), (1, 0)] },
],
edges: vec![
RawEdge { faces: vec![0, 1], origins: vec![] },
RawEdge { faces: vec![0, 2], origins: vec![(0, 7)] },
RawEdge { faces: vec![1, 2], origins: vec![(0, 3), (1, 3)] },
],
}
}
#[test]
fn new_edges_are_where_their_faces_meet() {
let raw = Raw::decode(RECORDS).unwrap();
let raw = joined();
let h = raw.history(&[Order::default(), Order::default()], &Order::default()).unwrap();
assert_eq!(h.operands, 2);
assert_eq!(h.faces[0].origins, vec![face(0, 2)]);
@@ -59,7 +42,8 @@ fn new_edges_are_where_their_faces_meet() {
/// pieces of one face, where nothing met, so it comes from nothing.
#[test]
fn a_split_line_comes_from_nothing() {
let raw = Raw::decode(&[1, 2, 1, 2, 1, 0, 0, 2, 1, 0, 0, 2, 0, 1, 0]).unwrap();
let piece = RawFace { change: Change::Split, origins: vec![(0, 0)] };
let raw = Raw { faces: vec![piece.clone(), piece], edges: vec![RawEdge { faces: vec![0, 1], origins: vec![] }] };
let h = raw.history(&[Order::default()], &Order::default()).unwrap();
assert_eq!(h.faces.iter().map(|f| (f.origins.clone(), f.change)).collect::<Vec<_>>(), vec![(vec![face(0, 0)], Change::Split); 2]);
assert_eq!(h.edges[0].faces, vec![0, 1]);
@@ -68,7 +52,7 @@ fn a_split_line_comes_from_nothing() {
#[test]
fn renumbers_into_canonical_order() {
let raw = Raw::decode(RECORDS).unwrap();
let raw = joined();
// Operand 0's SMLib face 2 is its canonical face 5, edge 7 its edge 0; the result's faces
// and edges are listed backwards.
let a = Order { faces: vec![1, 2, 5, 0, 3, 4], edges: vec![1, 2, 3, 4, 5, 6, 7, 0], pieces: vec![] };
@@ -85,7 +69,7 @@ fn renumbers_into_canonical_order() {
#[test]
fn bad_orders_are_errors() {
let raw = Raw::decode(RECORDS).unwrap();
let raw = joined();
let ok = Order::default();
// An origin on an operand that isn't there.
assert!(raw.history(std::slice::from_ref(&ok), &ok).is_err());
@@ -112,7 +96,6 @@ fn a_fillet_face_comes_from_its_edge() {
assert!(h.faces[..6].iter().all(|f| f.origins.len() == 1));
}
#[cfg(feature = "native")]
#[test]
fn a_tagged_body_is_its_own_history() {
let b = Brep::make_box([0.0; 3], [1.0, 2.0, 3.0]).unwrap();
+9 -4
View File
@@ -1,6 +1,6 @@
//! Schema 4 records (`solidcraft_geom::brep::Brep`) into SMLib: the record is laid out as the C
//! API's flat arrays (`capi/include/smlib.h`, "Flat B-rep arrays"), which `smlib_brep_import`
//! builds into an `SmBrep`.
//! API's flat arrays (`capi/include/smlib.h`, "Flat B-rep arrays"), which solidcore builds into
//! its B-rep ([`crate::core::from_arrays`]).
//!
//! The two models differ in four places, and the mapping takes care of each:
//! - A closed shell is one record listed by the two regions it separates; SMLib keeps one shell
@@ -666,15 +666,20 @@ mod analytic;
mod tests;
impl crate::Brep {
/// A body from flat arrays.
pub(crate) fn from_arrays(a: &Arrays) -> crate::Result<crate::Brep> {
crate::core::from_arrays(a).map(crate::Brep::made_core)
}
/// The SMLib B-rep of a schema 4 record. `trusted` as in [`arrays`].
pub fn from_record(brep: &b::Brep, trusted: bool) -> crate::Result<crate::Brep> {
let a = arrays(brep, trusted).map_err(crate::Error::Record)?;
crate::flat::Flat::from_arrays(&a)?.import()
crate::Brep::from_arrays(&a)
}
/// [`crate::Brep::from_record`] through [`arrays_keeping_splines`].
pub fn from_record_keeping_splines(brep: &b::Brep, trusted: bool) -> crate::Result<crate::Brep> {
let a = arrays_keeping_splines(brep, trusted).map_err(crate::Error::Record)?;
crate::flat::Flat::from_arrays(&a)?.import()
crate::Brep::from_arrays(&a)
}
}
-2
View File
@@ -117,7 +117,6 @@ fn records_smlib_cannot_take_are_errors() {
assert!(arrays(&r, true).is_err());
}
#[cfg(feature = "native")]
#[test]
fn a_cube_record_imports_into_smlib() {
let b = crate::Brep::from_record(&cube(10.0), true).unwrap();
@@ -126,7 +125,6 @@ fn a_cube_record_imports_into_smlib() {
assert!(b.is_manifold_solid().unwrap());
}
#[cfg(feature = "native")]
#[test]
fn a_plane_on_other_axes_goes_as_smlibs_plane() {
// A tetrahedron: its faces' natural frames are unit and orthogonal, so make one plane's axes
+44 -121
View File
@@ -1,23 +1,15 @@
//! NVIDIA's SMLib B-rep kernel (tangency-labs/smlib, Apache-2.0), statically linked behind its C
//! API, with a safe Rust wrapper. Only `crates/kernel` uses this crate (ADR 0002).
//! The B-rep kernel, solidcore (tangency-labs/solidcore, the Rust port of NVIDIA's SMLib,
//! Apache-2.0), behind a safe wrapper. Only `crates/kernel` uses this crate (ADR 0002).
//!
//! The C API never lets a C++ exception, type or pointer out, never changes its inputs, and
//! serialises every call behind one lock (SMLib keeps process-global state). This wrapper owns
//! the handles, turns status codes into [`Error`], and sends SMLib's one-line-per-call log to the
//! `log` crate (target `smlib`).
//!
//! Built without the `native` feature, the crate needs no C++ toolchain and every call returns
//! [`Error::NotBuilt`].
//! Every call runs in a solidcore kernel of its own, never changes its inputs, turns solidcore's
//! errors into [`Error`] and catches its panics ([`Error::Internal`]).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::ffi::CString;
use std::path::Path;
use std::sync::Once;
use std::sync::atomic::{AtomicI32, Ordering};
mod check;
mod core;
pub mod export;
mod ffi;
pub mod flat;
pub mod history;
pub mod import;
@@ -32,7 +24,7 @@ pub mod trim;
pub use check::{CheckLevel, CheckReport};
pub use measure::{MEASURE_ACCURACY, MassProperties, PointClass};
pub use ops::{Blend, BooleanOp, ChamferSecond, Fit, Hole, HoleEdge, MAX_POINTS, MAX_UNION_BREPS, blend_fit};
pub use ops::{Blend, BooleanOp, ChamferSecond, Hole, HoleEdge, MAX_POINTS, MAX_UNION_BREPS};
/// Why an SMLib call failed.
#[derive(Clone, Debug, PartialEq)]
@@ -71,14 +63,18 @@ pub enum Error {
NotExact,
/// A drafted extrusion could not close the corner (or wall) at `at` on its profile, for `why`.
Corner { why: sweeps::CornerRefusal, at: [f64; 3] },
/// A C++ exception (most likely out of memory) was caught at the boundary.
/// solidcore found its own state inconsistent part way through a call (a hole it rebuilt did
/// not close).
Exception,
/// The C API is a different version from the one this crate was written against.
Version(i32),
/// This build has no SMLib (the `native` feature is off).
NotBuilt,
/// A B-rep record SMLib cannot take (see [`import::Unsupported`]).
Record(import::Unsupported),
/// solidcore reported an error (the call, then its message).
Solidcore(String),
/// solidcore panicked; the guard caught it (the call, then the panic's message).
Internal(String),
/// A step of SMLib solidcore has not ported yet, refused rather than built another way: what
/// is missing, and the solidcore issue that ports it.
Unported(&'static str),
}
impl std::fmt::Display for Error {
@@ -113,9 +109,10 @@ impl std::fmt::Display for Error {
}
}
Error::Exception => write!(f, "SMLib raised an internal exception"),
Error::Version(v) => write!(f, "SMLib C API version {v}, expected {}", ffi::ABI_VERSION),
Error::NotBuilt => write!(f, "not supported yet: this build has no SMLib"),
Error::Record(u) => write!(f, "{u}"),
Error::Solidcore(e) => write!(f, "solidcore: {e}"),
Error::Internal(e) => write!(f, "solidcore failed internally in {e}"),
Error::Unported(what) => write!(f, "not supported yet: {what}"),
}
}
}
@@ -124,101 +121,20 @@ impl std::error::Error for Error {}
pub type Result<T> = std::result::Result<T, Error>;
fn error(s: i32) -> Error {
match s {
ffi::ERR_ARG | ffi::ERR_BUFFER => Error::Argument,
ffi::ERR_EMPTY => Error::Empty,
ffi::ops::ERR_NOT_SOLID => Error::NotSolid,
ffi::ops::ERR_NO_ROOM => Error::NoRoom,
ffi::ops::ERR_WALLS_TOUCH => Error::WallsTouch,
ffi::ops::ERR_UNSUPPORTED => Error::Unsupported,
ffi::ops::ERR_CORNER_UNSUPPORTED => Error::CornerUnsupported,
ffi::ops::ERR_NO_EFFECT => Error::NoEffect,
ffi::trim::ERR_NOT_EXACT => Error::NotExact,
ffi::ERR_EXCEPTION => Error::Exception,
ffi::NOT_BUILT => Error::NotBuilt,
s => Error::Status(s),
}
/// Where failing calls leave their inputs for replay ([`core::dump`]); `None` turns that off.
pub fn set_dump_dir(dir: Option<&Path>) {
core::dump::set_dir(dir.map(Path::to_path_buf));
}
fn check(s: i32) -> Result<()> {
if s == ffi::OK { Ok(()) } else { Err(error(s)) }
}
fn c_path(p: &Path) -> Result<CString> {
CString::new(p.to_string_lossy().as_bytes()).map_err(|_| Error::Argument)
}
/// One C API call's log line, `smlib_fn(args) -> status (1.234 ms)`: the function and the time
/// the C API measured for it (inside its lock, so waiting for another thread's call is not in it).
fn call_time(line: &str) -> Option<(&str, std::time::Duration)> {
let name = line.get(..line.find('(')?)?;
if !name.starts_with("smlib_") || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
return None;
}
let tail = line.strip_suffix(" ms)")?;
let ms: f64 = tail.get(tail.rfind(" (")? + 2..)?.parse().ok()?;
(ms.is_finite() && ms >= 0.0).then(|| (name, std::time::Duration::from_secs_f64(ms / 1000.0)))
}
fn log_line(level: i32, text: String) {
// Every call's log line carries the C API's own timing: the profiler's SMLib spans.
if solidcraft_profile::enabled()
&& let Some((name, took)) = call_time(&text)
{
solidcraft_profile::record("smlib", solidcraft_profile::intern(name), took);
}
if level == 0 {
log::debug!(target: "smlib", "{text}");
} else {
log::warn!(target: "smlib", "{text}");
}
}
static INIT: Once = Once::new();
/// What [`init`] found, kept for every later call (0: ready).
static INIT_RESULT: AtomicI32 = AtomicI32::new(0);
/// Checks the C API's version, creates SMLib's context and routes its log to the `log` crate.
/// Every other call runs it first; calling it at start-up keeps the cost out of the first operation.
pub fn init() -> Result<()> {
INIT.call_once(|| {
let v = ffi::abi_version();
let result = if v == ffi::NOT_BUILT {
v
} else if v != ffi::ABI_VERSION {
v.saturating_add(10_000)
} else {
ffi::set_log(Some(log_line));
ffi::init()
};
INIT_RESULT.store(result, Ordering::Release);
});
match INIT_RESULT.load(Ordering::Acquire) {
ffi::OK => Ok(()),
v if v >= 10_000 => Err(Error::Version(v - 10_000)),
s => Err(error(s)),
}
}
/// Where failing calls leave their inputs for replay (see the C API's `smlib_set_dump_dir`);
/// `None` turns that off.
pub fn set_dump_dir(dir: Option<&Path>) -> Result<()> {
init()?;
let c = dir.map(c_path).transpose()?;
check(ffi::set_dump_dir(c.as_deref()))
}
/// The SMLib call running right now, if any: for crash reports. Takes no lock.
/// The kernel call running right now, if any: for crash reports. Takes no lock.
pub fn current_call() -> Option<String> {
let mut buf = [0u8; 512];
let n = ffi::current_call(&mut buf);
let s = buf.get(..n).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default();
(!s.is_empty()).then_some(s)
core::dump::current()
}
/// An SMLib B-rep. Immutable: operations return new bodies.
pub struct Brep(ffi::Handle);
/// A B-rep. Immutable: operations return new bodies.
pub struct Brep {
body: core::Body,
}
impl std::fmt::Debug for Brep {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -227,26 +143,34 @@ impl std::fmt::Debug for Brep {
}
impl Brep {
/// A body solidcore made.
fn made_core(body: core::Body) -> Brep {
Brep { body }
}
/// solidcore's body.
fn core(&self) -> &core::Body {
&self.body
}
/// An axis-aligned box from its lower corner, with positive edge lengths (mm).
pub fn make_box(origin: [f64; 3], size: [f64; 3]) -> Result<Brep> {
init()?;
ffi::make_box(&origin, &size).map(Brep).map_err(error)
core::make_box(origin, size).map(Brep::made_core)
}
/// Reads a native SMLib `.smb` file.
pub fn read(path: &Path, ascii: bool) -> Result<Brep> {
init()?;
ffi::read(&c_path(path)?, ascii).map(Brep).map_err(error)
core::file::read(path, ascii).map(Brep::made_core)
}
/// Writes this body to a native SMLib `.smb` file.
pub fn write(&self, path: &Path, ascii: bool) -> Result<()> {
check(ffi::write(&self.0, &c_path(path)?, ascii))
core::file::write(self.core(), path, ascii)
}
/// A deep copy.
pub fn try_clone(&self) -> Result<Brep> {
ffi::copy(&self.0).map(Brep).map_err(error)
core::transform::copy(self.core()).map(Brep::made_core)
}
/// This body moved by the affine `matrix` (column-major: columns 0–2 the images of x, y and
@@ -254,18 +178,17 @@ impl Brep {
/// mirror it, a non-uniform scale or a shear turns its geometry into NURBS, tolerances scale
/// with it.
pub fn transformed(&self, matrix: &[[f64; 4]; 4]) -> Result<Brep> {
ffi::transform(&self.0, matrix).map(Brep).map_err(error)
core::transform::transform(self.core(), matrix).map(Brep::made_core)
}
/// The enclosed volume (mm³) at `relative_accuracy` (SMLib clamps it to [1e-8, 1e-1]).
pub fn volume(&self, relative_accuracy: f64) -> Result<f64> {
ffi::volume(&self.0, relative_accuracy).map_err(error)
core::volume(self.core(), relative_accuracy)
}
/// Face, edge and vertex counts.
pub fn counts(&self) -> Result<[usize; 3]> {
let c = ffi::counts(&self.0).map_err(error)?;
Ok(c.map(|n| usize::try_from(n).unwrap_or(0)))
core::counts(self.core())
}
}
+112 -70
View File
@@ -2,7 +2,7 @@
//! surfaces rather than a tessellation; the exact box; how far an edge lies from a face's surface;
//! where a point lies against a body.
use crate::{Brep, Result, error, ffi};
use crate::{Brep, Result, core};
/// The accuracy [`Brep::mass_properties`] and [`Brep::area`] use unless told otherwise: the
/// finest SMLib allows. Analytic primitives then come out within 2e-7 of their closed forms.
@@ -50,13 +50,7 @@ impl Brep {
/// else inside a material region or outside. A body with no material (a sheet, an open shell)
/// has no inside. A non-finite coordinate is [`Error::Argument`](crate::Error::Argument).
pub fn classify_point(&self, p: [f64; 3]) -> Result<PointClass> {
match ffi::measure::classify_point(&self.0, p).map_err(error)? {
ffi::measure::POINT_OUTSIDE => Ok(PointClass::Outside),
ffi::measure::POINT_INSIDE => Ok(PointClass::Inside),
ffi::measure::POINT_ON_BOUNDARY => Ok(PointClass::OnBoundary),
// The C API writes only the three above; any other value is passed on as it came.
other => Err(crate::Error::Status(other)),
}
core::classify_point(self.core(), p)
}
/// Mass properties at `relative_accuracy` (clamped to [1e-8, 1e-1]; [`MEASURE_ACCURACY`] is
@@ -64,15 +58,13 @@ impl Brep {
/// touching along edges or at points, measure too. A body with no material (a sheet, an open
/// shell) has volume 0 and no inertia, its faces' area and the centroid of that area.
pub fn mass_properties(&self, relative_accuracy: f64) -> Result<MassProperties> {
let m = ffi::measure::mass_properties(&self.0, relative_accuracy).map_err(error)?;
let [area, volume, cx, cy, cz, ixx, iyy, izz, iyz, izx, ixy] = m;
Ok(MassProperties { area, volume, centroid: [cx, cy, cz], moments: [ixx, iyy, izz], products: [iyz, izx, ixy] })
core::mass_properties(self.core(), relative_accuracy)
}
/// The exact axis-aligned box, least corner then greatest: the extrema of every vertex, edge
/// and face on its exact curve or surface, not grown by tolerances or control hulls.
pub fn bounds(&self) -> Result<([f64; 3], [f64; 3])> {
ffi::measure::bounds(&self.0).map_err(error)
core::bounds(self.core())
}
/// How far edge `edge` (its curve over its interval) lies from the surface of face `face`,
@@ -82,22 +74,113 @@ impl Brep {
/// goes; SMLib keeps such an edge whole when making its UV trim only when its zone tolerance
/// (half the flat arrays' `EDGE_TOLERANCE`) covers this.
pub fn edge_face_distance(&self, edge: usize, face: usize) -> Result<f64> {
let (Ok(e), Ok(f)) = (i64::try_from(edge), i64::try_from(face)) else { return Err(error(ffi::ERR_ARG)) };
ffi::measure::edge_face_distance(&self.0, e, f).map_err(error)
core::edge_face_distance(self.core(), edge, face)
}
/// Total area of every face, each counted once, so sheets and open shells too.
pub fn area(&self, relative_accuracy: f64) -> Result<f64> {
ffi::measure::area(&self.0, relative_accuracy).map_err(error)
core::area(self.core(), relative_accuracy)
}
}
#[cfg(all(test, feature = "native"))]
/// The closed forms of the primitives' measures, for the tests.
#[cfg(test)]
pub(crate) mod closed {
use std::f64::consts::PI;
/// Area, volume, centroid, Ixx, Iyy, Izz about it (products are 0 for all), and the exact box.
pub(crate) struct Closed {
pub area: f64,
pub volume: f64,
pub centroid: [f64; 3],
pub moments: [f64; 3],
pub bounds: ([f64; 3], [f64; 3]),
}
/// An axis-aligned box from its lower corner.
pub(crate) fn cuboid(o: [f64; 3], size: [f64; 3]) -> Closed {
let [a, b, c] = size;
let v = a * b * c;
Closed {
area: 2.0 * (a * b + b * c + c * a),
volume: v,
centroid: [o[0] + a / 2.0, o[1] + b / 2.0, o[2] + c / 2.0],
moments: [v * (b * b + c * c) / 12.0, v * (a * a + c * c) / 12.0, v * (a * a + b * b) / 12.0],
bounds: (o, [o[0] + a, o[1] + b, o[2] + c]),
}
}
/// A cylinder about +Z on its base centre.
pub(crate) fn cylinder(o: [f64; 3], r: f64, h: f64) -> Closed {
let v = PI * r * r * h;
let ixx = v * (3.0 * r * r + h * h) / 12.0;
Closed {
area: 2.0 * PI * r * r + 2.0 * PI * r * h,
volume: v,
centroid: [o[0], o[1], o[2] + h / 2.0],
moments: [ixx, ixx, v * r * r / 2.0],
bounds: ([o[0] - r, o[1] - r, o[2]], [o[0] + r, o[1] + r, o[2] + h]),
}
}
pub(crate) fn sphere(o: [f64; 3], r: f64) -> Closed {
let v = 4.0 / 3.0 * PI * r * r * r;
let i = 0.4 * v * r * r;
Closed {
area: 4.0 * PI * r * r,
volume: v,
centroid: o,
moments: [i; 3],
bounds: ([o[0] - r, o[1] - r, o[2] - r], [o[0] + r, o[1] + r, o[2] + r]),
}
}
/// A frustum (a cone when `rt` is 0) about +Z on its base centre. Its volume integrals are
/// polynomials in z of degree at most 4, so three-point Gauss-Legendre gives them exactly.
pub(crate) fn cone(o: [f64; 3], rb: f64, rt: f64, h: f64) -> Closed {
let (x, w) = ([-(0.6f64).sqrt(), 0.0, (0.6f64).sqrt()], [5.0 / 9.0, 8.0 / 9.0, 5.0 / 9.0]);
let (mut v, mut mz, mut zz, mut izz) = (0.0, 0.0, 0.0, 0.0);
for (xi, wi) in x.iter().zip(w) {
let (z, wi) = (h / 2.0 * (1.0 + xi), wi * h / 2.0);
let rho = rb + (rt - rb) * z / h;
let disc = PI * rho * rho;
v += wi * disc;
mz += wi * disc * z;
zz += wi * disc * z * z;
izz += wi * disc * rho * rho / 2.0;
}
let zc = mz / v;
let ixx = izz / 2.0 + zz - v * zc * zc;
let s = ((rb - rt).powi(2) + h * h).sqrt();
let m = rb.max(rt);
Closed {
area: PI * (rb * rb + rt * rt + (rb + rt) * s),
volume: v,
centroid: [o[0], o[1], o[2] + zc],
moments: [ixx, ixx, izz],
bounds: ([o[0] - m, o[1] - m, o[2]], [o[0] + m, o[1] + m, o[2] + h]),
}
}
/// A ring torus about +Z (Pappus for area and volume).
pub(crate) fn torus(o: [f64; 3], big: f64, r: f64) -> Closed {
let v = 2.0 * PI * PI * big * r * r;
let ixx = v * (big * big / 2.0 + 5.0 * r * r / 8.0);
let m = big + r;
Closed {
area: 4.0 * PI * PI * big * r,
volume: v,
centroid: o,
moments: [ixx, ixx, v * (big * big + 0.75 * r * r)],
bounds: ([o[0] - m, o[1] - m, o[2] - r], [o[0] + m, o[1] + m, o[2] + r]),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Error;
use std::f64::consts::PI;
fn rel(a: f64, b: f64) -> f64 {
if b == 0.0 { a.abs() } else { (a / b - 1.0).abs() }
}
@@ -152,7 +235,9 @@ mod tests {
v
}
/// Closed forms: area, volume, centroid and Ixx, Iyy, Izz about it (products are 0 for all).
use closed::Closed;
/// A primitive with its closed forms.
struct Expected {
name: &'static str,
body: Brep,
@@ -163,67 +248,24 @@ mod tests {
}
#[allow(clippy::unwrap_used)] // test fixture
fn expected(name: &'static str, body: Result<Brep>, c: Closed) -> Expected {
Expected { name, body: body.unwrap(), area: c.area, volume: c.volume, centroid: c.centroid, moments: c.moments }
}
fn cylinder(name: &'static str, o: [f64; 3], r: f64, h: f64) -> Expected {
let v = PI * r * r * h;
let ixx = v * (3.0 * r * r + h * h) / 12.0;
Expected {
name,
body: Brep::make_cylinder(o, r, h).unwrap(),
area: 2.0 * PI * r * r + 2.0 * PI * r * h,
volume: v,
centroid: [o[0], o[1], o[2] + h / 2.0],
moments: [ixx, ixx, v * r * r / 2.0],
}
expected(name, Brep::make_cylinder(o, r, h), closed::cylinder(o, r, h))
}
#[allow(clippy::unwrap_used)] // test fixture
fn sphere(name: &'static str, o: [f64; 3], r: f64) -> Expected {
let v = 4.0 / 3.0 * PI * r * r * r;
let i = 0.4 * v * r * r;
Expected { name, body: Brep::make_sphere(o, r).unwrap(), area: 4.0 * PI * r * r, volume: v, centroid: o, moments: [i; 3] }
expected(name, Brep::make_sphere(o, r), closed::sphere(o, r))
}
/// A frustum (a cone when `rt` is 0) about +Z on its base centre. Its volume integrals are
/// polynomials in z of degree at most 4, so three-point Gauss-Legendre gives them exactly.
#[allow(clippy::unwrap_used)] // test fixture
fn cone(name: &'static str, o: [f64; 3], rb: f64, rt: f64, h: f64) -> Expected {
let (x, w) = ([-(0.6f64).sqrt(), 0.0, (0.6f64).sqrt()], [5.0 / 9.0, 8.0 / 9.0, 5.0 / 9.0]);
let (mut v, mut mz, mut zz, mut izz) = (0.0, 0.0, 0.0, 0.0);
for i in 0..3 {
let (z, wi) = (h / 2.0 * (1.0 + x[i]), w[i] * h / 2.0);
let rho = rb + (rt - rb) * z / h;
let disc = PI * rho * rho;
v += wi * disc;
mz += wi * disc * z;
zz += wi * disc * z * z;
izz += wi * disc * rho * rho / 2.0;
}
let zc = mz / v;
let ixx = izz / 2.0 + zz - v * zc * zc;
let s = ((rb - rt).powi(2) + h * h).sqrt();
Expected {
name,
body: Brep::make_cone(o, rb, rt, h).unwrap(),
area: PI * (rb * rb + rt * rt + (rb + rt) * s),
volume: v,
centroid: [o[0], o[1], o[2] + zc],
moments: [ixx, ixx, izz],
}
expected(name, Brep::make_cone(o, rb, rt, h), closed::cone(o, rb, rt, h))
}
/// A ring torus about +Z (Pappus for area and volume).
#[allow(clippy::unwrap_used)] // test fixture
fn torus(name: &'static str, o: [f64; 3], big: f64, r: f64) -> Expected {
let v = 2.0 * PI * PI * big * r * r;
let ixx = v * (big * big / 2.0 + 5.0 * r * r / 8.0);
Expected {
name,
body: Brep::make_torus(o, big, r).unwrap(),
area: 4.0 * PI * PI * big * r,
volume: v,
centroid: o,
moments: [ixx, ixx, v * (big * big + 0.75 * r * r)],
}
expected(name, Brep::make_torus(o, big, r), closed::torus(o, big, r))
}
/// Every primitive against its closed form, at the origin and 2e5 mm out, to 1e-6 (tests/oracle
+38 -160
View File
@@ -6,36 +6,7 @@
//! edge (face) nearest to it, which must lie within `tolerance` of it and be nearer than any other
//! by more than the body's modelling tolerance ([`Error::NoMatch`], [`Error::Ambiguous`]).
use crate::{Brep, Error, Result, error, ffi, init};
/// What size builds a blend SMLib refused ([`blend_fit`]): from the refusal's own cause, never by
/// building again.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Fit {
/// The largest size (a round's radius, a chamfer's distance, a chord's width) that builds.
Size(f64),
/// The largest setback of a set-back corner that fits (the one asked for is too long).
Setback(f64),
/// The factor (< 1) a two-distance chamfer's distances are both scaled by to build.
Scale(f64),
/// The smallest setback that builds the corner (the one asked for is too short: its fill is
/// certified convex only from there).
SetbackAtLeast(f64),
}
/// The size that builds the last blend this process's SMLib refused as too large
/// ([`Error::NoRoom`]) or whose setback it refused as too short; `None` where it names no single
/// size. Read it right after the refused call: SMLib keeps only the last one.
pub fn blend_fit() -> Option<Fit> {
let (size, what) = ffi::ops::blend_fit_size()?;
match what {
ffi::ops::FIT_SIZE => Some(Fit::Size(size)),
ffi::ops::FIT_SETBACK => Some(Fit::Setback(size)),
ffi::ops::FIT_SCALE => Some(Fit::Scale(size)),
ffi::ops::FIT_SETBACK_AT_LEAST => Some(Fit::SetbackAtLeast(size)),
_ => None,
}
}
use crate::{Brep, Error, Result, core};
/// How [`Brep::boolean`] combines two bodies.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -70,27 +41,12 @@ pub enum ChamferSecond {
Angle(f64),
}
/// Most bodies [`Brep::union_all`] takes (the C API's limit).
/// Most bodies [`Brep::union_all`] takes.
pub const MAX_UNION_BREPS: usize = 100_000;
/// Most points one selection may hold (the C API's limit).
/// Most points one selection may hold.
pub const MAX_POINTS: usize = 4096;
fn pick_error((s, at): ffi::ops::PickError) -> Error {
match (s, at) {
(ffi::ops::ERR_PICK_NONE, Some(point)) => Error::NoMatch { point },
(ffi::ops::ERR_PICK_AMBIGUOUS, Some(point)) => Error::Ambiguous { point },
(s, _) => error(s),
}
}
fn setback_error((s, at, corner): ffi::ops::SetbackError) -> Error {
match corner {
Some(corner) => Error::CornerRefused { corner },
None => pick_error((s, at)),
}
}
/// One edge round an opened corner's hole ([`Brep::blend_setback_open`]): its index in the
/// sheet's edge order, the face beside it in its face order, and whether that face, run
/// counter-clockwise about its outward normal, runs the edge from its start vertex to its end.
@@ -107,41 +63,13 @@ pub struct Hole {
pub edges: Vec<HoleEdge>,
}
/// The holes `smlib_blend_setback_open` wrote: `corners` of them, each n and n triples.
fn holes(values: &[i64], corners: usize) -> Option<Vec<Hole>> {
let mut at = 0usize;
let mut out = Vec::with_capacity(corners);
for _ in 0..corners {
let n = usize::try_from(*values.get(at)?).ok()?;
at += 1;
let mut edges = Vec::with_capacity(n);
for _ in 0..n {
let (e, f, fw) = (values.get(at)?, values.get(at + 1)?, values.get(at + 2)?);
edges.push(HoleEdge { edge: usize::try_from(*e).ok()?, face: usize::try_from(*f).ok()?, forward: *fw != 0 });
at += 3;
}
out.push(Hole { edges });
}
(at == values.len()).then_some(out)
}
impl Brep {
/// `self` and `other` combined by `op`: `None` when the result is empty (disjoint bodies
/// intersected, a body subtracted by one that encloses it). Several separate solids come back
/// as one body, counted by [`Brep::solid_count`]. Tagged operands ([`Brep::tagged`]) give a
/// result whose [`Brep::history`] says where each face and edge came from.
pub fn boolean(&self, other: &Brep, op: BooleanOp) -> Result<Option<Brep>> {
init()?;
let op = match op {
BooleanOp::Union => ffi::ops::BOOLEAN_UNION,
BooleanOp::Subtract => ffi::ops::BOOLEAN_SUBTRACT,
BooleanOp::Intersect => ffi::ops::BOOLEAN_INTERSECT,
};
match ffi::ops::boolean(&self.0, &other.0, op) {
Ok(h) => Ok(Some(Brep(h))),
Err(ffi::ERR_EMPTY) => Ok(None),
Err(s) => Err(error(s)),
}
core::boolean::boolean(self.core(), other.core(), op).map(|r| r.map(Brep::made_core))
}
/// The union of `breps` in one call: they are unioned pairwise, level by level, so none takes
@@ -149,92 +77,62 @@ impl Brep {
/// tagged each with its own number ([`Brep::tagged`]) give a result whose [`Brep::history`]
/// names them. At least one body and at most [`MAX_UNION_BREPS`].
pub fn union_all(breps: &[&Brep]) -> Result<Brep> {
init()?;
if breps.is_empty() || breps.len() > MAX_UNION_BREPS {
return Err(error(ffi::ERR_ARG));
return Err(Error::Argument);
}
let handles: Vec<&ffi::Handle> = breps.iter().map(|b| &b.0).collect();
ffi::ops::boolean_union_all(&handles).map(Brep).map_err(error)
let bodies: Vec<&core::Body> = breps.iter().map(|b| b.core()).collect();
core::boolean::union_all(&bodies).map(Brep::made_core)
}
/// `self` and `other`, sheets of planar faces on one plane ([`Brep::planar_faces`]), combined
/// as regions of that plane by `op`: `None` when the result is empty. The result's edges are
/// the inputs' own curves, split where they cross.
pub fn boolean2d(&self, other: &Brep, op: BooleanOp) -> Result<Option<Brep>> {
init()?;
let op = match op {
BooleanOp::Union => ffi::ops::BOOLEAN_UNION,
BooleanOp::Subtract => ffi::ops::BOOLEAN_SUBTRACT,
BooleanOp::Intersect => ffi::ops::BOOLEAN_INTERSECT,
};
match ffi::ops::boolean2d(&self.0, &other.0, op) {
Ok(h) => Ok(Some(Brep(h))),
Err(ffi::ERR_EMPTY) => Ok(None),
Err(s) => Err(error(s)),
}
core::boolean::boolean2d(self.core(), other.core(), op).map(|r| r.map(Brep::made_core))
}
/// `self` with each face `tool` meets cut along the intersection, the pieces kept as faces of
/// one body: `None` when the tool meets no face. Tagged operands give the pieces' history
/// (each piece is a split of the face it was cut from).
pub fn imprint(&self, tool: &Brep) -> Result<Option<Brep>> {
init()?;
match ffi::ops::imprint(&self.0, &tool.0) {
Ok(h) => Ok(Some(Brep(h))),
Err(ffi::ops::ERR_NO_CUT) => Ok(None),
Err(s) => Err(error(s)),
}
core::boolean::imprint(self.core(), tool.core()).map(|r| r.map(Brep::made_core))
}
/// The number of separate solid regions (voids not counted).
pub fn solid_count(&self) -> Result<usize> {
ffi::ops::solid_count(&self.0).map(|n| usize::try_from(n).unwrap_or(0)).map_err(error)
crate::core::solid_count(self.core())
}
/// The edges nearest to `edges` (each within `tolerance`), exactly those, rounded
/// at `radius`.
pub fn fillet(&self, edges: &[[f64; 3]], tolerance: f64, radius: f64) -> Result<Brep> {
self.pick_op(ffi::ops::Pick::Fillet, edges, tolerance, radius, false)
self.core_blend(Blend::Round, edges, &[radius], tolerance, &[], &[], core::blend::Corners::Filled)
}
/// The edges nearest to `edges` (each within `tolerance`), exactly those, cut by
/// an equal-distance chamfer `distance` back along each face (measured at each edge's
/// midpoint), with bevelled corners.
pub fn chamfer(&self, edges: &[[f64; 3]], tolerance: f64, distance: f64) -> Result<Brep> {
self.pick_op(ffi::ops::Pick::Chamfer, edges, tolerance, distance, false)
self.core_blend(Blend::Chamfer, edges, &[distance], tolerance, &[], &[], core::blend::Corners::Filled)
}
/// The edges nearest to `edges` (each within `tolerance`), exactly those, blended
/// as `kind`, edge i at `sizes[i]`.
pub fn blend(&self, kind: Blend, edges: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<Brep> {
init()?;
if edges.len() > MAX_POINTS || edges.is_empty() || sizes.len() != edges.len() {
return Err(Error::Argument);
}
let kind = match kind {
Blend::Round => ffi::ops::BLEND_ROUND,
Blend::Curvature => ffi::ops::BLEND_CURVATURE,
Blend::Chord => ffi::ops::BLEND_CHORD,
Blend::Chamfer => ffi::ops::BLEND_CHAMFER,
};
ffi::ops::blend(kind, &self.0, edges, sizes, tolerance).map(Brep).map_err(pick_error)
self.core_blend(kind, edges, sizes, tolerance, &[], &[], core::blend::Corners::Filled)
}
/// [`Brep::blend`], with whether each point picked an edge that a larger round, overflowing
/// its faces, removed whole: that edge is gone and not rounded (SMLib rounds the edges whose
/// blends overflow first, then what is left of the others).
pub fn blend_dropping(&self, kind: Blend, edges: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<(Brep, Vec<bool>)> {
init()?;
if edges.len() > MAX_POINTS || edges.is_empty() || sizes.len() != edges.len() {
return Err(Error::Argument);
}
let kind = match kind {
Blend::Round => ffi::ops::BLEND_ROUND,
Blend::Curvature => ffi::ops::BLEND_CURVATURE,
Blend::Chord => ffi::ops::BLEND_CHORD,
Blend::Chamfer => ffi::ops::BLEND_CHAMFER,
};
ffi::ops::blend_dropping(kind, &self.0, edges, sizes, tolerance).map(|(b, d)| (Brep(b), d)).map_err(pick_error)
core::blend::blend_dropping(self.core(), kind, edges, sizes, tolerance).map(|(b, d)| (Brep::made_core(b), d))
}
/// [`Brep::blend`] with corner setbacks: each of `corners` picks the vertex nearest it
@@ -251,7 +149,6 @@ impl Brep {
setbacks: &[f64],
tolerance: f64,
) -> Result<Brep> {
init()?;
if edges.len() > MAX_POINTS
|| edges.is_empty()
|| sizes.len() != edges.len()
@@ -261,13 +158,7 @@ impl Brep {
{
return Err(Error::Argument);
}
let kind = match kind {
Blend::Round => ffi::ops::BLEND_ROUND,
Blend::Curvature => ffi::ops::BLEND_CURVATURE,
Blend::Chord => ffi::ops::BLEND_CHORD,
Blend::Chamfer => ffi::ops::BLEND_CHAMFER,
};
ffi::ops::blend_setback(kind, &self.0, edges, sizes, corners, setbacks, tolerance).map(Brep).map_err(setback_error)
self.core_blend(kind, edges, sizes, tolerance, corners, setbacks, core::blend::Corners::Filled)
}
/// [`Brep::blend_setback`] with each set-back corner left open: SMLib's patch between the
@@ -284,7 +175,6 @@ impl Brep {
setbacks: &[f64],
tolerance: f64,
) -> Result<(Brep, Vec<Hole>)> {
init()?;
if edges.len() > MAX_POINTS
|| edges.is_empty()
|| sizes.len() != edges.len()
@@ -294,16 +184,8 @@ impl Brep {
{
return Err(Error::Argument);
}
let kind = match kind {
Blend::Round => ffi::ops::BLEND_ROUND,
Blend::Curvature => ffi::ops::BLEND_CURVATURE,
Blend::Chord => ffi::ops::BLEND_CHORD,
Blend::Chamfer => ffi::ops::BLEND_CHAMFER,
};
let (h, values) = ffi::ops::blend_setback_open(kind, &self.0, edges, sizes, corners, setbacks, tolerance).map_err(setback_error)?;
// A buffer that does not read as the C API documents it is a buffer error.
let holes = holes(&values, corners.len()).ok_or_else(|| error(ffi::ERR_BUFFER))?;
Ok((Brep(h), holes))
core::blend::blend_holes(self.core(), kind, edges, sizes, tolerance, corners, setbacks, core::blend::Corners::Open)
.map(|(b, holes)| (Brep::made_core(b), holes))
}
/// The one edge nearest to `edge` (within `tolerance`) rounded with a radius
@@ -311,13 +193,7 @@ impl Brep {
/// `radii` over the position along the edge, 0 at its end nearest `start` and 1 at the other
/// (the edge curve's parameter scaled to 0..1).
pub fn fillet_variable(&self, edge: [f64; 3], tolerance: f64, start: [f64; 3], degree: usize, knots: &[f64], radii: &[f64]) -> Result<Brep> {
init()?;
let degree = i32::try_from(degree).map_err(|_| Error::Argument)?;
ffi::ops::fillet_variable(&self.0, &edge, tolerance, &start, degree, knots, radii).map(Brep).map_err(|s| match s {
ffi::ops::ERR_PICK_NONE => Error::NoMatch { point: 0 },
ffi::ops::ERR_PICK_AMBIGUOUS => Error::Ambiguous { point: 0 },
s => error(s),
})
core::blend::fillet_variable(self.core(), edge, tolerance, start, degree, knots, radii).map(Brep::made_core)
}
/// The edges nearest to `edges` (each within `tolerance`), exactly those, cut by
@@ -325,15 +201,10 @@ impl Brep {
/// and by `second` on the other, with bevelled corners (setbacks measured at each edge's
/// midpoint).
pub fn chamfer_sides(&self, edges: &[[f64; 3]], tolerance: f64, distance: f64, second: ChamferSecond, first: [f64; 3]) -> Result<Brep> {
init()?;
if edges.is_empty() || edges.len() > MAX_POINTS {
return Err(Error::Argument);
}
let (value, angle) = match second {
ChamferSecond::Distance(d) => (d, false),
ChamferSecond::Angle(a) => (a, true),
};
ffi::ops::chamfer_sides(&self.0, edges, tolerance, distance, value, angle, &first).map(Brep).map_err(pick_error)
core::blend::chamfer_sides(self.core(), edges, tolerance, distance, second, first).map(Brep::made_core)
}
/// Hollowed to a wall of `thickness` (negative: inside the original surface, positive:
@@ -342,7 +213,7 @@ impl Brep {
/// whose walls would just meet (a hole exactly twice the wall from a side) is
/// [`Error::WallsTouch`].
pub fn shell(&self, open_faces: &[[f64; 3]], tolerance: f64, thickness: f64) -> Result<Brep> {
self.pick_op(ffi::ops::Pick::Shell, open_faces, tolerance, thickness, true)
core::offset::shell(self.core(), open_faces, tolerance, thickness).map(Brep::made_core)
}
/// The solid with the faces nearest to `faces` (each within `tolerance`) moved `distance`
@@ -350,15 +221,14 @@ impl Brep {
/// faces around them extended or trimmed to meet them. Faces moved in past all of the body
/// behind them are [`Error::NoRoom`].
pub fn offset_faces(&self, faces: &[[f64; 3]], tolerance: f64, distance: f64) -> Result<Brep> {
self.pick_op(ffi::ops::Pick::OffsetFaces, faces, tolerance, distance, false)
core::offset::offset_faces(self.core(), faces, tolerance, distance).map(Brep::made_core)
}
/// A sheet thickened into a solid by `thickness` along its surfaces' normals (negative:
/// against them), or by half each way when `symmetric`, with ruled walls along its open
/// edges. A closed solid is [`Error::Argument`].
pub fn thicken(&self, thickness: f64, symmetric: bool) -> Result<Brep> {
init()?;
ffi::ops::thicken(&self.0, thickness, symmetric).map(Brep).map_err(error)
core::offset::thicken(self.core(), thickness, symmetric).map(Brep::made_core)
}
/// This body with its topological edges and vertices removed (SMLib's
@@ -367,11 +237,10 @@ impl Brep {
/// The edges nearest to `keep` (each within `tolerance`) stay. The shape is unchanged, and
/// SMLib's history tags follow the merge.
pub fn merge_faces(&self, keep: &[[f64; 3]], tolerance: f64) -> Result<Brep> {
init()?;
if keep.len() > MAX_POINTS {
return Err(Error::Argument);
}
ffi::ops::merge_faces(&self.0, keep, tolerance).map(Brep).map_err(pick_error)
core::merge::merge_faces(self.core(), keep, tolerance).map(Brep::made_core)
}
/// The flat faces nearest to `faces` (each within `tolerance`) tilted by `angle` radians
@@ -388,19 +257,28 @@ impl Brep {
pull: [f64; 3],
angle: f64,
) -> Result<Brep> {
init()?;
if faces.is_empty() || faces.len() > MAX_POINTS {
return Err(Error::Argument);
}
ffi::ops::draft(&self.0, faces, tolerance, &neutral_origin, &neutral_normal, &pull, angle).map(Brep).map_err(pick_error)
core::offset::draft(self.core(), faces, tolerance, neutral_origin, neutral_normal, pull, angle).map(Brep::made_core)
}
fn pick_op(&self, kind: ffi::ops::Pick, points: &[[f64; 3]], tolerance: f64, size: f64, allow_none: bool) -> Result<Brep> {
init()?;
if points.len() > MAX_POINTS || (points.is_empty() && !allow_none) {
/// [`Brep::blend`] and its kin on solidcore (`core::blend::blend`).
#[allow(clippy::too_many_arguments)]
fn core_blend(
&self,
kind: Blend,
edges: &[[f64; 3]],
sizes: &[f64],
tolerance: f64,
corners: &[[f64; 3]],
setbacks: &[f64],
open: core::blend::Corners,
) -> Result<Brep> {
if edges.len() > MAX_POINTS || edges.is_empty() {
return Err(Error::Argument);
}
ffi::ops::pick_op(kind, &self.0, points, tolerance, size).map(Brep).map_err(pick_error)
core::blend::blend(self.core(), kind, edges, sizes, tolerance, corners, setbacks, open).map(Brep::made_core)
}
}
+3 -9
View File
@@ -1,6 +1,5 @@
//! The operations against analytic volumes and topology counts (built with SMLib only). `SMLIB_OPS_SHOTS=<dir>` also
//! The operations against analytic volumes and topology counts. `SMLIB_OPS_SHOTS=<dir>` also
//! writes every result there as a native `.smb`, to render.
#![cfg(feature = "native")]
use super::*;
use crate::CheckLevel;
@@ -397,19 +396,14 @@ fn draft_tilts_a_side_about_its_line_on_the_neutral_plane() {
fn a_setback_corner_left_open_returns_its_hole() {
let b = Brep::make_box([0.0; 3], [60.0, 40.0, 20.0]).unwrap();
let edges = [[30.0, 40.0, 20.0], [60.0, 20.0, 20.0], [60.0, 40.0, 10.0]];
solidcraft_profile::set_enabled(true);
let mark = solidcraft_profile::mark();
let (open, holes) = b.blend_setback_open(Blend::Round, &edges, &[5.0; 3], &[[60.0, 40.0, 20.0]], &[8.0], 0.1).unwrap();
// The call is a profiler span like every other, timed by the C API.
let report = mark.report();
assert!(report.rows.iter().any(|r| r.category == "smlib" && r.name == "smlib_blend_setback_open"), "{report:?}");
shot(&open, "setback_open");
assert_eq!(open.counts().unwrap(), [9, 30, 22]);
assert!(!open.is_manifold_solid().unwrap());
let [hole] = holes.as_slice() else { panic!("one corner: {holes:?}") };
assert_eq!(hole.edges.len(), 12);
let flat = open.export().unwrap();
let ends = flat.get_i64(crate::flat::EDGE_VERTICES).unwrap();
let arrays = open.arrays().unwrap();
let ends = &arrays.ints[&crate::flat::EDGE_VERTICES];
let end = |h: &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];

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