Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aa1ee2e83 | ||
|
|
df6a1cc96f | ||
|
|
5b5dc3474d | ||
|
|
df89568c32 | ||
|
|
4b5894b009 | ||
|
|
d84acb66be | ||
|
|
127e0751b3 | ||
|
|
b476a05eb5 |
34 changed files with 6973 additions and 51 deletions
105
Cargo.lock
generated
105
Cargo.lock
generated
|
|
@ -149,6 +149,15 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
|
|
@ -181,12 +190,31 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
|
|
@ -208,6 +236,16 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "disqualified"
|
||||
version = "1.0.0"
|
||||
|
|
@ -288,6 +326,27 @@ dependencies = [
|
|||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gamecult-geometry-csg"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"bevy_math",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
|
|
@ -597,6 +656,25 @@ dependencies = [
|
|||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp-serde"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
|
||||
dependencies = [
|
||||
"rmp",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
|
|
@ -667,6 +745,17 @@ dependencies = [
|
|||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
|
@ -764,6 +853,12 @@ version = "1.0.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
|
@ -800,12 +895,10 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "vg_csg"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"bevy_math",
|
||||
]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
[package]
|
||||
name = "vg_csg"
|
||||
name = "gamecult-geometry-csg"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Lean Bevy-math constructive geometry assembler for VibeGeometry blockouts"
|
||||
description = "CultGeometry constructive-solid-geometry kernel"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/GameCult/vg-csg"
|
||||
repository = "https://github.com/GameCult/GameCult.Geometry.Csg"
|
||||
readme = "README.md"
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
23
README.md
23
README.md
|
|
@ -1,8 +1,11 @@
|
|||
# vg-csg
|
||||
# GameCult.Geometry.Csg
|
||||
|
||||
The project is known as **CultGeometry CSG**. Its Rust package is
|
||||
`gamecult-geometry-csg`, exposed to Rust code as `gamecult_geometry_csg`.
|
||||
|
||||
Lean constructive geometry tooling for Rust/Bevy-math scene blockouts.
|
||||
|
||||
`vg-csg` is the extracted CSG kernel from VibeGeometry. It is not trying to be
|
||||
CultGeometry CSG is the extracted CSG kernel from VibeGeometry. It is not trying to be
|
||||
a full CAD kernel. It is a small, inspectable machine for agent-authored level
|
||||
and habitat geometry:
|
||||
|
||||
|
|
@ -39,7 +42,7 @@ experiments/generated/
|
|||
|
||||
```rust
|
||||
use bevy_math::{Quat, Vec3};
|
||||
use vg_csg::{LevelDsl, MaterialId};
|
||||
use gamecult_geometry_csg::{LevelDsl, MaterialId};
|
||||
|
||||
let mut level = LevelDsl::new();
|
||||
level.solid_box(
|
||||
|
|
@ -70,6 +73,20 @@ That bias is visible in `examples/industrial_maintenance_loop.rs`, where a
|
|||
curved habitat room is authored in tangent/radial/up space and then lowered into
|
||||
world-space oriented boxes and mesh bands.
|
||||
|
||||
## Signed Distance
|
||||
|
||||
`Assembler::distance(p)` (and `stream_distance` over any brush slice) lowers the
|
||||
same ordered brush stream to a scalar field instead of triangles: negative
|
||||
inside, positive outside. `Add` folds with `min`, `Subtract` with `max(d, -b)`,
|
||||
`Intersect` with `max`. It is the seam for isosurface meshing and GPU field
|
||||
refinement.
|
||||
|
||||
Boxes, oriented boxes and Z cylinders are exact distances. Domes (a
|
||||
half-ellipsoid) and floret arms (max of face planes) are bounds that are exact
|
||||
on the surface and never overestimate. The field honours every operation on
|
||||
every primitive, including the non-box cutters the polygon kernel currently
|
||||
warns about and skips.
|
||||
|
||||
## Docs
|
||||
|
||||
Research and implementation notes live in `docs/`:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Run our current kernel:
|
|||
.\tools\run_csg_perf.ps1
|
||||
```
|
||||
|
||||
The script builds `vg_csg` in release mode and writes JSONL to
|
||||
The script builds `gamecult-geometry-csg` in release mode and writes JSONL to
|
||||
`experiments/generated/csg-perf-latest.jsonl`. Each record carries:
|
||||
|
||||
- `kernel`
|
||||
|
|
@ -19,7 +19,7 @@ The script builds `vg_csg` in release mode and writes JSONL to
|
|||
- emitted triangle, fragment, and warning counts
|
||||
- demand-frontier `candidate_pairs` and `rejected_pairs`
|
||||
|
||||
The `vg_csg` fixture measures:
|
||||
The CultGeometry CSG fixture measures:
|
||||
|
||||
- `stable`: cached output after warmup
|
||||
- `dirty`: full rebuild through the current ordered assembler
|
||||
|
|
@ -47,7 +47,7 @@ still rebuild.
|
|||
The same wrapper can append reference timings:
|
||||
|
||||
```powershell
|
||||
$env:VIBEGEOMETRY_REFERENCE_CSG_PERF = "path\to\reference-perf.exe"
|
||||
$env:GAMECULT_GEOMETRY_CSG_REFERENCE_PERF = "path\to\reference-perf.exe"
|
||||
.\tools\run_csg_perf.ps1
|
||||
```
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ inside/outside/aligned/reverse-aligned categories, crossing polygon splits, and
|
|||
logical routing tables. Use it as readable doctrine. Use the native bridge as
|
||||
the performance target.
|
||||
|
||||
`vg_csg` now reports the first demand-frontier counters. These are not yet a
|
||||
CultGeometry CSG reports the first demand-frontier counters. These are not yet a
|
||||
new kernel path; they expose the ordered brush stream as affected source/operator
|
||||
pairs and rejected bounds pairs so future router/index work can prove it is
|
||||
refusing real work rather than merely rearranging it.
|
||||
|
|
|
|||
|
|
@ -41,14 +41,14 @@ to Bevy render assets.
|
|||
|
||||
## Current Port Shape
|
||||
|
||||
`vg-csg` is the first lean Rust organ.
|
||||
CultGeometry CSG is the lean Rust CSG organ.
|
||||
|
||||
It uses `bevy_math` for `Vec3`, `Vec2`, and Bevy-compatible spatial types while
|
||||
keeping output as simple arrays:
|
||||
|
||||
```rust
|
||||
use bevy_math::Vec3;
|
||||
use vg_csg::{LevelDsl, MaterialId};
|
||||
use gamecult_geometry_csg::{LevelDsl, MaterialId};
|
||||
|
||||
let mut level = LevelDsl::new();
|
||||
level.solid_box(
|
||||
|
|
@ -109,7 +109,7 @@ level script can mix hard architectural cuts with habitat-specific forms:
|
|||
```rust
|
||||
level.dome_cap_z(
|
||||
"city dome",
|
||||
vg_csg::DomeCapZSpec {
|
||||
gamecult_geometry_csg::DomeCapZSpec {
|
||||
center: Vec3::ZERO,
|
||||
radius: 20.0,
|
||||
height: 8.0,
|
||||
|
|
@ -120,7 +120,7 @@ level.dome_cap_z(
|
|||
);
|
||||
level.floret_arm(
|
||||
"radiator petal",
|
||||
vg_csg::FloretArmSpec {
|
||||
gamecult_geometry_csg::FloretArmSpec {
|
||||
anchor: Vec3::new(20.0, 0.0, 0.2),
|
||||
direction: Vec3::X,
|
||||
length: 120.0,
|
||||
|
|
@ -188,7 +188,7 @@ slot for a public-demo/reference harness when one is available.
|
|||
|
||||
## Limits
|
||||
|
||||
`vg_csg` is not yet a full RealtimeCSG replacement.
|
||||
CultGeometry CSG is not yet a full RealtimeCSG replacement.
|
||||
|
||||
- subtractive convex support currently covers axis-aligned and oriented boxes
|
||||
- non-box subtractors are reported and ignored
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ CSG tree, and emits the pieces that remain visible. It does not treat the final
|
|||
mesh as the authority. The brush tree is the authority; the mesh is cache and
|
||||
evidence.
|
||||
|
||||
The highest-value shift for `vg_csg` is therefore:
|
||||
The highest-value shift for CultGeometry CSG is therefore:
|
||||
|
||||
```text
|
||||
from: subtract cutter -> produce new convex fragments -> triangulate fragments
|
||||
|
|
@ -63,7 +63,7 @@ A brush should be authored and cached as a set of planes, not merely as a
|
|||
triangle soup. Brush mesh creation is the act of finding valid intersections
|
||||
between those planes and linking the resulting topology.
|
||||
|
||||
Implication for `vg_csg`: add a `ConvexBrush` authored by planes, then derive
|
||||
Implication for CultGeometry CSG: add a `ConvexBrush` authored by planes, then derive
|
||||
polygons, half-edges, bounds, and render buffers from that. Boxes, cylinders,
|
||||
wedges, stairs, panels, and dome approximations should become constructors for
|
||||
plane sets where possible.
|
||||
|
|
@ -120,7 +120,7 @@ Top-level output policy:
|
|||
- overlapping coplanar surfaces are resolved by tree/order policy so duplicate
|
||||
faces do not z-fight
|
||||
|
||||
Implication for `vg_csg`: material propagation should be natural if source
|
||||
Implication for CultGeometry CSG: material propagation should be natural if source
|
||||
polygons remain source polygons. Generated cut faces need explicit policy:
|
||||
inherit cutter material, source material, or a domain material such as "raw
|
||||
concrete cut." Do not leave that as accidental array order.
|
||||
|
|
@ -138,7 +138,7 @@ allow early-outs before cutting:
|
|||
The optimization post reports a major speedup from branch-specific bounds
|
||||
checks alone. This is not a garnish. Bounds are semantic gates.
|
||||
|
||||
Implication for `vg_csg`: every brush, polygon batch, and branch needs cheap
|
||||
Implication for CultGeometry CSG: every brush, polygon batch, and branch needs cheap
|
||||
bounds. Later BVH, hashed grid, or sweep-and-prune should be measured against
|
||||
the built-in tree culling instead of assumed superior by vibes in a lab coat.
|
||||
|
||||
|
|
@ -150,7 +150,7 @@ sampled many times. Dreams' prototype refined frustum cells, shortened object
|
|||
lists as cells split, then switched to per-pixel sorted lists that could truncate
|
||||
at the first solid hit.
|
||||
|
||||
For `vg_csg`, that translates to consumer-shaped query frontiers:
|
||||
For CultGeometry CSG, that translates to consumer-shaped query frontiers:
|
||||
|
||||
- dirty brush movement asks for affected brush pairs, not a world rebuild;
|
||||
- branch evaluation asks for left/right bounds gates before polygon tests;
|
||||
|
|
@ -242,7 +242,7 @@ Use procedural fields and meshes for:
|
|||
The level DSL should let these coexist. Boolean nodes cut space; procedural
|
||||
nodes grow matter and detail around that space.
|
||||
|
||||
## Implementation Target For `vg_csg`
|
||||
## Implementation Target For CultGeometry CSG
|
||||
|
||||
Near-term kernel shape:
|
||||
|
||||
|
|
@ -269,7 +269,7 @@ Process brush:
|
|||
emit visible pieces into render mesh
|
||||
```
|
||||
|
||||
`vg_csg` now exposes the first version of that public tree surface:
|
||||
CultGeometry CSG exposes the first version of that public tree surface:
|
||||
|
||||
- `CsgOperationType::{Additive, Subtractive, Intersecting}` with parity-tested
|
||||
ordinals matching the public API
|
||||
|
|
@ -285,7 +285,7 @@ backend grows into the category router.
|
|||
|
||||
Algorithm migration steps:
|
||||
|
||||
1. Add polygon categories and visible/reversed flags to `vg_csg`.
|
||||
1. Add polygon categories and visible/reversed flags to CultGeometry CSG.
|
||||
2. Add plane-set brush construction independent of `Aabb`.
|
||||
3. Add a topology arena with vertices, directed edges, polygons, and polygon
|
||||
plane/material ids.
|
||||
|
|
@ -303,7 +303,7 @@ Algorithm migration steps:
|
|||
|
||||
## Acceptance Tests To Add
|
||||
|
||||
- Where `vg_csg` exposes the same behavior as public RealtimeCSG/demo APIs,
|
||||
- Where CultGeometry CSG exposes the same behavior as public RealtimeCSG/demo APIs,
|
||||
maintain exact observable parity fixtures. These should compare public
|
||||
behavior such as category vocabulary, brush plane/polygon counts, split
|
||||
counts, visible/reversed semantics, bounds early-outs, and mesh output
|
||||
|
|
|
|||
145
docs/research/aetheris-evaluation/README.md
Normal file
145
docs/research/aetheris-evaluation/README.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Aetheris evaluation (2026-10-14)
|
||||
|
||||
Serves the operator's request to evaluate https://github.com/yuechen-li-dev/Aetheris (a C# AGPL-3.0 B-rep kernel) for capability and ideas, campaign brush-tree, session self-2026-10-14-ag. Facts only, measured at Aetheris 335dfb07df on Yggdrasil; Self's reading is in the conversation, not here. The torture harness is in `harness/`.
|
||||
|
||||
# Aetheris evaluation facts
|
||||
|
||||
Measured at Aetheris commit 335dfb07df97295c541ab3fcf50e37b9f97b1846 (2026-10-13, "Claude Perf optimization M3"), cloned to
|
||||
`<scratch>\eyes-aetheris\repo` (read only on Starfire; every build/run on Yggdrasil via ygg-verify.sh, image mcr.microsoft.com/dotnet/sdk:10.0, Linux).
|
||||
Tags: [V] measured or read this pass; [S] stated by the repo's own docs only, not verified; [M] the author's marketing claim.
|
||||
Job logs: `<scratch>\eyes-aetheris\jobs\job1..job9.log` (job2 = tests, job6 = torture+canonical, job7/8/9 = OCCT detail probes).
|
||||
Harness (mine, not Aetheris's): `harness\Program.cs`, `harness\cases.json`, `jobs\ocp_check.py`, `jobs\inspect.py`.
|
||||
|
||||
## Q1 Shape of the codebase
|
||||
|
||||
- [V] History: 2002 commits, all by one author (Yuechen Li), first 2026-02-25, last 2026-10-13. GitHub: 47 stars, AGPL-3.0 (gh api). 5119 files, 1545 .cs files, 293,876 lines of C#.
|
||||
- [V] Lines of C# (git ls-files, `.cs`): Kernel.Firmament 66,280; Kernel.Core 57,373; Kernel.Firmament.Tests 40,718; Kernel.Core.Tests 25,513; CLI 12,119; CLI.Tests 10,172; Continuum 8,511; Firmament.FrictionLab 8,244; Surfacing 6,217; SheetMetal 5,749; StandardLibrary 3,831; the rest each under 3.7k. ~50 projects in `Aetheris.slnx`.
|
||||
- [V] Test attribute counts ([Fact]/[Theory] lines): Firmament.Tests 1886, Core.Tests 909, CLI.Tests 379, FrictionLab.Tests 225, Continuum.Tests 135, Modules.Tests 94, SheetMetal.Tests 85, others smaller. 15 xunit projects.
|
||||
- [V] CI: one workflow `.github/workflows/dotnet.yml`; the main job is `ubuntu-latest`: restore/build server and kernel tests, `dotnet test Aetheris.Kernel.Core.Tests --filter "Category!=SlowCorpus"`, then build and test the whole solution (`-m:1`). A Windows job (`public-library-build`, `workflow_dispatch` only) packs NuGet libraries. It also checks out a second repo, `yuechen-li-dev/Copeland` at a pinned commit (`scripts/managed-wgsl-commit-pin.txt`), packs it into a local NuGet feed (`.github/actions/prepare-managed-wgsl`).
|
||||
- [V] Dependencies: Kernel.Core has one NuGet package, `Machina.Typography.OpenFont` (csproj). Kernel.Firmament adds `Copeland.TS.Backend.Wgsl 0.1.0-preview.1-a3.16c0ce996358af80`, which is NOT on nuget.org (restore error NU1101 `Copeland.TS`, job1.log) and has to be built from the Copeland repo. Copeland's licence is GPL-3.0 (gh api repos/yuechen-li-dev/Copeland/license). Other packages: Rhino3dm (3dm import), EF Core Sqlite, Roslyn, Swashbuckle, WebView2, DocumentFormat.OpenXml, PDFtoImage (each one project).
|
||||
- [V] No OCCT or other native geometry kernel: no P/Invoke except one DllImport in `Aetheris.CLI/CadAssistantInspection.cs`; grep for opencascade/occt in Kernel.Core and Kernel.Firmament .cs finds nothing. OCCT appears only as an interop/validation reference in docs and tests. Geometry code is all C#.
|
||||
- [V] Surface types (`Aetheris.Kernel.Core/Geometry/Surfaces/`): PlaneSurface, CylinderSurface, ConeSurface, SphereSurface, TorusSurface, LinearExtrusionSurface, SurfaceOfRevolutionSurface, BSplineSurfaceWithKnots (+ BSplineSurfaceRationalReduction), HelicalRibSurfaceRealizer. Curves (`Geometry/Curves/`): Line3, Circle3, Ellipse3, Hyperbola3, BSpline3Curve, CylindricalHelix3, CylinderCylinderIntersectionCurve, plus pcurves.
|
||||
- [V] So "NURBS genuinely absent" is false as stated: B-spline curve and surface types exist in the kernel, and the STEP outputs of the Perforation/Presentation fixtures contain `B_SPLINE_CURVE_WITH_KNOTS` (job7 ZERO probe). Docs say products are "rational-free"/non-rational (docs/public/reference/supported-features.md, README.md:"exact analytical geometry as its foundation").
|
||||
- [V] Booleans are not a general intersection engine. `Brep/Boolean/BrepBoolean.cs` (2158 lines) plus ~20 sibling files (8,346 lines total) classify the pair into hard-coded recognition families (`BooleanExecutionClass { PlanarOnly, PlanarWithAnalyticHole, UnsupportedGeneralCase }`, BrepBoolean.cs:36-41; `BooleanTopLevelCase` enum at :74-84): axis-aligned box vs box (orthogonal cells), box with a world-Z cylinder/cone/sphere hole ("safe composition"), cylinder-root centre bore / keyway, prismatic through-cuts. Selection is by `JudgmentEngine` utility scoring (Kernel.Core/Judgment). Everything else returns an Error diagnostic `NotImplemented`. Tolerances: `ToleranceContext.Default` = linear 1e-6, angular 1e-9, relative 1e-12 (Numerics/ToleranceContext.cs:36); the family validators compare against `tolerance.Linear`.
|
||||
- [V] No exact surface-surface intersection curve solver is used by the boolean entry point; the intersection-curve classes (CylinderCylinderIntersectionCurve, TransverseConePlaneIntersection) are used by feature builders, not by arbitrary boolean pairs (observed behaviour in Q3: Steinmetz refused).
|
||||
- [V] Firmament does not expose CSG: docs/public/firmament/geometry.md:136 states "public Union / Subtract / Intersect, and general CSG trees are not Preview 3 Firmament features"; `scripts/Test-CanonicalFixtures.ps1` lists `Union|Subtract|Intersect` as a forbidden "legacy arbitrary Boolean authoring" pattern in canonical fixtures. Firmament authors feature-based models (Base, Boss, Pocket, Hole, Slot, Revolve, Loft, SectionChain...). Pocket is documented as "intentionally not arbitrary solid subtraction" (geometry.md:90).
|
||||
- [V] Firmament -> kernel for CSG-ish work goes through `Aetheris.Kernel.Firmament/Materializer/CirBrepMaterializer.cs`: a registry of exactly three strategies, `subtract(box,cylinder)`, `subtract(box,box)`, `subtract(box,torus)` (the last one an "Unsupported" strategy), chosen by JudgmentEngine, from an SDF graph (`Aetheris.Continuum.Backends.Sdf`) (CirBrepMaterializer.cs:11-18).
|
||||
- [S] README claims Aetheris is "a solved problem" relative to Parasolid/ACIS/OpenCascade; supported-features.md labels nearly everything "Bounded"/"Experimental" and says "general blend networks ... remain unsupported".
|
||||
|
||||
## Q2 Build and tests on Linux (Yggdrasil container)
|
||||
|
||||
- [V] Builds clean on Linux: `dotnet build` of Kernel.Core.Tests, Kernel.Firmament.Tests and Aetheris.CLI, Release, 0 errors, 0 warnings in the shown tails (job1/job2), after packing Copeland at the pin by hand. SDK 10.0 image; global.json asks 10.0.203 with latestFeature rollForward.
|
||||
- [V] `Aetheris.Kernel.Core.Tests` (filter `Category!=SlowCorpus`): Passed 1063, Failed 0, Skipped 0, 27 s (job1.log).
|
||||
- [V] `Aetheris.Kernel.Firmament.Tests`: Passed 2131, Failed 0, Skipped 0, 4 m 35 s (job2.log).
|
||||
- [V] SlowCorpus lane (`Category=SlowCorpus`, 52 occurrences in Core.Tests): the run produced no output lines in my tail (job2.log "CORE-SLOW" empty); not established whether it ran zero tests or lacks the NIST corpus files. Unmeasured.
|
||||
- [-] The other 13 test projects (CLI.Tests, Continuum.Tests, SheetMetal.Tests, Server.Tests, etc.) were not run; the web/TypeScript parts (Telos, Cadmata) were not built.
|
||||
- [S] README/known-issues: "Release binaries are qualified for Windows x64 only. Framework tests on other platforms are not a binary-support promise." CI nonetheless runs the kernel and full solution tests on ubuntu-latest.
|
||||
|
||||
## Q3 Boolean torture (35 cases through `BrepBoolean.Union/Subtract/Intersect` via the public Core API; plus 11 Firmament models; plus 320 canonical fixtures)
|
||||
|
||||
Method: `harness/Program.cs` is a console app compiled as assembly `Aetheris.Kernel.Core.Tests` (to reuse the kernel's InternalsVisibleTo, same as the repo's own tests) that builds `BrepPrimitives.CreateBox/Cylinder/Sphere/Torus`, places them with a copy of the repo tests' `TransformBody` helper (extended to Sphere and Torus; plane/cylinder/line/circle only otherwise), runs the boolean, then `Step242Exporter.ExportBody`. Cases in `harness/cases.json`. Primitives are origin-centred; translation/rotation only. Caveat: the test helper does not transform cone surfaces or ellipse curves, which the cases do not use.
|
||||
Results (job6.log; "vol" is Aetheris's own mass-property status):
|
||||
|
||||
| # | case | outcome |
|
||||
|---|---|---|
|
||||
| 01 | coplanar face union, box+box face contact | OK, exported |
|
||||
| 02 | flush slab subtract (coplanar top and sides) | OK |
|
||||
| 03 | coplanar top pocket | OK |
|
||||
| 04 | coincident-edge union (edge-only contact) | REFUSED "edge-only/point-only unions are non-manifold" |
|
||||
| 05 | coincident-edge subtract | REFUSED "rejects tangent/zero-thickness subtract contact" |
|
||||
| 06 | tangent cylinders union | REFUSED "only supports recognized axis-aligned boxes" |
|
||||
| 07/08/09 | Steinmetz: cyl r5 x cyl r5 intersect / union / subtract | all REFUSED (07, 08 "only supports ... boxes"; 09 "supports only world-Z aligned root and center-bore cylinders") |
|
||||
| 10 | subtract leaving two 1e-7 side walls | REFUSED, with the misleading message "subtraction fully removes the left box" |
|
||||
| 11 | pocket leaving a 1e-7 floor | OK, exported (no sliver refusal at kernel level) |
|
||||
| 12 | pocket leaving a 1e-4 floor | OK |
|
||||
| 13/14/15/16 | spheres: tangent / 1e-9 / 1e-4 overlap / 6-apart union | all REFUSED "only supports ... boxes" |
|
||||
| 17 | box minus sphere centred on top face | OK, exported (see Q4: wrong volume) |
|
||||
| 18 | torus half cut by a box | REFUSED "only supports ... boxes" |
|
||||
| 19/20 | torus union cyl / torus minus cyl | REFUSED |
|
||||
| 21 | 10x10 hole grid, 100 sequential cylinder subtracts | OK, 100/100 steps, 954 ms |
|
||||
| 22 | two tangent holes | REFUSED "tangent safe-hole composition is rejected to avoid zero-thickness geometry" |
|
||||
| 23 | hole tangent to side wall | REFUSED with a long explanatory message ("would leave a wall of zero thickness along a line") |
|
||||
| 24 | two holes with a 1e-7 web | REFUSED (same "tangent" message: 1e-7 < tolerance 1e-6 treated as tangent) |
|
||||
| 25 | perpendicular cylinder through cylinder, union | REFUSED |
|
||||
| 26 | coaxial tube (cyl minus smaller coaxial cyl) | OK, exported (see Q4: OCCT segfaults reading it) |
|
||||
| 27 | blind hole from top | OK |
|
||||
| 28 | oblique (30 deg) through hole | OK (Aetheris mass-property call threw ArgumentOutOfRangeException; STEP exported) |
|
||||
| 29 | box intersect sphere | REFUSED |
|
||||
| 30 | box union box overlapping at an offset corner | OK |
|
||||
| 31 | blind pocket leaving a 1e-7 side wall | REFUSED "bounded orthogonal pocket family only supports a single mouth or one opposite-face through-slot" (refused for family reasons, not sliver) |
|
||||
| 32 | union of boxes overlapping by a 1e-7 slab | OK |
|
||||
| 33 | union with partial face contact | OK |
|
||||
| 34 | sphere fully inside box subtract (void) | OK, exported as BREP_WITH_VOIDS (see Q4) |
|
||||
| 35 | cylinder minus enclosed sphere | REFUSED "does not support analytic tool surface kind 'Sphere'" |
|
||||
|
||||
- [V] Score: 35 cases: 14 succeeded, 21 refused (typed `NotImplemented` errors, no exceptions), 0 crashed, 0 timeouts. Every refusal message names a missing family rather than the geometry's true difficulty; only 04, 05, 22, 23, 24 are refusals of degeneracy (tangent/edge contact/zero-thickness).
|
||||
- [V] All general curved-curved cases (tangent cylinders, Steinmetz x3, spheres x4, torus x3, perpendicular cylinders) are refused; none produced a result. The Steinmetz case is not computed.
|
||||
- [V] A kernel-level sliver of thickness 1e-7 is NOT refused when it is a pocket floor (11) or a union overlap slab (32); the refusal claim holds for hole-hole and hole-wall tangency (22-24) and for pocket floor policy at the Firmament layer (below).
|
||||
- [V] Firmament models (`harness/fa/*.firmament`, job6.log "FIRMAMENT-TORTURE"): 11 models via `aetheris build`: 4 built (fa05 hole poking through the edge, fa06 two holes with ample web, fa07 10x10 hole grid, fa10 pocket with a 3 mm floor), 7 refused with named diagnostics:
|
||||
- fa01 hole tangent to side face: `FirmamentV2.SemanticHoleBuild: rejected: Hole 'Base.H0' ... is exactly tangent to the +X face ... zero thickness along a line` (Materializer/AirHoleFootprint.cs:55)
|
||||
- fa02 tangent pair and fa03 1e-7 web: `firmament-v2-semantic-hole-overlap ... would be tangent to previously accepted hole` (AirHoleCompositeMaterializer.cs:80; Core BrepBooleanSafeCompositionGraphValidator.cs:229)
|
||||
- fa04 overlapping holes: `overlaps previously accepted hole ... overlapping safe-hole composition is not supported`
|
||||
- fa11 pocket with floor 9.9999999 mm deep in 10 mm host, required floor 1e-7: `firmament-pocket-through-depth ... remainingFloor=9.99999993922529E-08mm requiredFloor=1E-07mm` (float rounding put the floor just under the required value)
|
||||
- fa12 required floor 0 mm: `firmament-pocket-minimum-floor-policy-invalid`
|
||||
- fa13 floor 2 mm < required 3 mm: `firmament-pocket-minimum-floor-thickness` (PrismaticProfileComposition.cs:404)
|
||||
- [V] Canonical fixtures (`fixtures/Canonical`, 320 `.firmament` files) through `aetheris build` (job6.log): 249 exit 0, 71 exit non-zero. The 71 are almost all not buildable by the `build` verb: garments (11, "garment-step-export-unsupported ... use garment build"), scenes (8, "scene-step-export-unsupported"), FEA/Analysis files (7, "firmament-v2-canonical-declaration-unknown:Analysis"), gear pairs (7, "gear-build-body-count-invalid ... use Assembly"), BodyState (6, "section-chain-section-count-invalid:minimum=2"), assembly components and Guitar/Atlas sub-files (~30, parse errors for partial files or "AssemblyIR must be successfully materialized"), 1 `Pipeline/profile-with-inner-loop` ("profile-segment-identity-collision"). I did not confirm each as expected-failure; they are reported as the CLI printed them.
|
||||
|
||||
## Q4 STEP validity checked independently with OpenCASCADE
|
||||
|
||||
Method: cadquery-ocp (pip, Ubuntu 24.04, Python 3.12) `STEPControl_Reader` default settings (`ReadFile`, `TransferRoots`, `OneShape`), `BRepCheck_Analyzer.IsValid`, `TopExp_Explorer` counts, `BRepGProp.VolumeProperties`; volume reference from an OCCT boolean (`BRepAlgoAPI_Fuse/Cut/Common`) on identical OCCT primitives and from analytic formulas. (job7.log OCP-CASES, job8.log INSPECT.)
|
||||
|
||||
- [V] Harness cases that produced STEP (14): BRepCheck_Analyzer.IsValid = True for the 12 that OCCT could read. Detail:
|
||||
|
||||
| case | OCCT reads as | volume Aetheris STEP | OCCT reference |
|
||||
|---|---|---|---|
|
||||
| 01 face union | 1 solid, 1 shell, 6 faces | 2000 | 2000 |
|
||||
| 02 flush slab | 1 solid, 6 faces | 500 | 500 |
|
||||
| 03 top pocket | compound, 0 solids, 4 shells, 14 faces | 920 | 920 |
|
||||
| 11 floor 1e-7 | compound, 0 solids, 6 shells, 16 faces | 839.999992 | 840.000001 |
|
||||
| 12 floor 1e-4 | compound, 0 solids, 4 shells, 14 faces | 840.0016 | 840.0016 |
|
||||
| 17 box minus sphere (dimple) | 1 solid, 7 faces | **8261.799** | **7738.201** (= 8000 - 261.8); Aetheris's result is 8000 + 261.8, a bump, not a dimple |
|
||||
| 21 hole grid 10x10 | 1 solid, 1 shell, 106 faces | 87433.629386 | 87433.629386 (analytic 87433.6294) |
|
||||
| 27 blind hole | 1 solid, 8 faces | 3858.628331 | 3858.628331 |
|
||||
| 28 oblique hole | 1 solid, 7 faces | 3673.516114 | 3673.516114 |
|
||||
| 30 offset corner union | compound, 0 solids, 3 shells, 18 faces | 1875 | 1875 |
|
||||
| 32 1e-7 slab union | 1 solid, 6 faces | 1999.99999 | 1999.999993 |
|
||||
| 33 partial face contact | single SHELL, 0 solids, 10 faces | 1500 | 1500 |
|
||||
| 26 coaxial tube | OCCT **segfaults** inside `TransferRoots` (python faulthandler: job9.log SEGV-26) | n/a | 904.1 analytic |
|
||||
| 34 enclosed sphere void | OCCT **segfaults** inside `TransferRoots` (BREP_WITH_VOIDS + ORIENTED_CLOSED_SHELL .F., job8.log STEP-HEAD-34) | n/a | 7886.9 analytic |
|
||||
|
||||
- [V] Every STEP file contains one `MANIFOLD_SOLID_BREP` over one `CLOSED_SHELL` (job8 INSPECT), but for cases 03, 11, 12, 30, 33 OCCT returns no SOLID. Inspecting the case-03 file (job9.log STEPFILE-03, saved as `jobs/case03.step`): 38 `EDGE_CURVE`s, 14 faces, 20 vertices; of the 56 `ORIENTED_EDGE` uses, 18 edges are used twice and 20 edges are used once. A closed shell requires every edge used twice, so this "CLOSED_SHELL" has 20 unmatched edges: faces are fragmented at coplanar/face-contact boundaries without edge sharing. OCCT's volume still sums to the right number; BRepCheck calls the result valid; downstream tooling expecting one solid gets a compound of shells.
|
||||
- [V] Case 26's file (first 70 non-point lines, `jobs/case26.step`): two sets of three faces, circles r=5 and r=3, each end face a full disc with a single outer loop, no annulus; i.e. the "tube" is expressed as two cylinder shells, not a through-bore. Remaining entities were not inspected.
|
||||
- [V] Case 17 (box minus sphere): both Aetheris's tessellated volume (8263.2) and OCCT's volume of Aetheris's STEP (8261.8) say the exported body is box + hemisphere. Cause not isolated (could be face orientation of the sphere face in the STEP, `ADVANCED_FACE(...,#119 SPHERICAL_SURFACE, .T.)`, or the harness's translated sphere frame).
|
||||
- [V] Aetheris's own `BrepMassProperties` returned status `Unavailable` (volume 0) for cases 03, 11, 12, 26, 30, 33, and threw ArgumentOutOfRangeException for 28; `NumericalWithBound` (tessellation based, "not authoritative") for the others.
|
||||
- [V] Firmament outputs (job6.log OCP-FA): fa05, fa06, fa07, fa10 all read as 1 solid, 1 shell, BRepCheck valid; fa07 (10x10 holes) 106 faces, volume 87433.629386 equals the analytic value; fa10 volume 13648.0 equals 16000-2352 exactly; fa05 8326.43, fa06 8434.51.
|
||||
- [V] Canonical STEP outputs (253 files from job6, job6.log OCP-CANON, `jobs/canon_dir.txt`): all 253 read by OCCT, BRepCheck valid 253/253. Solid counts: 199 files with 1 solid; 48 multi-solid (assemblies and scenes, 2 to 1366 solids); 6 with 0 solids: SectionChain/open-chain (1 shell, 4 faces, intended open) and 5 where OCCT reported 1 root transferred but 0 solids/shells/faces and volume 0 (Perforation cylindrical-grid-8, -24, -ordning-blank, -staggered-32, Presentation ordning-utensil-holder-blended; files of 0.86 MB to 18 MB with many `B_SPLINE_CURVE_WITH_KNOTS`, `PCURVE`, `EDGE_CURVE`; a rebuild confirmed `success: true` from the CLI, job7 ZBUILD). Why those read as empty was not determined.
|
||||
- [S] The "no healing needed" claim: the measurement above uses OCCT's default reader (which applies its own shape processing); I did not disable it.
|
||||
|
||||
## Q5 GPU renderer ("Three Telos")
|
||||
|
||||
- [V] Location `Aetheris.Web.Runtime/telos/` (TypeScript package `@aetheris/three-telos`, AGPL-3.0-only): 3,169 lines of TS/WGSL (host.ts 1067, device.ts 284, temporal.ts 302...). First commit touching it 2026-10-05; 6 commits, latest 2026-10-07.
|
||||
- [V] It is a WebGPU renderer (`navigator.gpu`, `@webgpu/types`), not CUDA/Vulkan/native. It owns device, attachments, camera, ray queries, mesh/field/line submission; Three.js 0.183.2 is used only for math, input adapters and CPU triangle picks ("Three supplies math and input adapters, never a GPU renderer", telos/README.md).
|
||||
- [V] Two draw paths: (a) rasterising tessellated triangle meshes (`src/shaders/mesh.wgsl`, `line.wgsl`, `background.wgsl`, 4x MSAA, experimental temporal AA); (b) "field" draws: a per-occurrence quad whose fragment shader sphere-traces a signed distance function with a fixed 512-step budget, normal by central differences, writes `frag_depth` (`Aetheris.Kernel.Firmament/Display/telos-field.v.ts`; docs/release/THREE-TELOS-X0.md). The WGSL is compiled from typed shader source by the Copeland compiler (GPL-3.0 repo), browsers receive WGSL only. The field path is documented only for the cylinder/sphere smoke shaders; "Full CIR-DISPLAY-X1B packet/admission/field default qualification is not claimed" (X0.md), and docs/release/CIR-DISPLAY-X1.md is an "Honest stop" (Cadmata/Helios cannot yet use the field path by default).
|
||||
- [V] It does not ray trace the B-rep exactly. The B-rep is tessellated by `Brep/Tessellation/*` for the mesh path; picking is CPU (Three Raycaster/server BRep query).
|
||||
- [V] Headless: the only automation found is Playwright with `chromium.launch({executablePath: msedge.exe, headless: true})` hard-coded to a Windows Edge path (`scripts/display-qa-x1-playwright.mjs:12,51`) and a `tests/browser-witness.ts`; unit tests run with `node --test` on camera/inspection/overlay/temporal logic. Not run in the container; no WebGPU device was tried.
|
||||
|
||||
## Q6 Mechanisms
|
||||
|
||||
- Degeneracy refusal [V]: it is not a type-system or compile-time proof. It is a layered set of runtime checks that return Error diagnostics before geometry is emitted:
|
||||
1. Kernel family validators compare geometry to `ToleranceContext.Linear` (1e-6): tangent hole-hole (`BrepBooleanSafeCompositionGraphValidator.cs:224-233`, `:827`), hole-wall tangency (`BrepBooleanCylinderRecognition.cs:452`), box-box edge/point-only contact ("non-manifold"), tangent spherical cavity (test `Subtract_BoxSphereTangentBoundary_ReturnsTangentContactDiagnostic`, BrepBooleanTests.cs:1555).
|
||||
2. Firmament semantic layer: `AirHoleFootprint.cs:55`, `AirHoleCompositeMaterializer.cs:80`, `PrismaticProfileComposition.cs:404` (pocket `MinimumFloorThickness`), profile-level checks (explicit polygons reject degenerate area and self-intersection, boundaries.md:62), template `Require` clauses.
|
||||
3. Anything outside the recognised families is refused wholesale (Q3), which also blocks most degenerate cases by construction.
|
||||
Observed gap: 1e-7 pocket floors and 1e-7 overlap slabs pass the kernel API (cases 11, 32) and only the Firmament Pocket policy refuses (fa11-fa13).
|
||||
- Firmament -> kernel [V]: V2 frontend in `Kernel.Firmament/FirmamentV2` (parse, static authoring, Concepts/Templates, validation, lowering), features lowered to "section-stack" operations and `PrismaticProfileComposition`/`AirHole*` materializers, then `BrepPrimitives`/`BrepBoolean`/`Step242Exporter`. V1 TOON/JSON path is kept as frozen compatibility (Kernel.Firmament/README.md). Ownership audit at `docs/development/architecture/system/artifacts/archaeology-m1/`.
|
||||
- "Constraints erased at compile time" [V]: Firmament has no 2D constraint solver; layout is explicit coordinates in a `Concept Struct Layout On XY { Rect2 Stock { Center; Size } }` block and profiles are built by naming edges with `|>` (fixtures/Canonical/Integration/machined-mounting-block.firmament). Static Records/arrays/Tables, `with` derivation, `Template<...>` specialisation and `Pattern ... Over` are expanded and discarded before geometry (docs/public/firmament/overview.md; templates.md:100-108: `Require Name => BooleanExpression` is finite comparisons "not a general constraint solver", failure stops specialisation before materialisation). Units are part of value types and checked by the binder.
|
||||
- Forge Host [V]: `Aetheris.Forge.Host` (1.4k lines) is a process protocol "Forge Host Protocol v1": commands `list`, `describe <stable-id>`, `invoke <id> --request - --out <dir>` with a JSON envelope (`protocolVersion`, `arguments` with unit strings like "120 mm", `artifacts`), response JSON with `success`; `describe` returns signature, units, defaults, enum cases, nested Record fields and named `Require` constraints. Clients in Python, Go, Rust, TypeScript shipped in `samples/forge-interop-x1/`; released as NativeAOT exe (Windows-qualified). Doc: docs/public/forge/interop.md; code `Aetheris.Forge.Host/ForgeProtocolHost.cs`, `Aetheris.Forge.KernelSDK/KernelSdk.cs`.
|
||||
- LLM authoring [V]: repo-level `AGENTS.md` (use the CLI as ground truth, convergence rule: Success / Meaningful progression / Honest stop, fixture-location policy); a skill `docs/public/skills/firmament-modeling-strategy/SKILL.md` (+ `agents/openai.yaml`) on planning shape spines, separating measured vs estimated dimensions, and reporting compiler gaps with reproducible evidence; canonical cookbook `fixtures/Canonical/README.md`; `docs/development` has language audits and grammar inventory. README credits models (GPT-5/6, Claude 4/5, Gemini 3) for both the code and the showcase parts.
|
||||
- GPU/CIR [V]: see Q5; the shared SDF "CIR" graph (`Aetheris.Continuum`, 8.5k lines) feeds both the field shader and the materializer strategies in Q1.
|
||||
|
||||
## Q7 Licence
|
||||
|
||||
- [V] `LICENSE` is the 661-line unmodified GNU AGPL v3 text; every csproj inherits `PackageLicenseExpression AGPL-3.0-only` from `Directory.Build.props`; the Telos package.json says AGPL-3.0-only.
|
||||
- [V] README (section "License and acknowledgments"): "Aetheris code is licensed under the GNU Affero General Public License v3.0. Alternative licensing is available on request. Third-party assets retain their own terms and provenance; see THIRD_PARTY_NOTICES.md." The same line appears in docs/release/*README*.md and the preview-3 notes.
|
||||
- [V] CONTRIBUTING.md ("Contributor license agreement status"): intended CLA lets contributors keep copyright and grants the owner "non-exclusive rights to distribute contributions under AGPL-3.0 and alternative licenses", with a commitment to continued AGPL availability; `docs/legal/CLA-CANDIDATE.md` is "candidate only; pending review by qualified human legal counsel", no acceptance mechanism is defined, maintainers "should not represent a contribution as having accepted that draft". Companion `CLA-COUNSEL-QUESTIONS.md`.
|
||||
- [V] All 2002 commits are by the one author, so no third-party contributions exist yet to cover. NIST STEP models and the Stanford Bunny are noted as not under the Aetheris licence (README, THIRD_PARTY_NOTICES.md; earcut adapted from Mapbox, ISC).
|
||||
- [V] Kernel.Firmament depends at build time on the Copeland repo, licensed GPL-3.0 (gh api), same author.
|
||||
13
docs/research/aetheris-evaluation/harness/Harness.csproj
Normal file
13
docs/research/aetheris-evaluation/harness/Harness.csproj
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<AssemblyName>Aetheris.Kernel.Core.Tests</AssemblyName>
|
||||
<RootNamespace>Harness</RootNamespace>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="/src/Aetheris.Kernel.Core/Aetheris.Kernel.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
161
docs/research/aetheris-evaluation/harness/Program.cs
Normal file
161
docs/research/aetheris-evaluation/harness/Program.cs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Aetheris.Kernel.Core.Brep;
|
||||
using Aetheris.Kernel.Core.Brep.Boolean;
|
||||
using Aetheris.Kernel.Core.Brep.Verification;
|
||||
using Aetheris.Kernel.Core.Geometry;
|
||||
using Aetheris.Kernel.Core.Geometry.Curves;
|
||||
using Aetheris.Kernel.Core.Geometry.Surfaces;
|
||||
using Aetheris.Kernel.Core.Math;
|
||||
using Aetheris.Kernel.Core.Results;
|
||||
using Aetheris.Kernel.Core.Step242;
|
||||
using Aetheris.Kernel.Core.Topology;
|
||||
using Aetheris.Kernel.Core.Diagnostics;
|
||||
|
||||
static class P
|
||||
{
|
||||
static (double c, double s) CS(double deg)
|
||||
{
|
||||
var d = ((deg % 360) + 360) % 360;
|
||||
if (d == 0) return (1, 0);
|
||||
if (d == 90) return (0, 1);
|
||||
if (d == 180) return (-1, 0);
|
||||
if (d == 270) return (0, -1);
|
||||
var r = deg * Math.PI / 180;
|
||||
return (Math.Cos(r), Math.Sin(r));
|
||||
}
|
||||
|
||||
static Transform3D Make(double rxDeg, double ryDeg, double[] p)
|
||||
{
|
||||
var (cx, sx) = CS(rxDeg);
|
||||
var (cy, sy) = CS(ryDeg);
|
||||
var rx = Transform3D.FromRowMajor(new double[] { 1, 0, 0, 0, 0, cx, sx, 0, 0, -sx, cx, 0, 0, 0, 0, 1 });
|
||||
var ry = Transform3D.FromRowMajor(new double[] { cy, 0, -sy, 0, 0, 1, 0, 0, sy, 0, cy, 0, 0, 0, 0, 1 });
|
||||
var t = Transform3D.CreateTranslation(new Vector3D(p[0], p[1], p[2]));
|
||||
return rx * ry * t;
|
||||
}
|
||||
|
||||
static BrepBody Tr(BrepBody body, Transform3D t)
|
||||
{
|
||||
var g = new BrepGeometryStore();
|
||||
foreach (var c in body.Geometry.Curves)
|
||||
{
|
||||
g.AddCurve(c.Key, c.Value.Kind switch
|
||||
{
|
||||
CurveGeometryKind.Line3 => CurveGeometry.FromLine(new Line3Curve(t.Apply(c.Value.Line3!.Value.Origin), t.Apply(c.Value.Line3.Value.Direction))),
|
||||
CurveGeometryKind.Circle3 => CurveGeometry.FromCircle(new Circle3Curve(t.Apply(c.Value.Circle3!.Value.Center), t.Apply(c.Value.Circle3.Value.Normal), c.Value.Circle3.Value.Radius, t.Apply(c.Value.Circle3.Value.XAxis))),
|
||||
_ => c.Value
|
||||
});
|
||||
}
|
||||
foreach (var s in body.Geometry.Surfaces)
|
||||
{
|
||||
g.AddSurface(s.Key, s.Value.Kind switch
|
||||
{
|
||||
SurfaceGeometryKind.Plane => SurfaceGeometry.FromPlane(new PlaneSurface(t.Apply(s.Value.Plane!.Value.Origin), t.Apply(s.Value.Plane.Value.Normal), t.Apply(s.Value.Plane.Value.UAxis))),
|
||||
SurfaceGeometryKind.Cylinder => SurfaceGeometry.FromCylinder(new CylinderSurface(t.Apply(s.Value.Cylinder!.Value.Origin), t.Apply(s.Value.Cylinder.Value.Axis), s.Value.Cylinder.Value.Radius, t.Apply(s.Value.Cylinder.Value.XAxis))),
|
||||
SurfaceGeometryKind.Sphere => SurfaceGeometry.FromSphere(new SphereSurface(t.Apply(s.Value.Sphere!.Value.Center), t.Apply(s.Value.Sphere.Value.Axis), s.Value.Sphere.Value.Radius, t.Apply(s.Value.Sphere.Value.XAxis))),
|
||||
SurfaceGeometryKind.Torus => SurfaceGeometry.FromTorus(new TorusSurface(t.Apply(s.Value.Torus!.Value.Center), t.Apply(s.Value.Torus.Value.Axis), s.Value.Torus.Value.MajorRadius, s.Value.Torus.Value.MinorRadius, t.Apply(s.Value.Torus.Value.XAxis))),
|
||||
_ => s.Value
|
||||
});
|
||||
}
|
||||
var vp = new Dictionary<VertexId, Point3D>();
|
||||
foreach (var v in body.Topology.Vertices)
|
||||
if (body.TryGetVertexPoint(v.Id, out var pt)) vp[v.Id] = t.Apply(pt);
|
||||
return new BrepBody(body.Topology, g, body.Bindings, vp, body.SafeBooleanComposition);
|
||||
}
|
||||
|
||||
static KernelResult<BrepBody> Prim(JsonElement e)
|
||||
{
|
||||
var k = e.GetProperty("k").GetString()!;
|
||||
var d = e.GetProperty("d").EnumerateArray().Select(x => x.GetDouble()).ToArray();
|
||||
var p = e.GetProperty("p").EnumerateArray().Select(x => x.GetDouble()).ToArray();
|
||||
var rx = e.GetProperty("rx").GetDouble();
|
||||
var ry = e.GetProperty("ry").GetDouble();
|
||||
KernelResult<BrepBody> r;
|
||||
if (k == "box") r = BrepPrimitives.CreateBox(d[0], d[1], d[2]);
|
||||
else if (k == "cyl") r = BrepPrimitives.CreateCylinder(d[0], d[1]);
|
||||
else if (k == "sph") r = BrepPrimitives.CreateSphere(d[0]);
|
||||
else if (k == "tor") r = BrepPrimitives.CreateTorus(d[0], d[1]);
|
||||
else throw new Exception("kind " + k);
|
||||
if (!r.IsSuccess) return r;
|
||||
if (rx == 0 && ry == 0 && p.All(x => x == 0)) return r;
|
||||
return KernelResult<BrepBody>.Success(Tr(r.Value, Make(rx, ry, p)));
|
||||
}
|
||||
|
||||
static string Short(IEnumerable<KernelDiagnostic> ds, int n = 3)
|
||||
=> string.Join(" || ", ds.Take(n).Select(d => "[" + d.Severity + "/" + d.Code + "] " + d.Message)).Replace("\n", " ");
|
||||
|
||||
static int Main(string[] a)
|
||||
{
|
||||
var cases = JsonDocument.Parse(File.ReadAllText(a[0])).RootElement;
|
||||
var outDir = a[1];
|
||||
Directory.CreateDirectory(outDir);
|
||||
var only = a.Length > 2 ? a[2] : null;
|
||||
foreach (var c in cases.EnumerateArray())
|
||||
{
|
||||
var name = c.GetProperty("name").GetString()!;
|
||||
if (only != null && !name.StartsWith(only)) continue;
|
||||
var line = new Dictionary<string, object?> { ["name"] = name };
|
||||
var sw = Stopwatch.StartNew();
|
||||
var cc = c;
|
||||
var task = Task.Run(() =>
|
||||
{
|
||||
var cur = Prim(cc.GetProperty("a"));
|
||||
if (!cur.IsSuccess) throw new Exception("PRIM(a): " + Short(cur.Diagnostics));
|
||||
var op = cc.GetProperty("op").GetString();
|
||||
int i = 0;
|
||||
int n = cc.GetProperty("bs").GetArrayLength();
|
||||
foreach (var b in cc.GetProperty("bs").EnumerateArray())
|
||||
{
|
||||
var rb = Prim(b);
|
||||
if (!rb.IsSuccess) throw new Exception("PRIM(b" + i + "): " + Short(rb.Diagnostics));
|
||||
if (op == "union") cur = BrepBoolean.Union(cur.Value, rb.Value);
|
||||
else if (op == "subtract") cur = BrepBoolean.Subtract(cur.Value, rb.Value);
|
||||
else cur = BrepBoolean.Intersect(cur.Value, rb.Value);
|
||||
i++;
|
||||
if (!cur.IsSuccess) { line["failedAtStep"] = i + "/" + n; return cur; }
|
||||
}
|
||||
line["stepsDone"] = i + "/" + n;
|
||||
return cur;
|
||||
});
|
||||
try
|
||||
{
|
||||
if (!task.Wait(TimeSpan.FromSeconds(180))) { line["status"] = "TIMEOUT"; }
|
||||
else
|
||||
{
|
||||
var r = task.Result;
|
||||
if (!r.IsSuccess) { line["status"] = "REFUSED"; line["msg"] = Short(r.Diagnostics); }
|
||||
else
|
||||
{
|
||||
line["status"] = "OK";
|
||||
line["warn"] = Short(r.Diagnostics.Where(d => d.Severity != KernelDiagnosticSeverity.Info), 2);
|
||||
try
|
||||
{
|
||||
var m = BrepMassProperties.Evaluate(r.Value);
|
||||
line["vol"] = m.Status + ":" + m.AbsoluteVolume.ToString("R") + ":" + m.EvaluationMethod;
|
||||
}
|
||||
catch (Exception ex) { line["vol"] = "EX " + ex.GetType().Name; }
|
||||
try
|
||||
{
|
||||
var s = Step242Exporter.ExportBody(r.Value);
|
||||
if (s.IsSuccess) { File.WriteAllText(Path.Combine(outDir, name + ".step"), s.Value); line["step"] = "exported"; }
|
||||
else line["step"] = "EXPORT_REFUSED " + Short(s.Diagnostics);
|
||||
}
|
||||
catch (Exception ex) { line["step"] = "EXPORT_CRASH " + ex.GetType().Name + ": " + ex.Message; }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (AggregateException ex)
|
||||
{
|
||||
var m = ex.InnerException?.Message ?? ex.Message;
|
||||
bool prim = m.StartsWith("PRIM");
|
||||
line["status"] = prim ? "HARNESS_PRIM_FAIL" : "CRASH";
|
||||
line["msg"] = (ex.InnerException?.GetType().Name + ": " + m).Replace("\n", " ");
|
||||
if (!prim) line["stack"] = (ex.InnerException?.StackTrace ?? "").Split('\n').Take(3).Select(s => s.Trim()).ToArray();
|
||||
}
|
||||
line["ms"] = sw.ElapsedMilliseconds;
|
||||
Console.WriteLine("RESULT " + JsonSerializer.Serialize(line));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
2698
docs/research/aetheris-evaluation/harness/cases.json
Normal file
2698
docs/research/aetheris-evaluation/harness/cases.json
Normal file
File diff suppressed because it is too large
Load diff
9
docs/research/aetheris-evaluation/harness/edges.py
Normal file
9
docs/research/aetheris-evaluation/harness/edges.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import sys, re, collections
|
||||
for f in sys.argv[1:]:
|
||||
t = open(f).read()
|
||||
ec = set(re.findall(r"#(\d+)=EDGE_CURVE", t))
|
||||
uses = collections.Counter()
|
||||
for m in re.finditer(r"ORIENTED_EDGE\([^,]*,\*,\*,#(\d+),", t):
|
||||
uses[m.group(1)] += 1
|
||||
hist = collections.Counter(uses.values())
|
||||
print("EDGES", f.split("/")[-1], "edge_curves", len(ec), "oriented_edge_uses_per_edge_histogram", dict(hist), "edges_never_used", len(ec - set(uses)))
|
||||
29
docs/research/aetheris-evaluation/harness/inspect.py
Normal file
29
docs/research/aetheris-evaluation/harness/inspect.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import sys, re, collections, math
|
||||
from OCP.STEPControl import STEPControl_Reader
|
||||
from OCP.TopAbs import TopAbs_SOLID, TopAbs_SHELL, TopAbs_FACE, TopAbs_COMPOUND
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.BRepCheck import BRepCheck_Analyzer
|
||||
from OCP.GProp import GProp_GProps
|
||||
from OCP.BRepGProp import BRepGProp
|
||||
from OCP.ShapeAnalysis import ShapeAnalysis_FreeBounds
|
||||
from OCP.TopoDS import TopoDS
|
||||
def vol(s):
|
||||
p = GProp_GProps(); BRepGProp.VolumeProperties_s(s, p); return round(p.Mass(), 6)
|
||||
KEYS = ["MANIFOLD_SOLID_BREP","BREP_WITH_VOIDS","CLOSED_SHELL","OPEN_SHELL","ORIENTED_CLOSED_SHELL","SHELL_BASED_SURFACE_MODEL","ADVANCED_FACE","SPHERICAL_SURFACE","CYLINDRICAL_SURFACE","PLANE","FACE_BOUND","FACE_OUTER_BOUND"]
|
||||
for f in sys.argv[1:]:
|
||||
t = open(f).read()
|
||||
ents = collections.Counter(re.findall(r"=\s*([A-Z_0-9]+)\s*\(", t))
|
||||
print("INSPECT", f.split("/")[-1], {k: ents[k] for k in KEYS if ents[k]})
|
||||
rd = STEPControl_Reader(); rd.ReadFile(f); rd.TransferRoots(); s = rd.OneShape()
|
||||
print(" OCCT shape type", s.ShapeType(), "valid", BRepCheck_Analyzer(s).IsValid(), "vol", vol(s))
|
||||
for kind, nm in ((TopAbs_SOLID, "solid"), (TopAbs_SHELL, "shell")):
|
||||
ex = TopExp_Explorer(s, kind); i = 0
|
||||
while ex.More():
|
||||
sh = ex.Current()
|
||||
try:
|
||||
fb = ShapeAnalysis_FreeBounds(sh)
|
||||
nopen = fb.GetOpenWires().NbChildren() if hasattr(fb.GetOpenWires(), "NbChildren") else "?"
|
||||
except Exception as e:
|
||||
nopen = "err"
|
||||
print(" ", nm, i, "vol", vol(sh), "valid", BRepCheck_Analyzer(sh).IsValid(), "freeOpenWires", nopen)
|
||||
i += 1; ex.Next()
|
||||
94
docs/research/brush-surfaces-and-sdf-bricks-prior-art.md
Normal file
94
docs/research/brush-surfaces-and-sdf-bricks-prior-art.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Brush surfaces, CSG topology, microbevels, and SDF bricks/tapes: prior art
|
||||
|
||||
Eyes pass, 2026-10-10, before a campaign target for the collaborative Blender brush loop (operator: brushes carry UVs, structured trees, microbevels at cuts "like smin in sdf land", marching optimised tapes in a brick map; "Dreams had issues with this approach because their artists wanted long lists of edits. We are going for a structured approach. The tape should be pretty short after simplification."). Facts only; tags mark FETCHED, SEARCH and KNOWLEDGE (unverified). Related: CultLib docs/research/inverse-procedural-modelling-prior-art.md.
|
||||
|
||||
# Prior art: brush surfaces/UVs, CSG topology, SDF microbevels, SDF bricks and tapes (Eyes, 2026-10-10)
|
||||
|
||||
Tags: FETCHED = read in full or in part this pass from the primary source; SEARCH = search-result snippet only; KNOWLEDGE = memory, unverified.
|
||||
Local copies of fetched PDFs/sources: scratchpad\eyes-brush-sdf\dl\ (evans.txt, mpr.txt, clay.txt, hg.glsl, gst.cs).
|
||||
Not found / not read: Iq's smin article (fetch 404, search snippets only), TrenchBroom/Valve 220 spec (403/not surfaced), RealtimeCSG closed kernel, Houdini Boolean SOP docs, Blender bevel docs, Dreams later talks (not looked at), Lumen/Nanite talks.
|
||||
|
||||
## 0. Our substrate (FETCHED, local files read)
|
||||
- Primitive enum (brush.rs): Box{bounds Aabb}, OrientedBox{center,size,rotation}, CylinderZ{center,radius,depth,segments}, DomeCapZ{center,radius,height,rings,segments}, FloretArm{anchor,direction,length,root_width,tip_width,thickness,tip_lift}. BrushOp = Add | Subtract | Intersect. Brush carries id, name, op, primitive, material: MaterialId, rotation Quat, generation.
|
||||
- assembler.rs: BuildWarning::SubtractIgnoredForNonBox / IntersectIgnoredForNonBox (lines 14-15, 804, 837); BrushOp::Intersect returns None at lines 370, 411 in some paths. Doctrine doc says kernel is "still a carver; target is a classifier" (docs/realtime-csg-doctrine.md).
|
||||
- mesh.rs TriangleMesh: positions, normals, uvs, indices, triangle_materials (per-triangle MaterialId). append_quad assigns uvs (0,0),(1,0),(1,1),(0,1) to every quad (full unit square per quad); append_triangle takes caller uvs. No per-face smoothing group, no tangent, no brush-id/surface-id per triangle.
|
||||
- field.rs: Primitive::distance(p): Box/OrientedBox/CylinderZ exact; DomeCapZ half-ellipsoid bound (ellipsoid_bound .max(base plane)); FloretArm max-of-planes bound. Fold: Add=min, Subtract=max(d,-b), Intersect=max. Honours every op on every primitive (unlike polygon kernel). No smooth blend exists.
|
||||
- tree.rs has CsgTree/CsgBranchOp/CsgNode arena; cult_geometry.rs holds CultCache records incl. CultGeometryTriangleMesh.
|
||||
|
||||
## A. Surfaces/UVs/materials through CSG
|
||||
### Chisel (RadicalCSG/Chisel.Prototype, successor to RealtimeCSG; Sander van Rossen) - FETCHED source
|
||||
- Per-polygon surface = ChiselSurface { ChiselMaterial(material + metadata), SurfaceDetails } (Core/1.Input/Surfaces/ChiselSurface.cs). SurfaceDetails = { SmoothingGroup (uint, 0 = none), SurfaceDetailFlags (TextureIsInWorldSpace, VertexPainted), UVMatrix UV0 } (SurfaceDetails.cs).
|
||||
- UVMatrix = 2x4 matrix U,V (Vector4 each): UV = M * (position,1) - planar projection per polygon; "more UVMatrices can be added" for more channels. Comment: SmoothingGroup "should be part of postprocessing data"; normals smoothed only when generated per VertexChannelFlags.
|
||||
- GenerateSurfaceTrianglesJob.cs lines 201-312: UV0 matrix from baseSurfaces[surfaceIndex] is converted tree-space -> brush-local -> plane-space (math.mul(UV0.ToFloat4x4(), treeSpaceToPlaneSpace)) and applied to each emitted vertex position (uv0 = mul(uv0Matrix, float4(position,1)).xy). I.e. UVs are evaluated from the final vertex positions at output time, not interpolated through splits. If interiorCategory is ReverseAligned the normal is negated (line 293). Tangents/binormals computed afterwards from uv deltas (ComputeTangents, line 415+, double precision).
|
||||
- Each emitted polygon carries the surfaceIndex of the base polygon of the brush that owns the plane; the cut face of a subtractive brush is that cutter's own base polygon, categorised ReverseAligned and rendered with the cutter's surface (inferred from categories + baseSurfaces[surfaceIndex] usage; not stated in a doc).
|
||||
- MergeTouchingBrushVerticesJob.cs exists but its body is commented out (/* at line 13). HashedVertices container exists in 2.Processing/Containers (vertex welding by hash). Pipeline job list includes CreateIntersectionLoopsJob, PerformCSGJob, FindLoopOverlapIntersectionJob.
|
||||
### Quake/Hammer/Valve 220
|
||||
- SEARCH: Hammer "component texture lock" keeps UV representation when vertices/faces move (stretching possible). TrenchBroom feature lists: supports Quake Standard and Valve 220 formats, "precise texture lock for all brush editing operations", 2/3-point clip tool. DarkRadiant bug report (SEARCH): subtract gives newly exposed target faces the cutter's texture; reporter says that is usually undesired.
|
||||
- KNOWLEDGE (unverified): Valve 220 face line stores two texture axis vectors with offsets plus scale per face (`( x y z off ) ( x y z off ) rot sx sy`), world-space planar projection, so splitting a brush leaves the same projection on both pieces; Quake standard derives axes from the face normal (6 axis-aligned cases).
|
||||
- SEARCH: Valve wiki says axes should define a plane parallel to the face else stretching.
|
||||
### Blender / Houdini / Unity
|
||||
- Blender Boolean modifier (FETCHED, 5.2 manual): solvers Float (no overlap, no Intersect on collections), Exact (overlap, slower), Manifold (fastest, manifold meshes only). Exact-solver Materials option: Index Based (map slot i to slot i; higher index falls to first) or Transfer (use source mesh's materials, add slots). The extracted Boolean page text contains no statement about UV or attribute interpolation (checked for "uv": none).
|
||||
- Houdini (SEARCH only): SideFX forum - new Boolean SOP transfers shop_materialpath and uv attributes from a cutter; Boolean Fracture SOP has "Copy Cutting Surface Attributes", Recompute Normals, Exterior Cusp Angle; forum: Houdini does not auto-fix UVs on new openings, use pinned seams / UV Fuse.
|
||||
- ProBuilder, Unity-side texgen docs: NOT researched.
|
||||
### Seam placement on curved primitives
|
||||
- NOT researched with sources (cube-sphere vs UV sphere vs octahedral, torus, sweep). Evidence-bearing only for our own code: CylinderZ{segments}, DomeCapZ{rings,segments} are tessellated, mesh.rs quad uv = unit square per quad.
|
||||
|
||||
## B. Topology from CSG
|
||||
- Chisel (FETCHED): output is per-surface loops of edges (CreateIntersectionLoopsJob -> PerformCSGJob) triangulated in GenerateSurfaceTrianglesJob (polygon loops per base surface -> triangle index list; indexRemap per surface). Vertices are shared via HashedVertices (hash welding). Not found: explicit T-junction handling statement in code read.
|
||||
- Our doctrine doc names the RealtimeCSG pipeline "split only at crossing planes, classify through tree, emit visible polygon pieces" (docs/realtime-csg-doctrine.md, local).
|
||||
- Systems that regenerate clean topology from a parametric tree: none found this pass. KNOWLEDGE (unverified): libfive/Curv dual-contouring meshers produce triangle soup without UV (see section E).
|
||||
- Prior doc already gathered (not repeated): CultLib\docs\research\inverse-procedural-modelling-prior-art.md.
|
||||
|
||||
## C. Microbevels at cuts
|
||||
### hg_sdf (Mercury) - FETCHED source mercury.sexy/hg_sdf/hg_sdf.glsl lines 696-725, 823-830
|
||||
- fOpUnionChamfer(a,b,r) = min(min(a,b), (a - r + b)*sqrt(0.5)); fOpIntersectionChamfer = max(max(a,b),(a + r + b)*sqrt(0.5)); fOpDifferenceChamfer = IntersectionChamfer(a,-b,r).
|
||||
- fOpUnionRound(a,b,r): u = max(vec2(r-a, r-b), 0); return max(r, min(a,b)) - length(u) (quarter-circle join); fOpIntersectionRound: min(-r, max(a,b)) + length(max(vec2(r+a,r+b),0)); fOpDifferenceRound = IntersectionRound(a,-b,r).
|
||||
- fOpGroove(a,b,ra,rb) = max(a, min(a+ra, rb-abs(b))); fOpTongue(a,b,ra,rb) = min(a, max(a-ra, abs(b)-rb)).
|
||||
- Header (lines 5, 101-108, 670): library "for building signed distance bounds"; rule "Stay Lipschitz continuous"; never multiply distances to "fix" it; comment at line 670 says its operators "produce correct distances or distance bounds, unlike" smooth-min-style ones (line ~655: min() replacement "does not preserve Lipschitz").
|
||||
- Radius-limited: all join ops affect only where both fields are within r.
|
||||
### Dreams soft blend (FETCHED, Evans slides 30-31): soft_min(a,b,r){ e=max(r-abs(a-b),0); return min(a,b)-e*e*0.25/r; } credit Dave Smith (Media Molecule). "Equals hard min after distance r"; "Soft blend breaks ALL THE CULLING": must revert to hard min beyond r, and culling must account for the "future soft blend" range; visualisation shows blend increases per-voxel edit counts over a large area.
|
||||
### Quilez smin (SEARCH/ fetch failed)
|
||||
- SEARCH: iquilezles.org/articles/smin rewritten March 2024 (tweet by iq): adds normalization, kernels, circular smooth-min, analysis of quadratic/cubic/quartic/circular/exponential/sigmoid families. Exponential variant is order independent; polynomial and root variants are not (SEARCH). Polynomial: h = clamp(0.5+0.5(a-b)/k,0,1); mix(a,b,h) - k*h*(1-h) (FETCHED via vgracianos blog, matches).
|
||||
- SEARCH: a Shadertoy comment states smooth-min is not SDF-preserving (distorts distance near the surface). Exact/bound classification of each variant: NOT confirmed from the article.
|
||||
### Mesh-side (SEARCH only)
|
||||
- Hard Ops/Boxcutter docs: Sharpen marks sharp edges for bevel; Bevel adds a Bevel modifier; Normal Transfer from stashes needs booleans applied; Smart Apply applies booleans/mirrors preserving bevels; community tip to reduce bevel-modifier stress (profile 1, 3 segments). Hardsurface Doctor (third party): store bevel weights, apply booleans, safe repair.
|
||||
- Weighted normals, baking bevel normals from SDF onto a mesh: no source found. Keeter MPR computes normals by forward-mode autodiff of the tape (below), evaluated at 32-bit float then quantised to 8 bit.
|
||||
|
||||
## D. SDF bricks and tapes
|
||||
### Dreams, Evans SIGGRAPH 2015 "Learning from Failure" (FETCHED, evans.txt from advances.realtimerendering.com/s2015/AlexEvans_SIGGRAPH-2015-sml.pdf)
|
||||
- Representation: "simple list, not tree" of CSG edits ("right leaning"); add/subtract/colour-only; models "anything from 1 to 100,000 edits"; domain deformation and non-local effects (blur) excluded. Core idea "lists of simple SDF elements ... still how all sculptures are made in Dreams ... the opposite of a failure".
|
||||
- First PC prototype: compound SDF in 83^3 fp16 volume-texture blocks, incrementally updated per edit, each block meshed by marching cubes in compute (histopyramids); abandoned for storage/animation: "terrible for loading and animation", UGC must be small so SDF cannot be stored, "we need a fast evaluator".
|
||||
- Evaluator ("CS of doom"): edit list -> sparse SDF; pipeline 40+ compute shaders, a few 3000+ instruction shaders. Brute force = 100k edits x ~1000^3 grid = "100 billion evaluations, which is too many". Anton's hierarchical scheme: start 4x4x4 coarse grid, per-cell list of edits that can overlap, split by 4x4x4 (fits 64-wide wave), shortening lists per child; empty/full cells marked early; split heuristics hard (too little split = gaps, needs 1 to 1.5 voxels valid each side of surface; too much = orders of magnitude extra work; seamless splits required). Max-norm (d = max|x|,|y|,|z|) used because many primitives' distance is simpler (quadratics) and cubes match tree nodes (ref "Efficient Max-Norm Distance Computation and Reliable Voxelization"). Interval arithmetic added for extra culling ("because our distance fields are good quality").
|
||||
- Dispatch counts for "crystal's dad": eval 60, sweep 91, points 459, bricker 73.
|
||||
- Stats table (slide 32-33; columns elements, blocks, block evals, voxels, evaluator time, clusters, bricks): crystals_dad 8,274 elements, 5.24M voxels, evaluator 0.091 (units not labelled, likely s), 99.90% culling; big_mech 850 el, 4.12M vox, 0.0151, 99.78%; sphere_woman 24,407 el, 0.85M vox, 0.342, 99.22%; head_lion 53,976 el, 4.68M vox, 0.227, 99.96%; head40 22,484 el, 2.46M vox, 0.143, 99.93%; bolt_lock 1,830 el, 1.02M vox, 0.0217, 98.30%; whirlwind 791 el, 10.4M vox, 0.0344. Per-prim-per-block column ~1.7e-8 to 9e-8.
|
||||
- Slide text: "range of edits from 600 - 53000 (the worst is around 120k, but that's atypical)"; "culling rates compared to brute force are well over 99%. we get 10m - 100m voxels evaluated per second on a ps4, from a model with tens of thousands of edits." Slide caption: 8,274 edits "crystal's dad"; 22k edits head40.
|
||||
- Does the talk attribute brick-evaluation/raymarch cost to edit count? Evaluator cost is discussed vs edits/voxels (culling rate, soft blend enlarging edit-per-voxel counts, "orders of magnitude too much work" from over-splitting). The render-side rejection reasons in the talk are NOT edit count: Volumetric Billboards "too much fillrate"; Gigavoxels "single object"; rasterised-brick hybrid "too hard to render OIT overlaps"; froxel refinement "too slow" (~4x-10x too slow, ran ~30 fps "but only just", lit every leaf voxel, load imbalance from unbounded edge depth); gigavoxel brick memory struggled; art direction: rendered SDF "looked like untextured poly engine" yet slower; "thin feature aliasing" listed as an unsolved problem. No per-brick or per-pixel cost figure is given as a function of edit count.
|
||||
- Brick engine (Engine 2, slides 67-70): Gigavoxels-style brick tree with LOD cut chosen by view distance; each 8x8x8 brick rasterised as a cube in an atlas; pixel shader raymarches from cube face to 0-crossing ("tiny POM/raymarch inside each 8x8x8 cube"), writes depth; vertices skinnable; "main engine artists used for a couple of years" ("hard" variant, forward lit).
|
||||
- Shipped: splat engine (Engine 4). Evaluator output is a point cloud; Hilbert-sorted 4^3 bricks, clusters of ~256 points, LOD target ~1 splat/pixel; clusters splatted by compute with 64-bit atomic min (z+id) into a 1080p buffer, stochastic transparency + TAA; perf stat: 28.2M splats in 4.38 ms (~640M single-pixel splats/s) on PS4. Dreams still evaluates edit lists on GPU each load (UGC stores edits, not fields).
|
||||
### Claybook, Aaltonen GDC 2018 (FETCHED clay.txt from media.gdcvault.com/gdc2018/presentations/Aaltonen_Sebastian_GPU_Based_Clay.pdf)
|
||||
- World SDF: volume texture 1024x1024x512, 8-bit signed, 586 MB with 5 mips, distance clamped [-4,+4] voxels, 256 values/8 voxels = 1/32 voxel precision; max step doubles per mip.
|
||||
- World built by combining N brushes (offline-baked volume textures 32^3 to 128^3 = 32 kB to 2 MB, each with translation, rotation, uniform scale) in compute; "Runtime performance not dependent on brush count" (grid cull per tile); sparse mip generation; "only ~10% of mip0 8x8x8 tiles used" on one slide.
|
||||
- Rendering: sphere-trace with mip hopping; cone-trace pre-pass; timings Xbox One base @720p / Vega @4K: primary+AO rays 0.2 ms / 0.2 ms; cone pre-pass 1.5/1.6; shadow rays 1.7/1.9; material+gbuffer 0.8/1.0; 60 fps target all consoles. Cache note: "8 MB data accessed (512 MB), 99.85% cache hit rate". Failed: wave-ballot ray load balancing, random rollback; noise from mip sampling.
|
||||
- No microbevel/thin-feature statement read.
|
||||
### Keeter, MPR (SIGGRAPH 2020) (FETCHED, mpr.txt, mattkeeter.com/research/mpr/keeter_mpr20.pdf)
|
||||
- Hierarchy: 64^2 pixel (64^3 voxel) tiles, 8^2 (8^3) subtiles, 4^3 microtiles, evaluated by interval arithmetic; tile classed inside/outside/ambiguous; a shortened tape is built per ambiguous tile (pruneTape walks the tape in reverse, tracking live slots; min/max clauses whose interval choice is unambiguous drop the losing branch; CHOICE_BOTH kept).
|
||||
- Pruning figures: 2D text benchmark, full tape 6,056 clauses (2,354 min/max): 64x64 tiles average 356 +- 125 clauses (17x reduction); 8x8 subtiles average 28 +- 13 (216x reduction). Abstract: complexity "decreases by two orders of magnitude".
|
||||
- Tape sizes of models (Table 3): architectural model 961 clauses (465 CSG), bear head sculpt 541 (27 CSG, smooth blends via exp/log), text 6,056 (2,354), gears 1,735 (374). Slot counts after register allocation: 26/22/75/84 slots (Table 2).
|
||||
- Cost: interpreter brute force 58 ms/MP vs compiled 3 ms/MP (19x overhead); full algorithm beats compiled kernel from ~1536^2 and stays under 8 ms/frame at 4096^2 (2D). 3D (GTX 1080 Ti, perspective heightmap): 256^3 11.3 ms, 1024^3 41.1 ms, 2048^3 191.0 ms (GT 750M: 111.3 / 503.6 / 2352.1; V100 20.3 / 228.3 ... as tabled, columns partially garbled by text extraction). Tape generation + upload on PC: 7 ms for text benchmark, 1-2 ms others. Most effort is walking the tapes, "limited by local memory bandwidth (reads/writes of slots)". Scratch for tapes: 1 GB+ buffer, 64-clause chunks claimed atomically.
|
||||
- Normals: forward-mode autodiff over the (shortened) tape, float32 then 8-bit; shortened tape from the smallest region containing the pixel. Bear sculpt (smooth blends, few CSG clauses) is "less perfectly matched" to pruning: exp/log blends expensive and cannot be pruned as well (paraphrase of paper passage near Table 3). Only C0 required; non-Lipschitz fine.
|
||||
- Per-tile cost vs tape length after pruning: the paper gives lengths (above) and the frame times, not a per-tile ms vs length curve.
|
||||
### Fidget (FETCHED README, mattkeeter.com/projects/fidget / github mkeeter/fidget)
|
||||
- Rust; tape simplification from interval evaluation; JIT (aarch64/x86_64) for point, interval, SIMD (f32x4 ARM, f32x8 x86), gradient. Model rasterised, ms: 1024^3: libfive 66.8, MPR (GTX 1080 Ti) 22.6, Fidget VM 61.7, Fidget JIT 23.6; 1536^3: 127 / 39.3 / 112 / 45.4; 2048^3: 211 / 60.6 / 184 / 77.4 (M1 Max CPU for libfive/Fidget). README gives no tape lengths.
|
||||
### Tape lengths for structured hard-surface models
|
||||
- Only data points found: MPR architectural model 961 clauses (465 CSG) -> slots 75; gears 1,735; Dreams used 600 to 120k edits (typical 8k-54k) for organic sculpts. No published tape length for CSG-tree hard-surface models with ~10-100 brushes. Our substrate's own count: none measured.
|
||||
### Unreal distance fields (SEARCH, Epic docs)
|
||||
- Mesh Distance Field max volume per mesh 128^3 / 8 MB; Eight Bit option halves memory but may artifact on large or thin meshes; thin surfaces may not be representable, light leaking; Voxel Density project setting; non-uniform scale unsupported; generated offline (WPO deformation unknown to it). Global Distance Field = camera-centred clipmaps composited from mesh fields. A blog (unverified) says ~10 cm minimum thickness for Lumen. GigaVoxels, NanoVDB: not read (KNOWLEDGE only: GigaVoxels = brick tree w/ view-driven LOD, which Evans cites as basis of his brick engine).
|
||||
### Microbevel thinner than a voxel (observations from sources)
|
||||
- Claybook stores 1/32 voxel precision within a +-4 voxel band at 1024x1024x512 (FETCHED); Dreams required 1 to 1.5 voxels valid each side of surface (FETCHED) and lists "thin feature aliasing" unsolved in the refinement renderer; MPR/Fidget evaluate the closed-form tape per pixel/voxel, with no stored resolution, normals by autodiff (FETCHED). No source here measured bevel normal quality below voxel size for stored bricks.
|
||||
|
||||
## E. Hybrid: tree as source, mesh with UVs + SDF-derived detail
|
||||
- No shipped example found that keeps a CSG/SDF tree as source and emits UV'd mesh plus SDF-derived normals/AO from the same tree.
|
||||
- Pieces found (SEARCH): libfive/Curv manifold dual contouring for sharp-feature meshing; Unreal Geometry Script boolean + auto-UV/atlas; Prowl.Unwrapper (LSCM charts + pack); SDF Modeler 0.5.3 exports PLY only (artists decimate/UV elsewhere); Uniform 1.4 article title mentions SDF/UVs (content not read); SD5 Blender addon (CSG/SDF via libfive; content not read). Dreams ships point clouds from the edit list, not meshes (FETCHED). Claybook renders SDF directly, no mesh (FETCHED). Teardown (Aaltonen tweet, SEARCH): SDF converted to triangles for Jolt physics.
|
||||
|
||||
## Gaps for a follow-up pass
|
||||
Iq smin article body; Valve 220/TrenchBroom manual; ProBuilder; Houdini/Blender attribute interpolation docs for Boolean; sphere/torus/sweep UV parameterisations with sources; Chisel T-junction handling; bevel normal baking; NanoVDB/GigaVoxels numbers; Dreams later talks (e.g. 2016-2020).
|
||||
61
docs/research/octahedral-impostors-prior-art.md
Normal file
61
docs/research/octahedral-impostors-prior-art.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Relightable octahedral impostors: prior art
|
||||
|
||||
Eyes pass, 2026-10-10. Operator: "Wild idea, if we are marching, why not output into an impostor. Go look at those fancy impostors that shade and everything." and "Octahedral is what I was thinking. But we are just using it to cache ray marching samples? Kind of a stretch feels like". Companion to brush-surfaces-and-sdf-bricks-prior-art.md. Facts only; tags mark FETCHED, SEARCH and KNOWLEDGE (unverified).
|
||||
|
||||
# Relightable impostors: prior-art facts (eyes-impostors, 2026-10-10)
|
||||
Tags: FETCHED = read on the page; SEARCH = from search-result summary only; KNOWLEDGE = unverified recall.
|
||||
Gaps: shaderbits.com (Brucks blog) returned only tag list via WebFetch; fortnite.com 403; NVIDIA GPU Gems 3 ch.21 404; Amplify wiki 403; EG digital library 403; web.archive.org blocked. Brucks' primary text was NOT read; Epic's ImpostorBaker doc (primary) was.
|
||||
|
||||
## 1. Techniques
|
||||
### Octahedral impostors / Epic ImpostorBaker (UE)
|
||||
- FETCHED (dev.epicgames.com/documentation/en-us/unreal-engine/impostor-baker-plugin-in-unreal-engine): baker captures GBuffer, no material switch needed; translucent materials unsupported.
|
||||
Colour maps: BaseColor, Normal, Emissive, Subsurface, Tangent, CustomLighting, Lit (most need only BaseColor+Normal).
|
||||
Channel-packed masks: Specular, Roughness, Metallic, Opacity, Opacity Mask, Depth/Anisotropy, ClearCoat, ClearCoatRoughness; no alpha channel => DXT1 instead of DXT5.
|
||||
- FETCHED (same): FramesXY per axis; 16 => 256 frames. Epic uses 12 (144 frames) for distant foliage in Fortnite, final texture 2048 (~170 px per frame). Capture res default 512, >= 2x final subframe for supersampling. Traditional billboards = 3x3 (8 side + 1 top).
|
||||
- FETCHED (same): runtime finds the 3 nearest captured frames and blends them on one sprite (8 tris, 9 verts; billboard cross 72 tris/81 verts). Mobile: single frame sample. Pixel shader costlier from 3-frame blend.
|
||||
- FETCHED (same): parallax modes None / Single Sample (bump-offset-like) / Iterative (most expensive, reference quality; with Depth Derived Weights "nearly identical" to source mesh if all channels captured). Needs sprite vertex shader (clipping, more masked overdraw). Artifacts on very dense objects.
|
||||
- FETCHED (same): lighting is dynamic from baked normals + material masks; "Impostor Casts Shadows" affects editor preview only. Perspective capture needed for fresnel. No memory figures, no runtime switch distance (only editor "Swap by Distance").
|
||||
- FETCHED (80.lv/articles/impostor-baker-for-ue4/, 2018-04-09): modes Full Sphere / Upper Hemisphere / Traditional Billboards; octahedral layout uses texture space better than old generator; Pixel Depth Offset uses captured depth so cards intersect world; POM smooths frame transitions but expensive.
|
||||
- SEARCH: Brucks' 2018 blog (shaderbits.com/blog/octahedral-impostors, not read) is the origin cited by three.js, Godot, Unity ports.
|
||||
|
||||
### Open-source ports (reference code)
|
||||
- FETCHED (github.com/Usnul/IMP): Unity billboard-impostor baker, CC0, hemisphere or full sphere, Window>IMP, written for Unity 2017/2018. Credits Brucks, Bruneton&Neyret forest paper, Todt/Rezk-Salama/Kolb spherical light-field rendering. README gives no channels/frames/depth detail.
|
||||
- FETCHED (github.com/SIsilicon/Godot-Octahedral-Impostors): MIT; bakes base.png, norm_depth.png, orm.png; recommended grid 16; hemi vs full; grid blending; depth map (+Depth Scale) only in Standard shader, which also does shadows, metallic/roughness, alpha prepass. Names Unity impl as github.com/xraxra/IMP.
|
||||
- FETCHED (discourse.threejs.org/t/a-forest-of-octahedral-impostors/85735): demo 200k trees, mesh LOD 15-100 units, impostor beyond 100; hemi-oct, 16 sprites/side, 2048 texture, parallaxScale 0.5. Usnul's impl: variable atlas/grid res, mip margins, bakes depth/normal/PBR/albedo/alpha ("enabling relighting and depth compositing"); convex-hull sprite fit removed ~30% pixels with 5 verts; interpolation lines likely from missing secondary reprojection; switch only when impostor <= bake resolution on screen (no dithering needed); fading draws two LODs. Repo github.com/Ctrlmonster/three-octahedral-impostor (not read, licence unchecked).
|
||||
- FETCHED (app.cinevva.com blog 2026-05-04 part 19, hobby blog): diffuse + world-normal atlases only; 12x12 hemi-oct = 144 tiles x 2 passes = 288 RT passes, ~9 MB; 5x5 az-el ~1.6 MB; bilinear 2x2 tile blend = 8 taps; popping at tile swaps cured by blending; top-down view collapses azimuth; hemi-oct bake ~5x slower than az-el, run once at load.
|
||||
- SEARCH: SideFX Houdini impostor tool bakes full octahedral/3D/fixed-axis/animated, Unity package in its GitHub download (licence unverified). Unity asset "Impostor Graph" (octahedral) exists, not read.
|
||||
|
||||
### Amplify Impostors (Unity)
|
||||
- FETCHED (amplify.pt/_AI): types Spherical (no smooth view blend), Octahedron (smooth blend), HemiOctahedron (top-view games, more res per view). Bakes views plus "normals and depth"; depth enables intersection with meshes/impostors; casts and receives shadows, full lighting. Built-in, URP, HDRP; Forward and Deferred. Standard bake needs a shader with Deferred path (e.g. Standard); forward-only shaders need custom bake shader from Amplify Shader Editor. Channel list/parallax not on page.
|
||||
- FETCHED (assetstore.unity.com/packages/tools/utilities/amplify-impostors-119877): EUR 55.20 (search snippet: $60); Extension Asset, Standard Unity Asset Store EULA; v1.0.4, 2026-05-28; 201.2 MB; Built-in/URP/HDRP listed compatible (2019.4, 2021.3, 2022.3). Whether C# source ships: not stated.
|
||||
- KNOWLEDGE (unverified): Amplify bakes albedo/alpha, normal+depth, spec/smoothness, emission; depth offset via SV_Depth.
|
||||
- FETCHED (docs.unity3d.com Industry Toolkit 3.0 create-impostors): octahedron/hemi-octahedron; "Atlas size" = sqrt(frames); maps res e.g. 4096; shadows in BiRP/URP/HDRP; depth-offset object intersections HDRP only; LOD crossfade; not for Unity built-in trees; licence unstated.
|
||||
|
||||
### Relief / true impostors / IBR with depth
|
||||
- FETCHED (history.siggraph.org/?p=139448): Risser "True Imposters", SIGGRAPH 2006 poster (GPU Gems 3 ch.21, 2007 per SEARCH): billboard as window onto height-fields, up to 4 height-fields per texture, per-fragment linear march + binary search, any view angle, regenerated per frame; no cost/artefact numbers on page.
|
||||
- SEARCH: Andujar et al. "Omni-directional Relief Impostors", CGF 26(3) 2007 pp553-560: reduced set of relief maps, typically 3 selected per view, ray-heightfield intersection on GPU. Clua 2004 PUC-Rio thesis: relief impostors refreshed dynamically. Policarpo&Oliveira relaxed cone stepping (GPU Gems 3) is the march basis.
|
||||
- SEARCH: Decoret/Schaufler/Sillion/Dorsey 1999 "Multi-layered impostors for accelerated rendering" (EG99): dynamically refreshed layers, progressive refinement when viewer slows, de-occlusion error bounded to user threshold, update must fit frame budget. Jeschke thesis (cg.tuwien.ac.at/research/publications/2005/jeschke-05-ARI): impostor valid only in bounded view region; high memory for common scenes open problem; layer placement bounds parallax error; Jeschke&Wimmer 2002 textured depth meshes. Billboard clouds (Decoret 2003), Schaufler "dynamically generated impostors" 1995: not fetched (KNOWLEDGE).
|
||||
|
||||
### Artefacts, general
|
||||
- FETCHED/SEARCH: frame popping from angular undersampling or bad blend; hemi-oct top-down degeneracy; parallax artefacts on dense objects; sprite clipping with parallax; close-range disocclusion (regions unseen at bake appear); memory = frames x res x channels (9 MB for 12x12 two-channel example).
|
||||
|
||||
## 2. Raymarcher -> impostor atlas
|
||||
- SEARCH (several queries): no system found that raymarches SDFs/volumes into octahedral impostor atlases, offline or runtime re-bake; no re-bake cost figures.
|
||||
- Closest: Polycount thread (polycount.com/discussion/comment/2614341): SDF baked into impostor-style atlas in Houdini/Substance; raw SDF atlas needed 16K x 8K for 512 output vs 8K x 4K for 16x8 impostor layout (offline, not raymarcher output).
|
||||
- Cost data points only for mesh bakers: Epic captures 144-256 frames at 512 capture res (editor time); cinevva bake 288 RT passes once at load. Runtime re-bake benchmarks: none.
|
||||
- Dynamic impostor refresh is old art (Schaufler 1995, Decoret 1999, Clua 2004): refresh when error metric exceeds threshold, budgeted per frame; trigger details not fetched.
|
||||
|
||||
## 3. Depth-correct compositing
|
||||
- SEARCH (Unity forum 830595, gamedev.net MJP, Khronos): writing SV_Depth disables early-Z regardless of value; discard/clip/alpha-test likewise on most hardware; cost large under overdraw. Conservative outputs (SV_DepthGreaterEqual/LessEqual) can keep early-Z; gamedev.net thread reports mixed results (GreaterEqual+Less OK, LessEqual+Greater seemed to defeat early-Z); hardware-dependent.
|
||||
- FETCHED: Amplify and Epic both advertise depth-offset intersection (Epic Pixel Depth Offset; Unity Toolkit HDRP only). Amplify: shadow cast+receive. Godot port: shadows only in Standard shader (with depth). Unity Toolkit: depth capture enables shadow casting.
|
||||
- No shipped-title profiling numbers found.
|
||||
|
||||
## 4. Where impostors are used
|
||||
- FETCHED: Epic uses 12x12 octahedral for distant foliage in Fortnite. Unity Toolkit: "mainly far-distance LODs". three.js demo switches at 100 units (trees). Rule of thumb (Usnul): use when impostor <= bake resolution on screen.
|
||||
- SEARCH: asset listing warns impostors are designed for trees/thin foliage, other shapes mixed; one dev reports popping at LOD3 impostor switch on an asteroid. MSFS tree-LOD forum thread surfaced, not read.
|
||||
- NOT FOUND: any documentation of impostors for hero/mid-distance ships in Elite Dangerous, Star Citizen, EVE, Homeworld, No Man's Sky (Homeworld hit was a hyperspace-cut article, unrelated). KNOWLEDGE (unverified): none publish impostor details.
|
||||
- Near-hero fidelity claims only: Epic iterative parallax "nearly identical" to source mesh; Risser true impostors (per-frame, arbitrary angle) as the non-baked alternative.
|
||||
|
||||
## 5. Unity built-in specifics
|
||||
- Amplify Impostors: Built-in supported; paid (EUR 55.20 / ~$60); Standard Asset Store EULA; built-in bake needs Deferred-path shader or custom bake shader (so hand-written HLSL needs a deferred pass or Amplify custom bake shader).
|
||||
- Usnul/IMP: CC0, 2017/2018-era Unity, reference only. SIsilicon Godot port: MIT. three.js port: Ctrlmonster (licence unchecked). SideFX Unity package (licence unchecked). Unity Industry Toolkit impostors: licence unstated.
|
||||
122
docs/research/truck-evaluation/README.md
Normal file
122
docs/research/truck-evaluation/README.md
Normal 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`.
|
||||
2
docs/research/truck-evaluation/harness/.gitignore
vendored
Normal file
2
docs/research/truck-evaluation/harness/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
target
|
||||
Cargo.lock
|
||||
11
docs/research/truck-evaluation/harness/Cargo.toml
Normal file
11
docs/research/truck-evaluation/harness/Cargo.toml
Normal 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"
|
||||
2698
docs/research/truck-evaluation/harness/cases.json
Normal file
2698
docs/research/truck-evaluation/harness/cases.json
Normal file
File diff suppressed because it is too large
Load diff
151
docs/research/truck-evaluation/harness/check.py
Normal file
151
docs/research/truck-evaluation/harness/check.py
Normal 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}))
|
||||
38
docs/research/truck-evaluation/harness/job.sh
Normal file
38
docs/research/truck-evaluation/harness/job.sh
Normal 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"
|
||||
40
docs/research/truck-evaluation/harness/job5.sh
Normal file
40
docs/research/truck-evaluation/harness/job5.sh
Normal 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"
|
||||
32
docs/research/truck-evaluation/harness/job6.sh
Normal file
32
docs/research/truck-evaluation/harness/job6.sh
Normal 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"
|
||||
263
docs/research/truck-evaluation/harness/src/main.rs
Normal file
263
docs/research/truck-evaluation/harness/src/main.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ use std::{
|
|||
};
|
||||
|
||||
use bevy_math::{Quat, Vec3};
|
||||
use vg_csg::{
|
||||
use gamecult_geometry_csg::{
|
||||
Aabb, Assembler, BrushOp, DomainQuery, MaterialId, Primitive, lower_selected_cut_chunks,
|
||||
ragnarok_column_fixture, select_domain_cut,
|
||||
};
|
||||
|
|
@ -86,7 +86,11 @@ impl PerfMode {
|
|||
}
|
||||
}
|
||||
|
||||
fn build(self, assembler: &mut Assembler, iteration: usize) -> vg_csg::BuildOutput {
|
||||
fn build(
|
||||
self,
|
||||
assembler: &mut Assembler,
|
||||
iteration: usize,
|
||||
) -> gamecult_geometry_csg::BuildOutput {
|
||||
match self {
|
||||
Self::Stable => assembler.build(),
|
||||
Self::Dirty => {
|
||||
|
|
@ -168,7 +172,7 @@ fn run_case(case: PerfCase, mode: PerfMode) {
|
|||
let p95_ns = percentile_ns(&timings, 95);
|
||||
|
||||
println!(
|
||||
"{{\"kernel\":\"vg_csg\",\"mode\":\"{}\",\"scenario\":\"{}\",\"brushes\":{},\"iterations\":{},\"warmup_iterations\":{},\"mean_ns\":{},\"min_ns\":{},\"p50_ns\":{},\"p95_ns\":{},\"max_ns\":{},\"triangles\":{},\"fragments\":{},\"warnings\":{},\"candidate_pairs\":{},\"rejected_pairs\":{},\"reused_mesh\":{},\"used_checkpoint\":{},\"checkpoint_rebuild_from\":{},\"direct_dirty_rebuild\":{}}}",
|
||||
"{{\"kernel\":\"gamecult_geometry_csg\",\"mode\":\"{}\",\"scenario\":\"{}\",\"brushes\":{},\"iterations\":{},\"warmup_iterations\":{},\"mean_ns\":{},\"min_ns\":{},\"p50_ns\":{},\"p95_ns\":{},\"max_ns\":{},\"triangles\":{},\"fragments\":{},\"warnings\":{},\"candidate_pairs\":{},\"rejected_pairs\":{},\"reused_mesh\":{},\"used_checkpoint\":{},\"checkpoint_rebuild_from\":{},\"direct_dirty_rebuild\":{}}}",
|
||||
mode.as_str(),
|
||||
case.name,
|
||||
case.brushes,
|
||||
|
|
@ -314,7 +318,7 @@ fn run_lod_case(name: &'static str, query: DomainQuery) {
|
|||
let p95_ns = percentile_ns(&timings, 95);
|
||||
|
||||
println!(
|
||||
"{{\"kernel\":\"vg_csg\",\"mode\":\"lod_query\",\"scenario\":\"{}\",\"selected_nodes\":{},\"emitted_claims\":{},\"brushes\":{},\"iterations\":{},\"warmup_iterations\":{},\"mean_ns\":{},\"min_ns\":{},\"p50_ns\":{},\"p95_ns\":{},\"max_ns\":{},\"triangles\":{},\"collider_triangles\":{},\"candidate_pairs\":{},\"rejected_pairs\":{}}}",
|
||||
"{{\"kernel\":\"gamecult_geometry_csg\",\"mode\":\"lod_query\",\"scenario\":\"{}\",\"selected_nodes\":{},\"emitted_claims\":{},\"brushes\":{},\"iterations\":{},\"warmup_iterations\":{},\"mean_ns\":{},\"min_ns\":{},\"p50_ns\":{},\"p95_ns\":{},\"max_ns\":{},\"triangles\":{},\"collider_triangles\":{},\"candidate_pairs\":{},\"rejected_pairs\":{}}}",
|
||||
name,
|
||||
selected_nodes,
|
||||
emitted_claims,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use bevy_math::Vec3;
|
||||
use vg_csg::{DomeCapZSpec, FloretArmSpec, LevelDsl, MaterialId};
|
||||
use gamecult_geometry_csg::{DomeCapZSpec, FloretArmSpec, LevelDsl, MaterialId};
|
||||
|
||||
fn main() {
|
||||
let mut level = LevelDsl::new();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use std::{
|
|||
};
|
||||
|
||||
use bevy_math::{Quat, Vec3};
|
||||
use vg_csg::{DomeCapZSpec, LevelDsl, MaterialId, TriangleMesh};
|
||||
use gamecult_geometry_csg::{DomeCapZSpec, LevelDsl, MaterialId, TriangleMesh};
|
||||
|
||||
const FLOOR: MaterialId = MaterialId(1);
|
||||
const WALL: MaterialId = MaterialId(2);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use std::{
|
|||
};
|
||||
|
||||
use bevy_math::Vec3;
|
||||
use vg_csg::{LevelDsl, MaterialId, TriangleMesh};
|
||||
use gamecult_geometry_csg::{LevelDsl, MaterialId, TriangleMesh};
|
||||
|
||||
const DECK: MaterialId = MaterialId(21);
|
||||
const SHELL: MaterialId = MaterialId(22);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::{
|
|||
};
|
||||
|
||||
use bevy_math::Vec3;
|
||||
use vg_csg::{
|
||||
use gamecult_geometry_csg::{
|
||||
Aabb, CultGeometryBuildRequest, CultGeometryChunkArtifact, CultGeometryDomainDocument,
|
||||
CultGeometrySelectedCutManifest, DomainQuery, GEOMETRY_BUILD_REQUEST_SCHEMA,
|
||||
GEOMETRY_CHUNK_ARTIFACT_SCHEMA, GEOMETRY_DOMAIN_SCHEMA, GEOMETRY_SELECTED_CUT_SCHEMA,
|
||||
|
|
@ -21,8 +21,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
let spec = ragnarok_column_spec();
|
||||
let root = spec.compile_root();
|
||||
let query = high_lod_query();
|
||||
let domain =
|
||||
CultGeometryDomainDocument::from_spec(&spec, "vg-csg", "2026-05-29T00:00:00.0000000Z");
|
||||
let domain = CultGeometryDomainDocument::from_spec(
|
||||
&spec,
|
||||
"gamecult.geometry.csg",
|
||||
"2026-05-29T00:00:00.0000000Z",
|
||||
);
|
||||
let domain_key = domain.record_key();
|
||||
let request = CultGeometryBuildRequest::from_query(
|
||||
"ragnarok-column-high",
|
||||
|
|
|
|||
|
|
@ -535,8 +535,11 @@ mod tests {
|
|||
#[test]
|
||||
fn domain_document_emits_msgpack_payload_and_stable_key() {
|
||||
let spec = ragnarok_column_spec();
|
||||
let document =
|
||||
CultGeometryDomainDocument::from_spec(&spec, "vg-csg", "2026-05-29T00:00:00Z");
|
||||
let document = CultGeometryDomainDocument::from_spec(
|
||||
&spec,
|
||||
"gamecult.geometry.csg",
|
||||
"2026-05-29T00:00:00Z",
|
||||
);
|
||||
let payload = document.to_msgpack().unwrap();
|
||||
let decoded = CultGeometryDomainDocument::from_msgpack(&payload).unwrap();
|
||||
assert_eq!(document.record_key(), decoded.record_key());
|
||||
|
|
|
|||
|
|
@ -567,8 +567,6 @@ pub struct DomainChunkBuild {
|
|||
pub manifest: SelectedCutManifest,
|
||||
}
|
||||
|
||||
impl DomainChunkBuild {}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TriangleChunk {
|
||||
pub key: DomainKey,
|
||||
|
|
|
|||
235
src/field.rs
Normal file
235
src/field.rs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
//! Signed distance over the ordered brush stream.
|
||||
//!
|
||||
//! The polygon assembler lowers brushes to triangles; this lowers the same
|
||||
//! brushes to a scalar field: negative inside, positive outside, zero on the
|
||||
//! surface. Isosurface meshers and GPU field refiners consume it.
|
||||
//!
|
||||
//! The fold matches the stream's meaning: starting from empty space, `Add`
|
||||
//! unions (`min`), `Subtract` removes (`max(d, -b)`), `Intersect` clips
|
||||
//! (`max`). Unlike the polygon kernel, which currently warns and skips
|
||||
//! non-convex cutters, the field honours every operation on every primitive.
|
||||
//!
|
||||
//! Boxes, oriented boxes and Z cylinders are exact distances. The dome is a
|
||||
//! half-ellipsoid bound and the floret arm is a max-of-planes bound: both are
|
||||
//! exact on the surface and never overestimate, which is what sign tests,
|
||||
//! zero-crossing interpolation and Newton projection need.
|
||||
|
||||
use bevy_math::{Vec2, Vec3};
|
||||
|
||||
use crate::{Assembler, Brush, BrushOp, Primitive};
|
||||
|
||||
impl Primitive {
|
||||
/// Signed distance from `p` to this primitive's solid.
|
||||
pub fn distance(&self, p: Vec3) -> f32 {
|
||||
match *self {
|
||||
Self::Box { bounds } => box_distance(p - bounds.center(), bounds.size() * 0.5),
|
||||
Self::OrientedBox {
|
||||
center,
|
||||
size,
|
||||
rotation,
|
||||
} => box_distance(rotation.inverse() * (p - center), size * 0.5),
|
||||
Self::CylinderZ {
|
||||
center,
|
||||
radius,
|
||||
depth,
|
||||
..
|
||||
} => {
|
||||
let q = p - center;
|
||||
let d = Vec2::new(q.truncate().length() - radius, q.z.abs() - depth * 0.5);
|
||||
d.max(Vec2::ZERO).length() + d.x.max(d.y).min(0.0)
|
||||
}
|
||||
Self::DomeCapZ {
|
||||
center,
|
||||
radius,
|
||||
height,
|
||||
..
|
||||
} => {
|
||||
// Half-ellipsoid with radii (radius, radius, height) above its base plane.
|
||||
let ellipsoid = ellipsoid_bound(p - center, Vec3::new(radius, radius, height));
|
||||
ellipsoid.max(center.z - p.z)
|
||||
}
|
||||
Self::FloretArm {
|
||||
anchor,
|
||||
direction,
|
||||
length,
|
||||
root_width,
|
||||
tip_width,
|
||||
thickness,
|
||||
tip_lift,
|
||||
} => floret_distance(
|
||||
p, anchor, direction, length, root_width, tip_width, thickness, tip_lift,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Brush {
|
||||
/// Signed distance from `p` to this brush's solid, ignoring its operation.
|
||||
pub fn distance(&self, p: Vec3) -> f32 {
|
||||
self.primitive.distance(p)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold an ordered brush stream into one signed distance at `p`.
|
||||
pub fn stream_distance<'a>(brushes: impl IntoIterator<Item = &'a Brush>, p: Vec3) -> f32 {
|
||||
brushes.into_iter().fold(f32::INFINITY, |d, brush| {
|
||||
let b = brush.distance(p);
|
||||
match brush.op {
|
||||
BrushOp::Add => d.min(b),
|
||||
BrushOp::Subtract => d.max(-b),
|
||||
BrushOp::Intersect => d.max(b),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl Assembler {
|
||||
/// Signed distance from `p` to the solid this assembler's brush stream describes.
|
||||
pub fn distance(&self, p: Vec3) -> f32 {
|
||||
stream_distance(self.brushes(), p)
|
||||
}
|
||||
}
|
||||
|
||||
fn box_distance(q: Vec3, half: Vec3) -> f32 {
|
||||
let d = q.abs() - half;
|
||||
d.max(Vec3::ZERO).length() + d.max_element().min(0.0)
|
||||
}
|
||||
|
||||
/// Standard ellipsoid distance bound (Quilez): exact on the surface, never an overestimate.
|
||||
fn ellipsoid_bound(q: Vec3, radii: Vec3) -> f32 {
|
||||
let k0 = (q / radii).length();
|
||||
let k1 = (q / (radii * radii)).length();
|
||||
if k1 == 0.0 {
|
||||
return -radii.min_element();
|
||||
}
|
||||
k0 * (k0 - 1.0) / k1
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn floret_distance(
|
||||
p: Vec3,
|
||||
anchor: Vec3,
|
||||
direction: Vec3,
|
||||
length: f32,
|
||||
root_width: f32,
|
||||
tip_width: f32,
|
||||
thickness: f32,
|
||||
tip_lift: f32,
|
||||
) -> f32 {
|
||||
// Same frame as the mesh and bounds builders in primitives.rs and brush.rs.
|
||||
let forward = direction.normalize_or_zero();
|
||||
let forward = if forward.length_squared() == 0.0 { Vec3::X } else { forward };
|
||||
let mut side = forward.cross(Vec3::Z).normalize_or_zero();
|
||||
if side.length_squared() == 0.0 {
|
||||
side = Vec3::Y;
|
||||
}
|
||||
let tip = anchor + forward * length + Vec3::Z * tip_lift;
|
||||
let z = Vec3::Z * (thickness * 0.5);
|
||||
let r = side * (root_width * 0.5);
|
||||
let t = side * (tip_width * 0.5);
|
||||
let pts = [
|
||||
anchor - r - z, anchor + r - z, anchor - r + z, anchor + r + z,
|
||||
tip - t - z, tip + t - z, tip - t + z, tip + t + z,
|
||||
];
|
||||
let centroid = pts.iter().copied().sum::<Vec3>() / 8.0;
|
||||
// bottom, top, -side, +side, root, tip
|
||||
const FACES: [[usize; 3]; 6] = [[0, 1, 4], [2, 3, 6], [0, 2, 4], [1, 3, 5], [0, 1, 2], [4, 5, 6]];
|
||||
FACES
|
||||
.iter()
|
||||
.map(|&[a, b, c]| {
|
||||
let mut n = (pts[b] - pts[a]).cross(pts[c] - pts[a]).normalize_or_zero();
|
||||
if n.dot(centroid - pts[a]) > 0.0 {
|
||||
n = -n; // face normals point out of the solid
|
||||
}
|
||||
n.dot(p - pts[a])
|
||||
})
|
||||
.fold(f32::NEG_INFINITY, f32::max)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Aabb, MaterialId};
|
||||
use bevy_math::Quat;
|
||||
|
||||
const EPS: f32 = 1e-5;
|
||||
|
||||
fn unit_box() -> Primitive {
|
||||
Primitive::Box { bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_distance_is_exact_inside_on_faces_edges_and_corners() {
|
||||
let b = unit_box();
|
||||
assert!((b.distance(Vec3::ZERO) + 1.0).abs() < EPS);
|
||||
assert!((b.distance(Vec3::new(3.0, 0.0, 0.0)) - 2.0).abs() < EPS);
|
||||
assert!((b.distance(Vec3::new(2.0, 2.0, 0.0)) - 2f32.sqrt()).abs() < EPS);
|
||||
assert!((b.distance(Vec3::splat(2.0)) - 3f32.sqrt()).abs() < EPS);
|
||||
assert!(b.distance(Vec3::new(1.0, 0.3, -0.2)).abs() < EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oriented_box_rotates_the_query_not_the_answer() {
|
||||
let rotation = Quat::from_rotation_z(std::f32::consts::FRAC_PI_4);
|
||||
let b = Primitive::OrientedBox { center: Vec3::ONE, size: Vec3::splat(2.0), rotation };
|
||||
let along_local_x = Vec3::ONE + rotation * Vec3::new(3.0, 0.0, 0.0);
|
||||
assert!((b.distance(along_local_x) - 2.0).abs() < EPS);
|
||||
// A world-axis point that the unrotated box would contain lies outside this one.
|
||||
assert!(b.distance(Vec3::ONE + Vec3::new(0.95, 0.95, 0.0)) > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_distance_is_exact_radially_and_on_caps() {
|
||||
let c = Primitive::CylinderZ { center: Vec3::ZERO, radius: 1.0, depth: 2.0, segments: 16 };
|
||||
assert!((c.distance(Vec3::new(3.0, 0.0, 0.0)) - 2.0).abs() < EPS);
|
||||
assert!((c.distance(Vec3::new(0.0, 0.0, 4.0)) - 3.0).abs() < EPS);
|
||||
assert!((c.distance(Vec3::ZERO) + 1.0).abs() < EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dome_is_zero_at_apex_and_rim_and_solid_only_above_its_base() {
|
||||
let d = Primitive::DomeCapZ { center: Vec3::ZERO, radius: 2.0, height: 1.0, rings: 4, segments: 8 };
|
||||
assert!(d.distance(Vec3::new(0.0, 0.0, 1.0)).abs() < EPS);
|
||||
assert!(d.distance(Vec3::new(2.0, 0.0, 0.0)).abs() < EPS);
|
||||
assert!(d.distance(Vec3::new(0.0, 0.0, 0.5)) < 0.0);
|
||||
assert!(d.distance(Vec3::new(0.0, 0.0, -0.5)) > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floret_contains_its_axis_and_excludes_beyond_the_tip() {
|
||||
let f = Primitive::FloretArm {
|
||||
anchor: Vec3::ZERO, direction: Vec3::X, length: 4.0, root_width: 1.0,
|
||||
tip_width: 0.5, thickness: 0.4, tip_lift: 0.0,
|
||||
};
|
||||
assert!(f.distance(Vec3::new(2.0, 0.0, 0.0)) < 0.0);
|
||||
assert!((f.distance(Vec3::new(2.0, 0.0, 0.2))).abs() < EPS); // top face
|
||||
assert!(f.distance(Vec3::new(4.5, 0.0, 0.0)) > 0.0);
|
||||
assert!(f.distance(Vec3::new(3.9, 0.3, 0.0)) > 0.0); // tapered past the tip half-width
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_folds_add_subtract_intersect_in_order() {
|
||||
let mut asm = Assembler::new();
|
||||
assert_eq!(asm.distance(Vec3::ZERO), f32::INFINITY);
|
||||
asm.solid_box("slab", Aabb::from_center_size(Vec3::ZERO, Vec3::new(4.0, 4.0, 2.0)), MaterialId(1));
|
||||
asm.cut_box("hole", Aabb::from_center_size(Vec3::ZERO, Vec3::splat(1.0)));
|
||||
assert!((asm.distance(Vec3::ZERO) - 0.5).abs() < EPS); // inside the hole: 0.5 to its wall
|
||||
assert!(asm.distance(Vec3::new(1.5, 0.0, 0.0)) < 0.0);
|
||||
asm.add_brush(
|
||||
"clip",
|
||||
BrushOp::Intersect,
|
||||
Primitive::Box { bounds: Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(4.0)) },
|
||||
MaterialId(1),
|
||||
);
|
||||
assert!(asm.distance(Vec3::new(-1.5, 0.0, 0.0)) > 0.0); // clipped away
|
||||
assert!(asm.distance(Vec3::new(1.5, 0.0, 0.0)) < 0.0); // kept
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_agrees_with_the_sample_room_door() {
|
||||
let room = Assembler::sample_room_with_door();
|
||||
assert!(room.distance(Vec3::new(0.0, 4.0, 2.5)) < 0.0, "wall above the door is solid");
|
||||
assert!(room.distance(Vec3::new(0.0, 4.0, 1.0)) > 0.0, "door void is empty");
|
||||
assert!(room.distance(Vec3::new(2.0, 4.0, 1.0)) < 0.0, "wall beside the door is solid");
|
||||
assert!(room.distance(Vec3::new(0.0, 0.0, -0.1)) < 0.0, "floor is solid");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
//! Lean constructive geometry tools for VibeGeometry.
|
||||
//! Constructive-solid-geometry tools for CultGeometry.
|
||||
//!
|
||||
//! This crate borrows RealtimeCSG's useful public shape: ordered brushes,
|
||||
//! operation types, dirtied generations, prefix checkpoints, and rebuildable
|
||||
|
|
@ -13,6 +13,7 @@ mod convex;
|
|||
mod cult_geometry;
|
||||
mod domain;
|
||||
mod dsl;
|
||||
mod field;
|
||||
mod frontier;
|
||||
mod mesh;
|
||||
mod primitives;
|
||||
|
|
@ -37,6 +38,7 @@ pub use domain::{
|
|||
ragnarok_column_fixture, ragnarok_column_spec, select_domain_cut,
|
||||
};
|
||||
pub use dsl::LevelDsl;
|
||||
pub use field::stream_distance;
|
||||
pub use frontier::{DemandFrontier, DemandPair, DirtyDemandFrontier};
|
||||
pub use mesh::TriangleMesh;
|
||||
pub use primitives::{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use bevy_math::{Quat, Vec3};
|
||||
use vg_csg::{
|
||||
use gamecult_geometry_csg::{
|
||||
Aabb, Assembler, ConvexSolid, CsgBranchOp, CsgOperationType, CsgTreeArena, MaterialId,
|
||||
PolygonCategory, Primitive,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
param(
|
||||
[string]$OutputPath = ".\experiments\generated\csg-perf-latest.jsonl",
|
||||
[string]$ReferenceCommand = $env:VIBEGEOMETRY_REFERENCE_CSG_PERF,
|
||||
[string]$ReferenceCommand = $env:GAMECULT_GEOMETRY_CSG_REFERENCE_PERF,
|
||||
[switch]$UseRealtimeCsgCpp
|
||||
)
|
||||
|
||||
|
|
@ -15,17 +15,17 @@ Push-Location $root
|
|||
try {
|
||||
cargo build --release --example csg_perf_fixture | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "vg_csg perf fixture build failed with exit code $LASTEXITCODE"
|
||||
throw "CultGeometry CSG perf fixture build failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
$exe = Join-Path $root "target\release\examples\csg_perf_fixture.exe"
|
||||
if (!(Test-Path $exe)) {
|
||||
throw "Missing vg_csg perf fixture executable: $exe"
|
||||
throw "Missing CultGeometry CSG perf fixture executable: $exe"
|
||||
}
|
||||
|
||||
& $exe | Set-Content -Path $resolvedOutput -Encoding utf8
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "vg_csg perf fixture failed with exit code $LASTEXITCODE"
|
||||
throw "CultGeometry CSG perf fixture failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
if ($UseRealtimeCsgCpp) {
|
||||
|
|
@ -44,7 +44,7 @@ try {
|
|||
$missing = @{
|
||||
kernel = "reference"
|
||||
status = "missing"
|
||||
reason = "Set VIBEGEOMETRY_REFERENCE_CSG_PERF or pass -ReferenceCommand with an executable that emits the same JSONL scenario records."
|
||||
reason = "Set GAMECULT_GEOMETRY_CSG_REFERENCE_PERF or pass -ReferenceCommand with an executable that emits the same JSONL scenario records."
|
||||
} | ConvertTo-Json -Compress
|
||||
Add-Content -Path $resolvedOutput -Value $missing -Encoding utf8
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue