Research: truck B-rep kernel evaluation (facts, torture harness, OCCT STEP checks)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-14 04:58:39 +02:00
commit 1aa1ee2e83
9 changed files with 3357 additions and 0 deletions

View file

@ -0,0 +1,122 @@
# truck evaluation (2026-10-14)
Serves the operator's request to evaluate truck (https://github.com/ricosjp/truck), a Rust B-rep kernel, after the Aetheris evaluation (`../aetheris-evaluation/`), same method and cases. Campaign brush-tree, session self-2026-10-14-ag. Facts only, measured on Yggdrasil at truck 88ed0052.
Serves the operator's request to evaluate https://github.com/ricosjp/truck (Rust B-rep CAD kernel), campaign brush-tree, session self-2026-10-14-ag. Same method and cases as `F:\Projects\GameCult.Geometry.Csg\docs\research\aetheris-evaluation\README.md`. Facts only; no recommendation.
Measured at truck commit `88ed005249e5e3a6b07f62425399435905cd3ab6` (master, 2026-09-28, "Merge branch 'cargo-upgrade-20260928'"), cloned read-only to `<scratch>\eyes-truck\repo`. Nothing from truck was built or run on Starfire; every build/test/run was on Yggdrasil through `ygg-verify.sh` (image eureka-verify-rust, rustc/cargo 1.95.0, Debian 12, 4 CPUs, 6 GiB).
`<scratch>` = `C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\5ea0b87f-66d0-4b24-8d36-45015e4cedc1\scratchpad`.
Tags: [V] measured or read this pass; [S] stated by the repo's own docs only; [M] marketing claim.
Logs: `eyes-truck\jobs\job1.log` (truck's own tests), `job2.log` (first harness run, tol 0.05), `job4.log` (harness: 35 cases x tol 0.05/0.01/0.2, OCCT checks, truck reading OCCT STEP), `job5.log` (licences, wasm, determinism), `job6.log` (STEP entity census, case-25 stage timing, tolerance sweep). `jobs\analyze.py` digests job4.
Harness (mine, not truck's): `eyes-truck\harness\` = local git repo (`src\main.rs`, `check.py`, `cases.json` copied unchanged from the Aetheris harness, `job.sh`, `job5.sh`, `job6.sh`); depends on truck by git rev 88ed0052. Built `--release`.
## Q1 Shape
- [V] Repo: GitHub + GitLab (CI is `.gitlab-ci.yml`); 2872 commits since 2020-05-18; 273 `.rs` files, 71,555 lines of Rust (git ls-files). GitHub: 1621 stars, 41 open issues, not archived, Apache-2.0 (GitHub API). README: "Truck is a RUst Cad Kernel"; concept "Rust and WebGPU", "re-implementing the B-rep with NURBS".
- [V] Maintainers: one organisation's staff. Commit authors (all history): Yoshinori Tanimura 2482 + alias "Tanimura Yoshinori" 110, "truck" 244, then Moritz Moeller 9, Greg Rauhoeft 5, Jinlei Li 3, others 1-3. Since 2025-10-14: 253 commits, by Tanimura (127+72), "truck" 52, thayashi-tech 1. Commits per calendar year: 2022 298, 2023 393, 2024 389, 2025 325, 2026 (to 09-28) 189. Many recent commits are titled "cargo upgrade" (git log -- truck-shapeops: the last five are all "cargo upgrade", 2026-05-25..2026-09-28).
- [V] Releases: 73 git tags; newest tag/crates.io release is 2024-09-20 (truck-modeling 0.6.0, shapeops 0.4.0, stepio 0.3.0, meshalgo 0.4.0, geometry 0.5.0...; crates.io API). master is two years of unreleased work: CHANGELOG "Unreleased" lists EdgeBlendSurface, Offset geometry, fillet for a single edge, truck-drafting (2D crate), truck-assembly (assembly read/write in STEP), higher-order derivatives, wgpu v26. The git-rev dependency I measured is this unreleased master, not the crates.io 0.4.0 shapeops. crates.io downloads: truck-geometry 130k, truck-modeling 127k, truck-stepio 103k, truck-meshalgo 60k, truck-shapeops 13k.
- [V] Crates (version, Rust lines): truck-base 0.5.0 (3,207), truck-geotrait 0.4.0 (2,211), truck-geometry 0.5.0 (15,200: B-spline/NURBS, knot vectors, decorators), truck-topology 0.6.0 (5,956: vertex/edge/wire/face/shell/solid), truck-polymesh 0.6.0 (2,901: mesh structs, OBJ/STL), truck-meshalgo 0.4.0 (6,617: tessellation via `spade` CDT, mesh filters/analyzers, VTK), truck-modeling 0.6.0 (3,878: builder, tsweep/rsweep/homotopy), truck-shapeops 0.4.0 (8,531: booleans, fillet, healing), truck-stepio 0.3.0 (10,537: STEP in/out via `ruststep`), truck-assembly 0.1.0 (1,740), truck-drafting 0.1.0 (1,883, 2D), truck-derivers 0.1.0 (1,344, derive macros), truck-platform 0.6.0 (2,981, wgpu), truck-rendimpl 0.6.0 (3,797, wgpu rendering), truck-js 0.2.0 (592, wasm-bindgen wrapper). Plus example-pages-generator and readme-generator in the workspace.
- [V] Licence: repo `LICENSE` is Apache-2.0; 13 crate manifests say `license = "Apache-2.0"`; `truck-assembly` and `truck-drafting` manifests carry no `license` field (cargo metadata: truck-assembly licence null). Transitive dependency tree of the harness (truck-modeling+shapeops+meshalgo+stepio, 135 packages, cargo metadata, job5.log): MIT OR Apache-2.0 85, Apache-2.0 15, MIT 14, Apache-2.0 OR MIT 5, MIT/Apache-2.0 4, Unlicense OR MIT 3, Zlib OR Apache-2.0 OR MIT 2, BSD-2-Clause 1, Zlib 1, Unlicense/MIT 1, MIT OR Apache-2.0 OR LGPL-2.1-or-later 1 (r-efi, an OR), no licence field 2 (truck-assembly, my harness). No GPL/AGPL. Dependencies that matter: ruststep 0.4.0 (Apache-2.0, last published 2024-09-20), spade 2.15.1 (MIT OR Apache-2.0), cgmath (via truck-base), rayon, vtkio, nom 3.2.1 and quick-xml 0.22.0 (cargo prints a "future-incompat" warning for both, job4.log).
- [V] MSRV: no `rust-version` in any Cargo.toml and no MSRV text anywhere (grep). Editions: 2021 for most crates, 2024 for truck-assembly and truck-drafting (so those need rustc >= 1.85). Builds on 1.95.0.
- [V] CI: GitLab (`.gitlab-ci.yml`) jobs `cpu-test` (docker image, `cargo make ci-cpu-test`), `cpu-nightly-test`, `meshalgo-features-buildcheck`, `gpu-test` (artifacts: images), `wasm-test` (deno lint + `cargo make wasm-test`), `page-build`, deploy of example pages. `Makefile.toml` cpu-test = `cargo test -p truck-base -p truck-geotrait -p truck-geometry -p truck-topology -p truck-polymesh -p truck-meshalgo -p truck-modeling -p truck-shapeops -p truck-stepio --features derive --features polynomial`. Tests that use sample data need the git submodule `resources` (github.com/ricosjp/resources4truck).
- [V] Surface/curve types. truck-geometry: Plane, Sphere, Torus (specifieds/), Circle, Line, Hyperbola, Parabola; BSplineCurve/Surface, NurbsCurve/Surface; decorators RevolutedCurve, ExtrudedCurve, Processor (matrix-transformed), PCurve, IntersectionCurve, TrimmedCurve, AfSurface, EdgeBlend, RbfSurface, Offset, homotopy. The ready-made enums in truck-modeling are narrower: `Surface` = {Plane, BSplineSurface, NurbsSurface, RevolutedCurve(Processor<RevolutedCurve<Curve>, Matrix4>)} (truck-modeling/src/geometry.rs:126-135); `Curve` = {Line, BSplineCurve, NurbsCurve, IntersectionCurve} (:24-32). So a cylinder, sphere or torus built with the modeling builder is a surface of revolution (Line or NURBS circle swept), not an analytic Cylinder/Sphere/Torus surface type. NURBS is first-class.
- [V] How booleans work (truck-shapeops/src/transversal/integrate/mod.rs; entry points `and(solid0, solid1, tol)` and `or(...)` returning `Option<Solid>`; there is no subtract, the examples invert the second solid with `solid.not()` and call `and`): both shells are triangulated at `tol` (`shell.triangulation(tol)`); triangle-triangle intersections give polylines (`polyline_construction`); polylines are re-fitted onto the two true surfaces by Newton iteration (`IntersectionCurve`, `search_triple(..., 100)`, intersection_curve/mod.rs); faces are divided by the loops, classified and/or by crossing a ray against the other mesh; new edges are replaced by a quadratic B-spline approximation of the intersection curve within `tol` (`BSplineCurve::quadratic_approximation(ic, range, tol, 100)`, integrate/mod.rs:`altshell_to_shell`). So it is a general surface-surface path through tessellation, not a table of special cases. `tol` is both the tessellation tolerance and the intersection-curve approximation tolerance; truck-base `TOLERANCE` = 1e-6 (tolerance.rs:6) is the geometric coincidence tolerance (`near`). A non-positive tol hits `nonpositive_tolerance!`. Failure is `None` with no message; some failures are panics (Q3).
- [S] Documented limits (truck-shapeops/src/lib.rs:4-9): "Boolean operations are currently supported only for shapes where faces intersect transversally. Cases where faces are tangent to each other are not yet supported. ... performance optimization using BSP ... remains a future task." Fillet: "single edge whose end vertices are each adjacent to exactly three faces. Continuous edges are currently unsupported." truck-stepio lib.rs:1-6 states "Shapes created by set operations cannot be output yet" (contradicted by my measurement in Q4: 8 boolean results exported and read as valid by OCCT).
- [V] Also shipped in shapeops: `healing` (SplitClosedEdgesAndFaces, RobustSplitClosedEdgesAndFaces) and `fillet` (`simple_fillet`, `fillet_with_side`).
## Q2 Build and own tests on Linux
- [V] Job1 (job1.log), image rust 1.95.0, after `git submodule update --init resources` (fetched resources4truck at 9bf9de14): `cargo test --no-fail-fast -p truck-base -p truck-geotrait -p truck-geometry -p truck-topology -p truck-polymesh -p truck-meshalgo -p truck-modeling -p truck-shapeops -p truck-stepio -p truck-assembly -p truck-drafting -p truck-derivers --features derive --features polynomial` (debug profile): exit 0, 745 passed, 0 failed, 6 ignored (unit + integration + doc-tests).
- [V] Per crate (cargo prints crates alphabetically; the crate attribution below is inferred from that order and the test file names, passed counts unit+integration / doc-tests): assembly 2 / 39; base 28 / 55; derivers 0 / 0; drafting 20 / 25; geometry 82 / 139; geotrait 14 / 0; meshalgo 42 / 21; modeling 24 / 25; polymesh 13 / 15; shapeops 24 / 0 (21 unit + 3 in tests/fillet.rs); stepio 53 / 5 (3 unit + 39 tests/input + 3 tests/io + 8 tests/output); topology 7 / 112.
- [V] Not run: truck-platform, truck-rendimpl, truck-js tests (GPU/wasm CI jobs), nightly lane. `large-solid-torus` test is `#[ignore]`d (1 ignored in topology).
- [V] Compile times (4 CPUs, cold cache): my harness release build 1 m 06 s (job4.log); `truck-js` wasm release build 1 m 18 s (job5.log).
- [V] `cargo build --release --target wasm32-unknown-unknown -p truck-js` succeeds after `rustup target add wasm32-unknown-unknown`: `truck_js.wasm` 3,928,263 bytes (workspace sets opt-level "z" for truck-js). Not run in a browser. truck-js exports 12 builder fns, 20 shape, 5 polygon, 4 shapeops (and/or/not), 4 step_in wasm_bindgen items (grep counts).
## Q3 Boolean torture (the Aetheris cases.json: 35 cases, same ops/geometry/expected volumes)
Method: `harness\src\main.rs` builds primitives with truck-modeling (box = 3 tsweeps then scaled; cylinder = rsweep circle + tsweep; sphere = rsweep semicircle + `cone`; torus = two rsweeps), centred as in the Aetheris harness, rotates about X by rx, translates, flips any primitive whose tessellated volume is negative with `Solid::not()`. union = `or`, intersect = `and`, subtract = `and(a, not(b))`, sequential for multi-step cases. Each case is its own process with a 150 s timeout. "refused" = `None` (no message exists in the API). Release build. Tolerance `tol` is truck's only knob, so I ran three: 0.05, 0.01, 0.2 (model sizes are 10-100 units). Reference volumes: the cases' analytic `exp` plus OCCT booleans on identical primitives (`check.py ref`).
Per-case outcome (S = succeeded, R = refused/None, P = panic; boolean time at tol 0.05 / 0.01 / 0.2 in ms; steps for case 21 in parentheses):
| # | case | tol 0.05 | tol 0.01 | tol 0.2 |
|---|---|---|---|---|
| 01 | coplanar face union | R 2 | R 2 | R 2 |
| 02 | flush slab subtract | R 3 | R 2 | R 2 |
| 03 | coplanar top pocket | P 3 | P 4 | P 3 |
| 04 | coincident-edge union | R 2 | R 2 | R 2 |
| 05 | coincident-edge subtract | R 4 | R 2 | R 2 |
| 06 | tangent cylinders union | R 21 | R 34 | R 18 |
| 07/08/09 | Steinmetz intersect / union / subtract | R 23/30/26 | R 41/40/42 | R 16/16/18 |
| 10 | sliver wall 1e-7 | R 4 | R 2 | R 2 |
| 11 | pocket leaving 1e-7 floor | S 2 | S 2 | S 2 |
| 12 | pocket leaving 1e-4 floor | S 2 | S 2 | S 2 |
| 13 | spheres exactly tangent, union | S 17 | S 90 | S 13 |
| 14 | spheres 1e-9 apart/overlap, union | S 17 | S 90 | S 15 |
| 15 | spheres overlap 1e-4 | R 59 | R 152 | R 44 |
| 16 | spheres overlap (6 apart) | R 306 | R 1044 | R 159 |
| 17 | box minus sphere on top face | R 13 | R 65 | R 11 |
| 18 | torus half cut by box | P 1311 | P 2320 | P 757 |
| 19 | torus union cylinder | R 105 | R 163 | R 52 |
| 20 | torus minus cylinder (tangent) | R 96 | R 151 | R 51 |
| 21 | 10x10 hole grid (100 steps) | P at step 3 (2940 ms) | R at step 1 | R at step 2 |
| 22 | two tangent holes | R 743 | R 1114 | R 193 |
| 23 | hole tangent to side wall | R 26 | R 61 | R 19 |
| 24 | two holes 1e-7 web | P (step 2) 3667 | R (step 2) 1039 | P (step 2) 913 |
| 25 | perpendicular cylinders union | S 12-14 s (see below) | R 7799 | S 1587 |
| 26 | coaxial tube (cyl minus coaxial cyl) | S 3384 | R 8089 | S 333 |
| 27 | blind hole from top | P 144 | R 178 | R 49 |
| 28 | oblique 30 deg through hole | S 3706 | R 3096 | S 299 |
| 29 | box intersect sphere | R 1711 | R 7098 | R 837 |
| 30 | offset corner union | S 2 | S 2 | S 2 |
| 31 | pocket leaving 1e-7 side wall | R 2 | R 2 | R 2 |
| 32 | union overlap by 1e-7 slab | R 4 | R 2 | R 5 |
| 33 | partial face contact union | R 2 | R 2 | R 2 |
| 34 | sphere fully inside box (void) | P 11 | P 15 | P 10 |
| 35 | cylinder minus enclosed sphere | P 28 | P 52 | P 14 |
- [V] Score per tolerance, 35 cases, succeeded / refused / panicked / timed out: tol 0.05: 8 / 20 / 7 / 0 (case 25 at 0.05: boolean returned S in ~12-14 s in 6 of 8 runs; in 2 of 8 runs the harness process hit the 150 s timeout later, see below); tol 0.01: 5 / 26 / 4 / 0; tol 0.2: 8 / 22 / 5 / 0. Union over the three tolerances: 8 distinct cases ever succeed (11, 12, 13, 14, 25, 26, 28, 30); 7 cases panic at least once (03, 18, 21, 24, 27, 34, 35); the other 20 are refused at every tol. Invalid STEP among the successes: 0 of 21 exported files (Q4).
- [V] Panic causes (message captured with a backtrace filter, job4.log): "This shell is not oriented and closed." at truck-topology/src/lib.rs:187 reached from `truck_shapeops::transversal::integrate::and` (frame 6) for cases 03, 18, 24, 27, 34, 35: the boolean assembled a shell that fails `Solid::new`'s closedness check. And `called Option::unwrap() on a None value` at truck-geometry/src/decorators/intersection_curve.rs:220:67 (`IntersectionCurve::subs` -> `search_triple(t,100).unwrap()`) for case 21 at step 3 (tol 0.05) and in tessellation of results from cases 25/26/28 (below). Cases 34 and 35 (a solid wholly inside the other: no intersection curves) panic at every tol.
- [V] Outcome depends on tol non-monotonically: case 25 succeeds at 0.5, 0.2, 0.1, 0.05 and is refused at 0.02, 0.01, 0.005; case 26 succeeds at 0.5, 0.2, 0.1, 0.05, 0.02 and is refused at 0.01 and 0.005; case 28 succeeds at 0.2 and 0.05, refused at 0.01 (job6.log sweep, job4.log). Case 27 is R at 0.5, P at 0.1/0.05/0.02, R at 0.2/0.01/0.005. Hole grid (21): never gets past 2 of 100 holes at any of 0.5, 0.2, 0.1, 0.05, 0.02, 0.01, 0.005 (P at 0.1 and 0.05, R otherwise). Coplanar union (01): R at 0.5, 0.1, 0.02, 0.005 as well. Box-minus-sphere dimple (17): R at 0.5, 0.1, 0.02, 0.005 as well.
- [V] Time per op (release, tol 0.05, boolean call only): planar box cases 2-4 ms; sphere/sphere 17 ms (13 at 0.2, 90 at 0.01); coaxial tube 3.4 s (0.33 s at 0.2, 4.2 s at 0.02); perpendicular cylinders 12-14 s (1.6 s at 0.2, 0.28 s at 0.5, 5.8 s at 0.1); oblique through hole 3.7 s (0.3 s at 0.2); box/sphere refusal 1.7 s; torus/box panic 1.3 s; 10x10 grid first two holes 2.9 s total. Cost grows as tol shrinks for the curved cases.
- [V] Determinism: three repeats at tol 0.05 of cases 11, 13, 26, 28, 30 gave identical face counts and mesh volumes to ~1e-12 (job5.log). Case 25 at 0.05: 2 of 8 runs ended at the 150 s timeout (job4.log rc=124, job5.log run 2) with the stderr of the job4 run holding ruststep "Lookup failed for #219 ..." lines (emitted when truck reads STEP); in 3 runs with stage timing (job6.log) boolean 13.3-14.6 s, meshes +1.0 s, STEP write, re-read +20 ms, no hang. Which stage hung in the 2 timeouts was not captured.
- [V] Against the Aetheris result on the same 35 cases (14 succeeded / 21 refused / 0 crashed): both succeed on 11, 12, 26, 28, 30. truck succeeds where Aetheris refused: 13, 14 (spheres), 25 (perpendicular cylinders, coarse tol only). Aetheris succeeded where truck refused or panicked at every tol: 01, 02, 03, 17, 21, 27, 32, 33, 34. Steinmetz (07-09), tangent cylinders (06), torus (18-20), 15, 16, 22-24, 29, 31, 35 fail in both.
## Q4 STEP: export checked with OpenCASCADE; STEP import
Method: truck-stepio `out::StepDisplay` of `solid.compress()`; checked with cadquery-ocp (pip, Debian 12, Python 3.11, installed in the container) `STEPControl_Reader` + `BRepCheck_Analyzer.IsValid` + `TopExp_Explorer` counts + `BRepGProp.VolumeProperties`; each file in its own subprocess (job4.log "== check truck output"; reference volumes from OCCT booleans on identical primitives, "== ref").
- [V] Files: 21 STEP files exported (cases 11, 12, 13, 14, 30 at 3 tolerances; 25, 26, 28 at 0.05 and 0.2 where they succeeded). OCCT read 21/21, `IsValid` True for 21/21, no crashes. Header `FILE_SCHEMA(('ISO-10303-203'))`.
- [V] Volumes (OCCT of truck's STEP vs OCCT boolean reference / analytic): 11: 840.0000016 vs 840.000001; 12: 840.0016 vs 840.0016; 13 and 14: 2 solids, 2 shells, 24 faces, 1047.19755 vs 1047.19755; 30: 1 solid, 12 faces, 1875.0 vs 1875.0; 25 (perpendicular cylinders): 10 faces, 1865.747 at tol 0.05 (-0.064% vs 1866.935), 1870.690 at 0.2 (+0.20%); 26 (tube): 6 faces, 502.303 at 0.05 (-0.07% vs 502.655), 504.539 at 0.2 (+0.38%); 28 (oblique hole): 8 faces, 3673.266 at 0.05 (-0.007% vs 3673.516), 3680.588 at 0.2 (+0.19%). So exact for planar/analytic-intersection results and tolerance-bound for results that need new intersection curves.
- [V] Entity census of the exports (job6.log): faces are `FACE_SURFACE` over `PLANE` or `SURFACE_OF_REVOLUTION` (spheres: 24 SURFACE_OF_REVOLUTION + AXIS1_PLACEMENT); new edges are `INTERSECTION_CURVE` + `B_SPLINE_CURVE_WITH_KNOTS` (case 11, a planar pocket with 1e-7 floor, still carries 4 INTERSECTION_CURVE + 4 B_SPLINE_CURVE_WITH_KNOTS; case 30 carries 6 of each). Solid is `MANIFOLD_SOLID_BREP`; case 13/14 (two disjoint shells in one Solid) is written as `BREP_WITH_VOIDS` + `ORIENTED_CLOSED_SHELL`, which OCCT reads as 2 solids and volume 1047.2 (a union of two spheres), so the second shell is not subtracted as a void.
- [V] Truck's STEP writer supports (truck-stepio/src/out/geometry.rs): PLANE (:505), SPHERICAL_SURFACE (:537), TOROIDAL_SURFACE (:575), B_SPLINE_SURFACE_WITH_KNOTS (:621), rational B-spline (:677-680), SURFACE_OF_REVOLUTION (:741); no CYLINDRICAL_SURFACE writer line was found by that grep (cylinders from the builder are SURFACE_OF_REVOLUTION).
- [V] Truck reads STEP: yes (`truck_stepio::r#in`, `Table::from_step`, `step_assy`, `to_compressed_shell`; tests read OCCT-written cube, cone, cylinder, sphere, torus and four `abc-*` files from the `resources` submodule, tests/input/tessellate_shape.rs:5-15; input handles CylindricalSurface, SphericalSurface, ToroidalSurface, BSpline, assemblies). My probe: I had OCCT write all 35 reference results (`ref_NN.step`) and read each with truck (`Table::from_step`, every shell `to_compressed_shell`, `triangulation(0.01)`, `put_together_same_attrs`, volume; job4.log "== truck reads OCCT-written STEP"): no panic on any of 35 files; for 16 of them the tessellated shells are all `Closed` with summed volume within 2% of OCCT's (01-05, 11, 12, 13, 17, 21, 26, 27, 30-33); for the rest the shell is Irregular/Regular/Oriented, or 0 triangles, or a wrong volume (06: 783+431 vs 1570.8; 07: 9.6 vs 666.7; 08: 1937 vs 2475; 09: 2 x 481.9 vs 904.1; 14, 16: 0 triangles; 25: 1370 vs 1867; 28: 3892.8 vs 3673.5); cases 18-20, 22-24 and 29 read with volumes within 0.4% but shells not Closed. Reading ref_21 (the 100-hole plate) took 25.8 s in release. These are reads of OCCT trimmed surfaces and intersection-curve edges, tessellated at the repo-test pattern; I did not separate reader faults from tessellation faults.
- [V] Truck re-reading its own exported booleans (same probe, `step_reread` in job4.log): most results fail to come back as a closed mesh (11 and 12: shells "Oriented" with 2-10 triangles and volumes 0 / 750; 13 and 14: Irregular, negative volumes -1563.8 and -1648.7; 30 at 0.01: Oriented, 4 triangles, volume 10; 30 at 0.05 and 0.2: Closed with 0 triangles). Results vary between tolerance runs for the same geometry (case 30: 0 / 4 / 0 triangles at 0.05 / 0.01 / 0.2).
## Q5 Tessellation (truck-meshalgo) of boolean results
Method: `solid.triangulation(tol).to_polygon()` then `put_together_same_attrs(TOLERANCE*50).remove_degenerate_faces()`; `shell_condition()` and `volume()` from meshalgo; plus an independent check of the written OBJ (`check.py obj`: weld at 1e-6, every directed edge must pair with its reverse; signed volume).
- [V] Planar results (cases 11, 12, 30): 28 / 28 / 36 triangles, identical at tol 0.1, 0.01, 0.001, 0.5-1 ms, Closed, independent OBJ check watertight and oriented, volumes exact (840.0000016, 840.0016, 1875.0).
- [V] Spheres (cases 13/14, two spheres r=5): tol 0.1: 992 triangles, 6 ms, Closed, volume 1015.59; tol 0.01: 12,656-12,684 triangles, 111-153 ms, volume 1044.54 (analytic 1047.20); tol 0.001: 78,928 triangles, 1.1-1.8 s, Closed, volume 1046.76. Case 14 is watertight by the independent check at 0.01; case 13 at 0.01 is `Regular` by truck and by the independent check has 9 unmatched directed edges and 9 multi-used edges.
- [V] Results with new intersection curves: case 26 (tube, boolean tol 0.05): mesh tol 0.1: 1,102 triangles, 33-39 ms, Closed, volume 500.06; tol 0.01: 8,219 triangles, 186-214 ms, `Irregular` (independent: 17 unmatched directed edges, 6 multi-used edges), volume 501.79; tol 0.001: panic (intersection_curve.rs:220 unwrap). Case 25 (boolean tol 0.2): tol 0.1: 1,218 triangles, 57 ms Closed; tol 0.01: 11,172 triangles, 272 ms `Regular` (independent: 7 unmatched, 7 multi); tol 0.001 panic. Case 28 (oblique hole, boolean tol 0.05): tol 0.1: 376 triangles, 25-30 ms `Irregular`; tol 0.01 and 0.001: panic; the same case at boolean tol 0.2: tol 0.1 gives 318 triangles `Irregular`, 0.01 and 0.001 panic. Case 26 at boolean tol 0.2 panics at all three mesh tolerances.
- [V] Tessellating the bare primitives (tol 0.01): box 1000.0 exact; cylinder r5 h10 783.98 (analytic 785.40, -0.18%); sphere r5 522.28 (523.60, -0.25%); torus 10/3 1771.57 (1776.53, -0.28%) (job2.log prims).
- [V] The docs say the mesh of a Solid is not necessarily closed and recommend `put_together_same_attrs` (tessellation/mod.rs docs on `MeshableShape::triangulation`); `RobustMeshableShape` "does not close the mesh of a solid".
- [V] Output API: `PolygonMesh` (positions/normals/uv + tri/quad/other faces), OBJ and STL writers in truck-polymesh, VTK in meshalgo. Triangles counted with `faces().triangle_iter()`.
## Q6 Other things a consumer gets
- [V] WASM: `truck-js` (wasm-bindgen) builds for wasm32-unknown-unknown, 3.9 MB; meshalgo has a `cfg(target_arch = "wasm32")` path without rayon; CI runs wasm tests with deno (`.gitlab-ci.yml` job wasm-test). Python bindings: none found (grep pyo3/python in manifests and READMEs). C ABI: none found.
- [V] Rendering: `truck-platform` (graphic utility on wgpu) and `truck-rendimpl` (shape and polygon-mesh visualisation) with examples app.rs, simple-shape-viewer.rs, simple-obj-viewer.rs, textured-cube.rs, rotate-objects.rs, bsp-animation.rs; CI has a `gpu-test` job. Not run here (no GPU in the container).
- [V] Modelling API (truck-modeling/examples/cylinder.rs, whole body of the cylinder):
```rust
let vertex = builder::vertex(Point3::new(0.0, -height / 2.0, radius));
let circle = builder::rsweep(&vertex, Point3::origin(), Vector3::unit_y(), Rad(7.0), 2);
let disk = builder::try_attach_plane(&[circle]).unwrap();
builder::tsweep(&disk, Vector3::new(0.0, height, 0.0))
```
Box = `vertex` -> `tsweep` x/y/z. Punched cube (truck-shapeops/examples/punched-cube-shapeops.rs): build `cylinder` the same way, `cylinder.not();` then `truck_shapeops::and(&cube, &cylinder, 0.05).unwrap()`. Other builder fns: `line`, `circle_arc`, `bezier`, `homotopy`, `try_wire_homotopy`, `try_attach_plane`, `translated`/`rotated`/`scaled`, `rsweep`, `cone`. The examples pass `Rad(7.0)` (a little over 2 pi) for a full revolution.
- [V] Fillet: not reachable from `truck_modeling::Surface` (no fillet variant). The fillet test (truck-shapeops/tests/fillet.rs:1-76) defines its own `Curve` and `Surface` enums with seven derive macros and `ToSameGeometry` impls, builds two NURBS faces by hand, and calls `simple_fillet(&face0, &face1, shared_edge_id, 0.3, 0.001)`; 3 such tests pass (job1.log). Limits per docs: a single edge whose end vertices each touch exactly three faces. I did not build "filleted box with a hole": it would need that custom enum pair plus a boolean on the result, and the measured boolean cannot subtract a blind hole from a box with a coplanar top (cases 03 and 27 panic).
- [V] Serialisation: `Solid`/`Shell` are serde types (examples write `serde_json::to_vec_pretty(&solid)`); `CompressedSolid` for compact form (`solid.compress()`).
- [V] Parallelism: rayon in meshalgo (non-wasm) and `Send + Sync` bounds on `ShapeOpsSurface`/`ShapeOpsCurve`.
- [V] Panics are part of the surface: `Solid::new`, `builder` calls and tessellation panic on bad input (Q3), and there is no `Result` with a reason on `and`/`or`.

View file

@ -0,0 +1,2 @@
target
Cargo.lock

View file

@ -0,0 +1,11 @@
[package]
name = "trucktorture"
version = "0.0.0"
edition = "2021"
[dependencies]
truck-modeling = { git = "https://github.com/ricosjp/truck", rev = "88ed005249e5e3a6b07f62425399435905cd3ab6" }
truck-shapeops = { git = "https://github.com/ricosjp/truck", rev = "88ed005249e5e3a6b07f62425399435905cd3ab6" }
truck-meshalgo = { git = "https://github.com/ricosjp/truck", rev = "88ed005249e5e3a6b07f62425399435905cd3ab6" }
truck-stepio = { git = "https://github.com/ricosjp/truck", rev = "88ed005249e5e3a6b07f62425399435905cd3ab6" }
serde_json = "1"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,151 @@
"""Independent checks of truck output with OpenCASCADE (cadquery-ocp) and plain-python OBJ analysis.
modes:
ref <cases.json> <outdir> OCCT reference boolean per case: volume + writes ref_NN.step
one <file.step> BRepCheck / counts / volume of one STEP (own process; OCCT may segfault)
obj <file.obj> watertightness and volume of an OBJ mesh
all <dir> run `one` per *.step and `obj` per *.obj in subprocesses
"""
import sys, json, subprocess, math, os, glob
def vol_of(shape):
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
p = GProp_GProps()
BRepGProp.VolumeProperties_s(shape, p)
return p.Mass()
def count(shape, kind):
from OCP.TopExp import TopExp_Explorer
e = TopExp_Explorer(shape, kind)
n = 0
while e.More():
n += 1
e.Next()
return n
def one(path):
from OCP.STEPControl import STEPControl_Reader
from OCP.IFSelect import IFSelect_RetDone
from OCP.BRepCheck import BRepCheck_Analyzer
from OCP.TopAbs import TopAbs_SOLID, TopAbs_SHELL, TopAbs_FACE
r = STEPControl_Reader()
st = r.ReadFile(path)
out = {"file": os.path.basename(path), "read_ok": st == IFSelect_RetDone}
if st != IFSelect_RetDone:
print("RES " + json.dumps(out)); return
nroots = r.TransferRoots()
s = r.OneShape()
out.update({"roots": nroots, "null": s.IsNull()})
if not s.IsNull():
out.update({"valid": BRepCheck_Analyzer(s).IsValid(), "solids": count(s, TopAbs_SOLID),
"shells": count(s, TopAbs_SHELL), "faces": count(s, TopAbs_FACE), "volume": vol_of(s)})
print("RES " + json.dumps(out))
def objcheck(path):
pts, tris = [], []
for line in open(path):
t = line.split()
if not t:
continue
if t[0] == "v":
pts.append((float(t[1]), float(t[2]), float(t[3])))
elif t[0] == "f":
idx = [int(x.split("/")[0]) - 1 for x in t[1:]]
for i in range(1, len(idx) - 1):
tris.append((idx[0], idx[i], idx[i + 1]))
key = {}
remap = []
for p in pts:
k = (round(p[0] / 1e-6), round(p[1] / 1e-6), round(p[2] / 1e-6))
remap.append(key.setdefault(k, len(key)))
edges = {}
vol = 0.0
degenerate = 0
for a, b, c in tris:
pa, pb, pc = pts[a], pts[b], pts[c]
vol += (pa[0] * (pb[1] * pc[2] - pb[2] * pc[1]) - pa[1] * (pb[0] * pc[2] - pb[2] * pc[0]) + pa[2] * (pb[0] * pc[1] - pb[1] * pc[0])) / 6.0
ra, rb, rc = remap[a], remap[b], remap[c]
if len({ra, rb, rc}) < 3:
degenerate += 1
continue
for u, v in ((ra, rb), (rb, rc), (rc, ra)):
edges[(u, v)] = edges.get((u, v), 0) + 1
unmatched = sum(1 for (u, v), n in edges.items() if edges.get((v, u), 0) != n)
multi = sum(1 for n in edges.values() if n > 1)
print("RES " + json.dumps({"file": os.path.basename(path), "tris": len(tris), "welded_vertices": len(key),
"degenerate_after_weld": degenerate, "unmatched_directed_edges": unmatched,
"multi_edges": multi, "watertight_oriented": unmatched == 0 and multi == 0,
"volume": vol}))
def build_ref(cases_path, outdir):
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeTorus
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse, BRepAlgoAPI_Cut, BRepAlgoAPI_Common
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Ax1, gp_Ax2, gp_Pnt, gp_Dir, gp_Vec
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
from OCP.BRepCheck import BRepCheck_Analyzer
cases = json.load(open(cases_path))
o = gp_Pnt(0, 0, 0)
z = gp_Dir(0, 0, 1)
def prim(e):
d, k = e["d"], e["k"]
if k == "box":
s = BRepPrimAPI_MakeBox(gp_Pnt(-d[0] / 2, -d[1] / 2, -d[2] / 2), d[0], d[1], d[2]).Shape()
elif k == "cyl":
s = BRepPrimAPI_MakeCylinder(gp_Ax2(gp_Pnt(0, 0, -d[1] / 2), z), d[0], d[1]).Shape()
elif k == "sph":
s = BRepPrimAPI_MakeSphere(o, d[0]).Shape()
else:
s = BRepPrimAPI_MakeTorus(gp_Ax2(o, z), d[0], d[1]).Shape()
for axis, key in ((gp_Dir(1, 0, 0), "rx"), (gp_Dir(0, 1, 0), "ry")):
if e[key]:
t = gp_Trsf(); t.SetRotation(gp_Ax1(o, axis), math.radians(e[key]))
s = BRepBuilderAPI_Transform(s, t, True).Shape()
t = gp_Trsf(); t.SetTranslation(gp_Vec(*e["p"]))
return BRepBuilderAPI_Transform(s, t, True).Shape()
for i, c in enumerate(cases):
rec = {"case": c["name"], "idx": i, "exp": c["exp"]}
try:
cur = prim(c["a"])
for b in c["bs"]:
bb = prim(b)
alg = {"union": BRepAlgoAPI_Fuse, "subtract": BRepAlgoAPI_Cut, "intersect": BRepAlgoAPI_Common}[c["op"]](cur, bb)
cur = alg.Shape()
rec["occt_volume"] = vol_of(cur)
rec["occt_valid"] = BRepCheck_Analyzer(cur).IsValid()
w = STEPControl_Writer(); w.Transfer(cur, STEPControl_AsIs)
w.Write(os.path.join(outdir, "ref_%02d.step" % (i + 1)))
except Exception as ex:
rec["error"] = str(ex)[:200]
print("RES " + json.dumps(rec))
if __name__ == "__main__":
m = sys.argv[1]
if m == "one":
one(sys.argv[2])
elif m == "obj":
objcheck(sys.argv[2])
elif m == "ref":
build_ref(sys.argv[2], sys.argv[3])
elif m == "all":
for f in sorted(glob.glob(sys.argv[2] + "/*.step")) + sorted(glob.glob(sys.argv[2] + "/*.obj")):
if os.path.basename(f).startswith("ref_"):
continue
mode = "one" if f.endswith(".step") else "obj"
try:
r = subprocess.run([sys.executable, "-I", __file__, mode, f], capture_output=True, text=True, timeout=180)
lines = [l for l in r.stdout.splitlines() if l.startswith("RES ")]
if r.returncode != 0 or not lines:
print("RES " + json.dumps({"file": os.path.basename(f), "rc": r.returncode, "stderr_tail": r.stderr[-300:]}))
else:
print(lines[0])
except subprocess.TimeoutExpired:
print("RES " + json.dumps({"file": os.path.basename(f), "timeout": True}))

View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
# usage: job.sh <tol> (runs inside the stopgap rust container, cwd = repo root)
TOLS="${*:-0.05}"
set -u
OUT=/tmp/out; mkdir -p $OUT
echo "== build"
cargo build --release 2>&1 | tail -30
BIN=target/release/trucktorture
[ -x $BIN ] || { echo BUILD-FAILED; exit 1; }
echo "== prims"
$BIN prims 2>&1 | tail -8
N=$(grep -c '"name"' cases.json)
echo "cases=$N"
for TOL in $TOLS; do
echo "== cases tol=$TOL"
for i in $(seq 0 $((N-1))); do
s=$(date +%s.%N)
timeout ${CASE_TIMEOUT:-150} $BIN run cases.json $i $TOL $OUT > /tmp/case_$i.out 2> /tmp/case_$i.err
rc=$?
e=$(date +%s.%N)
echo "CASE idx=$i rc=$rc wall=$(echo "$e - $s" | bc 2>/dev/null || echo ?)"
grep -h '^RES' /tmp/case_$i.out | cut -c1-1500
if [ $rc -ne 0 ]; then echo "STDERR-TAIL idx=$i: $(tail -c 400 /tmp/case_$i.err | tr '\n' ' ')"; fi
done
done
echo "== python/ocp"
apt-get update -qq 2>&1 | tail -2
apt-get install -y -qq python3 python3-pip libgl1 libglu1-mesa libxrender1 2>&1 | tail -3
PY=python3
$PY -m pip install -q --break-system-packages cadquery-ocp 2>&1 | tail -3
$PY -c "import OCP; print('OCP ok')" 2>&1 | tail -2
echo "== ref (OCCT reference booleans)"
$PY check.py ref cases.json $OUT 2>&1 | cut -c1-400
echo "== check truck output (STEP via OCCT, OBJ independent)"
$PY check.py all $OUT 2>&1 | cut -c1-500
echo "== truck reads OCCT-written STEP"
for f in $OUT/ref_*.step; do timeout 120 $BIN readstep $f 2>&1 | grep -h '^RES' | cut -c1-500 || echo "READSTEP-FAIL $f"; done
echo "== done"

View file

@ -0,0 +1,40 @@
#!/usr/bin/env bash
# second probe batch: STEP entity census, determinism, tolerance sweep, licences, wasm
set -u
echo "== build"; cargo build --release 2>&1 | tail -2
BIN=target/release/trucktorture
OUT=/tmp/out5; mkdir -p $OUT
apt-get update -qq 2>&1 | tail -1; apt-get install -y -qq python3 2>&1 | tail -1
echo "== step census"
for spec in "25 0.2" "12 0.05" "29 0.05" "27 0.2"; do set -- $spec
timeout 150 $BIN run cases.json $1 $2 $OUT > /tmp/o.txt 2>/dev/null
f=$(ls $OUT/$(printf %02d $(($1+1)))_t$2.step 2>/dev/null)
echo "-- case idx=$1 tol=$2 file=$f"
[ -n "$f" ] && { grep -ho '^#[0-9]*= *[A-Z_0-9]*' $f | sed 's/#[0-9]*= *//' | sort | uniq -c | sort -rn | head -22 | tr '\n' ';'; echo; grep -m3 -n 'FILE_SCHEMA\|FILE_NAME\|MANIFOLD_SOLID_BREP\|CLOSED_SHELL' $f | cut -c1-120; }
done
echo "== determinism (3 runs each, tol 0.05)"
for idx in 10 12 25 27 29; do for r in 1 2 3; do
timeout 150 $BIN run cases.json $idx 0.05 $OUT 2>/dev/null | grep '^RES' | grep -o '"status":"[a-z_]*"\|"faces":[0-9]*\|"vol":[-0-9.e]*' | head -4 | tr '\n' ' '; echo " idx=$idx run=$r"
done; done
echo "== tolerance sweep"
for idx in 0 20 24 25 26 16; do for t in 0.5 0.1 0.02 0.005; do
/usr/bin/time -f "wall=%es rss=%MKB" timeout 200 $BIN run cases.json $idx $t $OUT > /tmp/o.txt 2> /tmp/e.txt
echo "idx=$idx tol=$t rc=$? $(grep '^RES' /tmp/o.txt | grep -o '"status":"[a-z_]*"\|"steps_done":[0-9]*\|"boolean_ms_total":[0-9.]*' | tr '\n' ' ') $(tail -1 /tmp/e.txt)"
done; done
echo "== licences (transitive deps of harness)"
cargo metadata --format-version 1 > /tmp/md.json 2>/dev/null
python3 -c "
import json,collections
d=json.load(open('/tmp/md.json'));c=collections.Counter();odd=[]
for p in d['packages']:
c[p.get('license') or 'NONE']+=1
if not p.get('license') or 'GPL' in (p.get('license') or '') or 'MPL' in (p.get('license') or ''): odd.append((p['name'],p.get('license')))
print('n_packages',len(d['packages']));print(c.most_common(12));print('odd',odd)
"
echo "== wasm build of truck-js"
rustup target add wasm32-unknown-unknown 2>&1 | tail -1
D=$(ls -d /usr/local/cargo/git/checkouts/truck-*/88ed005 | head -1); echo "checkout=$D"
cd $D && (time cargo build --release --target wasm32-unknown-unknown -p truck-js 2>&1 | tail -4)
ls -la target/wasm32-unknown-unknown/release/*.wasm 2>&1 | head -3
cd $D && grep -c "wasm_bindgen" truck-js/src/*.rs | tr '\n' ' '
echo "== done"

View file

@ -0,0 +1,32 @@
#!/usr/bin/env bash
# third probe batch: STEP entity census, case-25 stage timing, tolerance sweep
set -u
echo "== build"; cargo build --release 2>&1 | tail -2
BIN=target/release/trucktorture
OUT=/tmp/out6; mkdir -p $OUT
echo "== step census"
for spec in "25 0.2" "12 0.05" "29 0.05" "27 0.2" "10 0.05"; do
set -- $spec
timeout 150 $BIN run cases.json $1 $2 $OUT > /tmp/o.txt 2>/dev/null
f=$(ls $OUT/$(printf %02d $(($1+1)))_t$2.step 2>/dev/null)
echo "-- case idx=$1 tol=$2 file=$f"
if [ -n "$f" ]; then
grep -ho '^#[0-9]* *= *[A-Z_0-9]*' $f | sed 's/#[0-9]* *= *//' | sort | uniq -c | sort -rn | head -24 | tr '\n' ';'
echo
fi
done
echo "== case 25 stage timing x3"
for r in 1 2 3; do
timeout 200 $BIN run cases.json 24 0.05 $OUT 2>&1 >/dev/null | grep STAGE | tr '\n' ' '
echo " run=$r"
done
echo "== tolerance sweep"
for idx in 0 20 24 25 26 16; do
for t in 0.5 0.1 0.02 0.005; do
s0=$(date +%s%N)
timeout 200 $BIN run cases.json $idx $t $OUT > /tmp/o.txt 2> /tmp/e.txt
rc=$?
echo "idx=$idx tol=$t rc=$rc wall=$(( ($(date +%s%N)-s0)/1000000 ))ms $(grep '^RES' /tmp/o.txt | grep -o '"status":"[a-z_]*"\|"steps_done":[0-9]*\|"boolean_ms_total":[0-9.]*' | tr '\n' ' ')"
done
done
echo "== done"

View file

@ -0,0 +1,263 @@
// Torture harness for truck (ricosjp/truck), mirrors the Aetheris harness cases (cases.json).
use serde_json::{json, Value};
use std::f64::consts::PI;
use std::io::BufWriter;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::Mutex;
use std::time::Instant;
use truck_meshalgo::prelude::*;
use truck_modeling::*;
use truck_stepio::out;
static PANIC: Mutex<String> = Mutex::new(String::new());
fn take_panic() -> String {
let mut g = PANIC.lock().unwrap();
let s = g.clone();
g.clear();
s.chars().take(300).collect()
}
fn box_(d: &[f64]) -> Solid {
let v = builder::vertex(Point3::origin());
let e = builder::tsweep(&v, Vector3::unit_x());
let f = builder::tsweep(&e, Vector3::unit_y());
let c: Solid = builder::tsweep(&f, Vector3::unit_z());
let c = builder::scaled(&c, Point3::origin(), Vector3::new(d[0], d[1], d[2]));
builder::translated(&c, Vector3::new(-d[0] / 2.0, -d[1] / 2.0, -d[2] / 2.0))
}
fn cyl(r: f64, h: f64) -> Solid {
let v = builder::vertex(Point3::new(r, 0.0, -h / 2.0));
let circle = builder::rsweep(&v, Point3::new(0.0, 0.0, -h / 2.0), Vector3::unit_z(), Rad(7.0), 2);
let disk = builder::try_attach_plane(&[circle]).unwrap();
builder::tsweep(&disk, Vector3::new(0.0, 0.0, h))
}
fn sph(r: f64) -> Solid {
let v0 = builder::vertex(Point3::new(0.0, r, 0.0));
let wire: Wire = builder::rsweep(&v0, Point3::origin(), Vector3::unit_x(), Rad(PI), 3);
let shell = builder::cone(&wire, Vector3::unit_y(), Rad(7.0), 4);
Solid::new(vec![shell])
}
fn tor(big: f64, small: f64) -> Solid {
let v = builder::vertex(Point3::new(big, 0.0, small));
let w = builder::rsweep(&v, Point3::new(big, 0.0, 0.0), Vector3::unit_y(), Rad(7.0), 2);
let shell = builder::rsweep(&w, Point3::origin(), Vector3::unit_z(), Rad(7.0), 2);
Solid::new(vec![shell])
}
fn mesh_of(s: &Solid, tol: f64) -> PolygonMesh {
let mut m = s.triangulation(tol).to_polygon();
m.put_together_same_attrs(TOLERANCE * 50.0).remove_degenerate_faces();
m
}
fn orient(mut s: Solid) -> Solid {
if mesh_of(&s, 0.05).volume() < 0.0 {
s.not();
}
s
}
fn nums(v: &Value) -> Vec<f64> {
v.as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect()
}
fn prim(e: &Value) -> Solid {
let d = nums(&e["d"]);
let s = match e["k"].as_str().unwrap() {
"box" => box_(&d),
"cyl" => cyl(d[0], d[1]),
"sph" => sph(d[0]),
"tor" => tor(d[0], d[1]),
k => panic!("kind {k}"),
};
let mut s = orient(s);
let rx = e["rx"].as_f64().unwrap();
let ry = e["ry"].as_f64().unwrap();
if rx != 0.0 {
s = builder::rotated(&s, Point3::origin(), Vector3::unit_x(), Rad(rx.to_radians()));
}
if ry != 0.0 {
s = builder::rotated(&s, Point3::origin(), Vector3::unit_y(), Rad(ry.to_radians()));
}
let p = nums(&e["p"]);
builder::translated(&s, Vector3::new(p[0], p[1], p[2]))
}
fn apply(op: &str, a: &Solid, b: &Solid, tol: f64) -> Option<Solid> {
match op {
"union" => truck_shapeops::or(a, b, tol),
"intersect" => truck_shapeops::and(a, b, tol),
"subtract" => {
let mut nb = b.clone();
nb.not();
truck_shapeops::and(a, &nb, tol)
}
o => panic!("op {o}"),
}
}
fn mesh_stats(s: &Solid, tol: f64, objpath: Option<&str>) -> Value {
let t = Instant::now();
let r = catch_unwind(AssertUnwindSafe(|| mesh_of(s, tol)));
let ms = t.elapsed().as_secs_f64() * 1e3;
match r {
Ok(m) => {
if let Some(p) = objpath {
let f = std::fs::File::create(p).unwrap();
let _ = truck_meshalgo::rexport_polymesh::obj::write(&m, BufWriter::new(f));
}
json!({"tol": tol, "ms": ms, "tris": m.faces().triangle_iter().count(), "faces": m.faces().len(),
"closed": format!("{:?}", m.shell_condition()), "vol": m.volume()})
}
Err(_) => json!({"tol": tol, "panic": take_panic()}),
}
}
fn step_string(s: &Solid) -> String {
let c = s.compress();
let repr = out::StepDesign::from_model(out::StepModel::from(&c));
out::StepDisplay::new(
out::StepHeaderDescriptor { organization_system: "trucktorture".to_owned(), ..Default::default() },
repr,
)
.to_string()
}
fn reread(text: &str) -> Value {
use truck_stepio::r#in::*;
let r = catch_unwind(AssertUnwindSafe(|| {
let table = Table::from_step(text).ok_or("parse failed")?;
let mut shells = vec![];
for (_, sh) in table.shell.iter() {
match table.to_compressed_shell(sh) {
Ok(cs) => {
let mut m = cs.triangulation(0.01).to_polygon();
m.put_together_same_attrs(TOLERANCE * 50.0).remove_degenerate_faces();
shells.push(json!({"closed": format!("{:?}", m.shell_condition()), "vol": m.volume(),
"tris": m.faces().triangle_iter().count()}));
}
Err(e) => shells.push(json!({"err": format!("{e:?}").chars().take(200).collect::<String>()})),
}
}
Ok::<_, &str>(json!({"shells": shells}))
}));
match r {
Ok(Ok(v)) => v,
Ok(Err(e)) => json!({"err": e}),
Err(_) => json!({"panic": take_panic()}),
}
}
fn run_case(cases: &Value, idx: usize, tol: f64, out_dir: &str) {
let c = &cases[idx];
let name = c["name"].as_str().unwrap();
let op = c["op"].as_str().unwrap();
let bs = c["bs"].as_array().unwrap();
let t0 = Instant::now();
let mut step_ms: Vec<f64> = vec![];
let r = catch_unwind(AssertUnwindSafe(|| {
let mut cur = prim(&c["a"]);
for b in bs {
let bb = prim(b);
let t = Instant::now();
let n = apply(op, &cur, &bb, tol);
step_ms.push(t.elapsed().as_secs_f64() * 1e3);
match n {
Some(n) => cur = n,
None => return None,
}
}
Some(cur)
}));
let total = t0.elapsed().as_secs_f64() * 1e3;
eprintln!("STAGE boolean done {:.0}ms", total);
let maxstep = step_ms.iter().cloned().fold(0.0, f64::max);
let mut res = json!({"case": name, "idx": idx, "tol": tol, "steps_done": step_ms.len(), "steps_total": bs.len(),
"boolean_ms_total": total, "boolean_ms_max_step": maxstep, "exp": c["exp"]});
match r {
Err(_) => {
res["status"] = json!("panic");
res["panic"] = json!(take_panic());
}
Ok(None) => res["status"] = json!("refused_none"),
Ok(Some(s)) => {
res["status"] = json!("ok");
res["shells"] = json!(s.boundaries().len());
res["faces"] = json!(s.boundaries().iter().map(|b| b.len()).sum::<usize>());
let tag = format!("{}/{:02}_t{}", out_dir, idx + 1, tol);
res["mesh"] = json!([
mesh_stats(&s, 0.1, None),
mesh_stats(&s, 0.01, Some(&format!("{tag}.obj"))),
mesh_stats(&s, 0.001, None)
]);
eprintln!("STAGE meshes done {:.0}ms", t0.elapsed().as_secs_f64() * 1e3);
let t = Instant::now();
let sr = catch_unwind(AssertUnwindSafe(|| step_string(&s)));
match sr {
Ok(text) => {
res["step_ms"] = json!(t.elapsed().as_secs_f64() * 1e3);
res["step_bytes"] = json!(text.len());
std::fs::write(format!("{tag}.step"), &text).unwrap();
eprintln!("STAGE step written {:.0}ms", t0.elapsed().as_secs_f64() * 1e3);
res["step_reread"] = reread(&text);
eprintln!("STAGE reread done {:.0}ms", t0.elapsed().as_secs_f64() * 1e3);
}
Err(_) => res["step_panic"] = json!(take_panic()),
}
}
}
println!("RES {}", res);
}
fn read_step(path: &str) {
let text = std::fs::read_to_string(path).unwrap();
let t = Instant::now();
let mut v = reread(&text);
v["file"] = json!(path);
v["ms"] = json!(t.elapsed().as_secs_f64() * 1e3);
println!("RES {}", v);
}
fn prims() {
for (n, s, exp) in [
("box 10x10x10", box_(&[10.0, 10.0, 10.0]), 1000.0),
("cyl r5 h10", cyl(5.0, 10.0), PI * 25.0 * 10.0),
("sph r5", sph(5.0), 4.0 / 3.0 * PI * 125.0),
("tor 10,3", tor(10.0, 3.0), 2.0 * PI * PI * 10.0 * 9.0),
] {
let raw = mesh_of(&s, 0.01).volume();
let o = orient(s.clone());
println!(
"RES {}",
json!({"prim": n, "analytic": exp, "mesh_vol_raw_orientation": raw,
"mesh_vol_after_orient": mesh_of(&o, 0.01).volume()})
);
}
}
fn main() {
std::panic::set_hook(Box::new(|i| {
let bt = std::backtrace::Backtrace::force_capture().to_string();
let frames: Vec<String> = bt
.lines()
.filter(|l| l.contains("truck_") && !l.trim_start().starts_with("at "))
.map(|l| l.trim().chars().take(70).collect::<String>())
.take(6)
.collect();
*PANIC.lock().unwrap() = format!("{i}").replace('\n', " | ") + " || " + &frames.join(" ; ");
}));
let a: Vec<String> = std::env::args().collect();
match a[1].as_str() {
"run" => {
let cases: Value = serde_json::from_str(&std::fs::read_to_string(&a[2]).unwrap()).unwrap();
run_case(&cases, a[3].parse().unwrap(), a[4].parse().unwrap(), &a[5]);
}
"readstep" => read_step(&a[2]),
"prims" => prims(),
_ => panic!("mode"),
}
}