Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48773009a3 | ||
|
|
40faab6f69 | ||
|
|
19e2f9b668 | ||
|
|
842d101514 | ||
|
|
34cccf3b9a | ||
|
|
492f83ab93 | ||
|
|
6a575d22bc | ||
|
|
c513992ce0 | ||
|
|
a7879dad51 | ||
|
|
bd69e403b7 | ||
|
|
4b55c58d81 | ||
|
|
fd36e4e649 | ||
|
|
d426f865ad | ||
|
|
ebe8d97840 | ||
|
|
536df30473 | ||
|
|
def3c0b59b | ||
|
|
f0a00bcbee | ||
|
|
390db63130 | ||
|
|
91babd43cc | ||
|
|
253f34df49 | ||
|
|
488cfff3a2 | ||
|
|
c362c589ca | ||
|
|
fa1ee72aa6 | ||
|
|
49948c8b80 |
47 changed files with 9468 additions and 6412 deletions
32
docs/brush-tree-map.md
Normal file
32
docs/brush-tree-map.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Brush tree: map (model page: identity, lifecycle and authority)
|
||||
|
||||
One row for each persistent or crossing kind. A cell this draft cannot settle
|
||||
names the question that settles it (questions in the mind, campaign brush-tree). No cut is mapped until
|
||||
those questions are answered.
|
||||
|
||||
| Kind | What names it | What happens to it over time | Who decides |
|
||||
|---|---|---|---|
|
||||
| **Brush-tree document** (CultCache `.cc`, Geometry.Csg schema) | One document per ship hull. The key is the hull's asset id (store location and granularity: Q `tree-store`). | Created by the first commit. Each accepted commit replaces it and bumps a monotonic `revision`. It is never edited in place outside a commit. It is deleted only by the operator. | The commit primitive (I3) decides whether a change lands, by revision check. The operator and the model propose changes; neither writes around the commit. |
|
||||
| **Branch node** (union, difference, intersection, and grouping `module` instances if kept) | Its name path within the tree, siblings unique (Q `brush-identity`). | Lives and dies with tree revisions. Ops apply to its children only (I6). | The tree commit. |
|
||||
| **Parametric layer** (top-level parameters, module definitions, symmetry and relations) | Parameter name and module name, tree-scoped. | Whether it persists in the tree or is flattened at parse is Q `openscad-surface`. If kept, evaluation to brushes is derived per revision. | The tree commit. Fits propose values; the commit accepts them. |
|
||||
| **Brush** (leaf: primitive, op, transform, params) | Q `brush-identity` (recommended: a stable minted id carried in the text as a named argument, plus a human name). | Created, edited and deleted only by tree commits. `generation` bumps on each edit, for dirty-frontier rebuilds. | The tree commit. A fitter or a Blender gizmo only proposes. |
|
||||
| **Primitive vocabulary** (the set of primitive kinds and ops) | Kind name in the schema, matched to an OpenSCAD built-in where one exists. | Grows by schema version. A kind enters the authoring surface only when the polygon kernel and the field both honour it (I5). Which kinds: Q `vocabulary-growth`. | Geometry.Csg, through Hands cuts; the operator rules the set. |
|
||||
| **Brush surface** (material, UV parameterisation, seam, smoothing, bevel or blend radius) | `(brush id, surface slot)`. Slots are fixed per primitive kind, e.g. cylinder side, top cap, bottom cap. | Exists while its brush exists, edited with it. A cut face takes one explicit policy (Q `cut-face-surface`). Seam placement per curved kind: Q `seam-policy`. UV channel meaning: Q `uv-channels`. | The brush (I7). Lowerings only evaluate it. |
|
||||
| **Blend or bevel radius** | A surface or op argument on the brush (Q `bevel-operator`). | Edited with the brush. | The brush. Operator family and placement: Q `bevel-operator`. |
|
||||
| **Authoring text** (OpenSCAD-shaped) | Not persisted as truth. A printed text carries a header naming the tree key and its base revision. | Printed on demand from a revision. Parsed back only through the commit primitive. A stale base is refused. It may be written to scratch for the model to edit, then discarded. | The printer derives it. The commit decides whether a parse lands. |
|
||||
| **Lowered mesh** (triangles, normals, UVs, per-triangle brush and surface ids) | `(tree key, revision, LOD)`. | A cache, regenerated whenever the revision changes. Never edited and never read back (I8). | Geometry.Csg's lowering derives it. |
|
||||
| **Field and bevel geometry** (SDF bound, smooth-blended cut strips) | Same as the lowered mesh. | Derived per revision. | Geometry.Csg's field (I4, I9). How bevels reach the mesh: Q `bevel-to-mesh`. |
|
||||
| **Fit request and result** (primitive, parameters, residual, inlier set) | Not persisted. The `brokkr-ctl` call returns it; the receipt id is its only handle. | Ephemeral. Accepting a fit is a tree commit; the rejected ones vanish. | Fitter math computes it with I4's distance (location: Q `fitting-home`). The model and the operator decide acceptance. |
|
||||
| **Selection read** (Edit Mode vertices and faces with world coordinates) | The receipt of the `selection` command. | Read live per call, never cached as truth. Written as a scratch file the receipt points to (format: Q `blender-transport`). | Blender owns selection. The bridge reports it. |
|
||||
| **Viewport image** (`look`) | A file path printed by `brokkr-ctl`, named by command id. | Written per call to a Brokkr scratch directory, pruned by count, never kept as state. | Blender renders it. The bridge writes it. The retention cap is a proposed Defaulted ruling. |
|
||||
| **Deviation heatmap** | `(tree key, revision, source mesh)`. | Derived per revision. Shown in Blender as a colour attribute on a projection copy, never on the source. | Geometry.Csg's distance (I4) computes it. The bridge displays it. |
|
||||
| **Blender command intent** (`brokkr.blender.command_intent.v0`, extended: look, selection, fit, run, apply-tree) | Single-use `commandId` minted per act (Unity F6 carried over). | Written once by a caller, executed at most once, never rewritten. | Callers write it. The bridge alone decides execution, after admission. |
|
||||
| **Blender command receipt** (`brokkr.blender.command_receipt.v0`) | Keyed by `commandId`. | Written once by the bridge. Pruned by the cap rule Unity already uses. | The bridge alone. The `brokkr-ctl` exit code derives from its status. |
|
||||
| **Run script** (`brokkr-ctl blender run <script>`) | The script's content hash, recorded in the intent and the receipt. | Executed at most once as one undo step. The script is not stored by Brokkr. | Admission (allowed-action list, sink enabled) decides. Blender executes. Default admission: proposed Defaulted ruling. |
|
||||
| **Agent-command enable flag (Blender)** | One per Blender project or file, echoed in the snapshot. | Off by default. Toggled only by the operator. | The operator only (Unity F5 carried over). |
|
||||
| **Transport and auth material** | Q `blender-transport`. | Q `blender-transport`. | Q `blender-transport`. |
|
||||
| **Blender projection objects** (lowered-mesh object, brush proxies) | A custom property holding tree key plus brush id. | Rebuilt from the revision. An operator edit to a proxy, such as a gizmo move, becomes a proposed tree commit through the bridge. Edits to the lowered-mesh object are never read back. | The bridge projects them. The tree commit decides. |
|
||||
| **Tripo source mesh** | Its Blender object in the `Source` collection. | Read-only for the loop (I14). | The operator, and Aetheria's add-on. |
|
||||
| **Shipped hull geometry** (Aetheria ship `.cc`) | Aetheria's existing package keys. | Written by Package from the Blender object (I15). | Aetheria's Package. Whether the lowered mesh enters Blender first: proposed Defaulted ruling. |
|
||||
| **Impostor atlas** (far LOD) | `(tree key, revision)`. | Derived from the lowered mesh and materials, not from SDF marching. Whether built here: Q `impostor-scope`. | Whichever campaign builds it. Not this one, if recommended. |
|
||||
| **In-game rebuild** | Q `bake-or-runtime`. | Q `bake-or-runtime`. | Q `bake-or-runtime`. |
|
||||
263
docs/brush-tree-target.md
Normal file
263
docs/brush-tree-target.md
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
# Brush tree: target (r4)
|
||||
|
||||
Campaign `brush-tree`, owned by the Aetheria/Ghostlight Self. Drafted by Imagination
|
||||
(`imagination-brush-tree-target-r4b`, session `self-2026-10-12-ag`) on 2026-10-13.
|
||||
It supersedes r3 (`842d101`). This page holds the ends, not the means: no cut plan. The typed
|
||||
`brush-tree:target:r4` holds the invariants, the not-in-scope list and the canonical implementations;
|
||||
this page holds why, the authority map, and what is still unknown.
|
||||
|
||||
- **Repos:** `GameCult/GameCult.Geometry.Csg` (the tree, booleans, the weld, the scorer, and the Blender
|
||||
extension that runs them live), `GameCult/Brokkr` (the agent's hands in Blender), `GameCult/Aetheria`
|
||||
(the first consumer).
|
||||
- **Working branch:** `eureka/brush-tree`.
|
||||
|
||||
## The rulings this target rests on
|
||||
|
||||
Read them in the mind by id; their operator quotes are the authority, not this page.
|
||||
|
||||
- `brush-tree:ruling:hand-cages-enter-the-tree` (Operator, 2026-10-12): a continuous hull's primary form
|
||||
is an artist's cage, entered as a tree primitive; detail is cut into its evaluated surface. Automatic
|
||||
cage generation is research and gates nothing.
|
||||
- `brush-tree:ruling:blender-evaluates-cages` (Operator): Blender evaluates the cage's Mirror and
|
||||
Subdivision; Geometry.Csg cuts, splits and welds the result. One evaluator by construction.
|
||||
- `brush-tree:ruling:brush-modes-add-subtract-split` (Operator): a detail brush is add (union), subtract
|
||||
(cut) or split (keep both sides). All three are booleans on the evaluated cage mesh and never move the
|
||||
cage.
|
||||
- `brush-tree:ruling:weld-owner-local-corners-kept` (Operator): ownership is local along each loop. At
|
||||
each stretch, the surface whose vertices carry more of the silhouette keeps them and the other's spare
|
||||
vertices near that stretch merge away. A hard-edged cutter's corners always survive. On a flat split
|
||||
the hull carries the silhouette everywhere and the seam stays on the cutter's plane. It refines
|
||||
`weld-owner-defines-silhouette`, which superseded `boolean-cleanup-tighter-curve-wins`; neither is in
|
||||
force.
|
||||
- `brush-tree:ruling:weld-runs-as-live-result-object` (Operator): the cage keeps its own Mirror and
|
||||
Subdivision; brushes are ordinary objects in a brush collection, listed in order on the result object's
|
||||
Cuts panel (mode, enable, bevel weight); a Rust step in a GPL extension wheel runs the booleans and the
|
||||
weld and writes the result object's mesh, whose own stack is Bevel (weight mode) then Weighted Normal.
|
||||
Nothing is ever applied; the same code produces the shipped lowering.
|
||||
- `brush-tree:ruling:panel-lines-by-split-boolean-r2` (Operator): a panel line is a through-cut that keeps
|
||||
both sides, then the one-segment bevel with face-weighted normals. It is the only panel-line method
|
||||
(`aetheria-release:ruling:panel-lines-are-geometry`). Its text names the superseded cleanup ruling; the
|
||||
weld rule that applies to its seams is now `weld-owner-local-corners-kept`.
|
||||
- `brush-tree:ruling:no-unownable-tools-in-loop` (Operator): everything in the loop is open source we can
|
||||
build and patch. This now excludes Shewchuk's Triangle, whose licence bars commercial use.
|
||||
- `brush-tree:ruling:primary-form-is-the-hand-cage`, `:representation-per-hull`, `:shippable-part-bar`,
|
||||
`:openscad-target`, `:brokkr-beats-blender-mcp`, `:cli-not-mcp`, and the defaulted rulings on the
|
||||
Blender sink, `run`, docs lookup, look files, the per-ship tree `.cc`, persistence through
|
||||
cultcache-rs, the Package path, Studio and impostors: unchanged from r3.
|
||||
- `brush-tree:ruling:settled-direction-2026-10-10` still says "mesh CSG is the main representation". r4
|
||||
agrees in substance (every production part lowers to a mesh and all detail is mesh booleans) but its
|
||||
primary form for continuous hulls is a subdivision cage, which `representation-per-hull` already
|
||||
ruled. Self holds a draft superseding text that states this; until it is admitted, read "mesh CSG" as
|
||||
"meshes and mesh booleans, over a cage or kernel primary form".
|
||||
|
||||
## What changed from r3, in one paragraph
|
||||
|
||||
r3 had one weld rule (the surface with larger normal curvature across the loop owns the whole loop),
|
||||
detail as cut or split only, an open question about who evaluates the cage, and no answer to where the
|
||||
weld runs. Spike rounds 1 to 3 (`spike-boolean-weld.md` on `spike/boolean-weld` @ db2c162) showed that
|
||||
curvature across the loop picks the wrong owner for every cylinder cut, that a flat split's seam must stay
|
||||
on the cutter's plane, and that ownership must be decided per stretch with corners protected. The operator
|
||||
then ruled the three modes, the local owner with corners kept, Blender as the cage evaluator, and a live
|
||||
result object fed by a Rust wheel. So in r4 Blender owns evaluation and finishing, Geometry.Csg owns
|
||||
everything between, and the authoring buffer is Blender objects that commit into the typed tree.
|
||||
|
||||
## What the pipeline turns a brush tree into
|
||||
|
||||
**The tree** is one typed document per ship (`tree-store-per-ship-cc`). Every part declares its
|
||||
representation, `cage` or `kernel`, and carries an ordered detail list. Each entry names a brush, its
|
||||
mode (`add`, `subtract`, `split`), an enable flag and a bevel weight. A brush is a closed manifold mesh:
|
||||
either a kernel primitive lowered alone or a mesh authored in Blender (a polyhedron in the text).
|
||||
|
||||
**A `cage` part** (continuous body, Dexter Quiet):
|
||||
|
||||
1. **Primary form: the cage primitive.** Control points, quad and triangle faces, edge crease weights,
|
||||
the mirror plane, and her UVs if she has unwrapped it. She authors it in Blender; a commit reads the
|
||||
cage object into a new revision against its base revision. A commit is refused when a crease chain is
|
||||
open and does not end on the mirror plane.
|
||||
2. **Evaluated mesh, by Blender.** The cage object's own Mirror and Subdivision modifiers, at the declared
|
||||
level. Blender is the only evaluator (`blender-evaluates-cages`). Geometry.Csg never subdivides a cage,
|
||||
for production or for measurement.
|
||||
3. **Detail, by Geometry.Csg.** The detail list is folded over the evaluated mesh left to right: `add` is
|
||||
union, `subtract` is difference, `split` is difference plus intersection kept as two pieces sharing one
|
||||
seam loop. A disabled entry stays in the tree and is not applied; that is authored state, not a skip.
|
||||
Every intersection loop is welded by the rule below. The edges each brush creates take its bevel
|
||||
weight; per-edge weights authored on the brush mesh carry onto the edges it creates; the cage's crease
|
||||
weights carry through as bevel weights. Detail never moves the cage, and outside the weld band the
|
||||
output's vertices are the evaluated mesh's vertices, unchanged.
|
||||
4. **Finishing, by Blender.** The result object's own stack: Bevel (one segment, weight-limited), then
|
||||
Weighted Normal. Nothing in the stack is ever applied.
|
||||
|
||||
**A `kernel` part** (mechanical, the squatter mech ship): the polygon kernel builds exact planar faces
|
||||
with scoped booleans and per-face brush and surface ids; its detail list runs through the same booleans,
|
||||
weld and finishing stack as a cage part's.
|
||||
|
||||
**Both** reach Aetheria the same way (`lowered-hull-through-package`): an object in the ship file,
|
||||
exported by the existing Package.
|
||||
|
||||
### The weld, as ruled
|
||||
|
||||
The weld's job is to choose which samples of each intersection curve survive and to clean the band
|
||||
around it. It never chooses the curve's path.
|
||||
|
||||
- **Owner, per stretch.** Along each loop, compare each surface's curvature along the loop,
|
||||
`k = II(t, t)` with `t` the loop tangent. Spike round 1 showed this is the reading that matches her
|
||||
sentence; curvature across the loop gives the opposite owner for every cylinder. The surface whose
|
||||
vertices carry more of the silhouette at that stretch owns it. A planar brush face has `k = 0`, so on a
|
||||
flat split or a box cut-in the hull owns every stretch. A small curved brush in a flatter surface owns
|
||||
its loop.
|
||||
- **Spare vertices.** The yielding surface's spare vertices near an owned stretch merge away: loop
|
||||
vertices made only by the yielding side's edges merge along the loop into the nearest owner-made loop
|
||||
vertex, and yielding-side vertices one ring off the loop merge onto it.
|
||||
- **Corners survive.** A loop vertex on a hard brush edge (dihedral above a threshold) or on a hull crease
|
||||
edge is never a merge source and never moves.
|
||||
- **The path is fixed.** No weld step moves a loop vertex off the intersection of the two surfaces. A flat
|
||||
seam stays on the cutter's plane to float precision. No snapping of the loop onto owner vertices on any
|
||||
stretch, curved cutters included.
|
||||
- **Band vertices move only within their own surface,** by at most the bevel width. This is how a vertex
|
||||
that sits almost on the seam (round 3's residual 0.04 to 0.7 deg needles and bevel-clamped edges) is
|
||||
resolved: it slides onto the seam inside its own surface, never across it. Whether this applies to the
|
||||
owner's own vertices on a flat split is spike round 4's measure and the operator's eye (see "Not yet
|
||||
known").
|
||||
- **Created faces are ours to re-triangulate:** split caps, and brush walls trimmed by the hull, are
|
||||
re-triangulated within their own plane by a constrained Delaunay triangulation with the loop, crease
|
||||
and brush edges as constraints and interior Steiner points only. The CDT is `spade` or our own; never
|
||||
Triangle.
|
||||
- **Guards.** No collapse or flip inverts a triangle or drops the band's worst angle below the floor. In a
|
||||
split, every operation must pass both pieces' guards, so the shared seam stays identical vertex for
|
||||
vertex (crack 0).
|
||||
- **Brush density.** A brush's tessellation along the loop stays near the hull's local density (about a
|
||||
quarter to a third of the local edge length, never far finer): round 2's 48-segment port kept fans no
|
||||
merge could remove.
|
||||
|
||||
Still undefined: equal curvature (spike: ties to the hull), saddles, and the vertex where ownership
|
||||
switches. Round 3's switch probe keeps a 1.2e-6 deg needle on the owner side.
|
||||
|
||||
### What Blender evaluates and what Rust owns
|
||||
|
||||
| Step | Owner | Notes |
|
||||
|---|---|---|
|
||||
| Cage evaluation (Mirror, Subdivision, crease rules) | Blender, the cage object's own modifiers | `blender-evaluates-cages`; Geometry.Csg reads the evaluated mesh |
|
||||
| Brush lowering (kernel primitive to mesh) | Geometry.Csg kernel | a Blender-authored brush mesh is read as is |
|
||||
| Booleans, all three modes | Geometry.Csg, behind a port | boolmesh or manifold-csg; identical outputs in round 1 |
|
||||
| Weld, re-triangulation, bevel-weight transfer, face provenance | Geometry.Csg | pure mesh math, testable on exported fixtures |
|
||||
| Result object's mesh | written by the extension from Geometry.Csg's output | derived; never hand-edited |
|
||||
| Finishing (Bevel then Weighted Normal) | Blender, the result object's own stack | never applied |
|
||||
| Tree revision | Geometry.Csg tree, through the one commit primitive | the truth at rest |
|
||||
|
||||
Blender's Boolean modifier, Weld modifier and Geometry Nodes Mesh Boolean decide no shipped detail. The
|
||||
nondestructive-weld pass (`nondestructive-weld-in-blender.md` @ 19e2f9b) found that Python cannot
|
||||
register a modifier and that Geometry Nodes cannot express the guarded sequential collapse or the joint
|
||||
split guard, so a wheel feeding a result object is the route that keeps one implementation.
|
||||
|
||||
### Where the truth is while she edits
|
||||
|
||||
The typed tree is the truth at rest. The Blender objects (the cage, the brush collection, the Cuts panel
|
||||
rows) are its edit buffer. The result object regenerates from the buffer on every change, so it is a
|
||||
preview of uncommitted edits as well as of committed ones. The shipped lowering comes from a committed
|
||||
revision only: whatever Package exports must be the result of a revision, and a scene that differs from
|
||||
its revision is committed first or refused. This keeps `one-truth` with a live, nondestructive loop. How
|
||||
the dirty check is made is a cut's business. If the operator expects the `.blend` itself to be the
|
||||
truth, that is a different machine (Blender scene authority, the tree as an export) and needs her ruling
|
||||
first.
|
||||
|
||||
### Distance, collapsed to one definition
|
||||
|
||||
r3 defined the cage's distance as distance to its limit surface, which would have needed a limit
|
||||
evaluator in Geometry.Csg beside Blender's. Under `blender-evaluates-cages` that is a second evaluator. In
|
||||
r4 there is one mesh distance in Geometry.Csg: unsigned distance to a closed mesh, signed by winding
|
||||
number. A cage's distance is that distance to Blender's evaluated mesh at a declared measuring level; a
|
||||
Blender-authored brush's is the same distance to its own mesh. Kernel primitives keep their analytic
|
||||
distance in `field.rs`. Mesh distances serve measurement only (heatmaps, scores, fit residuals); they
|
||||
have no cheap bound away from the surface, so they are refused in any field composition and in sphere
|
||||
tracing.
|
||||
|
||||
### Authority map (r3 to r4)
|
||||
|
||||
- **Owner of evaluation:** Blender. Geometry.Csg is no longer a candidate cage evaluator; the r3
|
||||
`one-subdivision-rule` question is closed.
|
||||
- **Owner of detail and the weld:** Geometry.Csg, run in Blender by the extension, and run headless by
|
||||
the same code for the shipped lowering.
|
||||
- **Owner of finishing:** Blender's Bevel and Weighted Normal on the result object.
|
||||
- **Inputs:** the cage object's evaluated mesh; the brush objects and their Cuts rows; the tree revision.
|
||||
- **Outputs:** the result object's mesh (with bevel weights, face provenance and UVs), and scores.
|
||||
- **Derived state:** the evaluated mesh, brush meshes, boolean pieces, the welded mesh, the result
|
||||
object, heatmaps and renders. Cache only.
|
||||
- **Forbidden writers:** Blender's Boolean and Weld modifiers and Geometry Nodes deciding shipped detail;
|
||||
hand edits to the result object; any applied modifier; a Rust subdivision evaluator; a research
|
||||
generator writing a production cage; the "curvature across the loop" owner and loop snapping (both
|
||||
demoted by the spike and the ruling).
|
||||
- **Shared paths:** her edit in Blender, a model's script through `brokkr-ctl blender run`, and a
|
||||
research generator's candidate all reach the tree through the one commit primitive. The live preview
|
||||
and the shipped lowering call the same Rust entry point. All three brush modes and both part
|
||||
representations use the same booleans and weld.
|
||||
- **Deletion line:** r3's `weld-rule` (across-loop curvature, one owner per loop), `detail-is-boolean`
|
||||
(cut and split only) and `one-subdivision-rule` (owner open) leave the target. The cage limit-surface
|
||||
distance leaves Geometry.Csg's plans. The spike's Triangle dependency and its Python weld are scratch
|
||||
and do not carry.
|
||||
|
||||
### Owners, inputs, outputs, mock points
|
||||
|
||||
| Owner | Inputs | Outputs | Mock point |
|
||||
|---|---|---|---|
|
||||
| Tree (Geometry.Csg) | text, a cage object or brush objects, through the one commit primitive | typed tree revision | in-memory store |
|
||||
| Cage evaluator (Blender) | cage object at a declared level | evaluated mesh | exported fixtures (no Bevel) |
|
||||
| Brush lowering | kernel primitive or authored mesh | closed brush mesh | the kernel, per brush |
|
||||
| Boolean | evaluated mesh, brush mesh, mode | pieces with per-corner provenance | trait; boolmesh or manifold-csg |
|
||||
| Weld | pieces, provenance, curvature along the loop | welded pieces, loop edges with bevel weights | pure mesh math |
|
||||
| Extension (Blender) | cage, brush collection, Cuts rows | result object's mesh | the Rust entry point, called headless |
|
||||
| Scorer | two meshes | the bar's numbers | pure mesh math, no Blender |
|
||||
| Research generators | a reference | candidate cage | port; never a production writer |
|
||||
| Brokkr bridge | intents from `brokkr-ctl` | receipts, commits, exported fixtures | per `blender-transport` |
|
||||
|
||||
## The hand cage
|
||||
|
||||
`docs/research/dexter-quiet-cage/` @ d426f86 (note @ 4b55c58): half hull of 29 vertices and 25 faces (19
|
||||
quads, 6 triangles); nine creases that the mirror closes into one loop round the wing rim. At level 3 it
|
||||
evaluates to 3,154 vertices and 3,152 quads with 31 irregular vertices and no UVs. It is the production
|
||||
primary form and the yardstick research generators are scored against (`primary-form-is-the-hand-cage`),
|
||||
with the cage bar unchanged from r3: at most about 200 faces, at most 24 % triangles, closed creases, and
|
||||
a watertight level-3 limit mesh with at most 62 irregular vertices.
|
||||
|
||||
## Not yet known (owed before or during the next cuts)
|
||||
|
||||
- **The flat seam's last needles.** Round 3 kept the seam on the plane and reached crack 0, but a
|
||||
boolean-inserted seam vertex almost on a hull vertex leaves a 0.04 to 0.7 deg triangle and
|
||||
bevel-clamped edges, so her uniform Longinus groove is not reached. Round 4 slides such vertices onto
|
||||
the seam within the hull's surface. On a flat split those are the owner's own vertices, and her words
|
||||
were "they survive"; a moved vertex survives in identity, not in position. Her eye on the round-4 render
|
||||
decides whether that reading holds.
|
||||
- **Add mode.** No union has been run. A union's loop is a concave edge; the same owner rule and guards
|
||||
apply, and the brush's outer faces are its own surfaces.
|
||||
- **Which boolean kernel.** boolmesh (pure Rust, MPL-2.0, 14 s build) and Manifold through manifold-csg
|
||||
(Apache-2.0, C++ fetched at build, 89 s) gave identical output on all 11 cases. A per-platform Blender
|
||||
wheel favours pure Rust; Imagination's lean is boolmesh behind the port, decided by the first cut, not
|
||||
here.
|
||||
- **The extension wheel's build.** Per-platform wheels for the Extensions platform (windows-x64,
|
||||
macos-arm64, linux-x64 at least), Blender's pinned Python, GPL-3.0-or-later for the extension: no cut
|
||||
has built one.
|
||||
- **How a `cage` part and a `kernel` part meet** on one hull: presumably `add` with the same weld;
|
||||
unmeasured.
|
||||
- **Text for a cage part and its detail list.** `subdivision_cage(points, faces, creases, mirror, uv)`
|
||||
followed by the ordered detail entries. OpenSCAD renders neither the limit surface nor the weld, so cage
|
||||
parts and detail modes sit outside the "OpenSCAD renders it the same" half of `text-round-trips`.
|
||||
- **UVs on created faces.** Her unwrap rides through Subdivision and the booleans as UV0; split caps and
|
||||
trimmed brush walls need placement that keeps UV0 unique (`aetheria-release:ruling:hull-paint-mask-texture`
|
||||
samples the livery mask on UV0).
|
||||
|
||||
## Research it rests on
|
||||
|
||||
All in GameCult.Geometry.Csg `docs/research/` on `eureka/brush-tree` unless named:
|
||||
|
||||
- `spike-boolean-weld.md` on `spike/boolean-weld` @ db2c162: rounds 1 to 3, the along-loop owner, the
|
||||
flat seam on the plane, the cap CDT, local ownership with corners kept, the kernel comparison.
|
||||
- `nondestructive-weld-in-blender.md` @ 19e2f9b: modifier routes, Geometry Nodes limits, Hard Ops and
|
||||
Boxcutter cleanup (distance welds, no ownership rule), Manifold timings in Blender.
|
||||
- `boolean-cleanup-prior-art.md` @ 34cccf3: no named precedent for the rule; Manifold's collapse guard;
|
||||
ownable kernels.
|
||||
- `detail-on-subd-prior-art.md` @ c513992: cut into the evaluated mesh keeps the cage independent.
|
||||
- `cage-connectivity-prior-art.md` @ 492f83a: no open method reaches a coarse quad cage at this quality.
|
||||
- `dexter-quiet-cage/` @ 4b55c58, `sdf-authoring-text-prior-art.md` @ 6a575d2,
|
||||
`subd-cages-mixed-hulls-and-open-kernels.md` @ 390db63, `shippable-bar/`, `mesh-probe-run1/` to
|
||||
`mesh-probe-run7-im-field/`, `openscad-evaluators-and-csg-export.md`,
|
||||
`primitive-seams-and-sdf-bevel-meshing-prior-art.md`, `brush-surfaces-and-sdf-bricks-prior-art.md`,
|
||||
`octahedral-impostors-prior-art.md`: as in r3.
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +0,0 @@
|
|||
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)))
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
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()
|
||||
95
docs/research/boolean-cleanup-prior-art.md
Normal file
95
docs/research/boolean-cleanup-prior-art.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Boolean cleanup near the intersection curve: prior art
|
||||
|
||||
Serves ruling `brush-tree:ruling:boolean-cleanup-tighter-curve-wins` (campaign brush-tree, Operator, 2026-10-12): along a boolean cut into the hull's evaluated surface, the surface with the higher curvature owns the cut's silhouette, and the vertices the flatter surface contributes near the cut are merged into it. Prior art first, then a spike on the Dexter Quiet cage with a cutter.
|
||||
|
||||
Pass: Eyes, 2026-10-12, agent `eyes-bt-boolean-cleanup`. Facts with evidence pointers; "(inferred)" marks inference; "(recall, unverified)" marks things stated from memory that were not fetched this pass. No recommendation. Builds on `detail-on-subd-prior-art.md` @ c513992 (section 1 records that practitioners weld and clean ragged vertices after a boolean cut).
|
||||
|
||||
Tool caveat: the page fetcher summarises with a small model; Blender manual pages returned only navigation, so Blender operator behaviour below comes from search snippets, not full pages.
|
||||
|
||||
## Headline findings
|
||||
|
||||
1. No source found that names or specifies "the tighter-curved surface defines the cut, merge the flatter surface's vertices onto it" as an algorithm. Nearest relatives are in section 2.
|
||||
2. Every mainstream tool found treats post-boolean cleanup as a distance or length threshold operation (merge-by-distance, collapse edges shorter than epsilon, aspect-ratio sliver removal), not a curvature-ownership rule. The one curvature-driven item found (Hu et al., SPIE 2022) sets target edge length from curvature before the boolean, not merge direction after it.
|
||||
3. Manifold runs a post-boolean degenerate-edge collapse internally with a triangle-flip guard; CGAL returns the intersection curve as a constrained-edge map that its isotropic remesher can protect; Houdini outputs seam edge groups and PolyReduce has hard/soft edges. These are the "intersection loop as protected feature" mechanisms found.
|
||||
|
||||
## 1. Research and tools that clean booleans near the intersection
|
||||
|
||||
### Libraries and kernels
|
||||
|
||||
- **Manifold** (Apache-2.0, C++). After assembling a boolean result, `boolean_result.cpp` calls `outR.RemoveDegenerates(nPv + nQv)` then `RemoveUnreferencedVerts()` (https://github.com/elalish/manifold/blob/master/src/boolean_result.cpp). In `edge_op.cpp` (https://raw.githubusercontent.com/elalish/manifold/master/src/edge_op.cpp; fetcher summary, function names as reported):
|
||||
- `RemoveDegenerates`: `CleanupTopology`, collapse edges shorter than `epsilon_`, `SwapDegenerateEdges`, collapse colinear edges, recompute vertex normals.
|
||||
- `CheckEdge`/`CollapseEdge`: short edges collapse to the midpoint; longer edges get a fitted position in the plane of the edge and averaged normal, clamped to [-0.5, 0.5] along the edge, with cost in squared-length units. A collapse is rejected if a surrounding triangle would flip (cross product against face normal); a Delaunay-style worst-angle threshold (0.5 or 0.99) also gates it.
|
||||
- `Colinear`: an edge may collapse only if each rotated neighbour stays within epsilon of the face plane and does not invert.
|
||||
- `SwapDegenerateEdges`: swaps the longest edge of a degenerate triangle only if that reduces degenerates; per-edge cap `kMaxSwapsPerEdge = 255`.
|
||||
- `Decimate(tolerance)`: collapse/swap in cost-sorted rounds, `maxCost = tolerance^2`, tolerance floored at epsilon.
|
||||
- Epsilon is a global length tolerance, not curvature based; its value is set outside these files. The README (https://github.com/elalish/manifold) does not mention epsilon or short-edge collapse; it states reliability and guaranteed-manifold output as the goal. Whether `Decimate` is on the public API was not confirmed.
|
||||
- **CGAL Polygon Mesh Processing** (GPL v3+, some core parts LGPL; commercial licences from GeometryFactory). Corefinement booleans `corefine_and_compute_union/intersection/difference`; with a constrained-edge map supplied, edges on the intersection of the inputs are marked constrained, and sub-edges stay marked when split (search result quoting https://doc.cgal.org/latest/PMP_Boolean_operations/group__PMP__boolop__grp.html). Exact predicates needed for correct topology; exact constructions recommended when chaining. As of CGAL 6.2 remeshing lives in "Meshing and Remeshing of Polygon Meshes"; `isotropic_remeshing` with `protect_constraints(true)` and `face_patch_map` limits remeshing to a region and keeps constraint edges (https://doc.cgal.org/latest/PMP_Remeshing/PMP_Remeshing_2isotropic_remeshing_of_patch_example_8cpp-example.html). Curvature-adaptive sizing in that remesher: not confirmed in fetched pages (recall, unverified: a sizing-field parameter exists in recent versions).
|
||||
- **libigl**: README says "MPL-2.0-licensed", repo also carries LICENSE.GPL (https://github.com/libigl/libigl). `igl::copyleft::cgal::mesh_boolean` implements Mesh Arrangements for Solid Geometry (Zhou, Grinspun, Zorin, Jacobson, SIGGRAPH 2016; https://alecjacobson.com/weblog/4573.html) under a `copyleft/cgal` path; an old mirror header shows an MPL-2.0 notice and a CGAL dependency, so the effective licence is bounded by CGAL's (inferred). Related functions named there: `mesh_boolean_cork`, `intersect_other`, `remesh_self_intersections`. C++; no Rust bindings found.
|
||||
- **Cherchi et al., Fast and Robust Mesh Arrangements using Floating-point Arithmetic** (TOG 2020): https://github.com/gcherchi/FastAndRobustMeshArrangements, MIT, header-only (C++ presumed), four `solveIntersections` variants; no remeshing or cleanup mentioned on the page.
|
||||
- **Cherchi, Pellacini, Attene, Livesu, Interactive and Robust Mesh Booleans** (2022; https://ar5iv.arxiv.org/html/2205.14151): output triangles are portions of input triangles; the only cleanup stated is per-frame "degeneracy removal and rebuilding of adjacencies"; no simplification or coplanar merging described; C++ with CinoLib, Indirect Predicates, Abseil, TBB; code "will be released", no repo given in the text read.
|
||||
- **Exact predicates, exact constructions and combinatorics for mesh CSG** (arXiv 2405.12949; fetch failed on size, search snippet only): says results keep "scars" of intersection from co-refinement that the final mesh does not need; finds coplanar regions by traversal and simplifies them; notes poorly triangulated planar input areas are worth remeshing.
|
||||
- **Adaptive Mesh Booleans**, Schmidt and Brochu, Autodesk Research (https://arxiv.org/abs/1605.01760; read via https://ar5iv.arxiv.org/html/1605.01760). Steps: refine locally around the intersection (split, collapse, flip, smooth), delete intersecting triangles, zipper the two boundary loops by iterative closest-point evolution, reproject loop vertices onto the input surfaces with sharp-edge constraints (3.5, 3.6), simplify the intersection region as post-process (3.7). Density: constant max edge length `l_max`, min `0.4*l_max`; the paper "does not adapt density to surface curvature", adapting to local feature size is future work (3.4). Failure modes listed: loop-count mismatch giving holes or floating patches; 5-iteration refinement cap; slow convergence near coplanar; loops of very different shape give self-intersections or non-manifold seams; sharp-edge constraints can leave triangular corner holes (3.6.1). Shipped in Meshmixer (since 2011) without the sharp-edge enhancements; no source release stated. Closest published "both sides conform to one loop" method found.
|
||||
- **Hu et al., adaptive remeshing from local information for mesh booleans** (SPIE vol. 12331, 123314S; title and full text not obtained, search summary only, https://spiedigitallibrary.org/conference-proceedings-of-spie/12331/123314S/An-adaptive-remeshing-method-based-on-local-information-for-mesh/10.1117/12.2652878.full): edge length from local edge length and curvature; reported average edge length 55% shorter in high-curvature areas, 78% longer in low-curvature areas versus uniform remeshing. Verify before citing.
|
||||
|
||||
### Applications and add-ons
|
||||
|
||||
- **Houdini Boolean SOP** (https://www.sidefx.com/docs/houdini/nodes/sop/boolean.html): "Collapse tiny seam-adjacent edges" fuses microscopic edges from misaligned inputs with an Edge length threshold; Detriangulate (All / Only Unchanged / None) merges triangles back into original polygons; "Assume seam polygons are flat" drops the triangulation-crease point; "Unique Points Along Seams"; Output Edge Groups for A-A, B-B, A-B seams. Arbitrary precision internally, snapped to float on output, which can produce microscopic self-intersections when chained.
|
||||
- **Houdini PolyReduce SOP** (https://www.sidefx.com/docs/houdini/nodes/sop/polyreduce.html): hard points/edges never move, soft ones carry a weight, Boundaries weight, vertex-attribute seam weight, "Only use original point positions", normal-deviation limit, density retention by attribute or by view silhouette, Equalize lengths. Fetcher found no mention of quadric error on the page.
|
||||
- **Blender** (search snippets of https://docs.blender.org/manual/en/dev/modeling/meshes/editing/mesh/cleanup.html; bodies not read): Merge by Distance (can merge selected into unselected; newer manual preserves sharp edges when custom split normals exist), Degenerate Dissolve (collapses edges shorter than a length), Limited Dissolve (only linked in snippets), Weld modifier = non-destructive Merge by Distance, Decimate modifier Collapse mode = non-destructive Degenerate Dissolve. Boolean modifier Exact solver: overlap and self-intersection options, only manifold inputs guaranteed (https://docs.blender.org/manual/en/latest/modeling/modifiers/generate/booleans.html, via search). Nothing found saying Exact minimises vertices along the cut.
|
||||
- **Hard Ops / Boxcutter / Bool Tool / Fluent**: Hard Ops manual boolean page (https://hardops-manual.readthedocs.io/en/latest/boolean/) names no dissolve or weld options in the text fetched. The Blender Bros bundle page lists a "Boolean Cleanup" feature without detail (search snippet). A Blender Nation Bool Tool tutorial summary cleans up with the native Weld modifier, not a Bool Tool option. Add-on source not inspected (`F:\Projects\blender-tooling-surface-prior-art.md` line 71 says the same).
|
||||
- **Hardsurface Doctor** (https://superhivemarket.com/products/hardsurface-doctor/docs, snippet): "Safe Repair" merges doubles, removes zero-area and loose geometry, dissolves multi-face non-manifold edges. **Boolmend** (https://blenderartists.org/t/boolmend-one-click-post-boolean-cleanup-topology-repair-shading-fix-15-early-access/1650088): early-access add-on claiming one-click post-boolean cleanup and sliver-edge removal; vendor claims, unverified.
|
||||
- **Meshmixer**: Boolean adds triangles around the intersection, deletes strips to form clean boundary loops on each side, applies Reduce as post-process; on failure remeshes denser and retries (search snippets of https://help.autodesk.com/cloudhelp/2019/ENU/MSHMXR/files/GUID-355503DE-889B-4168-8398-084F9BBD508B.htm).
|
||||
- **Altair HyperMesh** `fix 2d mesh` (https://help.altair.com/hwdesktop/hwd/topics/reference/hm/_fix_2d_mesh.htm, snippet): sliver removal by minimum aspect ratio, intersection removal that avoids sub-threshold triangles, bitmask to disable edge swaps, collapses, node moves.
|
||||
- **COMSOL, Intersect with Line** (https://doc.comsol.com/6.3/doc/com.comsol.help.comsol/comsol_ref_mesh.24.70.html, snippet): vertices on intersected edges are snapped onto the intersection line, bounded by a repair tolerance. Closest found to "move one side's vertices onto the other's curve", but FE meshing and tolerance-driven.
|
||||
- **ZBrush**: DynaMesh with Groups, Polish by Features after remesh (Novedge tip, snippet). Not an ownership rule.
|
||||
|
||||
### Simplification with locked features
|
||||
|
||||
- **meshoptimizer** (MIT, C++; Rust `meshopt` crate; https://github.com/zeux/meshoptimizer): `meshopt_simplifyWithAttributes`, per-vertex `vertex_lock` array (Lock, Protect, Priority), `meshopt_SimplifyLockBorder`, absolute or relative error bound. Quadric basis not stated in the fetched text (recall, unverified: it is quadric-based).
|
||||
- Garland-Heckbert QEM with boundary constraint planes as the standard way to protect a loop in constrained collapse (recall, unverified; not fetched).
|
||||
|
||||
## 2. "Tighter-curved surface owns the cut; flatter surface's vertices merge onto it"
|
||||
|
||||
### Name or precedent
|
||||
|
||||
- No source found with this rule under any name. Searches for it returned Adaptive Mesh Booleans (symmetric: both sides conform to one loop, no side ownership), COMSOL snapping (tolerance based), Hu et al. (pre-boolean curvature sizing).
|
||||
- CAD trimmed-surface tessellation: the common boundary edge is discretised once and both faces reuse those vertices (patent text US 9,836,884 / 9,875,577, mental ray "Edge Merging" doc, a BUAA paper on trimmed-surface meshing using distance and angle tolerances; all search snippets, not read in full). Shared-edge-first has precedent, but sampling follows the edge's own chord and angle tolerance, not which adjoining surface is more curved.
|
||||
- Meshmixer deletes strips from each object and leaves loops on both sides, then zips: the loop is a joint product (inferred).
|
||||
- Practitioner side (`detail-on-subd-prior-art.md` section 1 @ c513992): booleans leave extra vertices and ragged tris/n-gons along the cut, which practitioners weld and clean by hand. The operator's rule describes what that manual weld converges to; no tool found automates it as stated.
|
||||
|
||||
### What goes wrong: sourced
|
||||
|
||||
- Triangle flips on collapse: Manifold `CollapseEdge` rejects flips and gates on worst angle (edge_op.cpp summary).
|
||||
- Collapse position bounded to [-0.5, 0.5] along the edge, epsilon-scale moves (same).
|
||||
- Loop mismatch when the two sides differ in feature size or shape: Adaptive Mesh Booleans 3.2, 3.4.
|
||||
- Sharp-edge constraints leaving corner holes: same paper, 3.6.1.
|
||||
- Chained booleans accumulate microscopic self-intersections after float snapping: Houdini Boolean page.
|
||||
- Merging across sharp edges loses them unless guarded: Blender Merge by Distance now marks edges to keep sharp edges when custom split normals exist (snippet).
|
||||
- Density near the cut scales with subdivision level, so the flatter surface's contribution count depends on level: `detail-on-subd-prior-art.md` section 1 (inferred there).
|
||||
|
||||
### What goes wrong: inferred, no source either way
|
||||
|
||||
(inferred) Cases the rule leaves undefined and no source addresses: both surfaces comparably curved (ownership ambiguous); saddle intersections where one surface has opposite-sign principal curvatures, so "tighter" depends on direction along versus across the curve; the cut crossing a crease or hard edge of either surface (local curvature unbounded; the stack's one-segment Bevel and Weighted Normal introduce creases); ownership switching along one loop and what happens at the switch vertex; a flatter-surface vertex moved onto the tighter surface's curve changing the flat surface's silhouette or flipping its triangles when the displacement is large relative to local edge length; curvature measured on a tessellated polyline being noisy at sliver density; moved vertices leaving the cage's Subsurf limit surface because the cut is on the evaluated mesh.
|
||||
|
||||
## 3. Implementations we could own
|
||||
|
||||
| Item | Licence | Language | Rust | Does |
|
||||
|---|---|---|---|---|
|
||||
| Manifold (elalish) | Apache-2.0 | C++ | `manifold-csg` safe bindings over the C API (docs.rs lists Apache-2.0 OR MIT for the crate; `manifold3d` re-exports it, v0.3.3 seen; f64 via MeshGL64). README also lists a native Rust port `manifold-rust` with no C++ dependency (not verified further). Other bindings: C, JS/WASM, Python, C# (`ManifoldNET`, external) | boolean with epsilon degenerate cleanup; decimate (public status unconfirmed) |
|
||||
| boolmesh | MPL-2.0 | pure Rust, `glam`, optional `rayon`/`bevy` | native | from-scratch boolean "inspired by Manifold"; v0.2.1 released 2026-10-11 (0.2.0 yanked); input must be manifold; per-corner attribute carry; docs.rs doc coverage 0%; whether it ports `RemoveDegenerates` not stated (https://docs.rs/crate/boolmesh/latest) |
|
||||
| meshoptimizer | MIT | C++ | `meshopt` crate | simplify with vertex locks and attribute weights |
|
||||
| CGAL PMP | GPL v3+ (LGPL parts), commercial available | C++ | none found | corefinement with constrained-edge output; isotropic remeshing with protected constraints |
|
||||
| libigl | MPL-2.0 core, GPL file present; copyleft/cgal boolean tied to CGAL | C++ | none found | Mesh Arrangements boolean |
|
||||
| FastAndRobustMeshArrangements | MIT | C++ header-only | none found | arrangement via indirect predicates, no cleanup |
|
||||
| Interactive and Robust Mesh Booleans | unstated | C++ | none found | per-frame degeneracy removal only |
|
||||
| csgrs | not checked | Rust | native | appeared in search results; not inspected |
|
||||
|
||||
Local: `F:\Projects\GameCult.Geometry.Csg` checkout is on branch main with `docs/research` holding brush-surfaces-and-sdf-bricks and octahedral-impostors notes; the eureka/brush-tree branch and the boolean kernel the crate uses were not inspected this pass.
|
||||
|
||||
## Not resolved in budget
|
||||
|
||||
- Blender Limited Dissolve semantics; Hard Ops / Boxcutter cleanup option names.
|
||||
- Whether Manifold's `Decimate`/`Simplify` is on the public C API and the Rust crate.
|
||||
- Full text of arXiv 2405.12949 and of Hu et al.
|
||||
- CGAL adaptive sizing field and curvature-adaptive isotropic remeshing parameters.
|
||||
- Garland-Heckbert boundary-constraint details and meshoptimizer's quadric basis (recall only).
|
||||
43
docs/research/cage-connectivity-prior-art.md
Normal file
43
docs/research/cage-connectivity-prior-art.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Cage connectivity prior art
|
||||
|
||||
Serves the operator fork: how the connectivity of a coarse Catmull-Clark control cage is generated automatically for brush-tree (invariants cage-is-fitted, cage-bar, creases-declared/closed in `docs/brush-tree-target.md`). Prior-art pass per Eureka SKILL step 2. Facts with evidence pointers; "(inferred)" marks the only non-sourced remarks. Written 2026-10-12 by eyes-bt-cage-connectivity.
|
||||
|
||||
Read depth is stated per row. Full texts read: Hoppe 1994, Marinov & Kobbelt 2004 conference version, Lavoue & Dupont 2009, Ma et al. 2015 (author manuscript), Campen et al. 2012 (preprint), Viertel/Osting/Staten 2019 (grep-level on method and limitations). Abstract or repo README only: QuadWild paper (PDF hosts refused or blocked), Lavoue 2007 CGF, Fitmaps 2011, Ma 2004, Instant Meshes paper, Kloiber 2020 (not found).
|
||||
|
||||
## Headline facts
|
||||
|
||||
1. Every method here that hits a coarse cage obtains connectivity by one of four routes: (a) decimate a dense mesh (QEM or Hoppe edge collapse/swap/split) then optimise positions; (b) cluster/segment the target into regions and link boundary control points (Lavoue); (c) build a coarse quad layout from a cross field (Dual Loops, QuadWild, Viertel); (d) connect feature points (ridges/umbilics) by distance transform (Ma 2015). No source generates connectivity from the fitted limit surface alone; all start from a dense target mesh or point cloud.
|
||||
2. Only route (a) with Hoppe's energy and route (b) with Lavoue declare creases as part of the output. Hoppe and Ma use Loop (triangles); Lavoue uses the Stam-Loop hybrid quad/triangle scheme; Fitmaps and the layout methods yield quads. None of the read papers targets Catmull-Clark creases on a quad-only cage at 200 faces.
|
||||
3. The coarsest figures reported: Lavoue 2009 control meshes of 119 to 187 vertices (200 to 260 faces) for 5K to 15K-vertex scans; Ma 2015 239 to 383 vertices; Hoppe 1994 down to 87 vertices (a swept/NURBS source); Dual Loops 20 to 275 patches (layouts, not cages).
|
||||
4. No read source treats thin wing tips or saddles as a named case. Failure modes recorded below are the ones the authors state.
|
||||
|
||||
## Table
|
||||
|
||||
| Method | Produces | How coarse | Creases | Open implementation, licence | Failure modes (as stated by authors) | Evidence |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Hoppe, DeRose et al. 1994, Piecewise smooth surface reconstruction (SIGGRAPH) | Tagged Loop control mesh (triangles) by 3 phases: surface from points, mesh optimisation, then phase 3 optimising the subdivision control mesh | Table 3: 87 to 656 vertices from 4K to 31K points; Table 2 teapot/knot cases 207 and 363 vertices. Controlled by `crep` penalty on vertex count | Yes, first-class. Energy `E = Edist + crep*m + csharp*e`; edge-tag move toggles an edge in sharp set L; creases, corners, darts classified by sharp-edge count (Sec 3, 4.1). `csharp = crep/5` worked on all examples | Original code not found. Algorithm is public in paper (Sec 4.2.2: random descent over edge collapse, swap, split, tag, with local approximate E). Hoppe 1993 mesh optimisation is the phase-2 ancestor | Local random descent only reaches a local minimum; paper omits the spring energy in phase 3 as unnecessary; times 0.9 to 7.7 hours on 1994 hardware (Table 3); Loop only, not Catmull-Clark; topology fixed (K same type as phase-2 mesh) | https://grail.cs.washington.edu/wp-content/uploads/2015/08/hoppe-1994-pss.pdf Sec 1 phases, 3.1, 4.1, 4.2.2, Tables 2-3 |
|
||||
| Marinov & Kobbelt 2004/2005, Optimization techniques for approximation with subdivision surfaces (SM'04; Graphical Models 67(5)) | Loop control mesh. Initial connectivity = QEM decimation of the dense mesh to a fixed vertex count (Sec 4.1); then L2 fitting with closest-point parameter correction and periodic L-inf phase that inserts vertices where error exceeds tolerance, removes under-determined vertices by half-edge collapse, and flips edges to regularise valence (R(M) metric, Sec 3.1 to 3.3) | Initial 1% to 5% of dense vertex count; finals 4.5K to 18K vertices at 0.03% to 0.05% tolerance (Table 1); coarsest 612 vertices at 0.63% error (Fig 7b) | No. "Full support of piecewise-smooth subdivision surfaces [ZK02] ... pending" (Sec 6) | Authors' implementation not found | Local flipping of the parameter mapping when the initial control mesh is "extremely coarse"; remedy given is more degrees of freedom from the start (Sec 6). Initial mesh must share the dense mesh's topology; valence regularisation is a greedy local minimum | https://www.graphics.rwth-aachen.de/media/papers/otass1.pdf Secs 3, 4.1, 5, 6, Table 1 |
|
||||
| Lavoue & Dupont 2009, Semi-sharp subdivision surface fitting based on feature lines approximation (Computers & Graphics 33); precursor Lavoue, Dupont, Baskurt 2007 CGF 26(1) | Mixed quad/triangle control mesh (Stam-Loop scheme). Pipeline: VSA segmentation + region merging by anisotropy (Sec 4.1, Eq 1), smoothed region-boundary network = feature lines (4.2), subdivision-curve approximation of each boundary gives the only control points (5.1), connectivity inside each region by edge scoring against minimum-curvature direction (5.2, Fig 8), local meshes glued, boundary edges tagged sharp (5.3); then integer semi-sharpness per edge (0 to 3) chosen by curvature-radius match (Sec 6.1) and geometry optimisation, 3 iterations (6.2) | Table 1: 119 to 187 control vertices, 200 to 260 faces for 2.5K to 15K-vertex models; abstract states "less than 200 vertices"; authors say objective is "coarsest possible" | Yes, integer semi-sharp creases per edge, closed boundary loops from segmentation. Weakness stated: boundary edges between patches are sharp, "can produce unpleasant discontinuities for smooth objects" (5.3) | Author code not found; method fully described; Stam-Loop scheme is published. Related: same group 2005 3DIM and 2007 CGF PDFs at https://perso.liris.cnrs.fr/fdupont/publications/ | Does not aim at quads or regular valence: "can possibly have high degree facets or high valence vertices" (Sec 5.3 end). Maximal errors larger than mean "particularly tiny details" because coarse mesh lacks degrees of freedom (Sec 7); fix suggested is local enrichment or finer segmentation. Threshold for region merging "hard to find a priori" (4.1) | https://perso.liris.cnrs.fr/fdupont/publications/2009-CAG-Lavoue-Dupont.pdf Secs 3, 4.1, 5.1 to 5.3, 6, 7, Tables 1-2; 2007 abstract https://diglib.eg.org/handle/10.2312/CGF.v26i1pp001-014 (abstract only) |
|
||||
| Ma, Keates, Jiang, Kosinka 2015, Subdivision surface fitting to a dense mesh using ridges and umbilics (CAGD 32) | Loop control mesh whose connectivity joins umbilics and ridge-crossings (feature points) by grass-fire distance transform on the dense mesh with a Hausdorff+curvature-vector metric (Secs 3 to 4), then ridge-colour-matching edge flips; control points by solving limit-point system | Rocker arm 239 vertices (from 10,044); bunny 381 (from 35,947); Igea 383 (from 8,268); a 254-vertex car-type case (Sec 5) | No dedicated crease mechanism: "To fit extreme explicit sharp features, some special subdivision rules would need to be used" (Sec 5, cites Lavoue 2009). Feature filtering needed on planes/cylinders where every point is a ridge point (3.4) | Code not found; manuscript CC BY-NC-ND https://gala.gre.ac.uk/id/eprint/14421/ | Accuracy depends on position and number of filtered umbilics and ridges; "some non-regular vertices" remain, post-process for valence suggested (conclusion). Related-work section (pp. 1 to 2) lists simplification/parametrisation/clustering/feature-based families and says typical features "may not align to salient edges" | gala.gre.ac.uk manuscript PDF, Secs 1, 3.3 to 3.4, 5, 6 |
|
||||
| Panozzo, Puppo, Tarini, Pietroni, Cignoni 2011, Automatic construction of quad-based subdivision surfaces using fitmaps (IEEE TVCG) | Catmull-Clark control mesh, "coarse and adaptive", by progressive simplification of a fine quad mesh with local operators guided by fitmaps | Not read (abstract only) | Not stated in abstract | Code not located; Tarini et al. 2010 quad simplification paper says implementation "will be available in MeshLab" (future tense; shipping not verified) | Not read | http://vcg.isti.cnr.it/publication/2011/PPTPC11/ (abstract); https://vcg.isti.cnr.it/Publications/2010/TPCPP10 |
|
||||
| Ma et al. 2004 (CAD 36(6) 525-536), Ma & Zhao 2002 | Subdivision fitting by simplification that tags vertex/edge types to keep sharp features (per Ma 2015 related work); Ma & Zhao 2002 uses an interactively defined topological model with Catmull-Clark and linear least squares | Not read | Sharp features preserved during simplification (per secondary description only) | Not located | Not read; Ma 2015 says interactive topology definition suits "simple topology" | Secondary: Ma 2015 manuscript p. 2; citation https://dblp.dagstuhl.de/rec/conf/gmp/MaMTP02.html |
|
||||
| Campen, Bommes, Kobbelt 2012, Dual Loops Meshing (SIGGRAPH / TOG 31(4)) | All-quad coarse layout (base complex) built in the dual: curvature-guided crossing loops chosen greedily to separate cross-field singularities, region valence tied to total curvature (Sec 4, Thm 4.1/4.2), dualised to patches (5.3); cross-field singularity index decides irregular node valence | Table in Sec 7: 20 to 275 patches (e.g. FERTILITY 20 to 76, ROCKERARM, LEVER 83 singularities); "a few seconds to a few minutes" on 20K to 122K faces | Feature curves enter as directional constraints in the cross field and as lifted curves loops may not cross, so arcs align to them (Sec 6). "Complexity of the layout is thus predetermined to a degree" by the feature network. Only dihedral-angle sharp edges tested (JOINT, LEVER) | Original code not found in this pass. Related open code: NSchertler/GeneralizedMotorcycleGraph (Schertler et al. 2018; CMake, optional Gurobi; licence not read) | Greedy loop choice, not global optimum; geometric fidelity only indirectly controlled; "narrow patches" break the initial embedding and need a domain-interpolation workaround (Sec 5.3); irregular nodes may have very high or low valence (Sec 4); manual layouts still simpler because professionals place tactical irregular nodes (Sec 7.1) | https://graphics.rwth-aachen.de/publication/3/campen_2012_sg_preprint1.pdf Secs 2 to 7.1 |
|
||||
| Viertel, Osting, Staten 2019, Coarse quad layouts through robust simplification of cross field separatrix partitions (arXiv 1905.09097) | Coarse quad layout: trace cross field separatrices, then collapse thinnest chords while keeping T-junction constraints | Tested on 100 objects; coarser than naive tracing (counts not extracted) | Not extracted | Code not located | States that naive tracing yields "small regions and limit cycles" and thin regions near singularities; chord collapse is the remedy; collapsing is limited by conditions that guarantee valid layouts | https://arxiv.org/pdf/1905.09097 abstract and Secs 1, chord-collapse section |
|
||||
| QuadWild, Pietroni, Nuvoli, Alderighi, Cignoni, Tarini 2021, Reliable feature-line driven quad-remeshing (SIGGRAPH / TOG) | Quad mesh from a cross field aligned to feature lines, via a coarse patch layout in which every feature line is a patch boundary; non-quad patches and T-junctions allowed in the layout so long as each patch admits a pure-quad tessellation (abstract) | Output size is a scale parameter (`scaleFact`), not a face target; repo exposes `alpha` (regularity vs isometry; near 1 inserts more singularities) and `sharp_feature_thr` dihedral threshold or a `.sharp` file. Layout patches can be coarse but the output is a dense-ish quad mesh | Sharp features are patch boundaries; border edges treated as sharp by default | https://github.com/nicopietroni/quadwild: GPL-3.0, C++/Qt, needs Boost and Gurobi; 245 commits, 22 open issues, README says results may differ from paper. Fork https://github.com/cgg-bern/quadwild-bimdf: GPL-3.0, CMake, Bi-MDF solver, Gurobi optional, 308 commits | Authors report under 0.5% failure on Thingi10K. A later paper (NeurCross, arXiv 2405.13745) says patching creates singularities at patch junctions and malformed quads on free-form models. Third-party note: face count approximate. Paper PDF not read | abstract via https://vcg.isti.cnr.it/publication/2021/PNACT21; READMEs as listed |
|
||||
| Instant Meshes, Jakob, Tarini, Panozzo, Sorkine-Hornung 2015 | Isotropic tri or quad-dominant remesh from an orientation/position field at a target vertex count | Target vertex count; used in earlier brush-tree runs (mesh-probe runs 3 to 7) | Snaps to sharp features per abstract; crease options not verified from source | https://github.com/wjakob/instant-meshes; licence BSD-3-Clause per FreeBSD port listing (packager, unverified against upstream LICENSE) | Paper limitations section not read; local runs in this repo are the evidence | https://rgl.epfl.ch/publications/Jakob2015Instant |
|
||||
| Tarini et al. 2010, Practical quad mesh simplification (Eurographics CGF 29(2)) and Daniels et al. 2008 quad simplification | Quad-preserving local simplification operators | Not read | Not read | MeshLab implementation promised in text; unverified | Viertel 2019 cites that collapse-the-chords style does not generalise to closed surfaces without extra machinery | https://vcg.isti.cnr.it/Publications/2010/TPCPP10 (abstract) |
|
||||
| Kloiber et al. 2020 | Not found by search under that title or authors | n/a | n/a | n/a | n/a | WebSearch returned only fitmaps, Peters 2000, tree-structure papers |
|
||||
|
||||
## Notes by question
|
||||
|
||||
Creases and closed loops. Lavoue's boundary network is closed by construction (each region is bounded by a closed cycle of control polygons, Sec 5.2) and tags every inter-region edge sharp, with semi-sharp relaxation afterward. Hoppe leaves crease placement to a discrete edge-tag search with a per-edge penalty. Dual Loops and QuadWild take feature curves as input and force the layout to follow them; neither chooses which curves are creases.
|
||||
|
||||
Irregular vertex count. Marinov regularises valence by greedy edge flips on R(M) (Sec 3.3) and says the result is a local minimum. Lavoue explicitly accepts high valence and n-gons. Dual Loops ties irregular nodes to cross-field singularities and total curvature and reports node counts per model (Sec 7 table). Ma 2015 leaves non-regular vertices to a possible post-process.
|
||||
|
||||
Thin wings and saddles. No source above names them. Stated adjacent hazards: narrow patches (Campen Sec 5.3), thin regions and limit cycles near singularities (Viertel abstract and Sec 1), coarse-mesh lack of degrees of freedom for "tiny details" and larger maximal error (Lavoue Sec 7), and parameter-mapping flips at extremely coarse starting meshes (Marinov Sec 6). Ma 2015 notes planes and cylinders make every point a ridge point.
|
||||
|
||||
Bar match (inferred). Against the cage-bar (at most about 200 faces, at most 24% triangles, at most 62 irregular vertices): only Lavoue's published outputs are at that count (200 to 260 triangle-or-mixed faces, vertex counts 119 to 187), and his method states it does not optimise for quads or valence. No read source reports an irregular-vertex count for its control mesh; Dual Loops reports singularities per layout (16 to 83), which counts the irregular nodes of the layout.
|
||||
|
||||
## Not verified in this pass
|
||||
|
||||
QuadWild paper internals (patch count, limitations); Instant Meshes licence and limitations; Fitmaps method detail and creases; Ma 2004 and Lavoue 2007 internals; licence of GeneralizedMotorcycleGraph; Kloiber 2020 existence; whether any source reports behaviour on thin wing-like features. PDFs from air.unimi.it, vcg-legacy.isti.cnr.it, dl.acm.org and diglib.eg.org returned 403 or refused connection from this host.
|
||||
63
docs/research/detail-on-subd-prior-art.md
Normal file
63
docs/research/detail-on-subd-prior-art.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Detail on a subdivision hull: prior art
|
||||
|
||||
Fork: brush-tree, detail layer. How does hard-surface detail (canopy, vents, intakes, gun ports, panel insets) get onto a smooth subdivision hull without moving the primary-form cage? Options floated: boolean into the evaluated mesh; inset/support loops in the cage; a separate interpenetrating (floating) piece.
|
||||
|
||||
Provenance: Eyes pass, 2026-10-12, agent `eyes-bt-detail-layer`. Facts carry sources; "(inferred)" marks inference; "(unsourced)" marks recall not checked against a page. Not read: `docs/brush-tree-target.md` at a7879dad (the commit did not resolve in the CultLib object store, so invariant wording is from the brief only).
|
||||
|
||||
Operator workflow (verbatim, 2026-10-12): "expressing the form elegantly with as few control points as possible, then I go and start cutting into it for all the hard surface details and bits that interrupt the idealized form like those gun ports". The detail layer in her practice is subtractive cuts into the subdivided form.
|
||||
|
||||
## 1. Cut into the evaluated mesh (boolean after subdivision)
|
||||
|
||||
- Blender modifiers run top to bottom, each taking the output above; order decides which operator sees which geometry. Boolean below Bevel means bevel acts on the clean base first; boolean above Bevel makes bevel work on boolean-generated geometry and can narrow it ([stack order](https://steveyang.readthedocs.io/Modeling/Non-destructive/ND-Beginning/Modifier_Stack_Order/), [cgcookie thread](https://cgcookie.com/community/9780-change-to-modifier-stack-changes-bevel-width)). Subdivision Surface docs: stack position "significantly affects the result" ([Blender 4.2 manual](https://docs.blender.org/manual/en/4.2/modeling/modifiers/generate/subdivision_surface.html)).
|
||||
- Blender Boolean modifier: Exact solver handles overlap and self-intersection, slow; Fast does not handle overlapping geometry; only manifold inputs are guaranteed correct ([manual](https://docs.blender.org/manual/en/4.2/modeling/modifiers/generate/booleans.html)).
|
||||
- Regenerable: yes. Cutter list plus cage are the inputs and the stack re-runs. Cage independence: full, cutters never touch cage vertices. Triangles: dense, because the cut runs on the subdivided mesh, scaling with subdivision level (inferred). Practitioners report booleans leave extra vertices and ragged n-gons/triangles along the cut, which they weld and clean ([Blender Artists](https://blenderartists.org/t/how-to-create-this-kind-of-side-cut-in-hard-surface-modeling-with-good-topology/1482837), [Blender Artists](https://blenderartists.org/t/questions-regarding-hard-surface-modeling/1207286)).
|
||||
- Shading: the cut wall meets the hull at a hard crease; the practitioner fix is a bevel/chamfer on the cut edges ([same thread](https://blenderartists.org/t/how-to-create-this-kind-of-side-cut-in-hard-surface-modeling-with-good-topology/1482837)). Weighted Normal rewrites custom normals by corner angle/face area; Keep Sharp preserves sharp edges; Face Influence uses per-face strength set by Set Strength or the Bevel modifier's Set Strength mode, so bevel faces drive the result ([manual 4.2](https://docs.blender.org/manual/fi/4.2/modeling/modifiers/normals/weighted_normal.html)). One add-on doc says at least a one-step bevel matters for good weighted-normal shading ([page](https://superhivemarket.com/products/shadingmod)). That matches the one-segment-bevel plus weighted-normals shippable stack.
|
||||
- Tooling precedent: Hard Ops cSharpen applies booleans by default (to keep cutting) but keeps bevel modifiers live and never applies bevels; default 3 segments, 0.7 profile, weight-limited; WN and Harden Normals are optional toggles ([manual](https://hardops-manual.readthedocs.io/en/latest/csharpen/)). Another add-on says it re-establishes non-destructive Bevel and Weighted Normal finishing layers after apply ([Arcane Rifter](https://superhivemarket.com/products/arcane-rifter)). These tools bake cuts into the mesh, so they are not regenerable; only a stack that never applies is.
|
||||
- No source found describing "boolean after Subsurf" as a deliberate standard modifier order. The sourced practice cuts the cage first (section 2); the usual finishing order Boolean, Bevel, Weighted Normal, Subsurf last is unsourced recall.
|
||||
|
||||
## 2. Cut the cage with support loops (subd modelling)
|
||||
|
||||
- Practitioners: after a boolean cut and subdivision "everything falls apart"; the fix is supporting loops around the cut, clean and weld verts, then chamfer/bevel the cut edges so it stays sharp after smoothing ([Blender Artists](https://blenderartists.org/t/how-to-create-this-kind-of-side-cut-in-hard-surface-modeling-with-good-topology/1482837)). Artifacts come from too few segments for the curvature; abrupt loop routing causes smoothing artifacts; carrying loops across the surface smooths more consistently ([Blender Artists](https://blenderartists.org/t/questions-regarding-hard-surface-modeling/1207286)).
|
||||
- Game-art counterpoint: some artists use bevel-first low poly instead of subd because a subdivided hull bakes less cleanly (bevel widens, wavy artifact, extra supports did not fix it) ([CG Cookie](https://cgcookie.com/community/9686-why-not-use-subdivision-surface-modifier-on-this-weapon), [polycount](https://polycount.com/discussion/comment/2573659)). Others hold that subd with support loops remains best for normal-mapped hard surface ([polycount](https://polycount.com/discussion/comment/2573688)).
|
||||
- Regenerable: only if loop insertion is itself generated from the tree. Cage independence: lost by construction, since loops and cut-ring vertices are cage vertices and the cage grows with every detail. The "detail never moves the cage" invariant excludes this option (inferred).
|
||||
- Semi-sharp creases are the in-cage alternative to extra loops. OpenSubdiv: per-edge sharpness, 10 or more is infinite, Chaikin rule propagates sharpness; infinite creases are tangent discontinuities where normal displacement can tear ([OpenSubdiv](https://graphics.pixar.com/opensubdiv/docs_3x_alpha/subdivision_surfaces.html)). Blender: weighted edge crease, Boundary Smooth, Use Limit Surface and Quality ([manual](https://docs.blender.org/manual/en/4.2/modeling/modifiers/generate/subdivision_surface.html)). Creases mark edges already in the cage, so a cut still needs cage edges where it falls (inferred).
|
||||
- OpenSubdiv feature-adaptive refinement clusters subdivision near creases, extraordinary points, boundaries, corners ([release info](https://graphics.pixar.com/opensubdiv/release_info.html)). Hierarchical edits (override values at any refinement level, localized detail without specifying the whole surface) were removed in OpenSubdiv 3.0 ([hedits](https://graphics.pixar.com/opensubdiv/docs/hedits.html)).
|
||||
|
||||
## 3. Boolean then retopo
|
||||
|
||||
- Common practice: model the high poly, copy, strip support loops and subdivisions that do not affect silhouette to a low poly, bake ([polycount](https://polycount.com/discussion/comment/2573659)). Retopo output is a new mesh with its own topology: not regenerable from a tree, and cage-independent only if the retopo output is itself the cage. No source found automating retopo from a CSG tree (gap).
|
||||
|
||||
## 4. Floating geometry / mid-poly (separate interpenetrating pieces)
|
||||
|
||||
- Mid-poly workflow, attributed to Star Citizen and Alien: Isolation by one tutorial: face-weighted normals, high-poly detail in normal maps rather than geometry ([80.lv](https://80.lv/articles/how-to-create-a-detailed-large-scale-scene-with-rich-props-assets-in-unreal-engine/)).
|
||||
- A polycount developer describes tri-planar local-space decals that modify only normals in targeted areas, usable for panel lines, "but sometimes it's worth more to cut that into the topology" ([polycount](https://polycount.com/discussion/comment/2333210)). Baked bevels in normal maps are cheap but resolution-dependent; the alternative cited is real bevels plus adjusted vertex normals ([polycount](https://polycount.com/discussion/comment/2333606)).
|
||||
- Regenerable: yes, pieces are tree outputs. Cage independence: full. Triangles: each piece adds its own, including hidden interior geometry; the hull is not cut. Shading: the intersection seam is a hard visual line unless a normal bake or bevel masks it. Floaters rely on a bake, which this campaign does not have (inferred; no source on floaters without a bake).
|
||||
|
||||
## 5. NURBS / CAD closed prior art
|
||||
|
||||
- Plasticity: pure NURBS, booleans advertised as watertight, fillets adapting to edge conditions, direct-edit with no history tree, meshes only on export (OBJ, STEP, IGES); no subdivision-surface mention found ([DigitalProduction](https://digitalproduction.com/2023/10/04/plasticity-3d-cad-for-artists/), [Plasticity](https://plasticity.xyz/)). Its boolean-then-fillet works on exact surfaces and tessellates afterwards, not on a subdivided mesh (inferred).
|
||||
|
||||
## 6. Displacement and detail transfer on subdivision surfaces
|
||||
|
||||
- Displaced Subdivision Surfaces (Lee, Moreton, Hoppe, SIGGRAPH 2000): scalar displacement over a smooth subdivision domain surface in one framework; the hard part is finding a simple domain surface; the project page does not discuss overhangs, holes or hard creases ([Hoppe](https://hhoppe.com/proj/dss/)). A scalar over a surface cannot represent undercuts or cut-through (inferred).
|
||||
- Kobbelt, Campagna, Vorsatz, Seidel, Interactive Multi-Resolution Modeling on Arbitrary Meshes (SIGGRAPH 1998): detail coefficients coded in local frames over a decimation hierarchy for detail-preserving edits ([RWTH](https://www.graphics.rwth-aachen.de/publication/03156/)). Frame definition not read.
|
||||
- Biermann, Martin, Bernardini, Zorin, Cut-and-paste editing of multiresolution surfaces (SIGGRAPH 2002): split source and target into base and detail, parameterize bases to a common domain, transfer detail offsets ([SIGGRAPH history](https://history.siggraph.org/learning/cut-and-paste-editing-of-multiresolution-surfaces-by-biermann-martin-bernardini-and-zorin/)). Same group, Approximate Boolean Operations on Free-Form Solids (Biermann, Kristjansson, Zorin, SIGGRAPH 2001), is cited as related; contents not read.
|
||||
- All three keep the base fixed and put detail in coefficients on top, which is the two-layer invariant in academic form. None addresses hard-edged cut-through features with bevels (inferred). Multires (sculpt subsurf) versus plain subsurf with runtime GPU subdivision instead of baked displacement is noted in a forum answer ([Blender Artists](https://blenderartists.org/t/difference-between-subdivision-surface-and-multiresolution/1277968)).
|
||||
|
||||
## Summary table
|
||||
|
||||
| Approach | Regenerable from tree | Cage independent | Triangles | Shading under 1-seg bevel + WN |
|
||||
|---|---|---|---|---|
|
||||
| Boolean on evaluated mesh, bevel + WN live | yes | yes | high, ragged along cuts | works if bevel sits below boolean and WN last; ragged cut tris need cleanup |
|
||||
| Support loops in cage | only if loop insertion is generated | no, cage grows | cage low, subdivided high | well behaved; artifact-prone with loop routing |
|
||||
| Boolean then retopo | no | only if retopo is the cage | controlled | controlled, bake needed |
|
||||
| Floaters / mid-poly | yes | yes | adds interior tris | seam visible without bake |
|
||||
| Displacement / detail transfer | yes | yes | displaced mesh resolution | no hard cut-through; bevel not in model |
|
||||
|
||||
## Gaps
|
||||
|
||||
- `brush-tree-target.md` unread (commit not resolvable locally).
|
||||
- Blender Bevel (Harden Normals, Set Strength) and Weighted Normal read via search summaries only; raw manual RST URLs 404 and the 4.2 pages returned navigation text first.
|
||||
- No primary source for Star Citizen, Sea of Thieves, or CDPR pipelines; mid-poly claim rests on one tutorial.
|
||||
- No source on boolean-after-subdivision as a named deliberate workflow with documented shading outcome.
|
||||
- OpenSubdiv limit-evaluation pages not reached; Support Loop Pro behaviour not read.
|
||||
28
docs/research/dexter-quiet-cage/README.md
Normal file
28
docs/research/dexter-quiet-cage/README.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Dexter Quiet: the operator's reference cage (2026-10-12)
|
||||
|
||||
The reference brush-tree target r2 is measured against (ruling brush-tree:ruling:pursue-sdf-composition-remeshing:
|
||||
the operator rebuilds Dexter Quiet by hand as the reference cage).
|
||||
|
||||
Source: `Asset Sources/Ships.blend` in Aetheria, object `Dexter Quiet Cage` (collection Quiet), saved 2026-10-12 17:31.
|
||||
Exported from a copy by `export.py` (Blender, background mode). Files:
|
||||
|
||||
- `cage_raw.obj`: the cage as authored, half a hull: 29 vertices, 25 faces, 9 edges creased at 1.0 (`cage_info.txt`).
|
||||
- `cage_eval.obj`: the evaluated stack: Mirror (merge on) -> Subsurf level 3 (render 2, limit surface on) ->
|
||||
Bevel (0.001, 1 segment, weight-limited) -> Weighted Normal. 3,154 vertices, 3,152 faces.
|
||||
- The scanned original `Dexter Quiet` (4,832 vertices, 5,600 faces) is not copied here; it stays in Ships.blend.
|
||||
|
||||
Fidelity of the hand cage to the scan (vertex-to-nearest-vertex, an upper bound on surface distance; hull diagonal 1.492):
|
||||
cage->scan mean 0.88% p95 2.04% max 3.11% of the diagonal; scan->cage mean 1.29% p95 4.12% max 6.03%.
|
||||
That spread mixes two things and is NOT yet a fidelity bar: the cage captures the primary form only (silhouette and main
|
||||
curves); the canopy, vents, intakes and nose details of the scan are absent by design and belong to a detail layer above
|
||||
the cage. A bar must be measured on primary form (smoothed scan, masked detail, or silhouettes), not raw vertices.
|
||||
|
||||
Operator, 2026-10-12, with the screenshots here (cage-limit-*.png beside scan-*.png): "Note that I'm specifically trying
|
||||
to be faithful here to the curves of the original, and my control points can go to unexpected places to achieve that.
|
||||
The cage controls the surface, the cage is not the surface." Producing such a cage is subdivision-surface fitting
|
||||
(control points off the surface, solved so the limit surface fits), not remeshing.
|
||||
|
||||
Operator, 2026-10-12: "I'm afraid I had to take a bit of artistic liberty to get the cage to behave, mostly because
|
||||
of how creases work in Blender (generates horrible geometry if the crease path isn't closed)". Body fact for the
|
||||
remesher: crease chains in the cage are closed loops; an open crease path ending inside a face region produces
|
||||
bad subdivision geometry in Blender's Catmull-Clark (OpenSubdiv) evaluation, so a generated cage must close its creases.
|
||||
BIN
docs/research/dexter-quiet-cage/cage-limit-side.png
Normal file
BIN
docs/research/dexter-quiet-cage/cage-limit-side.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
docs/research/dexter-quiet-cage/cage-limit-top.png
Normal file
BIN
docs/research/dexter-quiet-cage/cage-limit-top.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
6306
docs/research/dexter-quiet-cage/cage_eval.obj
Normal file
6306
docs/research/dexter-quiet-cage/cage_eval.obj
Normal file
File diff suppressed because it is too large
Load diff
4
docs/research/dexter-quiet-cage/cage_info.txt
Normal file
4
docs/research/dexter-quiet-cage/cage_info.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[('cage_raw', (29, 25)), ('cage_limit_eval', (3154, 3152)), ('dexter_ref', (4832, 5600))]
|
||||
creases=[(1, 3, 1.0), (5, 1, 1.0), (17, 11, 1.0), (3, 17, 1.0), (13, 16, 1.0), (24, 22, 1.0), (25, 16, 1.0), (13, 11, 1.0), (24, 25, 1.0)]
|
||||
mods=[('MIRROR', {'use_clip': False, 'use_mirror_merge': True}), ('SUBSURF', {'levels': 3, 'render_levels': 2, 'use_limit_surface': True}), ('BEVEL', {'width': 0.0010000001639127731, 'segments': 1, 'limit_method': 'WEIGHT'}), ('WEIGHTED_NORMAL', {})]
|
||||
cage_dims=(0.9702155590057373, 1.0190552473068237, 0.3775596618652344) ref_dims=(0.9811705946922302, 1.0477101802825928, 0.407174676656723)
|
||||
54
docs/research/dexter-quiet-cage/cage_raw.obj
Normal file
54
docs/research/dexter-quiet-cage/cage_raw.obj
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
v -0.050183 -0.515158 0.120381
|
||||
v -0.050183 -0.502594 0.165733
|
||||
v -0.066373 -0.290093 0.034410
|
||||
v -0.066373 -0.279099 0.154038
|
||||
v -0.000000 -0.521785 0.077995
|
||||
v -0.000000 -0.495967 0.196439
|
||||
v 0.000000 -0.257172 0.011836
|
||||
v 0.000000 -0.079878 0.194280
|
||||
v -0.085456 0.296420 0.036890
|
||||
v 0.000000 0.264006 -0.120438
|
||||
v -0.000000 0.324338 0.095861
|
||||
v -0.532850 -0.332750 0.201392
|
||||
v -0.000000 0.083429 0.170193
|
||||
v -0.445757 -0.036017 0.098265
|
||||
v -0.069995 0.072662 0.125021
|
||||
v -0.305911 -0.163824 0.119325
|
||||
v -0.219219 0.256351 -0.032856
|
||||
v -0.271429 -0.083411 0.118972
|
||||
v -0.071629 0.154301 -0.071808
|
||||
v 0.000000 0.096312 -0.125795
|
||||
v -0.230102 0.060001 0.075233
|
||||
v -0.231539 0.092497 -0.020192
|
||||
v 0.002669 0.513952 -0.201831
|
||||
v 0.002669 0.519207 -0.172940
|
||||
v -0.010721 0.515629 -0.180240
|
||||
v -0.106624 0.345361 -0.096021
|
||||
v -0.041564 0.406965 -0.070340
|
||||
v 0.001707 0.450794 -0.082077
|
||||
v 0.001374 0.391670 -0.173090
|
||||
f 1 2 4 3
|
||||
f 15 13 11 9
|
||||
f 5 6 2 1
|
||||
f 3 7 5 1
|
||||
f 8 4 2 6
|
||||
f 22 16 12 14
|
||||
f 20 19 10
|
||||
f 10 19 22 17
|
||||
f 16 18 12
|
||||
f 21 17 14
|
||||
f 15 9 17 21
|
||||
f 3 4 18 16
|
||||
f 19 3 16 22
|
||||
f 4 15 21 18
|
||||
f 18 21 14 12
|
||||
f 7 3 19 20
|
||||
f 17 22 14
|
||||
f 4 8 13 15
|
||||
f 24 23 25
|
||||
f 26 27 25
|
||||
f 9 11 28 27
|
||||
f 29 26 25 23
|
||||
f 10 17 26 29
|
||||
f 17 9 27 26
|
||||
f 27 28 24 25
|
||||
20
docs/research/dexter-quiet-cage/export.py
Normal file
20
docs/research/dexter-quiet-cage/export.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import bpy, sys, bmesh
|
||||
d=sys.argv[-1]
|
||||
dg=bpy.context.evaluated_depsgraph_get()
|
||||
cage=bpy.data.objects['Dexter Quiet Cage']; ref=bpy.data.objects['Dexter Quiet']
|
||||
def dump(o, ev, path):
|
||||
src=o.evaluated_get(dg) if ev else o
|
||||
me=src.to_mesh()
|
||||
with open(path,'w') as f:
|
||||
for v in me.vertices:
|
||||
p=o.matrix_world @ v.co; f.write(f"v {p.x:.6f} {p.y:.6f} {p.z:.6f}\n")
|
||||
for poly in me.polygons: f.write("f "+" ".join(str(i+1) for i in poly.vertices)+"\n")
|
||||
n=(len(me.vertices),len(me.polygons)); src.to_mesh_clear(); return n
|
||||
info=[]
|
||||
info.append(("cage_raw",dump(cage,False,d+"/cage_raw.obj")))
|
||||
info.append(("cage_limit_eval",dump(cage,True,d+"/cage_eval.obj")))
|
||||
info.append(("dexter_ref",dump(ref,False,d+"/dexter_ref.obj")))
|
||||
me=cage.data; a=me.attributes.get('crease_edge')
|
||||
cr=[(e.vertices[0],e.vertices[1],round(a.data[e.index].value,3)) for e in me.edges if a and a.data[e.index].value>0]
|
||||
mods=[(m.type,{k:getattr(m,k) for k in ('levels','render_levels','use_limit_surface','width','segments','limit_method','use_clip','use_mirror_merge') if hasattr(m,k)}) for m in cage.modifiers]
|
||||
open(d+"/cage_info.txt","w").write(f"{info}\ncreases={cr}\nmods={mods}\ncage_dims={tuple(cage.dimensions)} ref_dims={tuple(ref.dimensions)}\n")
|
||||
BIN
docs/research/dexter-quiet-cage/scan-side.png
Normal file
BIN
docs/research/dexter-quiet-cage/scan-side.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
BIN
docs/research/dexter-quiet-cage/scan-top.png
Normal file
BIN
docs/research/dexter-quiet-cage/scan-top.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
75
docs/research/greebles-prior-art.md
Normal file
75
docs/research/greebles-prior-art.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<!-- Research kept for the operator question "How should we do greebles?" (brush-tree, 2026-10-13). Eyes pass eyes-greebles; facts with sources, inferences marked. Serves the greebles fork, not yet a ruling. -->
|
||||
|
||||
# Greebles prior art (Eyes facts file, 2026-10-13)
|
||||
|
||||
Already covered, not repeated: eureka/brush-tree `docs/research/detail-on-subd-prior-art.md` section 4 (floaters/mid-poly: one tutorial attributes mid-poly to Star Citizen/Alien Isolation via 80.lv; polycount tri-planar normal-only decals; floaters need a bake; summary table), `boolean-cleanup-prior-art.md`, `nondestructive-weld-in-blender.md`, `brush-surfaces-and-sdf-bricks-prior-art.md`. Its own "Gaps" list: no primary source for Star Citizen pipeline. Also in F:\Projects: `aetheria-hull-materials-prior-art.md` line 65 (Insomniac Ultimate Trim, trim bevel normals). `brush-tree-target.md` line 28: ruling `aetheria-release:ruling:panel-lines-are-geometry`.
|
||||
Search tool returned snippet summaries; items marked (search-summary) were not read at the primary page. 403s: polycount.com direct, tripo3d blog.
|
||||
|
||||
## 1. Production approaches
|
||||
|
||||
Booleaned / welded into hull
|
||||
- Community workflow order: blockout, primary volumes, panel breaks, boolean cuts, bevel after boolean in stack, weighted normals, then greebles; booleans live until export prep. https://www.claudepluginhub.com/skills/arjun988-blender-skills/hard-surface (single community-authored page; search-summary). Same page: bevel tiers primary 0.005-0.02 m/2-3 seg, panel lines 0.001-0.003 m/1-2 seg, micro 0.0005-0.001 m/1 seg; "instance greebles, keep panels as separate pieces".
|
||||
- Scifi-Meshes tutorial: Hair-particle-on-collection scatter of ~2000 greeble objects, Convert to real objects, optionally BoolTool Union to one object "if you want geometry bevels to look clean". https://forums.scifi-meshes.com/discussion/10000616/quick-greebling-in-blender (read). No poly counts given.
|
||||
- Hard Ops/Boxcutter and KIT OPS: live boolean cut/union/intersect inserts, "bake to mesh only when you commit"; KIT OPS Pro authors cutter inserts; free tier places them. https://superhivemarket.com/products/kit-ops-3-pro , https://blenderbros.com/bf-ultimate-guide (search-summary). MESHmachine cleans boolean results via custom normal transfer (Blender Bros bundle page, search-summary).
|
||||
|
||||
Floaters / floating geometry
|
||||
- Polycount (Elite-ship thread, 403 on direct fetch; via search summary): ships "not baked from a single high-poly model... far too big to be custom baked and still retain decent texture resolution"; instead edge loops + custom vertex normals for the smooth high-poly look and "floating decals for livery and numbering". Poster's employer not confirmed. https://polycount.com/discussion/comment/2314421/
|
||||
- Polycount commenter: keep surface flat and build detail above so it looks indented; good for normal bake, worse where silhouette/shading matter in render. (search-summary) https://polycount.com/discussion/comment/1607703
|
||||
- Costs of normal-mapped floaters reported: no shadows from bump/normal maps (https://blenderartists.org/t/how-to-add-greeble-to-a-spaceship/455526); greebles baked as normal map distort at grazing angles (POV-Ray news post, https://news.povray.org/pan.2006.06.17.04.52.39.840345%40yahoo.co.uk) (both search-summary).
|
||||
|
||||
Decals / trim sheets
|
||||
- DECALmachine docs: decals are mostly single-polygon meshes; 4 types Simple/Subset/Panel/Info; Simple/Subset/Panel are normal-mapped with a parallax shader effect, works better going into surface than above, one depth level better than several; no UVs needed on host; clipping/flicker if too close to surface (raise via Displace). https://machin3.io/DECALmachine/docs/assets/ , https://machin3.io/DECALmachine (search-summary).
|
||||
- DECALmachine atlas docs (read): atlas = textures + data.json of positions; atlas and trim sheet share the format; using an atlas rewrites decal UVs so all decals share one Atlas Material (reversible). "HDRP supports deferred rendering... URP supports only forward rendering, and so no decal shaders"; in URP decals are opaque and cannot blend; docs recommend trim sheets instead. Unity parallax matching needs Amplify Shader Editor. Unreal mesh decals cannot be moved in-engine without re-export. https://machin3.io/DECALmachine/docs/export_atlas/ . (Built-in pipeline is not mentioned on that page.)
|
||||
- Trim sheet pack replicating a Destiny 2 Thrasher: 4 trim sheets x 3 variations (primary paint, secondary paint, metal), material blend masks, decals. https://www.artstation.com/marketplace/p/zAwqK/high-sci-fi-trimsheet-floater-set (search-summary).
|
||||
- Insomniac Ultimate Trim: see aetheria-hull-materials-prior-art.md:65.
|
||||
|
||||
Per-title figures found
|
||||
- EVE (CCP dev post, undated): high-poly source ~2M+ polys processed in Maya to normal maps for in-game low poly averaging ~5k polys for cruisers; AO baked into diffuse/spec. https://www.eveonline.com/news/view/odd-man-out (search-summary). Forum guess (unverified): 4-8k tris cruisers/capitals, 1-3k frigates, one 1024/2048 sheet. https://forums.sorcererking.com/175327/reply/1585554
|
||||
- Third-party Elite-style ship rebuild on polycount: 427k tris/27 materials reduced to 139k/8 (later 184k/8: 4 hull materials, 4 decal materials); greyscale inputs tinted in engine, 3 tints/material; 2048 normal, 2048 multimap, 512 colour variation per metal/paint; "not clearly an Elite ship". https://polycount.com/discussion/comment/2315769 (search-summary).
|
||||
- Fan Anvil Pathfinder (Star Citizen style): 4096 ship textures, 1024 alpha cards, 1024 detail cards; not CIG pipeline. https://three9.artstation.com/projects/d0nOPK (search-summary).
|
||||
- No CIG, Frontier, Homeworld (Gearbox) or No Man's Sky ship-detail pipeline source was found. Homeworld Remastered: only a Steam modder remark that remastered ships are split into more parts than classic; fleets "hundreds" of craft (futurefive.com.au/story/homeworld-return). NMS: ships are a fixed pool of parts randomly assembled (Steam player post / fan wiki, not developer); GDC talk gdcvault 1024265 not read.
|
||||
|
||||
Camera distance
|
||||
- Polycount commenters: normal maps common in 1st/3rd person because camera is close, "isometric and top-down usually don't"; a top-down ship dev kept "fat bevels and large details" because units are small on screen; a top-down strategy game pre-rendered very-high-poly ships to sprites (https://polycount.com/discussion/comment/2603097 family; https://www.positech.co.uk/cliffsblog/?p=267; search-summary). Oolite modder: normal maps affect lighting even at distance (https://bb.oolite.space/viewtopic.php?p=265324). Impostors map geometric detail to textures (https://documentation.simplygon.com/SimplygonSDK_9.0.666.0/concepts/impostor.html). Existing repo doc: octahedral-impostors-prior-art.md.
|
||||
- Size rule from Tripo guide: detail smaller than a few pixels at typical view distance belongs in a texture, not geometry; deep shadow-casting features modelled or height-mapped. https://www.tripo3d.ai/blog/explore/hd-model-hard-surface-greeble-design-principles (search-summary, vendor blog).
|
||||
|
||||
Authoring time: Scifi-Meshes: booleaning 50 objects ~20 s, 100 ~1 min, author settled on 100-200 per batch. No source gave hours-per-ship numbers.
|
||||
|
||||
## 2. Procedural greeble generators
|
||||
|
||||
Paper: Kinnear and Kaplan, "Procedural generation of surface detail for science fiction spaceships", Computational Aesthetics 2010. https://cs.uwaterloo.ca/~csk/publications/Papers/kinnear_kaplan_2010.pdf (read, all 8 pages). Facts:
|
||||
- Input is a user-supplied frame of rectangular "nodes" positioned/oriented arbitrarily; any flat surface can be embedded in a rectangle (sec 3, 5). One axis-plane designated bilateral mirror symmetry; rectangles bisected by the plane are split into a symmetric pair (sec 3).
|
||||
- Pipeline: node -> regular grid cells (3.1) -> randomized T-junction splitting into sub-panels (split point constrained to a scaled centred rectangle; aspect >2.5 forces one-line split; split symmetric with user probability) -> pattern cells (random n-omino, n=4, filled with 6 Wang-tile-like models selected by neighbour labelling, 3.2) -> nurnies: cuboids on 30% of sub-panels, wedges (50%) bridging to neighbour sub-panels, remaining sub-panels filled with a primitive or one of 70 kitbashed library parts, non-uniformly scaled; if x/y scale factors differ >2x, place multiple copies instead (3.3) -> secondary geometry via 7 hard-coded shape-grammar substitution rules (3.4) -> pipes routed on sub-panel edges by a modified Dijkstra spanning tree with straightness penalty (3.5).
|
||||
- Seams: where two nodes meet at <180 degrees, geometry is added to one side only to avoid interpenetration (3.3).
|
||||
- Aesthetic claim: details obey frame symmetry in proportion to size/semantic importance; larger more symmetric (2.1.3). Principle "uniformity amidst variety" (2.2).
|
||||
- Library selection rules: tanks best source; avoid too-recognisable shapes, overly complex geometry, models without roughly flat bottoms (3.3).
|
||||
- Implementation C++/OpenGL, Houdini used to tear kit parts; output OBJ; AO render. No timings, triangle counts, seeds, or edit-stability discussion. Authors say much work is needed for production (sec 5). Intro: "in many cases the need for efficient rendering relegates detail to painted textures instead of geometry."
|
||||
- Shape grammar refs in paper: Stiny and Gips 1972; Wonka et al. "Instant architecture" 2003; Mueller et al. 2006 "Procedural modeling of buildings" (CGA shape: staged mass model -> facade -> details; context-sensitive rules to avoid intersections; https://history.siggraph.org/?p=113547). Seed/edit-stability of CGA not found in the sources read.
|
||||
|
||||
Blender
|
||||
- Distribute Points on Faces writes builtin `id`; Random Value and Instance on Points read it implicitly, falling back to index; commit 40c3b8836b7 (Hans Goudey, Oct 2021). https://archive.blender.org/lists/bf-blender-cvs/2021-October/165730.html (read). Manual wording (search-summary): ids stay consistent for remaining points when the mesh is deformed or density changes.
|
||||
- Reported instability: seeds change for some faces when the source mesh moves (bug T100214, "Needs User Info"); https://developer.blender.org/T100214 (search-summary). Instances sharing a seed are identical copies (blenderartists geometry-node threads).
|
||||
- Anchoring to face attributes (a persistent per-face id surviving topology edits) was not found in any source; the commit mentions possible future builtins `edge_id`/`face_id`.
|
||||
- Add-ons: GreebleFX (Blender 2.79-2.9; Basic panels / Advanced nurnies; "best generated when mesh is quad-based"; v1.0) https://superhivemarket.com/products/greeblefx-non-destructive-greeble-generator (read; no seed/anchoring info). Greeble Generator (Gumroad; subdivides selected face, random extrusions, creates new mesh) (search-summary). Random Sci-Fi Generator, geometry-nodes panels (80.lv article, search-summary). Greeble collections: pieces with >=1 axis symmetry, retriangulated, "combining usually requires cleanup" https://superhivemarket.com/products/sci-fi-greebles-collection-i (search-summary). "Sci-fi Flats" returned no result.
|
||||
- Hard Ops/Boxcutter, KIT OPS: manual placement of inserts, not generators.
|
||||
|
||||
Houdini
|
||||
- SideFX "Greeble Tool" lesson: each primitive becomes a placeholder for complex geometry; extra-credit asks a system driven only by a random seed. https://learnsquared.com/courses/houdini-foundations/3?video=1153 ; SideFX community greeble HIP/HDA https://www.sidefx.com/ja/tutorials/greeble-generator-hip-file-included-1/ ; "procedural hardsurface design in houdini" greeble solver https://www.sidefx.com/tutorials/procedural-hardsurface-design-in-houdini/ (all search-summary). No source read states how edit-stability is handled (the search tool's stable-id-attribute advice was its own general knowledge, not sourced).
|
||||
|
||||
## 3. Unity built-in runtime options (Unity 2022.3 manual unless stated)
|
||||
|
||||
- GPU instancing: same mesh + same material, one draw call; shader must support; built-in pipeline supported; not for SkinnedMeshRenderer; "don't use GPU instancing for meshes with fewer than 256 vertices". https://docs.unity3d.com/2022.3/Documentation/Manual/GPUInstancing.html (read). Graphics.RenderMeshInstanced calls each issue separate draw calls.
|
||||
- Priority and exclusion: static batching beats GPU instancing (instancing disabled if static batching applies); instancing disables dynamic batching. https://docs.unity3d.com/2022.3/Documentation/Manual/optimizing-draw-calls.html (read). Material switching is among the most expensive state changes; Mesh.CombineMeshes gives one draw call.
|
||||
- Static batching: 64,000 vertices per batch (more -> another batch); meshes must share vertex attributes; extra CPU memory, shared meshes copied per use; after batching move root only; runtime StaticBatchingUtility.Combine available. https://docs.unity3d.com/2022.3/Documentation/Manual/static-batching.html (read). Manual combine loses per-mesh culling (search-summary of Unity docs).
|
||||
- Dynamic batching: not for meshes >300 vertices and 900 vertex attributes (pos+normal+UV0 = 300 verts; with UV1+tangent = 180); manual 2020.3-2023.2 (search-summary, https://docs.unity3d.com/2023.2/Documentation/Manual/dynamic-batching.html); Unity 6 pages discourage it for CPU transform cost.
|
||||
- LOD Group: up to 8 levels, each level a child GameObject; lowest can be billboard; LOD Bias and Maximum LOD Level quality settings. https://docs.unity3d.com/2022.3/Documentation/Manual/LevelOfDetail.html (read; page does not mention cull-to-zero or screen-relative threshold).
|
||||
- SRP Batcher applies to SRP only (optimizing-draw-calls page); no built-in-pipeline equivalent described.
|
||||
- No data found on triangle/draw-call budgets for detail at ARPG distances, or on separate-greeble-mesh vs merged-mesh cost in a many-ship scene (gap). Closest figures: item 1 per-title tri counts.
|
||||
|
||||
## 4. Greebles and booleans
|
||||
|
||||
- Failure modes reported (Scifi-Meshes read; blenderartists/polycount search-summary): overlapping coplanar faces and bad normals make booleans fail or merge instead of cut; one non-manifold operand can make the whole result garbage; fix cited: Randomize Transform with tiny location/scale jitter on greebles; batch operands in 10-200 groups. https://forums.scifi-meshes.com/discussion/10000616/quick-greebling-in-blender
|
||||
- Bevel: bevel-generated faces take neighbours' pre-bevel vertex normals, hence Weighted Normal; bevel corner streaks on curved holes, fix by limiting bevel weight to boolean edge loops only; Houdini user: "black triangles with boolean+bevel", suspected tessellation. https://blenderartists.org/t/boolean-and-bevel-on-curved-surface/1308394 , https://www.sidefx.com/forum/post/221668/ (search-summary). Merge small greebles into one object before geometry bevel (claudepluginhub page above).
|
||||
- Blender solvers (Boolean modifier manual 4.5): Exact is robust, "much slower"; Hole Tolerant and Self Intersection options cost more; Manifold solver (4.5) as fast as/faster than Float but manifold inputs only. https://docs.blender.org/manual/en/4.5/modeling/modifiers/generate/booleans.html ; forum https://devtalk.blender.org/t/manifold-boolean-feedback/40150 (search-summary). A vendor claim: converting meshes to instances before booleans gives up to 100x (Float) / 10x (Exact) speedup (blendernation listing, creator claim, unverified). No many-operand benchmark found.
|
||||
- Parenting vs booleaning: no report found of shipping games booleaning greebles into the hull; common published practice is booleans for cuts/panel breaks, instanced/separate pieces for repeated greebles (claudepluginhub, Tripo guide, Scifi-Meshes). Kinnear and Kaplan place greeble geometry as separate cuboids/parts (no boolean) and avoid interpenetration by one-side placement.
|
||||
- UV pressure: floaters/decals rely on atlas/trim or normal bake (DECALmachine atlas; Elite-style rebuild's 2048 maps; EVE bake). Only the polycount Elite statement above addresses a whole-hull bake being too large for texture resolution. No source found on atlas UV cost of unique-per-ship livery plus greeble bake.
|
||||
256
docs/research/mesh-probe-run1/RESULT.md
Normal file
256
docs/research/mesh-probe-run1/RESULT.md
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
# Mesh probe run 1: parametric rebuilds of Tripo hulls, with tools
|
||||
|
||||
Agent `probe-mesh-rebuild-1`, session `self-2026-10-10-ag2`, 2026-10-10. Disposable scratch; nothing
|
||||
committed, no repo edited. Root: this directory (`...\scratchpad\mesh-probe\run1\`).
|
||||
|
||||
Governing operator words: "I'm not interested in measuring Opus's spatial reasoning performance, in
|
||||
interested in exploiting it. Give it access, let it build and use tools." The probe ran the F2(b) arm
|
||||
(full tool access) with the plan's targets, metrics, pass bars and stopping rules.
|
||||
|
||||
## Headline
|
||||
|
||||
| Target | Vocabulary | Best chamfer (% diag) | F@1% / F@2% | Mean sil-IoU | Vol-IoU | B0 chamfer | Round-0 chamfer | Loop gain (best / r0) | Edit test | Bars |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Control | Geometry.Csg (12 brushes, truth 12) | **0.015** | 0.996 / 1.000 | 0.994 | 0.996 | 5.796 | 0.085 | 0.18x | 4 lines; 0.070 (4.7x best) | pass; edit fails the 1.5x bound |
|
||||
| Control | OpenSCAD | **0.029** | 0.990 / 1.000 | 0.985 | 0.992 | 5.796 | 0.090 | 0.32x | 1 line; 0.030 (1.03x) | pass |
|
||||
| Dexter Quiet run 1 | OpenSCAD (table of offsets) | **0.266** | 0.954 / 0.973 | 0.973 | n/a | 10.565 | 0.278 | 0.96x | 1 line; 0.266 (1.0x) * | pass; loop effect fails |
|
||||
| Dexter Quiet run 1 | Geometry.Csg (29 brushes) | **0.463** | 0.888 / 0.985 | 0.911 | n/a | 10.565 | 1.226 | 0.38x | 7 lines; 0.461 (1.0x) * | pass; edit > 5 lines |
|
||||
| Dexter Quiet run 2 | OpenSCAD (compact) | **0.682** | 0.788 / 0.947 | 0.924 | n/a | 10.565 | 0.803 | 0.85x | 1 line; 0.678 (1.0x) * | pass; loop effect in between |
|
||||
| Headliner | OpenSCAD | **1.121** | 0.563 / 0.821 | 0.913 | n/a | 8.078 | 1.134 | 0.99x | 1 line; 1.087 * | pass (narrowly); loop effect fails |
|
||||
|
||||
Quiet pass bars: chamfer <= 2%, F@2% >= 0.80, mean sil-IoU >= 0.90, chamfer <= 0.33 x B0 (3.49 on Quiet,
|
||||
2.67 on Headliner). Control pass bars: chamfer <= 1%, sil >= 0.95, vol >= 0.90, brushes <= 1.5x truth.
|
||||
Headliner has no bar of its own in the plan, so Quiet's bars were applied.
|
||||
|
||||
\* On Quiet and Headliner the edit test does not discriminate: the **unedited** model scores almost the
|
||||
same chamfer on the stretched target (Quiet r1 0.276, Quiet Csg 0.469, Quiet run 2 0.672, Headliner
|
||||
1.088). The nose is a few percent of the surface. Only side-view sil-IoU moves (Headliner 0.927 to
|
||||
0.948 with the edit; Quiet run 1 0.963 to 0.980). The control is the only target where the edit test
|
||||
measures anything.
|
||||
|
||||
Full scope (every part) equals main-body scope within 0.001 on every best round: the dropped parts are
|
||||
8 tiny floaters on Quiet and a few dozen specks on Headliner.
|
||||
|
||||
**Outcome letter: A by the table (control passes, Quiet passes, both vocabularies), qualified twice.**
|
||||
1. The loop effect (outcome D) fails for OpenSCAD on Quiet run 1 and Headliner. Render-compare bought
|
||||
little there, because round 0 was already built from measurement tools. It passes for Geometry.Csg
|
||||
(0.38x) and on the control. In both of those cases the gain came mostly from tools: numeric refine,
|
||||
plane fits, and the winding-number labels. Render critique contributed less.
|
||||
2. The edit readout says the Geometry.Csg description is a fit, not a description: 7 lines on Quiet,
|
||||
and 4.7x on the control. OpenSCAD is one line everywhere, because it has parameters and affine
|
||||
transforms. However, the edit test only discriminates on the control.
|
||||
|
||||
## What actually produced the numbers (read before trusting the table)
|
||||
|
||||
- **Control contamination.** No Hands harness existed, so I wrote the control generator
|
||||
(`tools/gen_control.py`). It used a random seed, and the truth (`sealed/truth.csg`) was never read.
|
||||
I still knew its *structure* from writing the code: core, nose cuts, sponsons, wings, engines,
|
||||
bridge and trench. The control therefore tests numeric recovery of a known structure. It does not
|
||||
test discovering structure.
|
||||
- **Quiet run 1 OpenSCAD is fit-heavy.** The skin is a table of offsets: 21 wing-body stations and 8
|
||||
tail stations, each with a half-span and 7 [z_bottom, z_top] pairs, emitted by `mp prof` and
|
||||
`tools/table.py` from the target. The model chose the decomposition: one wing-body skin, a separate
|
||||
tail skin, two scallop cylinders, and the nose_k parameter. Run 2 is the compact description: a
|
||||
17-point planform, one wing plane per side, 8 elliptical body stations, 4 tail stations, pods and a
|
||||
chin fin. That is about 110 numbers against about 460. It is 2.5x worse in chamfer and still passes.
|
||||
- **Run 2 is not independent.** It is the same agent with run 1 in context. It is a different
|
||||
*approach*, not a second sample.
|
||||
- **Nose direction.** The plan says nose -Y. On this mesh the beak and canopy end is at **+Y**: the
|
||||
operator's schematic has the canopy arc at the pointed end, and the stinger is at -Y. I used +Y
|
||||
for "the nose" in the edit test.
|
||||
- **Metrics differ from the plan's harness.** Everything was computed by my own `mp` tool. Chamfer is
|
||||
point-to-triangle with 30k area-weighted samples each way. Silhouette IoU uses 5 orthographic
|
||||
rasters at 384 px: front, side, top, and two obliques. Opposite axis views are identical in
|
||||
orthographic projection, so 3 axes and 2 obliques stand in for the plan's 6 + 2 Workbench renders.
|
||||
Vol-IoU is on the control only, from 128^3 ray parity. No Blender Workbench renders were made.
|
||||
- **Geometry.Csg scoring.** The brush list is lowered through the crate's own `Assembler::distance`
|
||||
(src/field.rs). Surface nets at 220 cells on the long axis mesh the field, and that mesh is
|
||||
scored. The control target is itself a field mesh at the same resolution, which slightly favours
|
||||
Csg candidates on the control. Kernel (polygon) meshes were also scored as a side readout; see the
|
||||
gaps below.
|
||||
- **Blender.** One background export job ran on a *copy* of `Desktop\Quiet.blend`
|
||||
(`targets/Quiet-copy.blend` to `targets/src_*.obj`). An operator GUI Blender (PID 36324, open on
|
||||
`Aetheria\Asset Sources\Ships.blend` since 2026-09-27) was present the whole time. I did not wait
|
||||
on it, because it is an interactive session, not a smoke run. A second Blender (PID 59588, not
|
||||
mine) appeared near the end, after my last Blender use. Nothing of mine is running.
|
||||
- **Not done.** Headliner was rebuilt in OpenSCAD only, not in Geometry.Csg. Quiet run 2 was
|
||||
OpenSCAD only, which the brief allows. The Quiet run 2 loop stopped after one regression (r3),
|
||||
not after two sub-2% rounds, to stay inside the call budget.
|
||||
|
||||
## Per-round logs
|
||||
|
||||
Scores are main-body chamfer % / mean sil-IoU. "ref" means `mp refine` was run on the round before.
|
||||
|
||||
**Control, Csg.** r0 0.085 / 0.977, built from planes, sections and depth readouts. r1 (refine, 12
|
||||
sweeps, 12 s) 0.015 / 0.994. The tools exposed what the renders did not: the trench length was 0.33,
|
||||
not 0.24, and the refine also moved the wing roll and the nose_top pitch. The refine also drifted
|
||||
nuisance parameters, for example a cutter roll of 13 deg that does nothing, which is a fit-not-
|
||||
description smell.
|
||||
|
||||
**Control, OpenSCAD.** r0 0.090 / 0.972, a direct translation. r1 0.029 / 0.985 uses the refined
|
||||
numbers, restructured with `nose_root`, `nose_len` and a `nose_frame()` that scales the nose cutters.
|
||||
|
||||
**Quiet run 1, OpenSCAD.** r0 0.278 / 0.970 (15 + 8 stations). The first render was NotManifold:
|
||||
the cap winding was wrong in my 10-line skin module, and the mesh tool caught it because Manifold
|
||||
warned. r1 0.266 / 0.973 adds denser stations at the beak. The residual is mostly the target's
|
||||
interior shells: cockpit internals and pods, visible as red loops *inside* the outline on
|
||||
`quiet1/r1.sec.png`. No solid can match those.
|
||||
|
||||
**Quiet run 1, Geometry.Csg.**
|
||||
|
||||
| Round | Chamfer / sil | Change |
|
||||
|---|---|---|
|
||||
| r0 | 1.226 / 0.827 | Hand-estimated wing plane, step boxes, nose wedge, tail block. |
|
||||
| r0ref | 0.800 / 0.857 | Refine. |
|
||||
| r1 | 0.971 / 0.873 | Wing plane re-fitted by least squares (`tools/wingfit.py`). The fit showed my hand estimate was wrong: dihedral slope 0.44, where the fit gave 0.17 with 3.5 mm rms. |
|
||||
| r1ref | 0.875 / 0.841 | Refine. Sil got worse. |
|
||||
| r2 | 1.025 / 0.860 | Bounded leading-edge and beak cuts for concave outline corners; keel. |
|
||||
| r2ref | 0.828 / 0.827 | Refine with ray-parity inside labels. |
|
||||
| r2ref2 | 0.644 / 0.874 | Same refine with **generalized-winding-number labels** (Jacobson 2013). 1454 of 14000 parity labels (10%) on the non-manifold target were wrong; the parity refine had been trading silhouette away. |
|
||||
| r3 | 0.630 / 0.887 | Inner and outer wing panels on their own fitted planes. |
|
||||
| r3ref | 0.555 / 0.891 | Refine. |
|
||||
| r4 | 0.541 / 0.908 | Dorsal spine and nose floor cut, from a centreline profile diff (`mp prof` target vs candidate). |
|
||||
| r4ref | **0.463 / 0.911** | Refine. |
|
||||
|
||||
**Quiet run 2, OpenSCAD.**
|
||||
- r0 0.803 / 0.919.
|
||||
- r1 0.729 / 0.926: joined the tail to the body (a gap was visible in the side view), added
|
||||
under-body pods and a chin fin.
|
||||
- r2 **0.682** / 0.924: a station-wise thickness diff showed the body 45-55 mm too thin at
|
||||
x = 0.13-0.25, so the body was widened.
|
||||
- r3 0.852 and r3b 0.775: superellipse sections (n = 3 and 2.5). These are regressions: the target's
|
||||
wing-root concavity punishes fuller sections.
|
||||
|
||||
**Headliner, OpenSCAD.**
|
||||
- r0 1.134 / 0.902: fuselage, blade hulls, pods and inner engines as superellipse lofts and
|
||||
cylinders, measured with `mp island`.
|
||||
- r1 1.188 / 0.910: deck and blade fins. Sil went up, chamfer went down: the deck buried target
|
||||
side walls.
|
||||
- r2 **1.121** / 0.913: thinner, higher deck.
|
||||
|
||||
## Renders (target | candidate | silhouette diff; red = target only, blue = candidate only)
|
||||
|
||||
- Control: `control/best-csg.sheet.png` and `control/best-scad.sheet.png`. Round 0 is
|
||||
`control/r0.sheet.png`, the OpenSCAD r0, which ran last under that basename.
|
||||
- Quiet run 1 OpenSCAD: `quiet1/best-scad.sheet.png` and `quiet1/best-scad.sec.png`. Round 0 is
|
||||
`quiet1/r0-scad.sheet.png`.
|
||||
- Quiet run 1 Csg: `quiet1/best-csg.sheet.png` and `quiet1/r4ref.sec.png`. Round 0 is
|
||||
`quiet1/r0-csg.sheet.png`.
|
||||
- Caveat: `rN.sheet.png` files in `control/` and `quiet1/` hold whichever vocabulary ran last under
|
||||
that basename, because .csg and .scad rounds share basenames. Use the `best-*` and `r0-*` files.
|
||||
- Quiet run 2: `quiet2/r2.sheet.png`, `quiet2/r0.sheet.png`.
|
||||
- Headliner: `headliner/r2.sheet.png`, `headliner/r0.sheet.png`, target sections `headliner/t_secy.png`.
|
||||
- B0 baselines: `quiet1/b0.sheet.png`, `headliner/b0.sheet.png`, `control/b0.sheet.png`.
|
||||
|
||||
Best programs: `control/r1.csg`, `control/r1.scad`, `quiet1/r1.scad` + `quiet1/tables1.scad`,
|
||||
`quiet1/r4ref.csg`, `quiet2/r2.scad`, `headliner/r2.scad`. Edits: `*_edit.*` beside each.
|
||||
|
||||
## Tools built (candidates for brokkr-ctl commands)
|
||||
|
||||
All are in `tools/`. `mp` is one Rust binary, about 650 lines, that depends on GameCult.Geometry.Csg
|
||||
by path. It was built once locally and offline, in 1 min 10 s; incremental rebuilds took 7-9 s. The
|
||||
target directory is 156 MB and is safe to delete. Every command runs in well under a second, except
|
||||
`refine` (12-40 s).
|
||||
|
||||
| Tool | What it does | Earned its keep? |
|
||||
|---|---|---|
|
||||
| `mp score` | Chamfer (both directions), F@1/2%, 5-view sil-IoU, vol-IoU | Yes, every round. Seeing the t2c/c2t split told me whether a surface was missing or extra. |
|
||||
| `mp sheet` | Contact sheet (4 views x target, candidate, diff) | Yes. The diff column found every missing part: tail gap, pods, deck, blade fins. |
|
||||
| `mp sections` | Station slices, overlaid, with numeric extents | Yes. Control round 0 numbers. It showed Quiet's residual is interior shells. |
|
||||
| `mp prof` | Table of offsets: per station, half-span and z-range at fixed span fractions (with an x window) | **Biggest single lever.** It built Quiet OpenSCAD round 0 at 0.278 directly, and its target-vs-candidate diff drove Quiet csg r4 and run 2 r2. |
|
||||
| `mp island` | Station extents inside an x/z window (off-centre parts) | Yes on Headliner: engines, blades, pods, deck. |
|
||||
| `mp depth` | ASCII depth map from an axis | Moderately. Control engines and trench; the Quiet scallop arc (circle fitted by hand from it). |
|
||||
| `mp planes` | Iterated 1-point RANSAC plus a least-squares plane refit | Yes on the control: every face angle (chines 60.5/124.3 deg, bridge 11.1 deg, wing roll 9.6/8.2 deg). Unused on Quiet, which has few planes. |
|
||||
| `tools/wingfit.py` | Least-squares plane over a region of the offsets table | Yes. It corrected a wrong hand estimate (dihedral 0.44 to 0.17). |
|
||||
| `mp sym` | Best mirror plane x0 and residual | Yes. It showed Quiet is exactly mirrored (0.000%) and Headliner 0.089%, so mirror pairs are safe. |
|
||||
| `mp parts` | Welded connected components, main-body scope, part table | Yes. It set the scope. |
|
||||
| `mp csg` | Kernel build (warnings, tri count, kernel STL) plus field mesh | Yes. It surfaced SubtractIgnoredForNonBox and the kernel-vs-field divergence. |
|
||||
| `mp refine` | Coordinate descent on brush parameters. Loss is mean surface \|SDF\| plus volume misclassification. Mirror-tied `_p`/`_s`, symmetry-locked centred brushes, winding-number labels. | **Yes for Csg** (control 0.085 to 0.015; Quiet about -25% per application). It drifts nuisance parameters and is blind to silhouette when its labels are wrong. |
|
||||
| winding-number labels (in refine) | Robust inside test for non-manifold targets | Yes. It replaced ray parity, which was 10% wrong on Quiet. |
|
||||
| `tools/warp.py`, `tools/warp_obox.py` | Stretch target forward of y_n; re-fit an obox under that stretch | Needed only because Csg has no scale transform. warp_obox even picked the wrong face for mirrored cutters (beak*_s). |
|
||||
| `tools/table.py`, `tools/ev.sh`, `tools/gen_control.py`, `tools/export.py` | Glue | Glue. |
|
||||
|
||||
As brokkr-ctl commands, the clear candidates are `score`, `sheet`, `sections` / `prof` (as one
|
||||
"stations" command), `planes` / `fit plane|circle`, `sym`, `parts` and `refine`. Each one is pure mesh
|
||||
math over exported geometry: no Blender API, millisecond to second latency, files in, text and PNG
|
||||
out.
|
||||
|
||||
## OpenSCAD constructs used and wanted (card fork: real OpenSCAD vs a subset)
|
||||
|
||||
Used:
|
||||
- **Language:** variables and vectors, `function` (the `ny` nose warp, a section generator, a
|
||||
superellipse, `mirrored`), list comprehensions with `each`, conditional expressions,
|
||||
`include <tables.scad>` for data.
|
||||
- **Geometry:** `polyhedron` (a hand-written 10-line `skin` loft), `hull()` (pairwise loft chains,
|
||||
pods, fins), `multmatrix` (shear: a planar wing slab on z = a + b|x| + c y), `scale` inside a
|
||||
module with `children()` (stretching nose cutters, so the edit is one line), `linear_extrude`,
|
||||
`polygon`, `circle`, `intersection` (planform with wing slab), `difference`, `union`, `rotate`,
|
||||
`translate`, `cube`, `sphere`, `cylinder`, `$fn`, mirroring via `for (s = [1,-1])`.
|
||||
|
||||
Not used: `minkowski`, `offset`, `rotate_extrude`, `import`, `surface`.
|
||||
|
||||
Wanted and absent even from OpenSCAD: a built-in loft or skin (BOSL2 has `skin()`; I wrote one) and a
|
||||
superellipse primitive.
|
||||
|
||||
**Evidence for the fork.** Every best OpenSCAD result leaned on functions, list comprehensions,
|
||||
`polyhedron`, `include`d data tables, `multmatrix` and `hull`. A subset limited to CSG-tree syntax
|
||||
(cube, cylinder, sphere, translate, rotate, the booleans) could not have expressed the Quiet run 1 or
|
||||
run 2 bodies. It also could not have made the edit a one-line parameter change.
|
||||
|
||||
I recommend real OpenSCAD files evaluated by OpenSCAD (or a full evaluator) as the authoring
|
||||
surface, with the lowering consuming its CSG tree. If a subset is chosen, it must keep `function`,
|
||||
list comprehensions, `polyhedron` (or a loft), `hull`, `multmatrix` and `scale`. Confidence:
|
||||
moderate. This is one agent on three hulls.
|
||||
|
||||
## Geometry.Csg vocabulary gaps hit (counts)
|
||||
|
||||
- **Mirror construct.** On Quiet, 18 of 29 lines are mirror twins (9 pairs); 4 of the 7 edit lines
|
||||
are twins. On the control, 2 pairs, and the generator's own engines and wings were asymmetric. The
|
||||
`_p`/`_s` tie had to live in my refine tool.
|
||||
- **Non-box cutters skipped by the polygon kernel.** 2 scallop `cylz` subtracts on Quiet produced
|
||||
`SubtractIgnoredForNonBox` x2. The kernel mesh then diverges from the field for the same list:
|
||||
Quiet r3ref kernel chamfer 1.170 / sil 0.809, against field 0.555 / 0.891. The concave trailing
|
||||
scallop is not expressible as a box cut.
|
||||
- **Kernel interior faces even with boxes only.** Control r1 kernel mesh chamfer 0.504 (c2t 0.996)
|
||||
against field 0.015. The silhouette is identical, so this is internal faces from overlapping Adds,
|
||||
not geometry error.
|
||||
- **No affine or scale transform, no parameters.** The control edit needed 4 hand-recomputed oboxes
|
||||
and lost fidelity (4.7x). The Quiet edit needed 7 lines.
|
||||
- **No curved sweep, loft or ellipsoid.**
|
||||
- The body is 4 step boxes plus a keel plus 2 spine blocks.
|
||||
- The wing is 2 planar slabs per side.
|
||||
- Best Csg 0.463 against OpenSCAD 0.266 on Quiet; the gap is curvature.
|
||||
- Headliner (y-axis engines, ellipsoidal pods) was not attempted in Csg.
|
||||
- **No arbitrary-axis cylinder.** It was needed for Headliner's 4 engines and 2 pods, and on Quiet for
|
||||
the pods (skipped in Csg).
|
||||
- **Global intersect and subtract need ordering tricks.**
|
||||
- The wing trims (6 cutters) had to come before the body Adds, so the body survived.
|
||||
- The nose cuts had to come after.
|
||||
- Concave outlines needed **bounded** cutters, 4 per side (lead0/lead1, beak1/beak2), each with a
|
||||
hand-computed extent.
|
||||
- A scoped intersect (planform ∩ wing plane, which OpenSCAD run 2 used directly) would have
|
||||
replaced all of these.
|
||||
- **FloretArm and DomeCapZ went unused.** FloretArm has constant thickness and lifts only along its
|
||||
arm. DomeCapZ has only a circular base. Both were considered for the wing, tail and canopy, and
|
||||
rejected.
|
||||
|
||||
## Card fork: where the fitting logic should live
|
||||
|
||||
Every fitting step was pure geometry over exported meshes, in a standalone CLI, with no Blender
|
||||
dependency:
|
||||
- station profiles;
|
||||
- plane, RANSAC and least-squares fits;
|
||||
- symmetry;
|
||||
- winding-number inside tests;
|
||||
- coordinate-descent refine;
|
||||
- scoring.
|
||||
|
||||
Blender was needed once, to export. The evidence favours fitting in the CLI process, or a library it
|
||||
links, operating on a selection or mesh dump. The Blender bridge should only supply geometry
|
||||
(selection, export) and renders. Confidence: moderate. The probe never exercised a live Edit Mode
|
||||
selection.
|
||||
|
||||
## Continuation
|
||||
|
||||
Finished within budget. Not done: Headliner in Geometry.Csg; a genuinely independent Quiet run 2 (fresh
|
||||
agent); an eyes-only arm for comparison; Workbench renders as the plan specified.
|
||||
43
docs/research/mesh-probe-run1/dexter-quiet-compact.scad
Normal file
43
docs/research/mesh-probe-run1/dexter-quiet-compact.scad
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// Dexter Quiet, run 2, round 3: a compact description instead of a table of offsets.
|
||||
// Manta: beak +Y, stinger tail -Y, starboard -X. Parts: wing sheet, body, tail.
|
||||
nose_root = 0.36; nose_k = 1.0; // beak starts at nose_root; nose_k stretches it
|
||||
function ny(y) = y > nose_root ? nose_root + nose_k*(y - nose_root) : y;
|
||||
|
||||
// Wing sheet: the planform (top view, +x half, beak tip to wingtip) cut by the trailing scallops,
|
||||
// lying on one plane per side, z = a + b|x| + c y, of thickness wing_t.
|
||||
planform = [[0,0.52],[0.011,0.51],[0.033,0.461],[0.062,0.412],[0.146,0.363],[0.184,0.265],[0.238,0.216],
|
||||
[0.289,0.167],[0.332,0.118],[0.369,0.069],[0.402,0.02],[0.431,-0.03],[0.46,-0.086],[0.475,-0.135],
|
||||
[0.49,-0.19],[0.49,-0.245],[0,-0.245]];
|
||||
wing_plane = [0.0146, 0.1735, -0.3128]; wing_t = 0.045;
|
||||
scallop_r = 0.2904; scallop_c = [0.3018, -0.4593];
|
||||
|
||||
// Body and tail: elliptical stations [y, z_bottom, z_top, half_width], skinned by pairwise hull.
|
||||
body = [[-0.185, 0.005, 0.178, 0.12], [-0.08, -0.075, 0.175, 0.17], [0.02, -0.10, 0.158, 0.19], [0.118, -0.127, 0.131, 0.21],
|
||||
[0.216, -0.13, 0.101, 0.19], [0.314, -0.165, 0.047, 0.13], [0.40, -0.16, -0.03, 0.065], [0.51, -0.189, -0.176, 0.012]];
|
||||
tail = [[-0.525, 0.146, 0.191, 0.024], [-0.42, 0.065, 0.187, 0.05], [-0.33, 0.03, 0.184, 0.077], [-0.24, 0.02, 0.18, 0.12]];
|
||||
|
||||
sect_n = 3; // station cross-section is a superellipse of this exponent (2 = ellipse)
|
||||
function se(t) = sign(t)*pow(abs(t), 2/sect_n);
|
||||
module station(s) translate([0, ny(s[0]), (s[1] + s[2])/2]) rotate([90, 0, 0]) linear_extrude(0.001, center = true)
|
||||
polygon([for (a = [0:10:359]) [s[3]*se(cos(a)), (s[2] - s[1])/2*se(sin(a))]]);
|
||||
module loft(st) for (i = [0:len(st)-2]) hull() { station(st[i]); station(st[i+1]); }
|
||||
module wing_side(s) intersection() { // s = +1 port, -1 starboard
|
||||
multmatrix([[1,0,0,0],[0,1,0,0],[s*wing_plane[1], wing_plane[2], 1, wing_plane[0]],[0,0,0,1]])
|
||||
translate([s > 0 ? 0 : -1, -1, -wing_t/2]) cube([1, 2, wing_t]);
|
||||
linear_extrude(height = 2, center = true) planform2d();
|
||||
}
|
||||
module planform2d() difference() {
|
||||
polygon(concat([for (p = planform) [p[0], ny(p[1])]], [for (i = [len(planform)-1:-1:0]) [-planform[i][0], ny(planform[i][1])]]));
|
||||
for (s = [1, -1]) translate([s*scallop_c[0], scallop_c[1]]) circle(scallop_r, $fn = 96);
|
||||
}
|
||||
union() {
|
||||
for (s = [1, -1]) wing_side(s);
|
||||
loft(body);
|
||||
loft(concat(tail, [body[0]])); // tail runs into the body
|
||||
for (s = [1, -1]) pod(s);
|
||||
chin_fin();
|
||||
}
|
||||
// Under-body pods either side of the keel, and the chin fin under the beak root.
|
||||
pod_a = [0.13, 0.22, -0.085, 0.03]; pod_b = [0.135, 0.38, -0.10, 0.035]; // [x, y, z, r] at each end
|
||||
module pod(s) hull() for (p = [pod_a, pod_b]) translate([s*p[0], ny(p[1]), p[2]]) sphere(p[3], $fn = 24);
|
||||
module chin_fin() hull() for (p = [[0.30, -0.15], [0.363, -0.215], [0.40, -0.16]]) translate([0, ny(p[0]), p[1]]) cube([0.012, 0.01, 0.01], center = true);
|
||||
367
docs/research/mesh-probe-run1/probe-plan.md
Normal file
367
docs/research/mesh-probe-run1/probe-plan.md
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
# Probe plan: can Opus describe a messy Tripo hull as a brush list?
|
||||
|
||||
Imagination, agent `imagination-brokkr-mesh-probe`, session `self-2026-10-10-ag2`, 2026-10-10.
|
||||
Ruling in force: `aetheria-release:ruling:brokkr-mesh-probe-ag-self`. Operator: "What if the model
|
||||
had some trash geo and its goal is to describe it parametrically?"
|
||||
|
||||
This is a probe plan, not a campaign. Nothing here is admitted. The probe is disposable scratch;
|
||||
only its result document is kept.
|
||||
|
||||
## 1. The question, narrowed
|
||||
|
||||
Given a messy hull mesh, can Opus write a GameCult.Geometry.Csg brush list that matches it? How
|
||||
close does it get, and does a render-and-compare loop improve the match?
|
||||
|
||||
The probe separates two things that VibeGeometry never separated:
|
||||
|
||||
- **Model reasoning.** Can the model place, size and cut brushes to match a shape it can only see?
|
||||
This is tested on a **control hull**: a synthetic target that is itself a known brush list, so the
|
||||
vocabulary can express it exactly.
|
||||
- **Vocabulary and noise.** Can the vocabulary describe a real Tripo hull, with its floaters,
|
||||
non-manifold edges and soft forms? This is tested on **Dexter Quiet**.
|
||||
|
||||
The pairing decides the next step. If the control passes and Quiet fails, the bottleneck is the
|
||||
vocabulary or the noise. If the control fails, the bottleneck is the model with this harness.
|
||||
|
||||
## 2. What exists today (facts the plan rests on)
|
||||
|
||||
**Brokkr cannot run this loop today.** The Blender adapter (`F:\Projects\Brokkr\surfaces\blender\brokkr_bridge`)
|
||||
admits eight commands: createObject, deleteObject, setObjectTransform, setObjectVisibility,
|
||||
setObjectParent, setObjectCustomProperty, selectObject and assignMaterial. It also captures a scene
|
||||
snapshot. It cannot render, import or export meshes, read mesh data, or run code. It needs GUI
|
||||
Blender with the add-on and CultLib's Python packages loaded. `docs/research/blender-mcp-capability-bar.md`
|
||||
records that rendering, code execution and mesh reads are exactly what the Blender MCP servers have
|
||||
and Brokkr lacks.
|
||||
|
||||
The probe therefore runs as plain `blender --background` scripts. It answers the same question,
|
||||
and its harness doubles as the requirements list for a Brokkr Blender surface, if the probe earns one.
|
||||
|
||||
**Blender.** Blender 5.2.2 LTS is at `C:\Program Files (x86)\Steam\steamapps\common\Blender\blender.exe`.
|
||||
This is the binary the Aetheria release map's probes used (M8, M16). It bundles numpy 2.3.4 but not
|
||||
scipy. `mathutils.bvhtree` provides nearest-point and ray queries. The probe needs no other Python
|
||||
packages, since system Python has no numpy, trimesh or open3d. Two other installs exist and are not
|
||||
used: `D:\Steam\SteamApps\common\Blender` (5.0), and 2.77 and 2.78 under `D:\Applications`.
|
||||
|
||||
**GameCult.Geometry.Csg (0.1.0, Rust 2024, deps bevy_math 0.17, rmp-serde, serde, sha2).**
|
||||
- `Primitive` has five variants: Box{bounds}, OrientedBox{center,size,rotation}, CylinderZ,
|
||||
DomeCapZ and FloretArm. The ops are Add, Subtract and Intersect, applied as one ordered stream.
|
||||
- Only Box and OrientedBox take part in the polygon CSG (`convex_cutter`, `assembler.rs:677`).
|
||||
Each of the other three is appended as its own mesh. A Subtract or Intersect brush of one of those
|
||||
types is skipped, and the build records `SubtractIgnoredForNonBox` or `IntersectIgnoredForNonBox`
|
||||
(`assembler.rs:803,836`).
|
||||
- CylinderZ is fixed to the Z axis. `Brush.rotation` is not read when CylinderZ is compiled.
|
||||
- Intersect is global: it clips every solid accumulated before it. `CsgTree` branches exist in
|
||||
`tree.rs`, but the assembler stream does not consume them.
|
||||
- `Assembler::distance(p)` and `stream_distance` honour every op on every primitive. For boxes,
|
||||
oriented boxes and CylinderZ, the field is the exact signed distance.
|
||||
- OBJ output lives in the examples (`examples/dream_machine_level.rs:355`), not the library. No
|
||||
primitive derives `Deserialize`.
|
||||
|
||||
**Hulls.** `C:\Users\Meta\Desktop\Quiet.blend` is the operator's file. Treat it as read-only and use
|
||||
it only through a copy. Measurements from M8 and M16 of `docs/aetheria-release-map.md` on
|
||||
Aetheria origin/master:
|
||||
|
||||
| Hull | Tris | Parts | Largest part | Parts < 2% length | Non-manifold edges | Size (Blender units) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Dexter Quiet | 9,614 | 12 | 87% | 8 | 524 | 0.98 x 1.05 x 0.41 |
|
||||
| Sinister Quiet | 10,880 | 12 | 88% | 7 | 356 | 1.07 x 1.05 x 0.41 |
|
||||
| Headliner | 50,008 | 189 | 33% | 23 | 6,145 | 0.68 x 1.00 x 0.35 |
|
||||
|
||||
Schematics are on the Desktop beside the .blend: `DexterQuietSchematic.png`, `SinisterQuietSchematic.png`
|
||||
and `HeadlinerSchematic.png`. The nose points along Blender -Y, and starboard is -X. The add-on keeps
|
||||
the Tripo mesh in the `Source` child collection (`tools/blender/aetheria_ships/__init__.py:30`), which
|
||||
is never exported. The release map already makes the Tripo mesh a bake source for the operator's
|
||||
rebuild (M16). A brush hull would be that rebuild's geometry.
|
||||
|
||||
## 3. Candidate targets, in increasing difficulty
|
||||
|
||||
1. **Control hull (synthetic).** Hands writes 12-20 Box and OrientedBox brushes in Quiet's
|
||||
proportions: a core hull, a wedge nose made by subtracting angled boxes, wings or sponsons,
|
||||
engine blocks and a bridge. It is meshed by the same evaluator as candidates. The truth file is
|
||||
never shown to the probe agent.
|
||||
2. **Dexter Quiet.** One dominant part (87%), a moderate number of non-manifold edges, and an
|
||||
operator schematic. This is the real test.
|
||||
3. **Headliner.** 189 parts, of which the largest is only 33%. Stretch only. Run it only if Quiet
|
||||
reaches at least the partial band (section 8).
|
||||
|
||||
Sinister Quiet is held back. It is a near-sibling of Dexter and adds little until variance is known.
|
||||
|
||||
## 4. Representation the model writes
|
||||
|
||||
The model writes the Geometry.Csg brush stream as-is, in a thin line form, with one brush per line.
|
||||
The fields map one-to-one onto `Primitive`:
|
||||
|
||||
```
|
||||
# op shape name center x y z size x y z rot_deg x y z (obox only)
|
||||
add box hull_core 0.00 0.02 0.00 0.34 0.80 0.16
|
||||
sub obox nose_chine_top 0.00 -0.46 0.10 0.50 0.30 0.12 -28 0 0
|
||||
add obox sponson_port 0.30 0.10 -0.02 0.14 0.55 0.10 0 0 4
|
||||
int box clip_envelope ...
|
||||
```
|
||||
|
||||
- `box` becomes `Primitive::Box { bounds: Aabb::from_center_size(..) }`.
|
||||
- `obox` becomes `Primitive::OrientedBox` with `Quat::from_euler(EulerRot::XYZ, ..)` in degrees.
|
||||
- Ops are add, sub and int, in stream order. Material is fixed at `MaterialId(0)`.
|
||||
- The vocabulary is Box and OrientedBox only (fork F3). These are the only primitives the kernel
|
||||
actually runs CSG on. A curved primitive cannot be cut, so offering one invites silent skips.
|
||||
|
||||
Why this form and not Rust `LevelDsl` source:
|
||||
- A Rust source form needs a compile on every round.
|
||||
- Rust source invites helper loops and abstractions. VibeGeometry's own failure record names "valid
|
||||
Python, … high node counts, … parameter sliders" among its seductive proxies.
|
||||
- Euler degrees in plain columns are easier to read and edit than quaternions. BenchCAD records
|
||||
single-literal errors as a failure mode, and a flat table makes each literal one visible edit.
|
||||
- No new DSL is introduced. A line is a Brush with its fields written out.
|
||||
|
||||
What Geometry.Csg cannot express for hard-surface hulls. Each gap is a probe readout to tally, not
|
||||
something to build:
|
||||
- Cylinders on any axis other than Z. Engines along Y need these.
|
||||
- Boolean cuts by curved primitives.
|
||||
- Bevels and chamfers, except as subtracted angled boxes.
|
||||
- Mirror and array constructs.
|
||||
- Scoped intersect, meaning an intersect that clips one part instead of the whole stream.
|
||||
|
||||
The probe counts how often the model wanted each one: brush pairs that duplicate each other as
|
||||
mirror images, faceted stand-ins for a cylinder, and many small chamfer cuts. Those counts are the
|
||||
evidence for any later vocabulary cut.
|
||||
|
||||
## 5. The verifier
|
||||
|
||||
Two disposable tools live in the probe scratch directory. Neither is committed to any repo.
|
||||
|
||||
**(a) `csgeval`.** A scratch Rust binary with a path dependency on `F:\Projects\GameCult.Geometry.Csg`,
|
||||
and `CARGO_TARGET_DIR` set inside scratch. It parses the line form (about 80 lines), assembles it,
|
||||
and writes:
|
||||
- an OBJ (using the example's `obj_text`);
|
||||
- the build warnings;
|
||||
- about 20k surface samples, area-weighted, kept only where `|stream_distance| <= 1e-4 x target
|
||||
diagonal`.
|
||||
|
||||
The sample filter exists because overlapping Add brushes can leave internal faces in the mesh. Only
|
||||
points on the field's zero set count as surface, so the kernel's own field decides what the surface is.
|
||||
The binary is built once; each round only runs it.
|
||||
|
||||
**(b) `eval.py`.** A Blender 5.2.2 background script, run as
|
||||
`blender --background --factory-startup --python eval.py -- <cand.obj> <cand_samples> <target.obj> <out>`.
|
||||
It uses only bundled numpy and mathutils, and computes:
|
||||
|
||||
| Metric | How | Role | Prior art |
|
||||
|---|---|---|---|
|
||||
| Chamfer, symmetric mean | 20k area-weighted target samples to the candidate BVH via `find_nearest`, plus candidate samples to the target BVH. Reported as % of the target bbox diagonal. | Headline | BlenderGym geometry metric; CAD-Recode and MeshCoder rank by it |
|
||||
| F-score at 1% and 2% of the diagonal | From the same nearest distances (precision and recall at threshold tau) | Headline. Chamfer hides outliers and F-score does not. | Tatarchenko et al., CVPR 2019 (KNOWLEDGE) |
|
||||
| Silhouette IoU per view | Workbench flat renders at 384 px. Masks for 6 axis views and 2 obliques. IoU per view and the mean. | Robust to the Tripo target's non-manifold edges and floaters | ShapeCraft and SEIG render loops; multi-view beat single-view (prior-art section 4) |
|
||||
| Volumetric IoU | 64^3 voxels. Inside test by majority vote over ray parity on 3 axes. | Control hull only. Too unreliable on the non-manifold Tripo target to report. | MeshCoder and CAD-Recode report IoU |
|
||||
| Brush count; mirror-pair count; kernel warnings | From the brush file and `csgeval` | Compactness and parametric quality | ShapeMOD, which minimises calls and free parameters |
|
||||
|
||||
Two target scopes are fixed in advance:
|
||||
- **Main body:** face-connected parts at least 2% of hull length. This is the headline scope.
|
||||
- **Full:** every part. Reported alongside.
|
||||
|
||||
The pre-registration is the point: the brush list should not be scored on the 8 greeble floaters,
|
||||
and choosing the scope after seeing the numbers would invite rationalisation.
|
||||
|
||||
**Reference band.** BlenderGym's procedural-geometry task scored Chamfer 0.334 for humans against
|
||||
1.120 for the best VLM (GPT-4-Turbo), a 3.4x gap. Those numbers are in BlenderGym's own units and
|
||||
from 2024-25 models, so only the ratio carries over. The probe's own band:
|
||||
- **Floor (B0):** one oriented bounding box, scored by the same verifier. It is computed once per hull.
|
||||
- **Ceiling:** the control's truth list, which scores 0 by construction. On Quiet, the floor is the
|
||||
self-Chamfer between the full and main-body scopes.
|
||||
- **Human band:** optional (fork F4).
|
||||
|
||||
## 6. What the model sees each round
|
||||
|
||||
The model receives two contact sheets of about 1536x1152 px, roughly 2.4k image tokens each, plus
|
||||
one text block.
|
||||
|
||||
1. **Views sheet.** Four rows (front -Y, side +X, top +Z, iso) by three columns: target shaded,
|
||||
candidate shaded, and a silhouette diff. In the diff, target-only pixels are red, candidate-only
|
||||
pixels are blue, and overlap is grey.
|
||||
- Prior art: SceneCraft and SEIG render-critique-revise. ShapeCraft bbox and global views.
|
||||
Multi-view conditioning beat single-view.
|
||||
- The diff column is there because BlenderGym's verifier agreement was low (Claude 3.5 Sonnet
|
||||
0.66 against human 0.79), so the model should not have to judge "close" unaided.
|
||||
2. **Stations sheet.** Seven cross-sections at fixed stations along Y. Each is a front orthographic
|
||||
slab render with clip start and end 1% of length apart, with target and candidate outlines
|
||||
overlaid.
|
||||
- This is naval architecture's own hull description, the station lines.
|
||||
- It also gives the model the interior profile that silhouettes hide. Text-to-B-Rep and BenchCAD
|
||||
name spatial misalignment and dropped features as the dominant failures.
|
||||
3. **Text block.**
|
||||
- The metrics table for this round and the best round so far.
|
||||
- Per-view IoU.
|
||||
- Brush count and kernel warnings.
|
||||
- Round 0 adds the target bbox and the per-station width and height extents as numbers.
|
||||
|
||||
Per prior art, numeric feedback alone peaked at round 1 (2607.05573), while render critique kept
|
||||
improving through rounds 2-3. Both are given. BenchCAD found the image "at best a clarifier" for
|
||||
thinking models, so the numeric extents carry the measurements and the images carry the shape.
|
||||
|
||||
## 7. The loop
|
||||
|
||||
**Session.** One fresh Opus agent session per hull run. The brief contains:
|
||||
- the operator's quote;
|
||||
- the line-form grammar and its semantics: stream order, global intersect, obox Euler XYZ in degrees;
|
||||
- the axis convention (nose -Y, starboard -X);
|
||||
- the brush budget;
|
||||
- the single evaluation command.
|
||||
|
||||
**Sight.** Eyes-only (fork F2). The agent may write `cand.txt`, run `eval.cmd`, which chains
|
||||
`csgeval` and `eval.py`, and read the sheets and text the command emits. It may not open the target
|
||||
mesh, the control truth file or the schematics. This restriction is a brief instruction, not
|
||||
something enforced, so the transcript is audited afterwards for any read of target paths.
|
||||
|
||||
**Rounds.**
|
||||
- Round 0 writes a first list from the target pack.
|
||||
- Rounds 1 to 7 each revise the list, so there are at most 8 evaluations per run.
|
||||
- The best round is the one with the lowest main-body Chamfer. A regression is kept in the log but
|
||||
never replaces the best, as in CAD-Recode's min-Chamfer selection.
|
||||
|
||||
**Stop** at the first of these:
|
||||
- the round cap is reached;
|
||||
- main-body Chamfer improves less than 2% (relative) for two consecutive rounds;
|
||||
- every success threshold is met.
|
||||
|
||||
**Held fixed across rounds and runs:**
|
||||
- the camera rig, resolution and sample counts, with a fixed seed;
|
||||
- the target scopes;
|
||||
- the vocabulary;
|
||||
- the brief text;
|
||||
- the model, Opus 5.5;
|
||||
- the brush budget: 40 for Quiet, 30 for the control, 80 for Headliner.
|
||||
|
||||
The budget forces description rather than voxelisation. A list of 500 tiny boxes is not parametric.
|
||||
|
||||
**Edit readout (one extra turn after the best round).** The model is asked to "lengthen the nose 20%
|
||||
and widen the sponsons 10%". The readout is how many lines it touches and whether Chamfer to a
|
||||
correspondingly scaled target stays within 1.5x of its best. That measures whether the description
|
||||
is parametric in the operator's sense, editable intent, or only a fit.
|
||||
|
||||
**Runs.**
|
||||
- Control x1.
|
||||
- Dexter Quiet x2, because one run of a stochastic model is an anecdote.
|
||||
- Headliner x1, only if Quiet reaches the partial band or better.
|
||||
|
||||
All runs go on Starfire, one at a time. Blender 5.2.2 is installed only there, and each evaluation is
|
||||
a small background Workbench job.
|
||||
|
||||
## 8. Success and failure, fixed before running
|
||||
|
||||
The thresholds are judgment calls, written down now so the result cannot move them.
|
||||
|
||||
| Target | Pass | Partial (blockout-grade) | Fail |
|
||||
|---|---|---|---|
|
||||
| Control | Chamfer <= 1% diag, mean sil-IoU >= 0.95, vol-IoU >= 0.90, brushes <= 1.5x truth | Chamfer <= 3%, sil-IoU >= 0.85 | Worse than partial |
|
||||
| Dexter Quiet (main body, best of 8, both runs) | Chamfer <= 2% diag, F@2% >= 0.80, mean sil-IoU >= 0.90, Chamfer <= 0.33 x B0 | sil-IoU 0.80-0.90 and Chamfer <= 0.5 x B0 | sil-IoU < 0.80, or Chamfer > 0.5 x B0 |
|
||||
| Loop effect (any target) | Best-round Chamfer <= 0.7 x round-0 Chamfer | | Best is round 0 or within 10% of it |
|
||||
|
||||
What each outcome means for the operator's plan:
|
||||
- **A. Control passes, Quiet passes.** The idea earns a campaign. The scope would be:
|
||||
- Brokkr's Blender surface gains the harness's capabilities as typed commands: import mesh,
|
||||
render views, evaluate against a target, apply a brush list.
|
||||
- Geometry.Csg gains only the vocabulary gaps the tallies prove.
|
||||
- The hull rebuild in M16 starts from the model's brush list, and the Tripo mesh stays the bake
|
||||
source.
|
||||
- **B. Control passes, Quiet partial or fails.** The model reasons well enough and the vocabulary or
|
||||
the noise is the limit. The next probe arm is either one vocabulary extension from the tallies, or a
|
||||
classical front end that hands the model primitive hints. RANSAC planes (Schnabel 2007) and the
|
||||
discretisation step from InverseCSG are the candidates. Neither is built before this result.
|
||||
- **C. Control fails.** Even in-vocabulary placement fails with this harness. Do not build Brokkr
|
||||
features for this. Two alternatives remain:
|
||||
- fitting without the model: InverseCSG-style search, or differentiable CSG refinement (UCSG-Net,
|
||||
the fuzzy-Boolean paper);
|
||||
- keeping the operator as the modeller, with the model editing her blockout instead of writing one.
|
||||
- **D. Loop effect fails, whatever the absolute scores.** Render-compare is not buying anything. One
|
||||
shot plus human editing is the shape, and a Brokkr render loop is not justified by this use.
|
||||
- **Edit readout.** If the edit touches more than 5 lines or blows the 1.5x bound, the output is a
|
||||
fit, not a description, even under outcome A. The campaign would then need structure the
|
||||
line form lacks (named parts, mirror), and that must be decided before any build.
|
||||
|
||||
## 9. Cost
|
||||
|
||||
- **Harness (Hands, one bounded batch, cap of about 60 calls).**
|
||||
- Copy `Quiet.blend` to scratch and export Dexter, Headliner and both main-body scopes to OBJ.
|
||||
- Write the control truth list.
|
||||
- Write `csgeval` (about 120 lines) and `eval.py` (about 250 lines) and `eval.cmd`.
|
||||
- Compute B0 for each hull.
|
||||
- Self-check: the control truth scores Chamfer about 0 and IoU 1; the OBB scores below truth.
|
||||
- One cargo build of a small dependency set (bevy_math, serde, rmp-serde, sha2) into scratch. That
|
||||
is a few hundred MB, deleted afterwards. This is the only compile.
|
||||
- **Probe runs.** About 30 calls per run (setup, then about 3 per round over 8 rounds, plus the edit
|
||||
readout).
|
||||
- Four runs make about 120 calls. Headliner is conditional, so three runs make about 90.
|
||||
- Each round adds roughly 5k image tokens, so context peaks around 60-80k tokens per run. Total input
|
||||
is about 1.5-2M tokens per run, mostly cache reads.
|
||||
- **Blender.** About 35 evaluations across all runs plus about 5 harness checks. Each is 15-40 s of
|
||||
background Workbench work. They run serially on Starfire, never two at once and never alongside
|
||||
another heavy job, for about 20 minutes of Blender time in all.
|
||||
- **Total.** About 150-190 agent calls and one session-day of wall clock. Self reads the results;
|
||||
Soul is not needed for a probe that admits nothing.
|
||||
|
||||
## 10. What not to build
|
||||
|
||||
- No Brokkr changes: no new commands, no render surface, no MCP server install. The Blender Lab MCP
|
||||
is GPL-3.0 and unauthenticated, and background scripts answer the question without it.
|
||||
- No service, daemon, Eve surface or CultCache persistence. The probe's state is files in scratch.
|
||||
- No new crate or package in any repo, and no commits to Geometry.Csg. `csgeval` is a scratch binary
|
||||
with a path dependency.
|
||||
- No grammar beyond the line form: no mirror, array or bevel constructs. Their absence is measured,
|
||||
not fixed.
|
||||
- No RANSAC front end, differentiable optimiser or point-cloud encoder in v1.
|
||||
- No writes to `C:\Users\Meta\Desktop\Quiet.blend` or anything under `F:\Projects\Aetheria`.
|
||||
- The VibeGeometry scar applies directly. That project "dived almost immediately into CSG grammar,
|
||||
parity fixtures, native bridge probing, mesh generation, and performance work" before measuring
|
||||
whether the authoring loop worked. This probe measures first. Infrastructure is earned by outcome A
|
||||
and scoped by the tallies.
|
||||
|
||||
Kept afterwards: one result document. It holds the metrics table per run and round, the best sheets,
|
||||
the brush lists, the vocabulary tallies, the transcript-audit line, and which outcome A-D held. Its
|
||||
home is suggested below.
|
||||
|
||||
## 11. Forks for the operator (questions, with recommendations; not admitted)
|
||||
|
||||
**F1. Evaluator: Geometry.Csg's own kernel, or Blender's exact booleans?**
|
||||
- (a) Scratch `csgeval` on the real kernel. This needs one small cargo build.
|
||||
- (b) `eval.py` builds the boxes in Blender with exact boolean modifiers. No Rust at all.
|
||||
|
||||
Recommend (a), confidence moderate. The question is whether the Geometry.Csg brush list can be the
|
||||
durable design surface, and only its own kernel and field prove that the list meshes. Kernel faults
|
||||
that turn up are findings, not noise. (b) answers "can Opus box-model this", which is a weaker
|
||||
question.
|
||||
|
||||
**F2. What the probe agent may see.**
|
||||
- (a) Eyes-only: renders, sections and scores.
|
||||
- (b) Full tool access, so the agent may script numeric analysis of the target mesh: sections, plane
|
||||
fits, its own optimiser.
|
||||
|
||||
Recommend (a) first, confidence high, because the operator asked about Opus's spatial reasoning.
|
||||
(b) answers "can Opus write a fitter", which is a different and probably easier question. It is a
|
||||
cheap second arm if (a) lands in B or C.
|
||||
|
||||
**F3. Vocabulary: boxes and oriented boxes only, or include CylinderZ, DomeCapZ and FloretArm?**
|
||||
- (a) Boxes only.
|
||||
- (b) All five, with the curved ones additive-only, since the kernel skips cuts by them.
|
||||
|
||||
Recommend (a), confidence moderate. In (b), a cut by a curved primitive is silently skipped, which
|
||||
corrupts the comparison. The cylinder demand is tallied instead.
|
||||
|
||||
**F4. Human reference band.** Should the operator spend about 30 minutes box-modelling Dexter Quiet
|
||||
by hand in Blender, scored by the same verifier?
|
||||
|
||||
Recommend no for v1. Ask only if Quiet lands on the partial and pass boundary, where a human number
|
||||
would decide the outcome.
|
||||
|
||||
**F5. Brokkr in the path now?**
|
||||
- (a) No Brokkr. Plain background scripts.
|
||||
- (b) First give Brokkr's Blender adapter render, import and evaluate commands, then probe through it.
|
||||
|
||||
Recommend (a), confidence high. Brokkr lacks every capability the loop needs, and building them first
|
||||
repeats VibeGeometry's order. Outcome A makes the harness the spec for those commands.
|
||||
|
||||
**F6. Targets and runs.** Control x1, Dexter Quiet x2, Headliner x1 only on a partial or better.
|
||||
Alternative: Quiet x1 to halve the cost.
|
||||
|
||||
Recommend x2. With n = 1, a pass or fail is indistinguishable from sampling luck.
|
||||
|
||||
Suggested home for the result document (Self decides): `F:\Projects\Brokkr\docs\research\parametric-hull-probe.md`,
|
||||
beside the capability bar. If outcome A holds, the result steers Brokkr's Blender surface. If B or C
|
||||
holds, it steers Aetheria's hull rebuild instead, and a pointer belongs in the release map.
|
||||
226
docs/research/mesh-probe-run2-shippable/RESULT.md
Normal file
226
docs/research/mesh-probe-run2-shippable/RESULT.md
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
# Mesh probe run 2: is the lowered geometry shippable?
|
||||
|
||||
Agent `probe-shippable-lowering`, session `self-2026-10-10-ag2`, 2026-10-10. Disposable scratch; no repo edited.
|
||||
Root: this directory (`...\scratchpad\mesh-probe\run2-shippable\`).
|
||||
|
||||
Operator question: "Sure, it can fit geometry, especially with 'real OpenSCAD', but is it good geometry? Do we
|
||||
end up with something we'll want to ship, if we go this way? Because I'm not sure we wouldn't be better off
|
||||
building with, say, Blender's geonodes."
|
||||
|
||||
Her bar (brush-tree:ruling:shippable-part-bar): clean, unbroken topology flow; UVs that keep the parts of one
|
||||
object together, with minimal distortion; hard edges sharpened by a one-segment bevel shaded with face-weighted
|
||||
normals.
|
||||
|
||||
Input: `docs/research/mesh-probe-run1/dexter-quiet-compact.scad` from the brush-tree branch, copied here as
|
||||
`A.scad`. It is run 1's round 3: superellipse stations, n = 3. All three lowerings use its numbers:
|
||||
- planform, 17 points;
|
||||
- wing plane z = a + b|x| + c·y, with wing_t 0.045;
|
||||
- trailing scallops;
|
||||
- 12 tail and body stations;
|
||||
- pods as hulls of two spheres;
|
||||
- the chin fin.
|
||||
|
||||
Python copy of the numbers: `tools/desc.py`.
|
||||
|
||||
## Contact sheets (rows top to bottom: Tripo target, A, B, C; orange squares = row number)
|
||||
|
||||
- **`sheet-main.png`**. Columns: 3/4 front, side and 3/4 rear in metallic grey under studio light, then a
|
||||
wireframe overlay, then the UV layout. In the UV column, colour marks the island and red marks overlap. The
|
||||
target has no UVs, so its UV tile is black. This is the one to judge at a glance.
|
||||
- **`sheet-closeup.png`**. Columns: close-up of the wing edge and wing-body junction (shaded, then wireframe),
|
||||
and a top view with wireframe. The bevel highlights and the faceting are visible here.
|
||||
- **`sheet-edit.png`**. Rows: A, A_edit, B, B_edit, C, C_edit. The edit is nose_k 1.0 to 1.2 plus a new
|
||||
wing_sweep of 0.1.
|
||||
- `sheet-top.png`: top views, shaded and wireframe.
|
||||
- Per-lowering frames are in `out/<X>.v0-4.png`, `out/<X>.w0,w2,w4.png` and `out/<X>.uv.png`. Metrics are in
|
||||
`out/<X>.metrics.json`, scores in `out/<X>.score.txt`, and the .blend files in `out/`.
|
||||
|
||||
Renders are Eevee (Blender 5.2.2). The look:
|
||||
- Principled metallic 1.0, roughness 0.3, grey;
|
||||
- Blender's bundled `studio.exr` world plus one big soft area key;
|
||||
- orthographic camera, dark ground composited.
|
||||
|
||||
This approximates her test render; it is not her scene.
|
||||
|
||||
## The three lowerings
|
||||
|
||||
- **A, OpenSCAD lowering with the best automatic cleanup** (`tools/lower_a.py`):
|
||||
- OpenSCAD 2026.09.23 with Manifold renders the file (2 s, 2836 triangles, genus 0).
|
||||
- Blender then runs: merge by distance, tris-to-quads, limited dissolve at 2°, Bevel (1 segment, by angle
|
||||
30°, width 0.004), Smart UV Project (66°) and Weighted Normal (face area, keep sharp).
|
||||
- A2 also tried QuadriFlow, see "Could not test".
|
||||
- **B, Blender built by script** (`tools/lower_b.py`, 174 lines):
|
||||
- Body: tail and body stations as one quad loft, 36 verts per ring, with 2 Catmull-Rom rings inserted per
|
||||
span. The ends get Coons-patch quad caps.
|
||||
- Wing: one quad sheet across the whole span, with columns at the planform breakpoints and at the
|
||||
trailing-edge kink, 16 chord rows, the scallop as the trailing edge, and a closed side band.
|
||||
- Exact Boolean only for the wing into the body.
|
||||
- Pods are quad capsules and the chin fin is a prism. Both are left as intersecting shells, not
|
||||
booleaned.
|
||||
- Seams go on edges over 30°, the body side lines and the cap rings. Then unwrap (angle based), average the
|
||||
island scale, pack, Bevel (1 segment, 30°), Weighted Normal.
|
||||
- **C, Geometry Nodes generated by Python** (`tools/lower_c.py`, 172 lines). It is the same construction as
|
||||
one node group, `DexterQuiet`, with 199 nodes and 271 links. Its 16 parameters are modifier inputs:
|
||||
- nose_root, nose_k, wing_t, wing_a/b/c, wing_sweep, scallop_r/cx/cy, sect_n, ring_n, loft_res,
|
||||
wing_cuts, chord_rows, seam_angle.
|
||||
- Station and planform data live in Index Switch nodes.
|
||||
- Body: Points, then Points to Curves (Catmull-Rom), then Curve to Mesh with a superellipse profile, then
|
||||
Set Position from the hw/zc/hh attributes.
|
||||
- Wing: a Grid whose columns sample the leading-edge curve, then Extrude Mesh, joined with a flipped copy.
|
||||
- Mesh Boolean (Manifold solver) for the wing into the body.
|
||||
- Pods are UV spheres reshaped into capsules; the fin is a convex hull.
|
||||
- UVs come from the UV Unwrap node, with the seam field = edge angle > 30° or a `cut_e` edge flag, then
|
||||
Pack UV Islands.
|
||||
- Modifier stack: GN, then Bevel (1 segment, 30°), then Weighted Normal.
|
||||
|
||||
## Measurements
|
||||
|
||||
| | A (OpenSCAD + cleanup) | B (bmesh script) | C (geonodes) |
|
||||
|---|---|---|---|
|
||||
| Faces: quads / tris / ngons | 1146 / 125 / 168 | 3039 / 49 / 215 | 3979 / 127 / 229 |
|
||||
| Largest ngon | **59** (wing top/bottom dissolved to single faces) | 11 (boolean seam) | 36 (loft end caps; GN has no grid fill) |
|
||||
| Poles (valence ≠ 4) per 1000 verts | **411.7** | 100.1 | 98.0 |
|
||||
| Poles of valence 6 or more | 10 | 0 | 4 (UV-sphere pod tips) |
|
||||
| Longest edge loop (edges) | **8** | 86 | 35 |
|
||||
| Closed loops ≥ 20 edges | 0 | 41 | 57 |
|
||||
| Share of edges in loops ≥ 20 edges | **0.013** | 0.528 | 0.605 |
|
||||
| Non-manifold edges / shells | 0 / 1 | 0 / 4 (pods and fin intersect) | 0 / 4 |
|
||||
| Exported triangles | 3392 | 6914 | 9022 |
|
||||
| Sliver tris (< 10°) after export triangulation | 2082 (61%) | 2112 (31%) | 2537 (28%) |
|
||||
| …excluding faces of bevel width | 491 | 336 | 157 |
|
||||
| Dihedral > 60° / > 80° left (unbevelled hard edges) | 86 / 15 | 83 / 7 | 77 / 9 |
|
||||
| UV islands: total / ≥ 1% of area / < 4 faces | 51 / 13 / 15 | 23 / 11 / 2 | 21 / 11 / 2 |
|
||||
| UV area distortion, mean \|log2\|, area-weighted | 0.120 | 0.113 | 0.118 |
|
||||
| UV area outside 0.5-2x | 0.1% | 0.7% | 1.1% |
|
||||
| UV angle error, mean | 2.74° | **1.04°** | 1.14° |
|
||||
| UV space wasted | 54% | 47% | 61% |
|
||||
| Chamfer % diag / F@2% / mean sil-IoU (run-1 `mp score`) | 0.852 / 0.901 / 0.919 | 0.841 / 0.899 / 0.920 | 0.840 / 0.900 / 0.920 |
|
||||
|
||||
Raw OpenSCAD output scores 0.852 / 0.901 / 0.919. Cleanup lost no shape, and B and C lose none against A.
|
||||
|
||||
Reading the numbers:
|
||||
- **Fidelity is the description's, not the lowering's.** All three are within 0.012 chamfer. This is the
|
||||
same 0.852 that run 1's round 3 scored. No lowering makes the description closer to the target.
|
||||
- **A fails the topology bar, and cleanup cannot fix it.** Hull-of-disks lofting plus CSG emits long fan
|
||||
triangles. Join-triangles and limited dissolve turn them into a patchwork with no loops longer than 8
|
||||
edges. The wing top and bottom become single ngons of up to 59 sides. 61% of the exported triangles are
|
||||
slivers. On the close-up, the body hull's facets show through the weighted normals as flat shading
|
||||
patches.
|
||||
- **A's UVs look fine on paper** (area 0.12), because Smart UV cuts the surface into 51 islands, 15 of them
|
||||
crumbs under 4 faces. That breaks "parts of one object kept together". See its UV tile.
|
||||
- **B and C meet the topology-flow bar on the loft and the wing.** Body rings are closed 36-edge loops from
|
||||
stinger to beak, and the wing is a quad grid.
|
||||
- **Where B and C miss the topology bar:**
|
||||
- The wing-body boolean seam leaves 215-229 ngons and most of the poles. That is the one place a hand
|
||||
modeller would retopologise.
|
||||
- The pods intersect the body as separate shells instead of being merged.
|
||||
- C's loft caps are 36-gons and its pod tips are pole fans. Geometry Nodes has no grid-fill or Coons
|
||||
patch, so B's quad caps have no direct node equivalent.
|
||||
- **The remaining slivers in B and C are mostly the bevel strips themselves.** A 0.004 strip against
|
||||
0.025 edges is inherently thin when triangulated; her bevel style has the same property. Excluding faces
|
||||
of bevel width, C has 157 and B 336, from the thin rings near the stinger and beak tips.
|
||||
- **The bevel bar is met on the outline in all three:** one segment, a bright highlight on the wing edge
|
||||
band, weighted normals on.
|
||||
- **It is not met at the wing-body junction**, where the crease is under 30° over much of its length, so
|
||||
it gets no bevel. That is a hard intersection line, not a fillet. The edge counts above 60° (77-86) are
|
||||
similar in all three, so the leftover hard edges are in the description (fin, pod ends, crease), not in
|
||||
the lowering.
|
||||
- **The normal-deviation metric** (mean angle between custom and face normals, 3.6-4.4° in all three) is
|
||||
not diagnostic. The largest faces here sit on the curved body, which is legitimately curved. I have
|
||||
dropped it as evidence.
|
||||
- **The UV bar is met by B and C.** Each has about 11 islands of 1% of area or more:
|
||||
- body top, body bottom;
|
||||
- wing top and wing bottom per side;
|
||||
- the wing edge band;
|
||||
- pods, caps, fin.
|
||||
|
||||
Angle error is about 1° and area distortion about 0.11. Packing wastes 47-61%, which is plain
|
||||
pack_islands; no trim or texel-density decisions were made.
|
||||
|
||||
## Editability (nose_k 1.0 to 1.2, plus a wing_sweep 0.1 that the description lacked)
|
||||
|
||||
| | What the edit is | Rerun | Topology after the edit |
|
||||
|---|---|---|---|
|
||||
| A | 2 `.scad` lines (one value, plus a `multmatrix` shear on the planform inside `wing_side`) | OpenSCAD 2 s + cleanup script ~3 s | It changes. Faces 1439 to 1434, ngons 168 to 173, islands 51 to 48. Every edit re-rolls the patchwork, and any hand fix-up in Blender is lost. |
|
||||
| B | `DESC_OVERRIDES` or `desc.py` values; sweep is 1 line in the wing loop | Script ~3.6 s, produces a new .blend | Stable. Loops 86 to 86, islands 23 to 23, +4 faces at the boolean seam. Hand work is lost on rerun. |
|
||||
| C | Two modifier input values. Sweep was one exposed input plus 2 math nodes. | Live in the modifier panel. Script build ~3 s. | Stable. Loops 35 to 35, islands 21 to 21, +2 faces. Hand work can only be stacked as later modifiers; editing the result means applying the modifier. |
|
||||
|
||||
`sheet-edit.png` shows the edits. They are small on screen: the nose grows from 0.52 to 0.552 and the wing
|
||||
tips sweep back about 0.05.
|
||||
|
||||
## B and C converge
|
||||
|
||||
Same fidelity (0.841 / 0.840). Same UV quality (angle error 1.04° / 1.14°, area 0.113 / 0.118). Same island
|
||||
structure, and the same misses at the boolean seam. The differences are resolution choices and two Geometry
|
||||
Nodes gaps:
|
||||
- the caps are ngons, and the pods are UV spheres with pole fans;
|
||||
- there is no bevel node, so the bevel is a modifier after the tree, and the unwrap happens before the
|
||||
bevel. The bevel faces inherit UVs from the neighbouring face, and that measured fine.
|
||||
|
||||
**Geonodes is a practical lowering target the model can drive.** One generator script, about 170 lines,
|
||||
produced a working parametric tree. I hit four Blender 5.2 API traps before it ran:
|
||||
1. The Index Switch has a virtual extend socket.
|
||||
2. Several enum properties became menu sockets that take UI labels: `'Angle Based'`, not `'ANGLE_BASED'`.
|
||||
3. Modifier inputs are set through `mod.properties.inputs[id]["value"]`. `mod[id]` is gone.
|
||||
4. **The GN Mesh Boolean with the Exact solver corrupted named attributes.** Wing vertices came out with
|
||||
`cut` and `endcap` = 1, values the wing never had, even after storing explicit zeros. The Manifold
|
||||
solver propagates them correctly.
|
||||
|
||||
Point-attribute seams also broke where the boolean split an edge, which merged the body's top and bottom UV
|
||||
islands. The fix was carrying the seam as an edge attribute, `cut_e`. I checked it with
|
||||
`tools/c_opunwrap.py`: the same geometry unwrapped by the operator gave the identical bad result, so this
|
||||
was a seam-definition issue, not the UV node.
|
||||
|
||||
**Readability:** the tree's node positions are auto-placed with no frames, so as a canvas it is spaghetti.
|
||||
The readable artefact is the Python generator. A tree she would want to open needs frames per part
|
||||
(body / wing / pods / UV) and a layout pass, which I did not do.
|
||||
|
||||
## Conclusion on her question
|
||||
|
||||
**OpenSCAD as the lowering does not give geometry she would ship, and automatic cleanup cannot rescue
|
||||
it.** Its output has already lost the construction intent: which vertices are a station ring, which faces
|
||||
are the wing sheet. The topology numbers above are the evidence: 412 poles per 1000 vertices, a longest loop
|
||||
of 8 edges, 59-gons, 61% slivers, and 51 UV crumbs.
|
||||
|
||||
**Blender lowering (B or C) from the same description meets her bar where the description has clean
|
||||
construction:** the body loft and the wing sheet. Both misses come from the construction, not the lowering
|
||||
tool:
|
||||
- the boolean seam where the wing enters the body (ngons and poles);
|
||||
- intersecting pods and fin.
|
||||
|
||||
Neither B nor C is a finished part yet. The description itself is crude, so the part is too: a
|
||||
constant-thickness slab wing, no crease fillet, blob pods, 0.84% chamfer.
|
||||
|
||||
**Recommendation: the hybrid.** The model writes the description, in OpenSCAD or anything else that carries
|
||||
the parameters and the construction intent: stations, planform, plane, part list. Blender lowers it by
|
||||
construction, not by CSG output.
|
||||
- **Geonodes (C)** is the better target for her. It is equal in quality to B and live-editable from the
|
||||
modifier panel, which is her tool.
|
||||
- **B's bmesh script** is the fallback for constructions nodes cannot express, such as quad caps or
|
||||
grid fill.
|
||||
- **"Real OpenSCAD" can stay as the authoring and fitting surface** only if the lowering reads its
|
||||
parameters and not its mesh. Lowering the CSG tree itself reproduces A.
|
||||
|
||||
Confidence: moderate for "A cannot meet the bar". That follows from what the output loses, and the numbers
|
||||
are large. Low to moderate for "C is shippable with work":
|
||||
- one hull, one description;
|
||||
- construction and seam choices are mine;
|
||||
- no human retopologised the seam for comparison;
|
||||
- the eye on the renders was mine, not hers.
|
||||
|
||||
## Could not test / caveats
|
||||
|
||||
- **QuadriFlow, A2's best automatic retopology**, refused in background mode. It reported "needs to be
|
||||
manifold" even on bmesh-manifold input, and again after a voxel remesh. A voxel remesh alone (0.004)
|
||||
gives 114k faces and 380 poles per 1000 vertices, and destroys the hard edges that bevel-by-angle needs.
|
||||
I did not try QuadriFlow in the GUI. `tools/lower_a2.py` and `out/A2.*` hold the failed attempt.
|
||||
- Her studio scene, texture baking and decals, export into the game engine, and LOD were not tested.
|
||||
Packing efficiency was not tuned.
|
||||
- Interpenetrating pods were a choice. Booleaning them in would add more seam ngons of the kind the wing
|
||||
produced.
|
||||
- The A cleanup parameters (dissolve 2°, join 10°/40°, Smart UV 66°) were set once, not swept.
|
||||
- Tools: `tools/run.sh measure|render|target|score <name>`, `measure.py`, `render.py` and `sheet.py`. The
|
||||
scorer is run 1's `mp`, rebuilt into `tools/mp-target`; that 156 MB build directory has been deleted, so
|
||||
rebuild it before rescoring. `dbg*.py` are throwaway diagnostics.
|
||||
- No processes of mine are running. The operator's GUI Blender (PID 36324) was never touched. I waited out
|
||||
another agent's background Blender smoke and a cargo build before my first jobs.
|
||||
248
docs/research/mesh-probe-run3-subd/RESULT.md
Normal file
248
docs/research/mesh-probe-run3-subd/RESULT.md
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
# Mesh probe run 3: Dexter Quiet as one Catmull-Clark body
|
||||
|
||||
Agent `probe-subd-cage-quiet`, session `self-2026-10-10-ag2`, 2026-10-11. Disposable scratch; no repo was edited.
|
||||
Root: this directory (`...\scratchpad\mesh-probe\run3-subd\`). Blender 5.2.2, `--background` only, bundled numpy
|
||||
and mathutils only. No downloads, no pip, no closed tools.
|
||||
|
||||
Serves brush-tree:ruling:representation-per-hull, :no-unownable-tools-in-loop and :shippable-part-bar.
|
||||
|
||||
## What I built
|
||||
|
||||
**One closed Catmull-Clark cage** (`tools/build.py`, about 330 lines), mirror-symmetric, fitted to the run-1
|
||||
target (`run1/targets/dexter_main.obj`). It has three parts.
|
||||
|
||||
1. **Designed topology. This is the part no prior art automates.** It is a "pillow" of a top sheet and a
|
||||
bottom sheet, joined by a rim band two faces tall. Each sheet has:
|
||||
- A **spine strip** along the centreline, 2 x 16 quads.
|
||||
- **5 concentric rings** from the spine out to the outline. Their loops run parallel to the manta outline:
|
||||
beak side, leading edge, wing tip, trailing scallop, stinger.
|
||||
- **Spokes** (the radial edges) from the spine out to the outline. These carry the wing sweep into the
|
||||
body.
|
||||
|
||||
The outline is 18 edges per half: beak 2, leading edge 8, tip 1, scallop 4, stinger 3. It comes from run
|
||||
1's planform numbers plus the scallop circle.
|
||||
|
||||
The only poles in the cage are the 4 strip corners per sheet plus the inset corners. The rim band is
|
||||
creased 1.0 along the wing stretch only; the beak and stinger stay smooth.
|
||||
2. **The fused mechanical sweep: a canopy frame.**
|
||||
- The region is chosen by topology, not by position: spine rows 11-15 plus rings 1-2 beside them.
|
||||
- It is inset twice, with creases of 0.5 and 0.6.
|
||||
- The frame loop is raised 6 mm after the fit.
|
||||
- It carries a 20x local shape regulariser, so the fit cannot crumple it.
|
||||
|
||||
The belly keel and side lobes come out of the fit on the bottom sheet; I modelled no separate parts for
|
||||
them. I did not model the chin pods, dorsal vents, wing strakes or the tail nozzle (see "Where it falls
|
||||
short").
|
||||
3. **Least-squares fit of cage positions.** This is my own, simpler version of the Marinov and Kobbelt
|
||||
approach:
|
||||
- The level-2 limit-surface vertices are linear in the cage, `X = S c`. I extract `S` exactly by probing
|
||||
Blender's Subdivision modifier with unit vectors, 2 s.
|
||||
- Each iteration: point-to-plane from the surface to the target, with a 0.15 point-to-point term; a
|
||||
coverage term from the target to the surface with normal-compatible correspondences; a fairness term
|
||||
‖Lc‖²; and a shape term ‖L(c − c₀)‖² that keeps the designed layout.
|
||||
- The solve is in a mirror-reduced space: 843 unknowns for a 542-vertex cage. 30 iterations take about
|
||||
55 s.
|
||||
|
||||
**Lowering** (same script):
|
||||
- Seams go on the cage: the rim's middle loop (top / bottom) and the canopy glass loop. The seam flags
|
||||
propagate through the subdivision.
|
||||
- Apply subdivision at the ship level, then Minimum Stretch (SLIM) unwrap, average island scale, pack.
|
||||
- Bevel, 1 segment, angle 30°, width 0.004, then Weighted Normal (face area, keep sharp). The stack is the
|
||||
same as run 2's B and C.
|
||||
|
||||
**Final variant `v7`:** ship level 2. `v7L1` is the same cage at ship level 1. `v7e` is the edit test.
|
||||
|
||||
## Contact sheets (orange squares = row number)
|
||||
|
||||
- **`sheet-main.png`**, the best one to judge at a glance. Rows: 1 Tripo target, 2 run 2's B (bmesh loft +
|
||||
boolean), 3 v7 (level 2), 4 v7L1 (level 1). Columns: 3/4 front, side, 3/4 rear, wireframe overlay, UV
|
||||
layout. The target has no UVs, so its tile is black.
|
||||
- `sheet-closeup.png`: the wing edge and wing-body junction (shaded, then wireframe), then a top view with
|
||||
wireframe. Rows: T, B, v7, v7L1.
|
||||
- **`sheet-cage.png`**: the cage I designed (orange) over its limit surface. Views: 3/4, top, side, bottom.
|
||||
- `sheet-edit.png`: v7 against v7e (wider sweep plus longer beak). Columns: 3/4, top, top with cage, side.
|
||||
- `sheet-under.png`: bottom and 3/4 front-high. Rows: T, v7.
|
||||
- Per-variant frames are in `out/<name>.v0-6.png`, `.w0/.w2/.w4.png` and `.c0/.c1/.c2/.c5.png` (cage).
|
||||
Metrics are in `.metrics.json`, scores in `.score.txt`, build info in `.build.json` and the .blend files
|
||||
in `out/`.
|
||||
- Renders use run 2's look. An earlier render pass shaded the live cage limit surface over the hull (it
|
||||
z-fought); that is fixed, and the sheets above are the clean re-renders.
|
||||
|
||||
## Measurements (same measure.py as run 2)
|
||||
|
||||
| | B (run 2) | **v7: subd, level 2** | v7L1: subd, level 1 |
|
||||
|---|---|---|---|
|
||||
| Faces: quads / tris / ngons | 3039 / 49 / 215 | 9297 / 196 / 180 | 2533 / 158 / 130 |
|
||||
| Largest ngon | 11 (boolean seam) | 6 (bevel corners) | 6 |
|
||||
| Shells | 4 | **1** | 1 |
|
||||
| Poles per 1000 verts | 100.1 | **47.1** | 108.7 |
|
||||
| Poles of valence 6 or more | 0 | 0 | 0 |
|
||||
| Longest loop (edges) | 86, open | **148, closed** | 62 |
|
||||
| Closed loops ≥ 20 edges | 41 | 30 | 3 |
|
||||
| Share of edges in loops ≥ 20 | 0.528 | **0.755** | 0.417 |
|
||||
| Exported triangles | 6914 | 19340 | **5624** |
|
||||
| Sliver tris < 10° (share) | 31% | 24% | 33% |
|
||||
| …excluding faces of bevel width | 336 | 254 | 340 |
|
||||
| Dihedral > 60° / > 80° left after bevel | **83 / 7** | 546 / 316 | 352 / 223 |
|
||||
| UV islands: total / ≥ 1% of area / crumbs | 23 / 11 / 2 | **3 / 3 / 0** | 3 / 3 / 0 |
|
||||
| UV area distortion, mean \|log2\| | 0.113 | **0.076** | 0.078 |
|
||||
| UV area outside 0.5-2x | 0.7% | 0.8% | 0.9% |
|
||||
| UV angle error, mean | **1.04°** | 2.61° | 3.04° |
|
||||
| UV space wasted | 47% | 59% | 60% |
|
||||
| Chamfer % diag / F@2% / sil-IoU | 0.852 / 0.898 / 0.920 | **0.228 / 0.990 / 0.967** | 0.233 / 0.990 / 0.965 |
|
||||
|
||||
- **Scorer.** Run 1's `mp` binary was deleted, and rebuilding it means a cargo build against
|
||||
GameCult.Geometry.Csg. I ported `mp score` to numpy instead (`tools/score.py`, same sample counts, views
|
||||
and τ). It scores B at 0.852 / 0.898 / 0.920; `mp` gave 0.841 / 0.899 / 0.920. The B column above is
|
||||
rescored with the port, so the comparison is like for like.
|
||||
- **Cage.** 542 vertices, 540 faces, 118 creased edges. The free parameters are 843 coordinates from 301
|
||||
half-cage vertices; the fitter owns those numbers.
|
||||
- **What a designer touches.**
|
||||
- Topology knobs: L, K, the 5 outline segment counts, strip width and extent, canopy rows, rings and front.
|
||||
- Look knobs: wing crease, 2 canopy creases, 2 inset widths, frame height, bevel width and angle, ship level.
|
||||
- Fit weights: lam, lam_s, canopy_reg.
|
||||
|
||||
That is about 20 numbers, plus the 25-point outline polyline.
|
||||
- **Overfitting is real.** The first fit, v1, had weak regularisation. It scored chamfer **0.128** / IoU
|
||||
0.979 but tangled the canopy and rippled the wings (`out/v1.v0.png`). v7 trades about 0.1% chamfer for
|
||||
clean surfaces. Fidelity is cheap with this method; cleanliness is what costs.
|
||||
|
||||
## Against her bar
|
||||
|
||||
- **Topology flow: met, and better than B or C, on the body and wings.**
|
||||
- It is one shell; nothing is booleaned, so there is no boolean seam.
|
||||
- Loops run unbroken around the whole outline (closed loops of 148 edges).
|
||||
- Pole density is half of B's at level 2.
|
||||
- Missed in three places:
|
||||
- The concentric rings crowd into the narrow beak and stinger, because a ring cannot end without a pole.
|
||||
- The stinger's tall walls have only the two-face rim band, so they fold.
|
||||
- The thin wing edge is thinner than its band, so the band folds there too.
|
||||
- Those three account for most of the 316 edges over 80°. The breakdown is from `tools/dbg_edges.py`:
|
||||
stinger about 100, wing outline about 160, canopy about 36, beak and tip about 20. A bevel can't fix a
|
||||
fold of nearly 180°.
|
||||
- **UVs: met.**
|
||||
- 3 islands: top (with the canopy frame), bottom, canopy glass. The parts stay together and there are no
|
||||
crumbs.
|
||||
- Area distortion 0.076 is better than B's.
|
||||
- Angle error is 2.6° against B's 1.0°. That is the SLIM trade-off: angle-based unwrap of the same seams
|
||||
gave area 0.63, which is unusable.
|
||||
- Packing is plain and wastes 59%.
|
||||
- **Hard edges (one-segment bevel + face-weighted normals):** the stack is in place, but partly met.
|
||||
- The creased wing band comes out as a soft rounded edge with a highlight. It does not read as the crisp
|
||||
single-face bevel in her test render (closeup row 3).
|
||||
- Where the crease meets the folds, the bevel can't resolve the angle.
|
||||
- Her look needs a deliberate edge design: a band of chosen thickness with support loops, or a knife edge
|
||||
with the crease on the middle loop. I did not get there.
|
||||
- **Triangle budget.** Level 2 exports 19.3k triangles, 2.8x B. Level 1 exports 5.6k, under B, at almost the
|
||||
same fidelity (0.233), but its flow metrics fall to B's level. A per-region level, or level 1 plus
|
||||
normal-map baking, is the real shipping question.
|
||||
|
||||
## Editability (v7 to v7e: wing sweep 0.06 plus beak 0.05)
|
||||
|
||||
- **The edit is 2 numbers.** Each is a smooth falloff field applied to the fitted cage.
|
||||
- **It moves 315 of the 542 cage vertices.** No hand-dragging is needed.
|
||||
- **The cage topology is identical, and the lowered topology stays stable:**
|
||||
- faces 9673 to 9686 (the angle-limited bevel picks up 13 more);
|
||||
- islands 3 to 3;
|
||||
- poles 456 to 452;
|
||||
- longest loop 148 to 202, because the stretched wing loop now crosses the loop-length cut-off differently.
|
||||
- **Rerun cost.** About 60 s with the refit, then lowering. An edit-only rerun without the refit would take a
|
||||
few seconds; I did not time one separately.
|
||||
- **The limit.** An edit is a deformation of fitted numbers. Moving a feature (a canopy further back, a new
|
||||
strake) is a topology edit: change knobs, rebuild and refit.
|
||||
|
||||
## What worked
|
||||
|
||||
- Exact `S` by probing the modifier (2 s, error 1.5e-7). It turns Blender's OpenSubdiv into an owned,
|
||||
linear fitting kernel, with creases included.
|
||||
- Concentric pillow topology for a manta. One coherent body with no booleans, which B and C could not
|
||||
achieve.
|
||||
- Selecting feature regions by topology index instead of by position. The position-picked canopy came out
|
||||
jagged and crumpled (v1-v4); the topology-picked one is clean (v5+).
|
||||
- The shape regulariser ‖L(c − c₀)‖², weighted up locally on the mechanical feature. It keeps designed
|
||||
geometry designed while the rest of the cage fits.
|
||||
- Seams authored on the cage propagate through the subdivision. Those seams plus SLIM give 3 clean islands.
|
||||
|
||||
## Where the cage design was hard
|
||||
|
||||
- **Concentric rings carry a constant loop count everywhere.** The wing tip gets about 9 cm spacing; the beak
|
||||
and stinger get 1-2 cm. A proper cage terminates rings (3-to-1 or 2-to-1 poles) where the body narrows,
|
||||
or uses a separate tail-boom tube. I tried neither.
|
||||
- **Feature insets on a fitted surface.**
|
||||
- `bmesh.ops.inset_region` with even offset fired spikes off the crowded beak (v0 to v2).
|
||||
- Thin insets crumple under the fit unless they are regularised.
|
||||
- The canopy reads as a recessed slot with a crisp rear arc, not the target's raised bubble with a framed
|
||||
rim. Getting there needs a glass-crown height knob and support loops, and more iteration than I had.
|
||||
- **Fidelity against fairness.** The target is Tripo output with lumpy panel detail. A coarse cage that
|
||||
chases it ripples. The fairness weights are the most consequential numbers in the file, and nobody
|
||||
designs them.
|
||||
- **Mechanical detail at this scale** (pods, vents, strakes, nozzle) does not belong in this cage. It
|
||||
belongs in either support-loop sub-features (more cage design) or separate bevelled primitives and decals
|
||||
(the mixed-hull path from the prior-art doc). I did neither.
|
||||
- **Pitfalls I hit:**
|
||||
- A Python late-binding closure bug.
|
||||
- Dense numpy matmuls in Blender's numpy were orders of magnitude slow. I worked around it with a sparse
|
||||
bincount assembly; I never checked whether the bundled numpy has an optimised BLAS.
|
||||
- Twice I started a job while another agent's Blender (PIDs 58156, 34064) was running. After that,
|
||||
`run.sh` waits for other Blender jobs.
|
||||
|
||||
## What a future brokkr-ctl command would have saved
|
||||
|
||||
- **`cage new --pillow --outline <poly> --spine L --rings K --segments …`.** Generates the concentric pillow
|
||||
cage with its index maps (spine, ring, spoke, rim), so regions are addressed by name. About 40% of my
|
||||
effort.
|
||||
- **`cage region <name> --rows a..b --rings 1..k` plus `cage inset/crease/seam`.** Topology-addressed
|
||||
features, which never fold the way position-picked ones do.
|
||||
- **`cage fit --target X --level 2 --fair λ --keep λs --lock <region>`.** The `S` probe, the sparse solve
|
||||
and the mirror reduction, with per-region locks. Iterations would drop from minutes of script surgery to
|
||||
one flag.
|
||||
- **`cage terminate-ring` / `cage reroute`.** Pole-placing ring terminations, for the beak and stinger
|
||||
crowding. This is the operation I most needed and did not have.
|
||||
- **`lower --level N --bevel … --uv slim --measure --score --sheet`.** Run 2 and run 3's measure, score,
|
||||
render and sheet scripts as one call, with the cage hidden in shaded renders by default.
|
||||
|
||||
## Verdict
|
||||
|
||||
**Can this path produce a hull she would ship? Yes, I think so, but this cage is not that hull yet.** Of the
|
||||
three paths probed, subdivision is the first whose structure matches her bar:
|
||||
- one continuous body;
|
||||
- loops that follow the manta's curves;
|
||||
- 3 part-coherent islands with low area stretch;
|
||||
- and, from the same fit, fidelity 3.7x closer to the target than run 2's lowerings (chamfer 0.23 against
|
||||
0.85).
|
||||
|
||||
**What is missing is cage design**, which is exactly where prior art is silent:
|
||||
- ring terminations at the beak and stinger;
|
||||
- a deliberately designed wing edge, so her one-segment bevel reads crisp instead of folding;
|
||||
- a canopy with crown and frame support loops;
|
||||
- the pods, strakes and nozzle as support-loop features or separate bevelled parts.
|
||||
|
||||
Each is a known modeller's move. None is automated, and each needs her eye in the loop.
|
||||
|
||||
Confidence:
|
||||
- **Moderate** that the representation can meet her bar.
|
||||
- **Low** that an agent reaches her standard on this hull without the cage tooling listed above, or without
|
||||
her reviewing cage topology at the sheet level.
|
||||
|
||||
The renders were judged by my eye, not hers.
|
||||
|
||||
## Caveats
|
||||
|
||||
- **One hull, one target, one topology family.** I did not try a tube-plus-wing cage, a quad-remesher seed
|
||||
or ring terminations.
|
||||
- **No retopology by a human** for comparison. Her studio scene, texture baking, decals and engine export
|
||||
were not tested.
|
||||
- **Scores come from the numpy port** of `mp score`; it matches `mp` to within 0.011 on B.
|
||||
- **Fit distances in `.build.json` are proxies**: level-2 vertex distances, not exact limit-surface
|
||||
closest points.
|
||||
- **Processes.** No processes of mine are running. The operator's GUI Blender (PID 36324) was never touched.
|
||||
Nothing outside this scratch directory was written. `F:\Projects\Aetheria` and the brush-tree worktree
|
||||
were only read, and only the research docs.
|
||||
- **Tools** (all in `tools/`):
|
||||
- `build.py`: cage, fit, edit, lowering. Use `CAGE_OVERRIDES` for knobs.
|
||||
- `run.sh build|post|render <name> [json]`: one Blender job at a time; waits for other Blender jobs.
|
||||
- `measure.py`, `render.py`, `sheet.py`, `desc.py`: copied from run 2. `render.py` gained bottom and
|
||||
front-high views, cage views, and the cage hidden in shaded renders.
|
||||
- `score.py`: the `mp` port.
|
||||
- `study.py`: target height fields and samples, written to `out/target.npz`.
|
||||
- `dbg_edges.py`: the location of the leftover hard edges.
|
||||
175
docs/research/mesh-probe-run4-sdf-cage/RESULT.md
Normal file
175
docs/research/mesh-probe-run4-sdf-cage/RESULT.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# Mesh probe run 4: SDF with smooth-min for the shape, a field-aligned cage for the topology
|
||||
|
||||
Agent `spike-sdf-smin-cage`, session `self-2026-10-10-ag2`, 2026-10-11. Disposable scratch; no repo edited.
|
||||
Root: this directory (`...\scratchpad\mesh-probe\run4-sdf-cage\`). Ruling: `brush-tree:ruling:spike-sdf-smin-to-cage`.
|
||||
|
||||
## Verdict
|
||||
|
||||
- **SDF for the shape works, and fusion costs nothing.** One program (`tools/sdf_quiet.py`, about 130 lines, 63 fitted
|
||||
numbers) fuses the body loft, wing, pods and chin fin with named smin radii. Fitted, it scores chamfer 0.520 % / F@2 % 0.949 / sil-IoU 0.895.
|
||||
- Chamfer is better than run 2's description (0.84).
|
||||
- Silhouette is worse than the unfitted start (0.934).
|
||||
- It is not at run 3's hand cage (0.19-0.23 / 0.967).
|
||||
- **Automatic field-aligned topology did not reach a cage.** Here is how far each step got:
|
||||
- The cross field is good. It uses analytic curvature, the program's exact crease and Ohtake-style ridges.
|
||||
- My numpy Instant-Meshes position field and extraction break. Result: 463 faces, of which 102 are quads, with 189 boundary edges (holes).
|
||||
- Blender's QuadriFlow refuses to run headless on every input, including a verified-manifold voxel remesh.
|
||||
- **The Catmull-Clark refit of a cage to the field works** (limit |f| mean 0.0016, chamfer 0.599 to 0.552). It is the
|
||||
reusable piece. But it needs a cage with sane topology, and this spike could not make one automatically.
|
||||
- **The shippable lowering fails her bar.** The cause is upstream: the cage is broken. Measured anyway, see below.
|
||||
- Plain answer: "SDF for the shape, fitted cage for the topology" is viable only if the cage comes from a designed layout
|
||||
(run 3's approach) or from a real global parametrisation (MIQ or the full Instant Meshes hierarchy and extraction). The local
|
||||
Jacobi version I could build in the budget does not do it.
|
||||
|
||||
## 1. The SDF program and its fidelity
|
||||
|
||||
`tools/sdf_quiet.py`:
|
||||
- `hull = smin(smin(smin(body, wing, k_wing), pods, k_pod), chin_fin, k_fin)`.
|
||||
- body: superellipse stations with Catmull-Rom between them.
|
||||
- wing: a slab on z = a + b|x| + c·y, tapered, cut by the planform polygon minus the scallops (IQ sdPolygon).
|
||||
- The wing edge is `max(slab, planform)` with `k_edge = 0`, a deliberate hard edge.
|
||||
- Start values: run 2's description numbers. The fit is Levenberg-Marquardt over the named parameters (`tools/fit.py`):
|
||||
- residuals: f(p) at 9.7k outer-shell target points, from ray first-hits in 96 directions, so cockpit internals are ignored;
|
||||
- a hinge on 15k ray-derived "outside" labels;
|
||||
- a small prior;
|
||||
- box bounds.
|
||||
|
||||
Scores use `tools/common.py`, a numpy/BVH port of run 1's `mp score` (the mp build dir was deleted). It is calibrated on run 2's B.stl: 0.852 / 0.898 / 0.920 here against mp's 0.841 / 0.899 / 0.920.
|
||||
|
||||
| Program state | Chamfer % diag | F@2% | Mean sil-IoU | Note |
|
||||
|---|---|---|---|---|
|
||||
| p0: run 2's numbers as an SDF (k_wing 0.06) | 0.897 | 0.910 | 0.934 | Smooth fusion alone already beats run 2's silhouette (0.920) |
|
||||
| LM, unbounded (`out/fit.log`) | 1.348 | 0.840 | 0.798 | Degenerate: self-crossing planform (x = -1.99), negative pod radii, taper -5.5. The fit exploits the program |
|
||||
| **LM, bounded + prior (`out/params_fit.json`)** | **0.520** | **0.949** | **0.895** | t2c 0.586, c2t 0.454 |
|
||||
|
||||
The bounded fit presses on its bounds:
|
||||
- wing_t is at its 0.08 cap and k_wing at its 0.12 cap;
|
||||
- a pod coordinate is at its bound;
|
||||
- the beak station came out inverted (z_bottom above z_top, half-width 0.084), which leaves a flat cap at the nose.
|
||||
|
||||
The program lacks structure the target has: canopy, ridges, a wing thicker at the root. The optimiser compensates with blend radius and thickness. The program is still readable, but the fitted values are partly a fit, not a description (run 1's finding again). Zero-set render: row 2 of `sheet-main.png`. The wing edge shows surface-nets staircase, because a hard `max()` edge is not feature-preserved by surface nets. The nose is ragged where the inverted station sits.
|
||||
|
||||
## 2. Cross field and quad meshing: how far it got
|
||||
|
||||
Pipeline (`tools/quad.py`, `tools/qmesh.py`):
|
||||
1. Surface nets at h = 0.008, 27,660 vertices, Newton-projected onto f = 0. It has 64 non-manifold edges where the wing is under 3 cells thick.
|
||||
2. Principal directions and magnitudes come from the program's Hessian, by central differences of the analytic field, not mesh estimates.
|
||||
3. Ridges: the derivative of |kmax| along dmax, a third-order quantity, crosses zero at strong |kmax| (Ohtake et al. 2004). This found 1,678 vertices.
|
||||
4. The exact crease comes from the program's structure: the tangent is cross(∇slab, ∇planform) where both are about 0 on the wing part. This found 912 vertices.
|
||||
5. Flat and umbilic rule: alignment weight = (anisotropy / p90)², so flat or umbilic regions get no curvature constraint and are filled by smoothing from features and curved regions. Ridges and creases are near-hard constraints (weight 5).
|
||||
6. 4-RoSy smoothing.
|
||||
- Representation: power vectors z = e^{4iθ}, with Rodrigues (minimal-rotation) transport between tangent planes. This is the Palacios-Zhang / Ray et al. / Knöppel et al. representation.
|
||||
- Method: Jacobi.
|
||||
- Why not IM matching: Instant Meshes' per-edge 4-way matching under Jacobi stalled at 1,500 singular faces on the coarse test. Plain projection transport left 404 false singularities on the wing edge; Rodrigues fixed that.
|
||||
7. Position field: the Instant Meshes 4-PoSy in numpy.
|
||||
- Lattice origins start random, because p = V is a harmonic phase ramp that Jacobi never removes.
|
||||
- Each edge uses the minimal lattice image.
|
||||
- A voxel-cluster hierarchy of 2-3 levels, capped at s/2.
|
||||
8. Extraction: union-find collapse of zero-offset edges, unit-offset edges become lattice edges, planar face tracing.
|
||||
9. Catmull-Clark refit (`tools/cc.py`): CC is built as an explicit linear operator. Cage positions are least-squares fitted so the limit points equal their own Newton projections (8 rounds).
|
||||
|
||||
Higher-order constraints against plain Instant-Meshes-style smoothing (same mesh and solver, alignment term off: `quad.py ... plain`):
|
||||
|
||||
| | Field (curvature + ridge + crease) | Plain IM smoothing |
|
||||
|---|---|---|
|
||||
| Misalignment to principal dir (aniso conf > 0.5), median / p90 | **0.3° / 2.1°** | 27.8° / 40.8° |
|
||||
| Misalignment to the wing-edge crease, median / p90 | **0.1° / 0.7°** | 17.9° / 33.0° |
|
||||
| Singular faces (±1 / ±2 index) | 220 (196 / 24) | 185 (164 / 21) |
|
||||
| Extraction at s = 0.035: faces / quads / tris / untraced cycles / boundary edges | 463 / 102 / 342 / 129 / 189 | 444 / 107 / 315 / 159 / 252 |
|
||||
| Same mesh as a CC cage after refit: chamfer / sil | 0.552 / 0.883 | 0.574 / 0.873 |
|
||||
| Extraction at s = 0.07 (cage density): faces / quads | 59 / 17 | 47 / 9 |
|
||||
|
||||
What the higher-order information bought:
|
||||
- An exactly aligned field on every feature and curved region.
|
||||
- 35 more singularities, most of them clustered at the ragged nose, where the fit is bad and the constraints conflict.
|
||||
- Downstream, slightly fewer holes and slightly better refit fidelity. That gain is swamped, because the position field and extraction break either way.
|
||||
|
||||
Pictures:
|
||||
- `out/X.top.png`, `out/X.q34.png` and `out/X.close.png`: field mode. Orange crosses, red singular faces, blue lines for the IM lattice edges that survived.
|
||||
- `out/Xplain.*.png`: plain mode.
|
||||
|
||||
Where it broke:
|
||||
- **The position field does not converge globally.** On a flat 60x60 grid test, the lattice phase still drifts (a smooth residual ramp, mean minimal offset 0.025 s). The extraction then splits or merges lattice nodes and produces tris and holes: 40 faces, of which 12 are quads, where about 49 perfect quads were expected. In the paper the hierarchy and its Gauss-Seidel solver are what make the solve global. My voxel-cluster hierarchy is shallow: coarser than s/2 makes the minimal image ambiguous. The paper's extraction also has a cleanup stage I did not build.
|
||||
- **The zero set has a hard wing edge thinner than the lattice.** Wing thickness is 0.03-0.08 at s = 0.035-0.07, so the top and bottom lattices of the wing interfere.
|
||||
- **QuadriFlow, Blender's built-in, does not run headless.** It returned `CANCELLED` with "needs to be manifold and have face normals that point in a consistent direction" on:
|
||||
- the surface-nets mesh (64 non-manifold edges);
|
||||
- a voxel remesh (0 non-manifold edges and 0 non-manifold verts by bmesh, normals made consistent, loose geometry deleted, degenerates dissolved), with and without Newton projection.
|
||||
|
||||
Run 2 hit the same refusal. I did not try the GUI.
|
||||
- **Not done:** an anisotropic target edge length (finer where |k| is high). The position field uses one global s.
|
||||
|
||||
## 3-4. Shippable lowering, measured as run 2 did
|
||||
|
||||
There was no automatic cage worth lowering, so I lowered the best automatic output to show where it lands: the field-mode dense IM mesh after CC refit (`out/qm_dense_refit.obj`).
|
||||
- 154 cage edges with dihedral over 50° were creased.
|
||||
- Then Subdivision Surface (2 levels, applied) and a Newton snap onto f = 0.
|
||||
- Seams at the program's part-label boundaries (body / wing / pods / fin from argmin of the part fields), at hard edges and at the mirror plane.
|
||||
- Angle-based unwrap, averaged scale, pack.
|
||||
- Bevel 1 segment at 30°, width 0.004, then Weighted Normal (face area, keep sharp).
|
||||
- Script: `tools/lower.py`. Measured with run 2's `measure.py`, unchanged.
|
||||
|
||||
| | **L (run 4: SDF + IM cage)** | run 2 B (bmesh) | run 2 C (geonodes) | run 3 v6 (hand cage, interim, no RESULT yet) |
|
||||
|---|---|---|---|---|
|
||||
| Faces quads / tris / ngons | 6662 / 176 / 231 | 3039 / 49 / 215 | 3979 / 127 / 229 | 9395 / 210 / 182 |
|
||||
| Poles per 1000 verts / valence ≥ 6 | **140.3 / 60** | 100.1 / 0 | 98.0 / 4 | 49.3 / n/a |
|
||||
| Longest loop / closed loops ≥ 20 / share of edges in loops ≥ 20 | 124 (open) / 7 / 0.243 | 86 / 41 / 0.528 | 35 / 57 / 0.605 | 148 / n/a / 0.753 |
|
||||
| Boundary edges / shells | **846 / 4 (holes)** | 0 / 4 | 0 / 4 | n/a / 1 |
|
||||
| Exported tris / sliver fraction / slivers excluding bevel strips | 14236 / 17.6 % / 205 | 6914 / 31 % / 336 | 9022 / 28 % / 157 | 19558 / 25 % / 256 |
|
||||
| Dihedral > 60° left | 751 | 83 | 77 | n/a |
|
||||
| UV islands total / ≥ 1 % area / under 4 faces | **182 / 16 / 83** | 23 / 11 / 2 | 21 / 11 / 2 | 3 / 3 / n/a |
|
||||
| UV area distortion (mean \|log2\|) / angle error | 0.189 / 0.74° | 0.113 / 1.04° | 0.118 / 1.14° | 0.631 / 0.97° |
|
||||
| UV wasted | 51 % | 47 % | 61 % | n/a |
|
||||
| Chamfer / F@2% / sil-IoU | 0.531 / 0.950 / 0.875 | 0.841 / 0.899 / 0.920 | 0.840 / 0.900 / 0.920 | 0.229 / 0.989 / 0.967 |
|
||||
|
||||
Against her bar, L fails every topology and UV criterion:
|
||||
- holes and torn shards (visible in row 3 of the sheet);
|
||||
- 140 poles per 1000 verts;
|
||||
- 182 UV islands, 83 of them crumbs.
|
||||
|
||||
Its high quad share is only Catmull-Clark turning every face into quads; the poles and holes show the real topology. Its chamfer (0.531) beats run 2 because the SDF fit does. Silhouette does not. Run 3's interim hand cage beats everything here on every axis except the UV distortion figure.
|
||||
|
||||
The bevel and normals stack runs, but on this geometry the 154 creased cage edges include spurious ones from the wrong-shaped faces. That is why 751 edges over 60° are left.
|
||||
|
||||
## Contact sheets (run 2's layout)
|
||||
|
||||
- **`sheet-main.png`**. Rows: Tripo target, **SDF zero set (fitted program)**, L, run 2 B, run 2 C. Columns: 3/4 front, side, 3/4 rear, wireframe, UV layout.
|
||||
- `sheet-closeup.png`. Columns: wing-edge and junction close-up, its wire, and the top wire. Same rows.
|
||||
- Cross field: **`out/X.q34.png`** (best single view of the field), `out/X.top.png`, `out/X.close.png`; plain: `out/Xplain.*`.
|
||||
- Per-row frames: `out/<Z|L>.v0-4.png` and `out/<Z|L>.w*.png`; `out/L.uv.png`; `out/L.metrics.json`; `out/L.score.txt`; `out/L.blend`.
|
||||
|
||||
## Sources relied on
|
||||
|
||||
All of these were used from my own knowledge of the papers and public formulas. Nothing was downloaded, and no third-party code was copied; every algorithm here was re-implemented in numpy. The licences are as I recall them; verify before relying on them.
|
||||
|
||||
- **Instant Meshes.** W. Jakob, M. Tarini, D. Panozzo, O. Sorkine-Hornung, "Instant Field-Aligned Meshes", ACM TOG 34(6), SIGGRAPH Asia 2015.
|
||||
- Used: the extrinsic orientation and position fields, random lattice initialisation, hierarchy, and extraction by edge-offset classification.
|
||||
- Reference code: github.com/wjakob/instant-meshes, BSD 3-clause.
|
||||
- **QuadriFlow.** J. Huang, Y. Zhou, M. Niessner, J. R. Shewchuk, L. J. Guibas, "QuadriFlow: A Scalable and Robust Method for Quadrangulation", Computer Graphics Forum 37(5), SGP 2018.
|
||||
- Code: github.com/hjwdzh/QuadriFlow, BSD-style; built into Blender (GPL) as `object.quadriflow_remesh`. It was tested and refused headless.
|
||||
- **N-RoSy power-vector representation.**
|
||||
- J. Palacios, E. Zhang, "Rotational Symmetry Field Design on Surfaces", SIGGRAPH 2007.
|
||||
- N. Ray, B. Vallet, W.-C. Li, B. Lévy, "N-Symmetry Direction Field Design", ACM TOG 2008.
|
||||
- F. Knöppel, K. Crane, U. Pinkall, P. Schröder, "Globally Optimal Direction Fields", SIGGRAPH 2013. That paper's global eigen-solve was not done; I used Jacobi.
|
||||
- **Ridges.** Y. Ohtake, A. Belyaev, H.-P. Seidel, "Ridge-valley Lines on Meshes via Implicit Surface Fitting", SIGGRAPH 2004.
|
||||
- **Not implemented, the named next step.** D. Bommes, H. Zimmer, L. Kobbelt, "Mixed-Integer Quadrangulation", SIGGRAPH 2009. Open implementation: libigl `igl::copyleft::comiso::miq` (libigl is MPL2; CoMISo is GPL, hence "copyleft").
|
||||
- **Surface nets.** S. Gibson, "Constrained Elastic Surface Nets", MICCAI 1998.
|
||||
- **Feature-preserving alternative, not used.** T. Ju, F. Losasso, S. Schaefer, J. Warren, "Dual Contouring of Hermite Data", SIGGRAPH 2002.
|
||||
- **Subdivision.** E. Catmull, J. Clark, "Recursively generated B-spline surfaces on arbitrary topological meshes", CAD 1978.
|
||||
- **SDF primitives and smooth-min.** Inigo Quilez, iquilezles.org articles "distfunctions", "smin" and "distfunctions2d" (sdPolygon). These are published formulas, re-implemented.
|
||||
|
||||
## What the next spike would need
|
||||
|
||||
1. **A global integer-grid parametrisation** instead of local Jacobi. The options are MIQ (libigl, MPL2 plus GPL CoMISo) or a faithful port of Instant Meshes' Gauss-Seidel hierarchy and its extraction cleanup (BSD). Either is open code we could own. The cross field from this spike (analytic curvature, ridges, exact crease) is the input it needs, and that part works.
|
||||
2. **Feature-aware treatment of the hard wing edge.** Either give the program `k_edge > 0` and crease it later, or extract with dual contouring. A sub-lattice-thin wing also needs the parametrisation to treat top and bottom as separate charts cut at the crease.
|
||||
3. **A seeded cage.** Run 3's evidence plus this one suggests the practical hybrid: a designed cage layout (run 3) whose vertex positions this spike's CC-to-field least-squares refit sets automatically. That keeps her topology and gets the field's fidelity. It is one small spike, and `tools/cc.py` and the `refit` in `tools/quad.py` / `tools/qf.py` are ready for it.
|
||||
4. **A better-structured program.** Add a canopy, a root-thickened wing, and a nose that is not a station clamp, so the fit stops pressing on its bounds.
|
||||
|
||||
## Process notes
|
||||
|
||||
- Blender jobs ran one at a time behind a wait loop on other agents' Blender PIDs. There were three overlaps:
|
||||
- My first fit started before probe-subd-cage's job appeared.
|
||||
- A 3-second QuadriFlow retry ran while another agent's Blender (PID 58336) was up.
|
||||
- A numpy test via Blender's bundled python overlapped my own fit job; I killed that test (PID 40264).
|
||||
- One job of mine (PID 55532) ran away to 14 GB, because it built a dense Catmull-Clark operator for a 27k-vertex mesh after QuadriFlow returned its input unchanged. I killed it within minutes. `qf.py` now refits only meshes under 2,500 vertices.
|
||||
- No processes of mine are running. The operator GUI Blender (PID 36324) was never touched.
|
||||
- Tools: `tools/` (common, sdf_quiet, fit, sn, qmesh, cc, quad, qf, lower, viz, score, chain.sh; measure/render/sheet copied from run 2). Logs: `out/*.log`.
|
||||
46
docs/research/mesh-probe-run5-quadriflow/RESULT.md
Normal file
46
docs/research/mesh-probe-run5-quadriflow/RESULT.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Run 5: Blender 5.2.2 QuadriFlow, headless, on Dexter Quiet's SDF zero set
|
||||
|
||||
## Why it "refused"
|
||||
Not headless, not context. The operator's exec path runs blocking when called from Python (`OP_IS_INVOKE` unset), poll needs only an active editable
|
||||
mesh object in object mode, and no window/area is used. An ico sphere and a UV sphere remesh fine with `--background --factory-startup` and no
|
||||
temp_override (`tools/diag.py`: both FINISHED).
|
||||
The refusal text comes from `mesh_is_manifold_consistent` (object_remesh.cc). Besides edge-with-3-faces, flipped winding and wire edges, it
|
||||
rejects **any edge shorter than 1e-4 (compare_v3v3)**. Run 2/4's voxel remesh (voxel 0.005, Newton-projected) had 20 edges of 1e-5..1e-4 although
|
||||
bmesh called it manifold and consistent. The message is the same for all four failure causes, which is why it looked like a headless problem.
|
||||
|
||||
## Recipe (works)
|
||||
Voxel remesh -> project onto f=0 -> `bmesh.ops.remove_doubles` on the verts of edges < 1.2e-4 (dist 2e-4) -> dissolve_degenerate ->
|
||||
recalc_face_normals -> (optional) mark edges with dihedral > 30 deg as sharp (`e.smooth=False`) ->
|
||||
`bpy.ops.object.quadriflow_remesh(target_faces=N, use_mesh_symmetry=False, use_preserve_sharp=..., use_preserve_boundary=False, seed=0)`.
|
||||
Active object, object mode, no override. Script `tools/qf5.py`; measure/score/render via `tools/one.py` + `tools/chain.sh` (run 4's measure/score/render unchanged).
|
||||
Input 69,911 faces; each run 6-30 s. One case hung: sharp-preserve at target 15000 ran >25 min, I killed my PID 55924 (no output).
|
||||
Guidance: QuadriFlow takes no cross field or user-supplied orientation; it computes its own from the (sharp-flagged) mesh. Only knobs:
|
||||
target faces/edge length, sharp, boundary, symmetry (mesh symmetry axes), seed, curvature (compiled out). Run 4's field cannot be fed in.
|
||||
|
||||
## Measurements (run 2/4 scorer; out/*.metrics.json, *.score.txt)
|
||||
| | QF 1796 f | QF 5032 f | QF 11534 f | QF sharp 1831 f | QF sharp 5581 f | run 4 L | run 3 v7 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| tris / ngons | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 0 | 176 / 231 | 196 / 180 |
|
||||
| poles per 1000 v | 39.0 | 20.3 | 12.8 | 36.5 | 18.6 | 140.3 | 47.1 |
|
||||
| boundary edges (holes) | 6 | 6 | 0 | 12 | 20 | 846 | 0 |
|
||||
| slivers <10 deg (share) | 0.3% | 0.03% | 0.08% | 3.0% | 0.5% | 17.6% | 24% |
|
||||
| longest loop (edges) | 346 open | 823 open | 1389 open | 561 | 800 | 124 | 148 closed |
|
||||
| closed loops >= 20 edges | 0 | 0 | 0 | 0 | 6 | 7 | 30 |
|
||||
| chamfer % diag | 0.494 | 0.495 | 0.501 | 0.502 | 0.501 | 0.531 | **0.228** |
|
||||
| F@2% / sil-IoU | 0.955 / 0.908 | 0.955 / 0.906 | 0.954 / 0.901 | 0.953 / 0.900 | 0.954 / 0.903 | 0.950 / 0.875 | **0.990 / 0.967** |
|
||||
(Targets 2000/6000/15000/sharp 2000/6000; QF returns about 0.75-0.9x the target. "Loops" are open and meander: no designed edge flow.)
|
||||
|
||||
## Reading
|
||||
- Topology is clean: pure quads, low poles (13-39/1000 v, falling with density), almost no slivers, near-watertight. Better than run 4 on every count
|
||||
and on poles/slivers better than v7. But loops are not aligned to design features and no loop closes.
|
||||
- Fidelity does NOT improve with face count and sits at 0.49-0.50, close to run 4 and 2x worse than v7. The sheet shows why: the thin wing
|
||||
(0.03-0.08 thick) is below QuadriFlow's lattice, so the trailing/leading rim is eaten into a ragged saw-edge and the wing-tip/tail go jagged; the
|
||||
body and dorsal detail (canopy ridge, pods, strakes) smooth away. Sharp preservation (dihedral > 30 flags, 3648 edges) did not fix the rim; it
|
||||
added boundary holes (12-20) and slivers. Unprojected output; I did not re-project QF verts onto f=0 (would help a little, not the rim).
|
||||
- So: QuadriFlow is a viable automatic quad-dominant retopo for the thick body, a poor one for a knife-edge wing. It does not replace run 3's designed
|
||||
cage; at best it could cover body parts with the wing handled separately.
|
||||
|
||||
## Files
|
||||
sheet-quadriflow.png (rows: target, SDF zero set, run 4 L, run 3 v7, QF 1796, QF 5032, QF-sharp 5581; cols 3/4, top-ish, side, wire 3/4, wire top).
|
||||
out/qf_s{0,1}_{N}.obj, out/*.metrics.json, out/chain.log, tools/diag.py qf5.py one.py chain.sh.
|
||||
Processes: all mine ended; operator GUI Blender PID 36324 untouched.
|
||||
62
docs/research/mesh-probe-run6-instant-meshes/RESULT.md
Normal file
62
docs/research/mesh-probe-run6-instant-meshes/RESULT.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Mesh probe run 6: Instant Meshes (Jakob et al. 2015, BSD-3) built and run headless on Dexter Quiet
|
||||
|
||||
Agent `probe-instant-meshes`, session `self-2026-10-10-ag2`, 2026-10-11. Scratch only; no repo edited. Root: this directory.
|
||||
|
||||
## Build recipe
|
||||
- Source `C:\Tools\instant-meshes-src` at 7b3160864a2e1025af498c84cfed91cbfb613698 (with submodules). Output `C:\Tools\instant-meshes-build\Release\Instant Meshes.exe`.
|
||||
- No option disables the GUI: nanogui/glfw/GL are linked into the one exe (batch mode is just `-o`, which never opens a window). Built whole.
|
||||
- Toolchain: VS 2022 BuildTools (MSVC 14.44), CMake 4.3.2, `tools/build.sh`: `cmake -G "Visual Studio 17 2022" -A x64 -DCMAKE_POLICY_VERSION_MINIMUM=3.5`, `_CL_=-MP4` (note: `/MP4` gets path-mangled by git-bash), `cmake --build . --config Release -- -m:2`. It waits for other agents' blender/cargo/node first. The VS "18" toolset was not needed.
|
||||
- **Source changes (one):** `ext/nanogui/ext/glfw/CMakeLists.txt` line 10, `cmake_policy(SET CMP0042 OLD)` to `NEW` (CMake 4 rejects OLD; the policy only affects macOS install names). Nothing in the C++ needed to change.
|
||||
|
||||
## Flags (`tools/runim.sh`)
|
||||
`-d -r 4 -p 4 -c 40 -S 2 -f N` (deterministic, 4-RoSy/4-PoSy, crease 40 deg, 2 reprojection steps). Variants at -f 2000 (zero set): no crease, `-i` (intrinsic), `-D` (quad-dominant), `-S 8`, `-c 20`. `-b` is moot: the inputs are closed or nearly so. Inputs: `in/zeroset.obj` (run 4's `sdf_fit.stl`, welded: 49k v / 98.6k tris) and `in/tripo.obj` (run 1 `dexter_main.obj`, 4.8k v / 9.5k tris).
|
||||
- Fast: 0.4-3.5 s per run on 1 job. Singularities in the orientation field: 304 (zero set), 483-700 (Tripo; its noisy surface makes more).
|
||||
- **`-f N` is not the output face count.** The pure-quad output has about 4x N faces (N=40 gives 105-144, 100 gives 330-342, 400 gives 1.3-1.6k, 2000 gives 7.4-8k, 8000 gives 31k). Read the real count from the table.
|
||||
- Guidance accepted: target size (`-f/-v/-s`), RoSy/PoSy, crease angle (hard feature edges found by dihedral threshold), boundary alignment, intrinsic/extrinsic, smoothing, dominance. **No input for an orientation/cross field, no per-region density, no feature curves.**
|
||||
|
||||
## Measurements (run 2/4 measure.py, score.py unchanged; raw = IM output, no subdivision/UV; L_ = cage refit to run 4's SDF, CC level 2, run 4's lower.py)
|
||||
| mesh | faces (q/t/n) | poles/1000v | boundary / nonmanifold edges | shells | slivers <10deg | longest loop | closed loops>=20 | chamfer % | F@2% | sil-IoU |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| zeroset_f40 | 144 (144/0/0) | 161.1 | 8 / 8 | 3 | 0.0 | 20 | 0 | 0.743 | 0.924 | 0.828 |
|
||||
| zeroset_f100 | 342 (342/0/0) | 114.6 | 8 / 8 | 5 | 0.0088 | 45 | 5 | 0.607 | 0.945 | 0.853 |
|
||||
| zeroset_f400 | 1596 (1596/0/0) | 77.7 | 28 / 28 | 2 | 0.0003 | 96 | 0 | 0.477 | 0.959 | 0.904 |
|
||||
| zeroset_f2000 | 8027 (8027/0/0) | 36.8 | 50 / 50 | 1 | 0.0001 | 284 | 0 | 0.498 | 0.954 | 0.900 |
|
||||
| zeroset_f8000 | 31967 (31967/0/0) | 19.8 | 98 / 98 | 3 | 0.0001 | 386 | 3 | 0.505 | 0.953 | 0.899 |
|
||||
| tripo_f40 | 105 (105/0/0) | 168.1 | 14 / 14 | 1 | 0.0 | 14 | 0 | 1.562 | 0.689 | 0.665 |
|
||||
| tripo_f100 | 330 (330/0/0) | 151.1 | 0 / 0 | 1 | 0.0 | 42 | 0 | 0.619 | 0.953 | 0.859 |
|
||||
| tripo_f400 | 1349 (1349/0/0) | 127.2 | 70 / 70 | 6 | 0.0019 | 38 | 0 | 0.199 | 0.994 | 0.949 |
|
||||
| tripo_f2000 | 7430 (7430/0/0) | 77.3 | 240 / 240 | 10 | 0.0009 | 120 | 0 | 0.053 | 1.000 | 0.986 |
|
||||
| tripo_f8000 | 30707 (30707/0/0) | 43.1 | 810 / 810 | 25 | 0.0004 | 314 | 4 | 0.017 | 1.000 | 0.994 |
|
||||
| zeroset_f2000_nocrease | 7984 (7984/0/0) | 37.6 | 48 / 48 | 2 | 0.0006 | 210 | 3 | 0.492 | 0.956 | 0.905 |
|
||||
| zeroset_f2000_intr | 7975 (7975/0/0) | 26.1 | 92 / 92 | 4 | 0.0 | 272 | 0 | 0.491 | 0.956 | 0.904 |
|
||||
| zeroset_f2000_dom | 2017 (1888/90/39) | 78.8 | 35 / 35 | 1 | 0.0 | 86 | 0 | 0.489 | 0.956 | 0.901 |
|
||||
| zeroset_f2000_S8 | 8022 (8022/0/0) | 36.3 | 54 / 54 | 2 | 0.0001 | 284 | 0 | 0.499 | 0.954 | 0.901 |
|
||||
| zeroset_f2000_c20 | 7891 (7891/0/0) | 38.0 | 64 / 64 | 6 | 0.0003 | 244 | 1 | 0.498 | 0.954 | 0.902 |
|
||||
| L_zeroset_f40 | 2980 (2699/146/135) | 118.9 | 49 / 50 | 1 | 0.2136 | 73 | 0 | 0.589 | 0.933 | 0.866 |
|
||||
| L_zeroset_f100 | 6981 (6426/308/247) | 103.5 | 68 / 68 | 2 | 0.2163 | 101 closed | 9 | 0.527 | 0.947 | 0.881 |
|
||||
| L_tripo_f40 | 2730 (2395/223/112) | 215.9 | 65 / 65 | 1 | 0.4027 | 64 | 0 | 0.690 | 0.916 | 0.854 |
|
||||
| L_tripo_f100 | 6750 (6211/310/229) | 123.4 | 4 / 4 | 1 | 0.2336 | 153 | 0 | 0.508 | 0.950 | 0.904 |
|
||||
|
||||
Reference rows (from run 3/4/5 RESULTs): run 4 L: poles 140/1000v, 846 boundary edges, chamfer 0.531 / F@2% 0.950 / sil 0.875. run 3 v7 (designed cage): poles 47.1, 1 shell, longest loop 148 closed, 0.228 / 0.990 / 0.967. QuadriFlow (run 5), 1.8k-11.5k faces: poles 13-39, slivers <=0.3%, longest loop open, 0.494-0.501 / 0.954 / 0.90.
|
||||
|
||||
Cage refit (limit |f| and score before refit, then after; `out/chain1.log`, `chain2.log`):
|
||||
| cage (IM faces) | before refit: chamfer / sil | after refit: chamfer / sil |
|
||||
|---|---|---|
|
||||
| zero set, 144 faces | 0.871 / 0.754 | 0.669 / 0.837 |
|
||||
| zero set, 342 faces | 0.646 / 0.814 | 0.555 / 0.868 |
|
||||
| Tripo, 105 faces | 1.806 / 0.604 | 0.970 / 0.770 |
|
||||
| Tripo, 330 faces | 0.746 / 0.800 | 0.553 / 0.889 |
|
||||
Lowered through run 4's stack (rows L_ above): the 342-face cage gives 0.527 / 0.947 / 0.881 but with 21 % sliver tris (bevel strips), 103 poles/1000v, 68 boundary edges and 160+ crumbs of UV islands; none reaches run 3 (0.228). The 1.5k-face (f400) refits were abandoned: `refit.py`'s dense Catmull-Clark operator ran over 30 min; I killed my PIDs.
|
||||
|
||||
## Reading
|
||||
- Raw IM output is pure quads, almost sliver-free, loops long (284 edges at 8k faces), poles fall with density (37/1000v at 8k, 20 at 32k): same class as QuadriFlow, far better than run 4's numpy attempt. Open boundaries/non-manifold edges (8-98) come from the zero set's own thin-wing holes.
|
||||
- Fidelity is the same wall as QuadriFlow: chamfer 0.48-0.50 on the zero set at every density from 1.6k to 32k faces, sil 0.90. The thin wing and the sharp rim are below its lattice; `-c`, `-i`, `-S 8` do not move it (0.49-0.50). The crease flag only changes the field (222 vs 304 singularities), not the score.
|
||||
- Fed the Tripo scan it reproduces the scan (0.05 % at 7k faces), meaning it is just a retopo of whatever noise is in the input (10-25 shells, up to 810 open edges).
|
||||
- A cage-like count (144-342 faces) is blocky: loops are not designed (longest 20-45 edges), poles are 115-170/1000v. Refit helps (0.87 to 0.67) but not to run 3's level.
|
||||
- It does not replace run 3's designed cage. As a body retopo it is as good as QuadriFlow, and it is BSD-3 source we can own and patch (QuadriFlow in Blender is closed to us).
|
||||
|
||||
## Injecting an external cross field: feasible, modest
|
||||
Source reading (`src/batch.cpp`, `field.cpp`, `hierarchy.cpp`): the batch path already has the hook. `align_to_boundaries` writes per-vertex constraints `mRes.CQ().col(i)` (direction), `CQw()[i]` (weight), `CO/COw` (position pin) and calls `mRes.propagateConstraints(rosy, posy)`; `Optimizer::optimizeOrientations` (field.cpp ~632-660) blends CQ by CQw. So run 4's exact-Hessian field goes in as: read a per-vertex tangent direction + weight file, set CQ/CQw (and optionally CO/COw on creases for position pinning), propagate, optimize. Alternatively set `mRes.Q(0)` directly and skip the orientation solve. Work: roughly 50-100 lines in batch.cpp plus a reader, around half a day to a day, no change to the solver. Gotchas: `subdivide()` may add vertices before the constraints (pre-subdivide the input so indices match, or evaluate the analytic field at `mRes.V()`), constraints must lie in the vertex tangent plane, and the weight scale is empirical. Whether it fixes the thin-wing fidelity wall is untested (the wall is lattice size vs. wing thickness, not orientation).
|
||||
|
||||
## Files
|
||||
`sheet-main.png`, `sheet-closeup.png` (rows: Tripo target, SDF zero set, run 3 v7, run 4 L, QuadriFlow 6000, IM zero-set f40/f100/f400/f2000, IM Tripo f400, L_zeroset_f100, L_tripo_f100; columns as run 2). `out/*.obj` (IM outputs), `out/*.metrics.json`, `*.score.txt`, `out/table.md`, `tools/` (build.sh, runim.sh, chain.sh, refit.py, rawmeasure.py, table.py, stl2obj.py). Processes: all mine ended; operator GUI Blender PID 36324 untouched.
|
||||
169
docs/research/mesh-probe-run7-im-field/RESULT.md
Normal file
169
docs/research/mesh-probe-run7-im-field/RESULT.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Run 7: an Instant Meshes fork driven by the SDF (cross field, hard rim crease, per-vertex size)
|
||||
|
||||
Agent `spike-im-field-features`, session `self-2026-10-10-ag2`, 2026-10-11. Ruling `brush-tree:ruling:spike-instant-meshes-field-features`, bar `brush-tree:ruling:shippable-part-bar`. Scratch only; no repo touched; operator GUI Blender (PID 36324) untouched; all my processes ended.
|
||||
|
||||
## Verdict
|
||||
|
||||
1. **The diagnosis was wrong.** The ~0.49 % chamfer wall is not lattice size. It is how well run 4's SDF program fits the Tripo target.
|
||||
- Measured against the SDF itself, every Instant Meshes output, plain included, sits at 0.01-0.07 % chamfer and 0.99+ top silhouette IoU.
|
||||
- Scored against Tripo, every variant lands at 0.48-0.55 % whatever the field, constraints or density.
|
||||
- The program alone scores 0.520 (run 4). No remesher can beat that against Tripo.
|
||||
2. **The real thin-feature defect was in the program.** Its fitted beak was a pinched neck (half-width 0.006) and an inverted station. That gave:
|
||||
- 119 non-manifold input edges;
|
||||
- ~300 extra orientation singularities (374 against 62);
|
||||
- every hole in the IM outputs (16-122 open edges).
|
||||
|
||||
Reverting those two stations to run 2's description values (`in/params7.json`) leaves every output with 0 holes, 0 non-manifold edges and 1 shell.
|
||||
3. **Hard rim crease (step 2) is what bought rim fidelity.** It works through position and orientation constraints on the exact crease plus Newton projection: at 8k quads the rim p95 error falls 10x, from 0.20 % to 0.02 % of the diagonal, and the rim renders crisp with a one-segment bevel. It needs the lattice finer than the rim wall (0.035-0.08 high). At 2k quads it only halves the error.
|
||||
4. **The exact-Hessian cross field (step 1) buys alignment and costs poles.** Median misalignment to the principal directions drops from 13° to 3.4°, but poles rise from 31 to 52 per 1000 v.
|
||||
5. **The size field (1b) made topology worse.** The isotropic per-vertex scale cost 3-4x poles and up to 1074 open edges for no fidelity gain at equal density. Anisotropic scale is too deep to patch (see 1b).
|
||||
6. **Step 3 was not run.** Fidelity at the rim was already met, and the wing joins the body through a smooth blend (k_wing 0.12), not a crease, so there is no curve to stitch along.
|
||||
7. **Her bar is half met.**
|
||||
- **Met:** watertight, 1 shell, pure-quad base, crisp beveled rim with face-weighted normals.
|
||||
- **Not met:**
|
||||
- Flow: loops are not designed, none closes, and poles are 50-69 per 1000 v against v7's 47.
|
||||
- UVs: 10 islands with no crumbs, but area distortion of 0.67-0.76 against v7's 0.076. The automatic seams join wing top and bottom wherever the snapped rim has gaps.
|
||||
- Automated remeshing of an SDF composition is now a fidelity solution, not yet a topology-and-UV solution.
|
||||
|
||||
## The fork
|
||||
|
||||
`C:\Tools\instant-meshes-fork` is a copy of `instant-meshes-src` at 7b31608, built out of tree in `C:\Tools\instant-meshes-fork-build` with `/m:4`. Patch: `instant-meshes-field.patch` (100 lines added, 10 removed, plus run 6's glfw CMP0042 line).
|
||||
- `--field <file>`: a text file of records `x y z qx qy qz qw ox oy oz ow s`.
|
||||
- Each solver vertex takes the nearest record through a uniform-grid lookup, so subdivision does not break indexing.
|
||||
- q and qw feed `CQ/CQw`. o and ow feed `CO/COw`, the same constraint path `-b` uses. Then `propagateConstraints`.
|
||||
- `s > 0` gives a per-vertex target edge length.
|
||||
- Per-vertex scale: `MultiResolutionHierarchy::mS`, one vector per level, built from `toUpper` averages.
|
||||
- `optimize_positions_impl` uses the edge scale s_ij = (s_i + s_j)/2 in the compat functor and s_i in the rounding.
|
||||
- `extract_graph` uses s_ij for its edge classification and s_i for its cluster weights.
|
||||
- The degenerate-triangle cleanup uses min(s).
|
||||
- Upstream bug fixed: `propagateConstraints` projected `cq - v` where it meant `co - v` (`hierarchy.cpp`).
|
||||
- Runtime is 2.4-5 s per run.
|
||||
|
||||
## Inputs
|
||||
|
||||
`tools/prep.py` and `prep2.py` produce them:
|
||||
- Surface nets at h 0.005, Newton-projected: 68k vertices for the repaired program, 71k for the fitted one.
|
||||
- The field is run 4's: exact-Hessian principal directions, anisotropy confidence, Ohtake ridges, and the program's crease.
|
||||
- New in `tools/feat.py`: the exact crease. Gauss-Newton on {slab = 0, planform = 0}, kept where the wing is the active surface.
|
||||
|
||||
Field variants:
|
||||
- **field2:** curvature weight 0.5·cw where cw > 0.3, else free; ridges 0.5; crease 1.
|
||||
- **creaseonly:** constraints only on the rim (2,865 vertices, orientation along the crease and position on it).
|
||||
- **crease2:** field2 plus the rim constraints.
|
||||
|
||||
Post-process (`tools/post.py`): output vertices within 0.35·s of the crease are snapped onto the exact crease, and the rest are Newton-projected onto f = 0 (the `_p` meshes).
|
||||
|
||||
## Measurements
|
||||
|
||||
All rows below are on the repaired program, flags `-d -r 4 -p 4 -c 40 -S 2`; `out/post3.log`.
|
||||
|
||||
Fidelity is in % of the bbox diagonal:
|
||||
- "SDF ch." is chamfer against the dense SDF zero set.
|
||||
- "rim p95" is the 95th-percentile distance from 2,202 exact crease samples to the mesh, raw then projected.
|
||||
- "top IoU" is the top-view silhouette against the SDF.
|
||||
- Tripo is run 4's scorer.
|
||||
|
||||
| mesh (IM -f) | quads | poles/1000v | open/nonmanifold | misalign to principal dir, med / p90 | rim edge misalign med / p90 | SDF ch. raw → proj | rim p95 raw → proj | top IoU proj | Tripo ch. / sil |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| plain f500 | 2020 | 51.4 | 0/0 | 14.5° / 35.9° | 2.9° / 9.1° | 0.046 → 0.033 | 0.644 → 0.541 | 0.9962 | 0.547 / 0.905 |
|
||||
| field2 f500 | 2010 | 79.0 | 0/0 | 4.9° / 21.3° | 2.4° / 6.7° | 0.043 → 0.031 | 0.552 → 0.513 | 0.9967 | 0.547 / 0.903 |
|
||||
| creaseonly f500 | 2038 | 67.2 | 0/0 | 17.3° / 37.0° | 2.3° / 6.4° | 0.036 → 0.030 | 0.334 → 0.151 | 0.9961 | 0.545 / 0.904 |
|
||||
| crease2 f500 | 2086 | 92.4 | 0/0 | 4.9° / 25.0° | 2.0° / 7.2° | 0.038 → 0.030 | 0.459 → 0.302 | 0.9966 | 0.546 / 0.904 |
|
||||
| plain f2000 | 7978 | **30.7** | 0/0 | 13.2° / 34.7° | 2.2° / 6.2° | 0.011 → 0.008 | 0.257 → 0.202 | 0.9990 | 0.555 / 0.902 |
|
||||
| field2 f2000 | 8132 | 51.5 | 0/0 | **3.4° / 16.1°** | 2.1° / 5.3° | 0.011 → 0.008 | 0.274 → 0.201 | 0.9991 | 0.554 / 0.903 |
|
||||
| creaseonly f2000 | 8052 | 41.1 | 0/0 | 15.6° / 37.6° | 1.2° / 4.1° | 0.010 → 0.008 | 0.123 → **0.020** | 0.9990 | 0.554 / 0.903 |
|
||||
| crease2 f2000 | 8242 | 57.6 | 0/0 | 3.7° / 15.9° | **1.1° / 4.0°** | 0.010 → 0.008 | 0.161 → 0.045 | 0.9991 | 0.553 / 0.904 |
|
||||
|
||||
### On the fitted program (beak defect present; `out/post1.log`, `post2.log`)
|
||||
|
||||
The same pattern holds, plus holes:
|
||||
- plain f2000: 39.8 poles, 70 open edges, rim p95 0.262 after projection.
|
||||
- creaseonly f2000: 48.3 poles, rim p95 0.023.
|
||||
|
||||
The open edges sit mostly on the body at the beak, for example 57 body / 28 wing / 12 pods.
|
||||
|
||||
### Step 1b, size field
|
||||
|
||||
Per vertex, s = min(0.35/|kmax|, inscribed radius, s0), clamped to [0.3, 1]·s0, with 0.6·s0 on the crease and a gradation limit of 0.3·|edge| (`fieldS`, `creaseS`). Fitted program:
|
||||
|
||||
| mesh | quads | poles/1000v | open edges | SDF ch. proj | rim p95 proj |
|
||||
|---|---|---|---|---|---|
|
||||
| fieldS f500 | 13009 | 115.2 | 134 | 0.010 | 0.235 |
|
||||
| creaseS f500 | 13229 | 120.7 | 178 | 0.010 | **0.014** |
|
||||
| plain f3300 (equal density) | 13024 | **33.7** | 104 | 0.010 | 0.198 |
|
||||
| creaseS f2000 | 26381 | 121.6 | **1074** | 0.009 | 0.214 |
|
||||
| creaseG f2000 (gentle: ≥ 0.6·s0, gradation 0.15) | 15203 | 78.6 | 250 | 0.009 | 0.006 |
|
||||
|
||||
- Why the size field fails: grading the lattice spacing breaks the integer-offset consistency between neighbours, which shows up as poles and holes. Fidelity at equal density is unchanged.
|
||||
- Thickness never limits the wing body: it is 0.035-0.08 thick against s = 0.025 at 8k quads. The only sub-lattice thing on the wing is the rim wall at 2k quads, and the crease constraint already handles it at 8k.
|
||||
- Anisotropic scale was not attempted, because the patch would have to go deep:
|
||||
- The functions are `position_round_3/4`, `position_floor_3/4`, `compat_position_{extrinsic,intrinsic}_{3,4}` and their `_index` variants (field.cpp ~290-610), `optimize_positions_impl`, `extract_graph`'s classify_edges, and `extract_faces`' scale thresholds.
|
||||
- Every one rounds q and n×q by a single scale.
|
||||
- A (s_u, s_v) pair must also swap when the 4-RoSy matching rotates q by 90°, but `compat_orientation_*` returns rotated vectors, not the rotation index. So the pair cannot follow without changing that interface across the solver and the extractor.
|
||||
- The anisotropic follow-up for that is Panozzo et al. 2014 frame fields with a non-orthogonal parametrisation. That is a different solver, not a patch.
|
||||
|
||||
### Lowered and measured as run 2 did
|
||||
|
||||
`tools/lower7.py` is run 4's `lower.py` with these changes:
|
||||
- no Catmull-Clark and no re-projection;
|
||||
- holes filled (none left on the repaired program);
|
||||
- seams at part-label boundaries, where the labels are majority-smoothed and split at the mirror;
|
||||
- seams on edges over 60° and on the exact crease.
|
||||
|
||||
It keeps the one-segment bevel at 30° with width 0.004, then Weighted Normal by face area. Measured with run 4's `measure.py`; Tripo score from `score.py`; `out/chain7n.log`, `chain7m.log`.
|
||||
|
||||
| | L7nplain (plain f2000, unprojected) | **L7ncreaseM (creaseonly f2000)** | L7nfieldM (crease2 f2000) | L7nfield500M (crease2 f500) | run 3 v7 (designed cage) | run 6 plain IM f2000 raw |
|
||||
|---|---|---|---|---|---|---|
|
||||
| faces q/t/n, max ngon | 8382/89/150, 6 | 8468/97/155, 6 | 8635/114/178, 6 | 2311/82/116, 6 | 9297/196/180, 6 | 8027/0/0 |
|
||||
| shells / open edges | 1 / 0 | 1 / 0 | 1 / 0 | 1 / 0 | 1 / 0 | 1 / 50 |
|
||||
| poles/1000v / valence ≥ 6 | **38.4** / 0 | 50.2 / 4 | 69.0 / 5 | 114.1 / 7 | 47.1 / 0 | 36.8 |
|
||||
| longest loop / closed ≥ 20 / share in loops ≥ 20 | 253 open / 2 / **0.764** | 274 open / 1 / 0.707 | 105 open / 1 / 0.593 | 94 open / 0 / 0.363 | 148 closed / 30 / 0.755 | 284 open / 0 |
|
||||
| exported sliver share / excluding bevel strips | 0.1 % / 0 | 1.8 % / 0 | 6.9 % / 1 | 15.7 % / 2 | 24 % / 254 | n/a |
|
||||
| dihedral > 60° / > 80° left | 28 / 9 | 16 / 3 | 11 / 4 | 22 / 4 | 546 / 316 | n/a |
|
||||
| UV islands / ≥ 1 % / crumbs | 11 / 6 / 1 | 10 / 6 / 0 | 10 / 6 / 0 | 11 / 6 / 1 | 3 / 3 / 0 | n/a |
|
||||
| UV area distortion mean \|log2\| / outside 0.5-2x | 1.074 / 25 % | 0.764 / 21 % | 0.668 / 17 % | 0.473 / 7 % | **0.076 / 0.8 %** | n/a |
|
||||
| UV angle error / wasted | 2.07° / 67 % | 1.50° / 60 % | 1.88° / 70 % | 2.91° / 63 % | 2.61° / 59 % | n/a |
|
||||
| Tripo chamfer / F@2% / sil | 0.552 / 0.950 / 0.907 | 0.553 / 0.950 / 0.905 | 0.552 / 0.951 / 0.904 | 0.546 / 0.953 / 0.904 | **0.228 / 0.990 / 0.967** | 0.498 / 0.954 / 0.900 |
|
||||
|
||||
- **The rim is the visible win.** In L7nplain, IM rounds the rim off the surface-nets staircase. Few edges pass 30°, so almost nothing is beveled, and it shades faceted (sheet row 5). The crease rows (6-8) have a straight, crisp beveled rim that shades clean with face-weighted normals. The rim wall is 1-2 quads high, with some poles on it.
|
||||
- **Topology.** Pure-quad bases, watertight, near-zero slivers outside the bevel strips.
|
||||
- **Flow is generic field flow.** Loops are long but open and not designed.
|
||||
- **The cross field's poles survive lowering.** L7nfieldM has 69 poles per 1000 v against 50 for crease-only and 38 for plain.
|
||||
- **UVs are the clear failure.**
|
||||
- The part-label, mirror and hard-edge seams leave each wing half as one chart, top and bottom joined through gaps in the snapped crease: only ~300 of the rim vertices land on the exact crease.
|
||||
- Area distortion is 6-10x v7's. A cut along the exact crease did not change the seams.
|
||||
- Fixing this needs either IM vertices placed on the whole crease (so it becomes a closed seam loop) or a distortion-driven seam method. Examples: OptCuts (Li, Kaufman, Kim, Sorkine-Hornung 2018) or Autocuts (Poranne et al. 2017); neither was tried.
|
||||
- **Tripo fidelity of every lowered row is the program's.** The 0.55 is the repaired beak; it was 0.51 with the fitted beak. v7 wins on Tripo because its cage was fitted to Tripo, not to this SDF.
|
||||
|
||||
## What each step bought
|
||||
|
||||
| step | bought | cost | where it broke |
|
||||
|---|---|---|---|
|
||||
| 1 cross field (field2) | misalignment 13° → 3.4° median; rim edges 2.2° → 1.1° | poles +68 % (31 → 52 per 1000 v); singularities 62 → 120 | no fidelity gain. The unfiltered field (`field`, every vertex weighted) doubled poles. |
|
||||
| 1b size field | nothing at equal density | poles 3-4x, holes | integer lattice offsets break under spacing gradients |
|
||||
| 2 rim crease + projection | rim p95 0.20 → 0.02 % at 8k quads; crisp bevelable rim | poles +34 % (31 → 41) | below s ≈ rim wall height (2k quads) it only halves the error. Snapped coverage of the crease is partial (~300 vertices). |
|
||||
| beak repair (program fix) | 0 holes, 0 non-manifold edges, singularities 374 → 62 | Tripo chamfer +0.05 | degenerate primitives in the program are the real "thin feature" hazard |
|
||||
| 3 wing as own patch | not run | | no crease between wing and body to stitch along (smooth blend). Fidelity was already met. |
|
||||
|
||||
**Next, if pursued:**
|
||||
1. Make the crease constraint cover the whole curve, so the rim becomes a closed seam loop. Seed lattice vertices along the curve at s spacing as hard COw rather than nearest-vertex hints.
|
||||
2. Add a validator that rejects degenerate SDF primitives: inverted or near-zero stations.
|
||||
3. Use a seam method for UVs.
|
||||
4. To get designed flow, refit run 3's designed cage to the SDF (run 4's refit) rather than an IM field. On this evidence that is still the only path to closed loops and v7-class UVs.
|
||||
|
||||
## Files
|
||||
|
||||
Root `...\scratchpad\mesh-probe\run7-im-field\`.
|
||||
- **Sheets:** `sheet-main.png` and `sheet-closeup.png`.
|
||||
- Main sheet columns: 3/4 front, side, 3/4 rear, wire, UV.
|
||||
- Close-up columns: rim close-up, rim wire, top wire.
|
||||
- Rows (in both): 1 Tripo target, 2 SDF zero set (repaired), 3 run 3 v7, 4 run 6 plain IM f2000, 5 L7nplain, 6 L7ncreaseM, 7 L7nfieldM, 8 L7nfield500M.
|
||||
- **Patch:** `instant-meshes-field.patch`.
|
||||
- **Tools** (`tools/`):
|
||||
- `prep.py`, `prep2.py`, `feat.py`, `post.py`, `lower7.py`;
|
||||
- `runim.sh`, `chain7.sh`, `bl.sh` (wait-first Blender wrapper), `build.sh`.
|
||||
- **Logs:** `out/*.log`; `post1-3.log` hold the per-mesh JSON.
|
||||
- **Meshes and renders:**
|
||||
- `out/<variant>.obj` and the projected `_p.obj`/`.stl`;
|
||||
- `out/L7*.blend`, `.metrics.json`, `.score.txt` and renders.
|
||||
- **Inputs:** `in/`, for the repaired program (`params7.json`); `in_fit/` holds the fitted-program inputs.
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
Submodule ext/nanogui contains modified content
|
||||
Submodule ext/glfw contains modified content
|
||||
diff --git a/ext/nanogui/ext/glfw/CMakeLists.txt b/ext/nanogui/ext/glfw/CMakeLists.txt
|
||||
index 8b861820..be54a918 100644
|
||||
--- a/ext/nanogui/ext/glfw/CMakeLists.txt
|
||||
+++ b/ext/nanogui/ext/glfw/CMakeLists.txt
|
||||
@@ -7,7 +7,7 @@ cmake_minimum_required(VERSION 2.8.11.2)
|
||||
if (NOT CMAKE_VERSION VERSION_LESS "3.0")
|
||||
# Until all major package systems have moved to CMake 3,
|
||||
# we stick with the older INSTALL_NAME_DIR mechanism
|
||||
- cmake_policy(SET CMP0042 OLD)
|
||||
+ cmake_policy(SET CMP0042 NEW)
|
||||
endif()
|
||||
|
||||
if(POLICY CMP0022)
|
||||
diff --git a/src/batch.cpp b/src/batch.cpp
|
||||
index b35769d..d5d8e36 100644
|
||||
--- a/src/batch.cpp
|
||||
+++ b/src/batch.cpp
|
||||
@@ -21,12 +21,79 @@
|
||||
#include "normal.h"
|
||||
#include "extract.h"
|
||||
#include "bvh.h"
|
||||
+#include <fstream>
|
||||
+#include <unordered_map>
|
||||
+
|
||||
+/* Fork: external constraint field. Text file: a count n, then n records
|
||||
+ x y z qx qy qz qw ox oy oz ow s
|
||||
+ (q: tangent direction + weight in [0,1] blended into the orientation solve;
|
||||
+ o: a point the lattice line along q must pass through + weight, as Instant Meshes' boundary
|
||||
+ constraints use it; s: target edge length at the point, <= 0 for the global scale).
|
||||
+ Each solver vertex takes the nearest record (uniform-grid lookup), so the records need not
|
||||
+ match the vertex indexing after subdivision. */
|
||||
+static void apply_field_file(MultiResolutionHierarchy &mRes, const std::string &path, int rosy, int posy) {
|
||||
+ std::ifstream is(path);
|
||||
+ if (!is) throw std::runtime_error("Could not open field file " + path);
|
||||
+ size_t n; is >> n;
|
||||
+ std::vector<Float> R(n * 12);
|
||||
+ for (size_t k = 0; k < n * 12; ++k) is >> R[k];
|
||||
+ if (!is) throw std::runtime_error("Field file is truncated: " + path);
|
||||
+ Vector3f lo = Vector3f::Constant(1e30f), hi = Vector3f::Constant(-1e30f);
|
||||
+ for (size_t r = 0; r < n; ++r) for (int k = 0; k < 3; ++k) { lo[k] = std::min(lo[k], R[r*12+k]); hi[k] = std::max(hi[k], R[r*12+k]); }
|
||||
+ Float cell = std::max((hi - lo).maxCoeff() / std::cbrt((Float) n) * 1.5f, (Float) 1e-6f);
|
||||
+ auto key = [&](const Vector3f &p, int dx, int dy, int dz) {
|
||||
+ int64_t x = (int64_t) std::floor((p.x() - lo.x()) / cell) + dx, y = (int64_t) std::floor((p.y() - lo.y()) / cell) + dy,
|
||||
+ z = (int64_t) std::floor((p.z() - lo.z()) / cell) + dz;
|
||||
+ return (x * 73856093) ^ (y * 19349663) ^ (z * 83492791);
|
||||
+ };
|
||||
+ std::unordered_multimap<int64_t, uint32_t> grid;
|
||||
+ for (size_t r = 0; r < n; ++r) grid.emplace(key(Vector3f(R[r*12], R[r*12+1], R[r*12+2]), 0, 0, 0), (uint32_t) r);
|
||||
+ mRes.clearConstraints();
|
||||
+ const MatrixXf &V = mRes.V(), &N = mRes.N();
|
||||
+ VectorXf S0(V.cols()); bool anyS = false;
|
||||
+ uint32_t nq = 0, no = 0;
|
||||
+ for (uint32_t i = 0; i < V.cols(); ++i) {
|
||||
+ Vector3f p = V.col(i); Float best = 1e30f; int64_t br = -1;
|
||||
+ for (int ring = 1; ring <= 3 && br < 0; ++ring)
|
||||
+ for (int dx = -ring; dx <= ring; ++dx) for (int dy = -ring; dy <= ring; ++dy) for (int dz = -ring; dz <= ring; ++dz) {
|
||||
+ auto range = grid.equal_range(key(p, dx, dy, dz));
|
||||
+ for (auto it = range.first; it != range.second; ++it) {
|
||||
+ const Float *q = &R[it->second * 12];
|
||||
+ Float d = (Vector3f(q[0], q[1], q[2]) - p).squaredNorm();
|
||||
+ if (d < best) { best = d; br = it->second; }
|
||||
+ }
|
||||
+ }
|
||||
+ if (br < 0) { S0[i] = mRes.scale(); continue; }
|
||||
+ const Float *q = &R[br * 12];
|
||||
+ Vector3f n_i = N.col(i), cq(q[3], q[4], q[5]);
|
||||
+ cq -= n_i * n_i.dot(cq);
|
||||
+ if (q[6] > 0 && cq.squaredNorm() > 1e-12f) {
|
||||
+ mRes.CQ().col(i) = cq.normalized(); mRes.CQw()[i] = std::min(q[6], (Float) 1); ++nq;
|
||||
+ if (q[10] > 0) { mRes.CO().col(i) = Vector3f(q[7], q[8], q[9]); mRes.COw()[i] = std::min(q[10], (Float) 1); ++no; }
|
||||
+ }
|
||||
+ S0[i] = q[11] > 0 ? q[11] : mRes.scale(); anyS |= q[11] > 0;
|
||||
+ }
|
||||
+ mRes.propagateConstraints(rosy, posy);
|
||||
+ cout << "Field file: " << n << " records, " << nq << " orientation and " << no << " position constraints";
|
||||
+ if (anyS) {
|
||||
+ std::vector<VectorXf> &S = mRes.Slevels(); S.clear(); S.push_back(S0);
|
||||
+ for (int l = 0; l + 1 < mRes.levels(); ++l) {
|
||||
+ const MatrixXu &up = mRes.toUpper(l); VectorXf next(up.cols());
|
||||
+ for (uint32_t i = 0; i < up.cols(); ++i)
|
||||
+ next[i] = up(1, i) == INVALID ? S[l][up(0, i)] : 0.5f * (S[l][up(0, i)] + S[l][up(1, i)]);
|
||||
+ S.push_back(next);
|
||||
+ }
|
||||
+ cout << ", per-vertex scale " << S0.minCoeff() << " .. " << S0.maxCoeff();
|
||||
+ }
|
||||
+ cout << endl;
|
||||
+}
|
||||
|
||||
void batch_process(const std::string &input, const std::string &output,
|
||||
int rosy, int posy, Float scale, int face_count,
|
||||
int vertex_count, Float creaseAngle, bool extrinsic,
|
||||
bool align_to_boundaries, int smooth_iter, int knn_points,
|
||||
- bool pure_quad, bool deterministic) {
|
||||
+ bool pure_quad, bool deterministic,
|
||||
+ const std::string &field_file) {
|
||||
cout << endl;
|
||||
cout << "Running in batch mode:" << endl;
|
||||
cout << " Input file = " << input << endl;
|
||||
@@ -157,6 +224,9 @@ void batch_process(const std::string &input, const std::string &output,
|
||||
mRes.propagateConstraints(rosy, posy);
|
||||
}
|
||||
|
||||
+ if (!field_file.empty() && !pointcloud)
|
||||
+ apply_field_file(mRes, field_file, rosy, posy);
|
||||
+
|
||||
if (bvh) {
|
||||
bvh->setData(&mRes.F(), &mRes.V(), &mRes.N());
|
||||
} else if (smooth_iter > 0) {
|
||||
diff --git a/src/batch.h b/src/batch.h
|
||||
index e9d7baa..298ea5c 100644
|
||||
--- a/src/batch.h
|
||||
+++ b/src/batch.h
|
||||
@@ -19,4 +19,5 @@ extern void batch_process(const std::string &input, const std::string &output,
|
||||
int rosy, int posy, Float scale, int face_count,
|
||||
int vertex_count, Float creaseAngle, bool extrinsic,
|
||||
bool align_to_boundaries, int smooth_iter,
|
||||
- int knn_points, bool dominant, bool deterministic);
|
||||
+ int knn_points, bool dominant, bool deterministic,
|
||||
+ const std::string &field_file = "");
|
||||
diff --git a/src/extract.cpp b/src/extract.cpp
|
||||
index 5a38653..34ea42f 100644
|
||||
--- a/src/extract.cpp
|
||||
+++ b/src/extract.cpp
|
||||
@@ -44,6 +44,8 @@ extract_graph(const MultiResolutionHierarchy &mRes, bool extrinsic, int rosy, in
|
||||
|
||||
const MatrixXf &Q = mRes.Q(), &O = mRes.O(), &N = mRes.N(), &V = mRes.V();
|
||||
const VectorXf &COw = mRes.COw();
|
||||
+ const VectorXf &S = mRes.S();
|
||||
+ const bool varS = S.size() == V.cols();
|
||||
const AdjacencyMatrix &adj = mRes.adj();
|
||||
|
||||
Timer<> timer;
|
||||
@@ -75,7 +77,8 @@ extract_graph(const MultiResolutionHierarchy &mRes, bool extrinsic, int rosy, in
|
||||
std::pair<Vector2i, Vector2i> shift = compat_position(
|
||||
V.col(i), N.col(i), Q_rot.first, O.col(i),
|
||||
V.col(j), N.col(j), Q_rot.second, O.col(j),
|
||||
- scale, inv_scale, &error);
|
||||
+ varS ? 0.5f * (S[i] + S[j]) : scale,
|
||||
+ varS ? 2.0f / (S[i] + S[j]) : inv_scale, &error);
|
||||
|
||||
Vector2i absDiff = (shift.first-shift.second).cwiseAbs();
|
||||
|
||||
@@ -280,7 +283,8 @@ extract_graph(const MultiResolutionHierarchy &mRes, bool extrinsic, int rosy, in
|
||||
continue;
|
||||
uint32_t j = it->second;
|
||||
|
||||
- Float weight = std::exp(-(O.col(i)-V.col(i)).squaredNorm() * inv_scale * inv_scale * 9);
|
||||
+ const Float is_i = varS ? 1.0f / S[i] : inv_scale;
|
||||
+ Float weight = std::exp(-(O.col(i)-V.col(i)).squaredNorm() * is_i * is_i * 9);
|
||||
if (COw.size() != 0 && COw[i] != 0) {
|
||||
tbb::spin_mutex::scoped_lock lock(mutex);
|
||||
crease_out.insert(j);
|
||||
@@ -325,7 +329,7 @@ extract_graph(const MultiResolutionHierarchy &mRes, bool extrinsic, int rosy, in
|
||||
bool changed_inner;
|
||||
do {
|
||||
changed_inner = false;
|
||||
- Float thresh = 0.3f * scale;
|
||||
+ Float thresh = 0.3f * (varS ? S.minCoeff() : scale); /* fork: smallest local spacing */
|
||||
|
||||
std::vector<std::tuple<Float, uint32_t, uint32_t, uint32_t>> candidates;
|
||||
for (uint32_t i_id=0; i_id<adj_new.size(); ++i_id) {
|
||||
diff --git a/src/field.cpp b/src/field.cpp
|
||||
index 4d986aa..e07ce29 100644
|
||||
--- a/src/field.cpp
|
||||
+++ b/src/field.cpp
|
||||
@@ -901,6 +901,8 @@ template <typename CompatFunctor, typename RoundFunctor> static inline Float opt
|
||||
const AdjacencyMatrix &adj = mRes.adj(level);
|
||||
const MatrixXf &N = mRes.N(level), &Q = mRes.Q(level), &V = mRes.V(level);
|
||||
const Float scale = mRes.scale(), inv_scale = 1.0f / scale;
|
||||
+ const VectorXf &S = mRes.S(level);
|
||||
+ const bool varS = S.size() == V.cols();
|
||||
const std::vector<uint32_t> *phase = nullptr;
|
||||
const MatrixXf &CQ = mRes.CQ(level);
|
||||
const MatrixXf &CO = mRes.CO(level);
|
||||
@@ -933,8 +935,9 @@ template <typename CompatFunctor, typename RoundFunctor> static inline Float opt
|
||||
q_j.normalize();
|
||||
#endif
|
||||
|
||||
+ const Float s_ij = varS ? 0.5f * (S[i] + S[j]) : scale;
|
||||
std::pair<Vector3f, Vector3f> value = compat_functor(
|
||||
- v_i, n_i, q_i, sum, v_j, n_j, q_j, o_j, scale, inv_scale);
|
||||
+ v_i, n_i, q_i, sum, v_j, n_j, q_j, o_j, s_ij, 1.0f / s_ij);
|
||||
|
||||
sum = value.first*weight_sum + value.second*weight;
|
||||
weight_sum += weight;
|
||||
@@ -955,7 +958,8 @@ template <typename CompatFunctor, typename RoundFunctor> static inline Float opt
|
||||
}
|
||||
|
||||
if (weight_sum > 0)
|
||||
- O.col(i) = round_functor(sum, q_i, n_i, v_i, scale, inv_scale);
|
||||
+ O.col(i) = varS ? round_functor(sum, q_i, n_i, v_i, S[i], 1.0f / S[i])
|
||||
+ : round_functor(sum, q_i, n_i, v_i, scale, inv_scale);
|
||||
}
|
||||
};
|
||||
|
||||
diff --git a/src/hierarchy.cpp b/src/hierarchy.cpp
|
||||
index 2882a67..7bca4ea 100644
|
||||
--- a/src/hierarchy.cpp
|
||||
+++ b/src/hierarchy.cpp
|
||||
@@ -813,7 +813,7 @@ void MultiResolutionHierarchy::propagateConstraints(int rosy, int posy) {
|
||||
}
|
||||
if (co != Vector3f::Zero()) {
|
||||
Vector3f n = N_next.col(i), v = V_next.col(i);
|
||||
- co -= n.dot(cq - v) * n;
|
||||
+ co -= n.dot(co - v) * n; /* fork: upstream projected cq - v */
|
||||
}
|
||||
#if 0
|
||||
cqw *= 0.5f;
|
||||
diff --git a/src/hierarchy.h b/src/hierarchy.h
|
||||
index 2b3c337..ee36928 100644
|
||||
--- a/src/hierarchy.h
|
||||
+++ b/src/hierarchy.h
|
||||
@@ -76,6 +76,9 @@ public:
|
||||
inline uint32_t size(int level = 0) const { return mV[level].cols(); }
|
||||
|
||||
inline Float scale() const { return mScale; }
|
||||
+ /* Fork: optional per-vertex target edge length (one vector per level; empty = global scale) */
|
||||
+ inline const VectorXf &S(int level = 0) const { static const VectorXf empty; return level < (int) mS.size() ? mS[level] : empty; }
|
||||
+ inline std::vector<VectorXf> &Slevels() { return mS; }
|
||||
inline void setScale(Float scale) { mScale = scale; }
|
||||
inline int iterationsQ() const { return mIterationsQ; }
|
||||
inline void setIterationsQ(int iterationsQ) { mIterationsQ = iterationsQ; }
|
||||
@@ -125,6 +128,7 @@ public:
|
||||
bool mFrozenQ, mFrozenO;
|
||||
ordered_lock mMutex;
|
||||
Float mScale;
|
||||
+ std::vector<VectorXf> mS;
|
||||
int mIterationsQ;
|
||||
int mIterationsO;
|
||||
uint32_t mTotalSize;
|
||||
diff --git a/src/main.cpp b/src/main.cpp
|
||||
index a2f6f11..439b11f 100644
|
||||
--- a/src/main.cpp
|
||||
+++ b/src/main.cpp
|
||||
@@ -29,7 +29,7 @@ int main(int argc, char **argv) {
|
||||
int rosy = 4, posy = 4, face_count = -1, vertex_count = -1;
|
||||
uint32_t knn_points = 10, smooth_iter = 2;
|
||||
Float crease_angle = -1, scale = -1;
|
||||
- std::string batchOutput;
|
||||
+ std::string batchOutput, fieldFile;
|
||||
#if defined(__APPLE__)
|
||||
bool launched_from_finder = false;
|
||||
#endif
|
||||
@@ -44,6 +44,12 @@ int main(int argc, char **argv) {
|
||||
deterministic = true;
|
||||
} else if (strcmp("--intrinsic", argv[i]) == 0 || strcmp("-i", argv[i]) == 0) {
|
||||
extrinsic = false;
|
||||
+ } else if (strcmp("--field", argv[i]) == 0) {
|
||||
+ if (++i >= argc) {
|
||||
+ cerr << "Missing field file argument!" << endl;
|
||||
+ return -1;
|
||||
+ }
|
||||
+ fieldFile = argv[i];
|
||||
} else if (strcmp("--boundaries", argv[i]) == 0 || strcmp("-b", argv[i]) == 0) {
|
||||
align_to_boundaries = true;
|
||||
} else if (strcmp("--threads", argv[i]) == 0 || strcmp("-t", argv[i]) == 0) {
|
||||
@@ -154,6 +160,7 @@ int main(int argc, char **argv) {
|
||||
cout << " -S, --smooth <iter> Number of smoothing & ray tracing reprojection steps (default: 2)" << endl;
|
||||
cout << " -D, --dominant Generate a tri/quad dominant mesh instead of a pure tri/quad mesh" << endl;
|
||||
cout << " -i, --intrinsic Intrinsic mode (extrinsic is the default)" << endl;
|
||||
+ cout << " --field <file> Per-point constraints: x y z qx qy qz qw ox oy oz ow s (see batch.cpp)" << endl;
|
||||
cout << " -b, --boundaries Align to boundaries (only applies when the mesh is not closed)" << endl;
|
||||
cout << " -r, --rosy <number> Specifies the orientation symmetry type (2, 4, or 6)" << endl;
|
||||
cout << " -p, --posy <number> Specifies the position symmetry type (4 or 6)" << endl;
|
||||
@@ -177,7 +184,7 @@ int main(int argc, char **argv) {
|
||||
batch_process(args[0], batchOutput, rosy, posy, scale, face_count,
|
||||
vertex_count, crease_angle, extrinsic,
|
||||
align_to_boundaries, smooth_iter, knn_points,
|
||||
- !dominant, deterministic);
|
||||
+ !dominant, deterministic, fieldFile);
|
||||
return 0;
|
||||
} catch (const std::exception &e) {
|
||||
cerr << "Caught runtime error : " << e.what() << endl;
|
||||
248
docs/research/nondestructive-weld-in-blender.md
Normal file
248
docs/research/nondestructive-weld-in-blender.md
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
# Nondestructive weld in Blender 5.2: shipping routes, Geometry Nodes feasibility, precedents, cost
|
||||
|
||||
Eyes pass for campaign `brush-tree`, session `self-2026-10-11-ag2`, agent `eyes-bt-gn-weld`, 2026-10-12. Facts with evidence
|
||||
pointers; "(inferred)" marks reasoning, not observation. No recommendation.
|
||||
|
||||
Operator's request, verbatim: "If we can turn this into a modifier then we can go fully nondestructive".
|
||||
"This" is ruling `brush-tree:ruling:weld-owner-defines-silhouette` (in force, ruled 2026-10-12): at each intersection loop, the
|
||||
surface whose own geometry shapes the loop keeps its vertices; the other surface's spare vertices near the loop merge away.
|
||||
Flat cutter on a hull (panel line): the hull owns. Small curved cutter in a flatter surface (gun port, dimple): the cutter
|
||||
owns. The ruling's equivalent probe is "curvature of each surface along the loop". Spike: GameCult.Geometry.Csg
|
||||
`spike/boolean-weld` (a507760), `docs/research/spike-boolean-weld.md`, code `spikes/boolean-weld/`. Note: the spike's weld pass
|
||||
(`py/weld.py`) is Python/numpy over a Rust driver (`src/main.rs`) that calls boolmesh and Manifold, not Rust throughout.
|
||||
|
||||
Blender: 5.2.2 LTS (hash d13f752e3b9c) at `C:\Program Files (x86)\Steam\steamapps\common\Blender\blender.exe`, run headless.
|
||||
|
||||
## 1. Ways to ship a live, nondestructive mesh operation in the modifier stack
|
||||
|
||||
| Route | Appears in the modifier stack | Native code | Evidence |
|
||||
|---|---|---|---|
|
||||
| Geometry Nodes node group on a Nodes modifier | yes, as a Nodes modifier | no (nodes only) | below |
|
||||
| Node group flagged `is_modifier`, marked as asset | yes, offered as a modifier | no | below |
|
||||
| Python add-on registering its own modifier type | no | n/a | `register_class` test below |
|
||||
| Python add-on driving existing modifiers / depsgraph handlers | indirectly | n/a | below |
|
||||
| C/C++ modifier | yes | yes, Blender fork | below |
|
||||
| Extensions platform with wheels | not a modifier route | yes, as Python wheels | below |
|
||||
|
||||
- **Geometry Nodes modifier.** `obj.modifiers.new(name, 'NODES')` with `.node_group = ng` works headless in 5.2.2 (used
|
||||
for every test below). `bpy.types.NodesModifier` carries `node_group`, `bakes`, `properties` and panel-state members
|
||||
(printed from `NodesModifier.bl_rna.properties`). In 5.2 `m['Socket_n'] = value` on the modifier raised
|
||||
`TypeError: bpy_struct[key] = val: id properties not supported for this type`; a Collection input was therefore set as the
|
||||
node's default value in the test, and `NodesModifier.properties` exists as the member to try instead (untested).
|
||||
- **Modifier-flagged node group.** `GeometryNodeTree.is_modifier` exists in 5.2.2 and defaults False (printed); the 5.3 API
|
||||
doc lists it with `is_tool` and `is_mode_*` flags (docs.blender.org/api/5.3/bpy.types.GeometryNodeTree.html). The Add
|
||||
Modifier menu and asset-library presentation of such groups was not verified here (searched, not found in sources read).
|
||||
- **Python cannot register a modifier.** Test: `class M(bpy.types.Modifier): pass; bpy.utils.register_class(M)` raised
|
||||
`ValueError: ... (Modifier does not support registration)`. The prior survey lists the registrable classes (Panel, Operator,
|
||||
NodeTree, Node, NodeSocket, ...; no Modifier) at `F:\Projects\blender-tooling-surface-prior-art.md` section 1, from
|
||||
`bpy.utils.register_class` docs. A community wiki draft proposing Python modifiers shows a nonexistent
|
||||
`bpy.types.Custom_Modifier` base (wiki.blender.jp/利用者:Vitorbalbio/PythonModifier); nothing shipped.
|
||||
- **Depsgraph handlers.** `bpy.app.handlers` has `depsgraph_update_pre` and `depsgraph_update_post` (printed). They run
|
||||
around evaluation, not inside the modifier stack; they cannot return a mesh into the stack position (inferred from
|
||||
the handler list; not exercised).
|
||||
- **Custom node tree.** Python can register a `NodeTree`/`Node` (prior survey section 1): Sverchok and Animation Nodes
|
||||
are such trees evaluated by their own add-on code, not by the modifier stack. Animation Nodes uses Cython per platform.
|
||||
- **C/C++ modifier.** Modifier types are registered in Blender's C tables (`ModifierTypeInfo`); no source was read here, so
|
||||
this rests on the `register_class` error above ("does not support registration") and general knowledge (inferred).
|
||||
Reaching it means a Blender fork.
|
||||
- **Extensions platform.** Extensions are manifests (`blender_manifest.toml`) with `platforms` (windows-x64, windows-arm64,
|
||||
macos-x64, macos-arm64, linux-x64) and `wheels`; compiled code ships as Python binary wheels bundled in the extension and
|
||||
built per platform with `--split-platforms` (docs.blender.org/manual/en/5.2/advanced/extensions/python_wheels.html and
|
||||
getting_started). The sources read describe no extension point that accepts a native modifier or node. A wheel's code runs
|
||||
from Python. Prior survey section 4f: distributed add-ons must be GPL (Extensions platform requires GPL-3.0-or-later).
|
||||
Both vendored add-ons below declare `SPDX:GPL-3.0-or-later`.
|
||||
|
||||
## 2. Can Geometry Nodes express the rule
|
||||
|
||||
### 2a. What exists in 5.2.2 (printed by `dir(bpy.types)`, 274 `GeometryNode*` classes)
|
||||
|
||||
Present: `GeometryNodeMeshBoolean`, `MergeByDistance`, `SampleNearest`, `SampleNearestSurface`, `SampleIndex`,
|
||||
`EdgePathsToSelection`, `EdgePathsToCurves`, `InputShortestEdgePaths`, `EdgesToFaceGroups`, `InputMeshIsland`,
|
||||
`InputMeshEdgeAngle`, `InputMeshEdgeNeighbors`, `InputMeshEdgeVertices`, `InputMeshVertexNeighbors`, `EdgesOfVertex`,
|
||||
`CornersOfEdge`, `RepeatInput`, `ForeachGeometryElementInput`, `CaptureAttribute`, `StoreNamedAttribute`, `DeleteGeometry`,
|
||||
`SetPosition`, `MeshToCurve`, `SplitEdges`, `MeshBevel`, `DualMesh`, `ScaleElements`, `AccumulateField`,
|
||||
`SDFGridMeanCurvature`, `GetGeometryBundle`, `SetGeometryBundle`, `ClosureToList*`, `Warning`. No class matched `Dissolve*`
|
||||
or an `EvaluateOnDomain` name by my substring query; the `FieldOnDomain` class exists (HOps' group lists
|
||||
`GeometryNodeFieldOnDomain`). No node named for surface curvature (second fundamental form) exists in that list; the
|
||||
only curvature-named class is `SDFGridMeanCurvature` (volume grids).
|
||||
|
||||
### 2b. Mesh Boolean node (printed from the node, then run)
|
||||
|
||||
- `solver` enum items: `EXACT`, `FLOAT`, `MANIFOLD` (Manifold is in 5.2.2). `operation`: `INTERSECT`, `UNION`, `DIFFERENCE`.
|
||||
- Inputs: `Mesh 1`, `Mesh 2`, `Self Intersection`, `Hole Tolerant`. Outputs: `Mesh`, `Intersecting Edges`.
|
||||
- Per solver (socket `enabled`): EXACT shows all four inputs and both outputs; MANIFOLD hides Self Intersection and Hole
|
||||
Tolerant and keeps `Intersecting Edges`; FLOAT hides `Intersecting Edges` (linking it raised an error in the test).
|
||||
- Manual 5.2: Manifold fastest and manifold-only (special case: Difference with a plane); Exact for coplanar/overlap, slower;
|
||||
Float lacks overlap support.
|
||||
|
||||
### 2c. Headless test on the spike's gun port (run, not just read)
|
||||
|
||||
Scripts: `...\scratchpad\eyes-bt-gn-weld\gn_probe2.py`, `q.py`; logs `out\probe_*.txt`. Inputs: `cage_eval.obj` was not found
|
||||
on disk (searched `F:\Projects` to depth 6), so the test used the spike's own fixtures from the branch: `hull_nobevel.obj`
|
||||
(3,010 vertices, 3,008 faces after OBJ import, the hull the spike measured) and `port_flank_s16.obj` (the 16-segment gun
|
||||
port cylinder, 34 vertices). No .blend was opened or saved; factory startup, scene built by script.
|
||||
|
||||
Group: Group Input hull -> Store Named Attribute (INT, FACE, `src`=0) -> Mesh Boolean Mesh 1; Collection Info (cutters,
|
||||
relative transform) -> Realize Instances -> Store Named Attribute (INT, FACE, `src`=1) -> Mesh 2; Boolean DIFFERENCE;
|
||||
output Mesh -> Store Named Attribute (BOOLEAN, EDGE, `isect`) fed by `Intersecting Edges`.
|
||||
|
||||
| case | solver | result verts / faces | `Intersecting Edges` selected | faces by `src` after boolean | time (s) |
|
||||
|---|---|---|---|---|---|
|
||||
| port s16 | MANIFOLD | 3,067 / 3,056 | 42 | {0: 3,008, 1: 48} | 0.026, 0.023 |
|
||||
| port s16 | EXACT | 3,067 / 3,056 | 42 | {0: 3,008, 1: 48} | 0.048, 0.041 |
|
||||
|
||||
Observed: the `src` face attribute written before the boolean survives onto the result faces (hull 3,008, cutter 48), so
|
||||
face provenance is available in nodes without a geometric test. `Intersecting Edges` evaluates on the result mesh and
|
||||
was stored as an edge attribute. 42 selected edges against the spike's 36 loop vertices: not reconciled (the spike loop is
|
||||
traced between provenance faces; this selection's definition for the extra 6 edges was not inspected).
|
||||
Manifold and Exact gave identical vertex and face counts and edge selection on this case.
|
||||
|
||||
### 2d. Sketch of the rule in nodes (inferred unless stated)
|
||||
|
||||
1. **Provenance and loop.** Face `src` before the boolean (tested) and `Intersecting Edges` (tested) give the loop edges and,
|
||||
by edge-to-point evaluation, loop vertices. Per loop vertex, which surface's original edge it sits on (the spike's H/C/both
|
||||
test, tolerance 1.5e-6): Sample Nearest or Sample Nearest Surface against the original hull and original cutter geometry
|
||||
(both exist as streams before the boolean); compare distances to a tolerance.
|
||||
2. **Loop ordering and tangent.** Mesh to Curve restricted to the intersecting edges gives ordered splines; Curve
|
||||
Tangent / Sample Curve give `t`. Edge Neighbors / Edge Vertices / Vertex Neighbors give local adjacency.
|
||||
3. **Curvature along the loop, `k = II(t,t)`.** No curvature node exists (2a). Nodes can read normals (Input Normal, Sample
|
||||
Index on neighbours) and form finite differences of normal along `t`: `k` approximated by
|
||||
`(n_next - n_prev) . t / |p_next - p_prev|` using the original surface's normal sampled at loop points (Sample Nearest
|
||||
Surface). The spike used a per-vertex quadric fit over the 2-ring, with hull crease edges counted as infinite curvature;
|
||||
the nodes version would be a different estimator. Its noise near creases/saddles is untested. A cutter with known shape has
|
||||
analytic `k` (cylinder `e_theta e_theta^T / r`); nodes see only a mesh.
|
||||
4. **Owner per loop vertex** = larger `|k|`, ties to the hull; Math/Compare/Switch nodes, per vertex. Fine.
|
||||
5. **Selecting yielding vertices.** Loop vertices whose origin is the non-owner surface, merged onto "the nearest owner-origin
|
||||
loop vertex along the loop". Nodes can: Sample Nearest on the owner subset (spatial nearest, not along-loop), Set Position
|
||||
of yielders onto that point, then Merge by Distance with Selection and a tiny distance. Merge by Distance semantics from the
|
||||
manual (3.5 page): selected points merge only among selected; `Mode` All/Connected; "merging surrounding geometry where
|
||||
necessary". Which position a merged group takes was not tested; the snap-first pattern avoids depending on it (inferred).
|
||||
Merge by Distance has no owner argument and no guard.
|
||||
6. **What nodes cannot express directly.**
|
||||
- The collapse guard: refuse a collapse on a face flip (old.new normal <= 0), on a worst angle under 10 degrees if worse, on
|
||||
a failed link condition. Each collapse is accepted or refused on the current mesh. Merge by Distance is one batch over the
|
||||
whole selection. A sequential accept/refuse needs a Repeat Zone holding the mesh as loop state with one candidate
|
||||
per iteration, each iteration running Merge by Distance and a flip test on the mesh (cost below), or a batch with a
|
||||
post-check that discards the whole batch (coarser than the spike's per-collapse refusal). A For-Each Geometry Element zone
|
||||
processes elements independently and joins results, so it cannot commit one collapse and see it before the next
|
||||
(inferred from the zone semantics; not run). Iteration count and the repeat zone's 5.2 limits were not tested.
|
||||
- Both-pieces acceptance for splits: the spike found replaying one piece's collapses opened a 6.8e-4 crack. Nodes would
|
||||
evaluate two Mesh Boolean nodes (Difference, Intersect) and need one decision applied to both: compute candidates
|
||||
deterministically on both, AND them by matching positions (Sample Nearest across the two meshes). Possible in principle;
|
||||
heavy; untested.
|
||||
- The panel-split case: the spike found the rule is a no-op there and the remaining slivers are the owner's own plus the cap
|
||||
triangulation (spike "Smallest changes" 3). Nodes have no retriangulate/dissolve node (2a); Mesh Bevel exists.
|
||||
- Cutter tessellation ("spacing no finer than about a quarter of the hull's local edge length") is an input constraint, not
|
||||
a node matter.
|
||||
7. **Not tested:** nodes 1 to 6 beyond the Boolean/attribute/edge-selection part. No weld chain was built.
|
||||
|
||||
## 3. Precedents
|
||||
|
||||
- **Searches.** Web searches for nondestructive boolean-cleanup node groups and developer discussion on post-boolean cleanup in
|
||||
Geometry Nodes returned only Blender manual pages (Merge by Distance, mesh Clean Up operators: Degenerate Dissolve, Limited
|
||||
Dissolve, Make Planar). No devtalk thread or community "clean boolean" group was found. One commercial add-on appeared:
|
||||
Hardsurface Doctor (superhivemarket.com/products/hardsurface-doctor), listed as scanning/repairing boolean-damaged meshes and
|
||||
re-applying bevel weights, Blender 4.2+; its code was not available to read.
|
||||
- **Operator-supplied copies of Hard Ops 26.1.7 and Boxcutter 26.1.5**, read as text only (untrusted; nothing run, imported or
|
||||
opened in Blender; .blend contents read as byte strings). Root: `...\scratchpad\vendor-src\hardops\HOps` and
|
||||
`...\vendor-src\boxcutter\Boxcutter` (abbreviated `HOps/` and `BC/` below).
|
||||
|
||||
### 3a. Licence field verbatim
|
||||
|
||||
- Hard Ops `HOps/blender_manifest.toml`: `license = ["SPDX:GPL-3.0-or-later"]` (name "Hard Ops 26'", version "26.1.7",
|
||||
`blender_version_min = "4.2.0"`).
|
||||
- Boxcutter `BC/blender_manifest.toml`: `license = [ "SPDX:GPL-3.0-or-later", ]` (name "BoxCutter 26'", version "26.1.5",
|
||||
`blender_version_min = "4.2.0"`). A `BC/LICENSE` file is present (not read).
|
||||
|
||||
### 3b. What their "cleanup" does
|
||||
|
||||
Neither contains an ownership, curvature or silhouette rule. Searches for `sstep`, `clean_boolean`, `boolean_clean`,
|
||||
`csharpen` found Csharpen as an operator and `Sstep` only as an icon name (`HOps/ui/Panels/a4_operations.py:40`).
|
||||
|
||||
- **Boolean modifier creation (Boxcutter).** `BC/addon/operator/shape/utility/modifier.py:395-400`: `obj.modifiers.new(name=
|
||||
'Boolean', type='BOOLEAN')`, `solver` from preferences (EXACT when mode is JOIN and `join_exact`), `show_viewport = show`.
|
||||
Lines 414-416: `sort(obj, option=preference.behavior)` and `sort(obj, types=['WEIGHTED_NORMAL'], last=True)`, so Weighted
|
||||
Normal is moved to the end of the target's stack. In the "nodes workflow" the boolean is the `NodeBoolean` wrapper
|
||||
(`BC/utility/geometry_nodes/node_boolean.py`) around the group `HOPS_Boolean`.
|
||||
- **Cutter-side modifier stack (Boxcutter), nondestructive.** `BC/addon/operator/shape/utility/data.py:117-141` builds on the
|
||||
cutter shape, in this order: `bevel_main` (BEVEL, `limit_method='WEIGHT'`, `edge_weight='bevel_weight_edge'`), `weld_main`
|
||||
(WELD, `mode='CONNECTED'`, `merge_threshold = MIN_DIM * 0.8`, line 120), `bevel_quad`, `weld_quad`, `bevel_front`, `weld_front`,
|
||||
then a `BC_Clean` Nodes modifier (data.py:143 on; class `BC/addon/operator/shape/utility/nodes/clean.py:8-12`, asset
|
||||
`BC_Clean` in `BC_Nodes/BC_Shape_nodes.blend`; inputs `Flipped Faces`, `Mark Sharp Angle`, `Mark Sharp`, `Remove Attrs`,
|
||||
`Geometry`, clean.py:5; set `remove_attrs='bevel*'`, `mark_sharp=False` at data.py ~143-147). So BC_Clean cleans attributes
|
||||
and flipped faces, not boolean seams. Bevel-dependent welds are re-added in `modal/bevel.py:161,184,220,249` with
|
||||
`merge_threshold = max(dimensions()) * 0.001` and re-ordered at `modal/bevel.py:264`:
|
||||
`modifier.sort(bc.shape, types=['VERTEX_WEIGHT_EDIT','VERTEX_WEIGHT_MIX','BEVEL','WELD'])`. These welds are on the cutter
|
||||
shape before it is used, not on the boolean result.
|
||||
- **Applied (destructive) cleanup in Boxcutter.** On accept, `clean_nodes` (data.py:1066 on): if `keep_modifiers` is false it
|
||||
applies all cutter modifiers except Smooth by Angle (data.py ~1124); otherwise `apply_unkept` (data.py ~1130-1156) applies
|
||||
those not kept and keeps `WELD`/`CLEAN` when a Bevel is kept; if no bevels remain only the last Weld is kept
|
||||
(data.py:1160-1170). bmesh calls (applied, on the cutter mesh): `bmesh.ops.remove_doubles` + two
|
||||
`dissolve_degenerate` passes (`BC/addon/operator/shape/utility/custom.py:130-133`); for circle cutters
|
||||
`spin`, `remove_doubles(dist=0.01)`, `dissolve_limit(angle_limit=0.01, use_dissolve_boundaries=False)`
|
||||
(data.py:455-456); further `remove_doubles` at data.py:560, 586 (1e-5), 1832 (1e-4) and `shader/snap_alt.py:880`
|
||||
(context not read). `BC/.../mesh.py:1034-1035` sets `use_merge_vertices = True`, `merge_threshold = 1e-7` on a modifier
|
||||
(which one not read). `BC/addon/property/preference/utility/update.py:347` adds an "Inset Weld" WELD modifier after an
|
||||
inset's Boolean.
|
||||
- **Hard Ops boolean as a node group.** `HOps/operators/booleans/bool_stack.py` converts a target's BOOLEAN modifiers into one
|
||||
Nodes modifier named `"{n} Booleans"` with `GeometryNodeMeshBoolean` nodes (bool_stack.py:266, 296-300; solver FLOAT/EXACT/
|
||||
MANIFOLD, Exact tracked in the node label because "nodes do not support it" per the option text at :66) and back to
|
||||
Boolean modifiers (:354-363). Hard Ops and Boxcutter both ship `HOPS_Boolean.blend` (`HOps/utility/geometry_nodes/
|
||||
HOPS_Nodes/HOPS_Boolean.blend`; `BC/utility/geometry_nodes/HOPS_Nodes/HOPS_Boolean.blend`; Blender 4.5 file header
|
||||
`BLENDER-v405`). Its interface (python wrapper input list, `BC/utility/geometry_nodes/node_boolean.py:6`): `Input Geometry`,
|
||||
`Object`, `Operation` (INTERSECT, UNION, DIFFERENCE, INSET, KNIFE), `Solver`, `Self Intersection`/`Non Manifold`,
|
||||
`Merge Threshold`, `Stitch Distance`, `Weld`, `Transfer Normals`, `Material Cut`, `Inset Thickness`, `Inset Slice`,
|
||||
`Inset Slice Offset`, tag names/values for faces and edges, `Clean Attribute 1` to `5` (wrapper properties for `weld` at
|
||||
node_boolean.py:130 and `merge_threshold` at :139). Byte-string scan of that file (wiring not read, so order inside the group
|
||||
is unknown): node classes `GeometryNodeMeshBoolean` (many), `GeometryNodeMergeByDistance` (at least 5 instances, tooltips
|
||||
"Merge distance for combining geometry", a label "Weld After Boolean"), `GeometryNodeSetMeshNormal`,
|
||||
`GeometryNodeCaptureAttribute`, `GeometryNodeInputNormal`, `GeometryNodeStoreNamedAttribute`, `GeometryNodeSetPosition`,
|
||||
`GeometryNodeFlipFaces`, `GeometryNodeJoinGeometry`, `GeometryNodeSwitch`/`IndexSwitch`, `GeometryNodeSetMaterial`, and
|
||||
descriptions "Subtract and intersect then stitch original geometry." (Knife) and "Fastest. Only Supports manifold
|
||||
geometry." Weld there is distance-based Merge by Distance after the boolean; it is a threshold, with no per-surface
|
||||
ownership (from the interface and node classes above; the graph itself was not read).
|
||||
- **Hard Ops Csharpen.** `HOps/operators/sharpeners/complex_sharpen.py:231-270`: `apply_mod` applies (destructively) the
|
||||
selected modifier types in this order of selection: BOOLEAN, MIRROR, BEVEL (all but the last), SOLIDIFY, ARRAY, plus any
|
||||
"others"; then `soft_sharpen_object`. `HOps/src/operator/shape/sharpen.py:263-273` adds a `WEIGHTED_NORMAL` modifier with
|
||||
`keep_sharp = True`; :281 `smart_apply`. So Csharpen = apply the stack, mark sharp edges, add Weighted Normal. No weld or
|
||||
dissolve in that path.
|
||||
- **Nondestructive or applied?** The boolean (Boolean modifier or `HOPS_Boolean` group), Bevel, Weld (on the cutter and an
|
||||
"Inset Weld" after an inset), BC_Clean and Weighted Normal are live modifiers until the user applies or accepts; the bmesh
|
||||
`remove_doubles`/`dissolve_*` calls and Csharpen's apply step are destructive. Hard Ops/Boxcutter therefore demonstrate that
|
||||
a GN node group with Mesh Boolean plus Merge by Distance can ship as a stack modifier; nothing read shows any
|
||||
curvature- or ownership-aware merge.
|
||||
|
||||
## 4. Performance
|
||||
|
||||
Measured, Blender 5.2.2 headless, same machine session (Starfire), hull 3,010 vertices, 16-segment cylinders at hull
|
||||
vertex xy positions (z centred, depth 0.2, radius 0.03, spaced > 0.1 apart, random seed 3; the cutters were not
|
||||
checked for non-overlap on the hull surface, but the cutter face counts below show each case cut). Time = one `depsgraph.update()`
|
||||
plus `evaluated_get().to_mesh()` after toggling the modifier, last two repetitions; includes the two Store Named Attribute
|
||||
nodes and Collection Info/Realize Instances.
|
||||
|
||||
| cutters | solver | result verts / faces | intersecting edges | cutter faces kept | time (s) |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 (gun port) | MANIFOLD | 3,067 / 3,056 | 42 | 48 | 0.026, 0.023 |
|
||||
| 1 | EXACT | 3,067 / 3,056 | 42 | 48 | 0.048, 0.041 |
|
||||
| 10 | MANIFOLD | 3,209 / 3,074 | 155 | 78 | 0.021, 0.019 |
|
||||
| 10 | EXACT | 3,209 / 3,074 | 155 | 78 | 0.128, 0.152 |
|
||||
| 23 | MANIFOLD | 3,306 / 3,100 | 247 | 113 | 0.028, 0.019 |
|
||||
| 23 | EXACT | 3,306 / 3,100 | 247 | 113 | 0.191, 0.203 |
|
||||
| 26 | MANIFOLD | 3,346 / 3,107 | 298 | 121 | 0.028, 0.024 |
|
||||
|
||||
Requested 30 and 60 cutters; the placement filter yielded 23 and 26. All cutters go through the one Mesh 2 input as a
|
||||
realized single mesh (Collection Info); the Boolean node takes Mesh 2 as one geometry here. No sequential or chained
|
||||
boolean run was timed. Repeat-zone cost: not measured. Sources read give no benchmark (manual: zone semantics only;
|
||||
search returned no timing data). A repeat-zone weld would run Merge by Distance plus a flip test per iteration over the
|
||||
mesh (3.0k to 3.3k vertices); Merge by Distance's selection input is documented as a way to speed the nearest-point search
|
||||
(manual 3.5 page). Per-iteration time at this size is untested (inferred only).
|
||||
|
||||
The spike's own kernel numbers for comparison: boolmesh 8 to 25 ms, Manifold 13 to 20 ms per op on these cases.
|
||||
|
||||
## Gaps
|
||||
|
||||
- The Add Modifier menu behaviour of `is_modifier` groups and the asset flow.
|
||||
- Whether `NodesModifier.properties` accepts Collection/Object inputs in 5.2 (item assignment failed).
|
||||
- Any weld chain beyond Boolean + `src` + `Intersecting Edges`; every node-level claim in 2d is inferred.
|
||||
- The 42-versus-36 count.
|
||||
- Hard Ops/Boxcutter node group wiring (strings only).
|
||||
63
docs/research/openscad-evaluators-and-csg-export.md
Normal file
63
docs/research/openscad-evaluators-and-csg-export.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<!-- Serves brush-tree:question:openscad-surface. Eyes pass eyes-openscad-evaluators, 2026-10-10. -->
|
||||
|
||||
# Facts: OpenSCAD evaluators, CSG export, round-trip precedent
|
||||
|
||||
Serves: brush-tree:question:openscad-surface (InForce, recommended c; Eyes pass owed before costing options a/b/c; unverified items: Rust evaluator existence, CSG export node set). Eyes, 2026-10-10. Facts only. Tags: FETCHED (page read this session), SEARCH (search-result snippet only), KNOWLEDGE (memory, unverified), LOCAL (ran on installed nightly).
|
||||
|
||||
Scratch dir: C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\1b994b80-e27e-42a8-8b75-ab8cb1dd9c54\scratchpad\eyes-openscad-evaluators\ (t.scad, t2.scad, out.csg, o2.csg, out.ast, t.nef3, t.param).
|
||||
|
||||
## 1. Evaluators / parsers outside OpenSCAD
|
||||
|
||||
| Name | What it is | Coverage | State | Licence | Tag |
|
||||
|---|---|---|---|---|---|
|
||||
| openscad-rs (github.com/ierror/openscad-rs, docs.rs/openscad-rs) | Rust parser to typed AST. No evaluator, no include resolution, no geometry. | literals, precedence, vectors, ranges, list comprehensions, functions, modules, children, modifiers, include/use (parsed only). Numbers as exact hyperreal::Real. Tested against vendored upstream OpenSCAD fixture corpus (regression floor, not full acceptance); cargo-fuzz. | v0.1.0, release 14 Sep 2026; 15 commits; 2 stars | Apache-2.0 OR MIT (repo page); docs.rs page said GPL-3.0-or-later (the two disagree) | FETCHED |
|
||||
| scad-rs (github.com/Michael-F-Bryan/scad-rs) | "A Rust implementation of the OpenSCAD virtual machine"; topics bytecode-VM, compiler, parser; scad.ungram grammar; openscad as submodule for compat tests | README gives no feature or maturity detail | 104 commits, 31 stars, not archived; last-commit date not shown; crates.io listing not confirmed | Apache-2.0 / MIT | FETCHED (README), maturity UNKNOWN |
|
||||
| scad, scadman, scad_tree, flowscad (crates.io) | Generators (Rust emits .scad). Not parsers/evaluators. scad 1.2.2 last updated 2019; scadman 0.2.2 Dec 2025 | n/a | | | SEARCH |
|
||||
| tree-sitter-openscad (github.com/openscad/tree-sitter-openscad) | Incremental tree-sitter grammar; npm @openscad/tree-sitter-openscad; Rust crate tree-sitter-openscad-ng; pyproject/setup.py present | syntax only | 154 commits, 11 stars; last-commit date not shown | MIT | FETCHED |
|
||||
| openscad_parser (github.com/BelfrySCAD/openscad_parser, PyPI) | Python parser (Arpeggio PEG; also an LALR variant openscad_lalr_parser feeds the evaluator) to AST | Claims "Full OpenSCAD language support": functions, modules, let/echo/assert, function literals, list comprehensions (for, C-style, if, let, each), modifiers, use/include; strict_commas() mimics 2021.01 | 202 commits; PyPI mirror snippet showed 2.4.7 (SEARCH) | MIT | FETCHED (README), version SEARCH |
|
||||
| openscad_evaluator / PyPI openscad-evaluator (github.com/BelfrySCAD/openscad_evaluator) | Python AST evaluator producing Manifold geometry from the openscad_lalr_parser AST. v1.8.1 (26 Sep 2026); 1.0.0 was 20 Jul 2026; Python 3.11+; 214 commits | docs/evaluator.md (read first 100k of 127k chars): user functions, closures, default params, tail calls; let (expr+stmt); list comprehensions incl. C-style for, let, intersection_for; modules, children() (index, separate=true extension), $children, parent_module(); include and use with namespace isolation; hull (accepts open meshes); minkowski (+ minkowski_difference extension, 3D); multmatrix (3D only; 2D children pass through unchanged); polyhedron (four input forms; open mesh dropped by booleans). README: full built-in coverage of primitives, transforms, booleans, 2D, text(), surface(), DXF/3MF import, roof(). Noted limitation: same-name double binding in one let "unhandled edge case". | | MIT | FETCHED |
|
||||
| (same) CSG tree exposure | evaluate() builds persistent `self.csg_tree` (list of CSGNode) in a resolve pass before any Manifold work; generate_tree() fills bodies; flatten_csg_tree(); id_to_node and id_to_call_chain map bodies back to source | | | | FETCHED |
|
||||
| openscad-wasm (github.com/openscad/openscad-wasm) | Full OpenSCAD port to WASM; ES module; `await OpenSCAD({noInitialRun:true})`, FS.writeFile, `callMain([...cli args])`, FS.readFile. 428 stars, 99 commits; no releases listed; no npm package named on page | same as desktop (it is the C++ core) | | GPL-2.0 | FETCHED |
|
||||
| openscad-playground (github.com/openscad/openscad-playground; npm openscad-playground "React component") | Browser app on a headless WASM OpenSCAD; README: "Licenses: see LICENSES" | | | not retrieved | SEARCH |
|
||||
| SolidPython | Generator (Python emits .scad), not an evaluator | | | | KNOWLEDGE (not re-checked) |
|
||||
| csgrs, synaps-cad | Named as related projects on openscad-rs README (geometry / interactive CAD) | not inspected | | | FETCHED (names only) |
|
||||
|
||||
No Rust crate found that evaluates OpenSCAD to a tree or mesh (SEARCH x2; scad-rs is the only candidate, maturity unknown). The only found evaluator is Python (openscad_evaluator).
|
||||
|
||||
## 2. OpenSCAD's own export (installed nightly)
|
||||
|
||||
LOCAL: `C:\Tools\OpenSCAD-2026.09.23-x86-64\openscad.com --version` -> `OpenSCAD version 2026.09.23` (git 28fe66bc). Default backend Manifold 3.5.2 (CGAL 6.1 available via --backend). `--info` Features list: roof, input-driver-dbus, lazy-union, vertex-object-renderers-indexing, textmetrics, import-function, object-function, predictible-output, vector-swizzle, discretization-by-error, ai-features, unicode-identifiers.
|
||||
|
||||
`--help` `-o` extension list: stl, off, wrl, 3mf, csg, dxf, svg, pdf, png, echo, ast, term, nef3, nefdbg, param, pov. `--export-format arg` overrides the format. `-o x.json` -> "Invalid suffix json" (no JSON export). `--summary-file` writes render stats as JSON (not a tree). `--help-export` lists -O settings (grep for csg/ast/json matched nothing). No library mode in help; `--enable` and `-D` exist. KNOWLEDGE: no libopenscad ships; wasm build drives it through callMain (see above).
|
||||
|
||||
Test file t.scad (10 lines): function sq, module m with for + translate, `let(a=3) m(a);`, hull{sphere,translate cube}, multmatrix+polyhedron, minkowski, linear_extrude(twist), rotate_extrude of translate+circle.
|
||||
|
||||
**.ast export** (out.ast, 558 B): pretty-printed source of the parsed (unevaluated) program. Keeps function/module definitions, `for`, `let`, calls, operators as text, with parentheses inserted: `function sq(x) = (x * x);` `for(i = [0 : (n - 1)]) translate(...)`. Nothing evaluated; named-argument order preserved. Text, not JSON.
|
||||
|
||||
**.csg export** (out.csg, 1267 B), evaluated tree as OpenSCAD text, all literals:
|
||||
- functions, module definitions, `let`, for loops are gone. The for loop becomes `group() { group() { group() { multmatrix(...) { cube } ... } } }` with i*sq(2) folded into translation matrices (4, 8).
|
||||
- `translate`, `rotate`, `scale`, `mirror` all become `multmatrix([[..4x4..]])` (translate([5,0,0]) -> matrix with 5 in column 4; mirror gives `-0` entries: `[-1, -0, -0, 0]`). No translate/rotate/scale/mirror survive.
|
||||
- Kept as nodes with literal params: `hull() { sphere($fn=8,$fa=12,$fs=2,r=1); multmatrix(..){cube(size=[2,2,2],center=false);} }`, `polyhedron(points=[..], faces=[..], convexity=1)`, `minkowski(convexity=0){cube; sphere}`, `linear_extrude(height=2, twist=10, $fn=0,$fa=12,$fs=2){square(size=[3,3],center=false);}`, `rotate_extrude(angle=360,start=180,convexity=2,$fn=12,...){multmatrix{circle(...)}}`, `difference(){cube; multmatrix{cube}}`, `polygon(points=..., paths=undef, convexity=1)`, `import(file="nonexist.stl", origin, scale, center, convexity, $fn,$fa,$fs, timestamp=0)` (written even though the file is missing).
|
||||
- Primitives are fully defaulted: `cube(size=[1,1,1], center=false)`, `cylinder($fn=5,$fa=12,$fs=2,h=1,r1=1,r2=0.5,center=false)`; `sphere(...)` gets `$fn,$fa,$fs,r`.
|
||||
- Variables/list comprehensions: table `tbl=[for...]` consumed at evaluation; only resolved values appear. `children()` in module `wrap` expanded inline (`multmatrix` for the translate then `group(){...}`).
|
||||
- Redundant `group()` wrappers appear with default settings (3 nested for the for-loop, extra for children).
|
||||
- `--enable=all` changes the output (diff vs default): group wrappers collapse (lazy-union) and `$fe = 0` is added to sphere/linear_extrude/rotate_extrude/circle calls. Default and enable=all are different texts for the same program.
|
||||
- Re-reading: `openscad o2.csg -o o2b.csg` succeeded, byte-identical size (1568 B both); emitted `WARNING: variable "timestamp" not specified as parameter in file o2.csg, line 37` for the import() line.
|
||||
- No source line numbers, instance paths, names or ids appear in the .csg.
|
||||
- `.param` export produced 13 bytes (customizer params); `.nef3` 256 KB (CGAL Nef dump of the geometry, not a tree).
|
||||
|
||||
## 3. Round-trip precedent: program + edits
|
||||
|
||||
- FreeCAD OpenSCAD workbench. FETCHED via search snippets only (wiki page itself returned an Anubis block): contains a CSG importer for OpenSCAD .csg and an exporter of a CSG-based tree; OpenSCAD itself must be installed to convert .scad to .csg first; non-CSG geometry is exported as a mesh; Ply required to parse .csg since 0.19. Sources do not say edits flow back to OpenSCAD variables (UNVERIFIED). https://wiki.freecad.org/OpenSCAD_Workbench
|
||||
- CadQuery / CQ-editor: SEARCH. CQ-editor edits the script, with debugger and export. CQGI discovers user parameters through script metadata and the host supplies new values at build(). Sketch API has tag()/select(tags); a roadmap page (test mirror) lists tag propagation as planned. No built-in direct-edit-to-override found. https://cadquery-ja.readthedocs.io/ja/latest/cqgi.html , https://cadquery.readthedocs.io/en/latest/selectors.html
|
||||
- build123d: SEARCH. Python CAD with builder/algebra/direct APIs; no override mechanism found. https://build123d.readthedocs.io/en/latest/
|
||||
- Fusion 360: SEARCH (2014 Autodesk announcement). Timeline is a history of commands; direct operations such as edit form and patch are not captured in the timeline. Current behaviour not checked. https://www.autodesk.com/products/fusion-360/blog/january-2014-update-is-available/
|
||||
- Onshape: SEARCH. Direct edits on imported geometry are captured as features in the feature list; a FeatureScript custom feature executes at regeneration and cannot modify the feature list (employee forum answer). https://www.onshape.com/en/blog/parametric-editing-vs-direct-editing-cad-systems , https://forum.onshape.com/discussion/20673/how-to-edit-the-code-of-a-shape
|
||||
- Blender Geometry Nodes: SEARCH returned nothing on apply-vs-override; the manual states per-modifier input values on a shared group (https://docs.blender.org/manual/es/3.0/modeling/modifiers/generate/geometry_nodes.html). KNOWLEDGE (unverified): the modifier is non-destructive and edit-mode changes to the evaluated mesh are not stored back; "Apply" bakes the result; Blender 4.x adds a Bake node.
|
||||
|
||||
## Gaps
|
||||
- scad-rs maturity/last commit and crates.io status unchecked; openscad-rs licence conflict (Apache/MIT vs GPL-3 on docs.rs) unresolved; openscad_evaluator last 27k chars of evaluator.md and TODO.md unread; no hull/minkowski/multmatrix correctness comparison run against OpenSCAD.
|
||||
- The Python evaluator's CSGNode shape was not inspected; whether it keeps pre-flattened structure (names, call chains) beyond id_to_call_chain unknown.
|
||||
- CSG export of `use`/`include`, `children(i)`, `$`-special vars, `text()`, `surface()`, `render`, `intersection_for`, modifiers (#, %, !) not tested.
|
||||
- FreeCAD importer's per-node mapping (hull, polyhedron, minkowski) not retrieved. Fusion/Onshape/Blender current behaviour not verified from vendor docs.
|
||||
- openscad-playground and openscad-wasm npm publication/licence text (LICENSES) not retrieved. SolidPython not re-checked.
|
||||
100
docs/research/primitive-seams-and-sdf-bevel-meshing-prior-art.md
Normal file
100
docs/research/primitive-seams-and-sdf-bevel-meshing-prior-art.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<!-- Serves brush-tree forks seam-policy, uv-channels, bevel-operator, bevel-to-mesh and brush identity on faces. Eyes pass eyes-brush-tree-seams-bevels, 2026-10-10. -->
|
||||
|
||||
# Prior art: UV seams/parameterisation, SDF microbevels to mesh, Manifold ID/property tracking (Eyes, 2026-10-10)
|
||||
|
||||
Serves forks (campaign brush-tree, target brush-tree:target:r1; drafts in scratchpad\brush-tree-target\questions.md): `seam-policy`, `uv-channels`, `bevel-operator`, `bevel-to-mesh`, and `brush-identity` on faces (Manifold ID tracking, section C).
|
||||
Extends GameCult.Geometry.Csg docs/research/brush-surfaces-and-sdf-bricks-prior-art.md (df89568) ([BS]); does not repeat it. Facts only; no conclusions.
|
||||
|
||||
Tags: FETCHED = page body read this pass (WebFetch returns a small-model extraction of the page, so quotes are extraction-mediated); SEARCH = search-result snippet/summary only; KNOWLEDGE = memory, unverified.
|
||||
Fetch failures: iquilezles.org/articles/smin (404 again, also via http redirect), developer.valvesoftware.com Valve_Map_Format (403), projects.blender.org issue 47478 (403), Blender manual bevel page body (extraction returned navigation only), raw manifold.h/common.h (MeshGL struct not in them), Houdini tube/sphere pages (first 100k chars, no UV text), cgal.geids.org (dns).
|
||||
|
||||
## A. UV seams and parameterisation
|
||||
|
||||
### A1. Blender primitive UVs (source: bmesh operators bmo_primitive.cc, raw GitHub main) FETCHED
|
||||
- Cylinder and cone are one path, `bmo_create_cone_exec`; UVs from `BM_mesh_calc_uvs_cone(bm, mat, rad2, rad1, segs, cap_ends, FACE_MARK, cd_loop_uv_offset)`.
|
||||
- Cap layout: circles packed in lower half of the unit square when capped: `uv_center_y = cap_ends ? 0.25f : 0.5f`; top cap centre x 0.25, bottom 0.75; `uv_radius = cap_ends ? 0.24f : 0.5f`. Cap scale = `uv_radius / radius_top` (own radius per cap), measured in local space (via inverse matrix); top/bottom chosen by `dot(f->no, local_up) > 0`.
|
||||
- Side strip: upper half when capped (`uv_height = cap_ends ? 0.5f : 1.0f`); U steps `1/segments` per face starting at `x = 1.0` and decreasing. No explicit seam marker in this code; seam is the start/end of the strip, and the extraction notes it depends on face iteration order.
|
||||
- UV sphere (`bmo_create_uvsphere_exec`): poles at (0,0,+-rad); duplicate pole vertices merged with `remove_doubles`; pole fan triangles take U from atan2 of the average XY of the three vertices (`f->len == 3 && |x|,|y| < 0.0001`); `luv[0] = 0.5 + theta/(2pi)`, `luv[1] = 1 - phi/pi` (equirectangular; poles at V=1 and V=0). Seam at the atan2 branch cut; borderline coordinates "shifted to the left"; straddling faces get +1.0 on far vertices when U gap > 0.5; final pass subtracts global min U.
|
||||
- UVs computed in local unrotated space before the matrix is applied.
|
||||
- Torus: not in this file. SEARCH: Blender issue #47478 (new default UVs on some primitives "poor/missing") proposed equirectangular UV sphere and noted pole rings are triangles; torus UVs tracked separately in #47489 (not read, 403). SEARCH: helper functions declared in bmesh_operators.h: `BM_mesh_calc_uvs_grid/_sphere/_circle/_cone/_cube`; grid-seam fixes T52066 (2017), T67523 (2019, wrap threshold slightly below 1.0 so last column does not overlap first); T51180 (2017) made the UV-layer offset a parameter.
|
||||
- Blender Geometry Nodes Cylinder node page: extraction returned only navigation (no UV text). Not read.
|
||||
|
||||
### A2. TrenchBroom (manual, trenchbroom.github.io/manual/latest, 256k chars; read chars 0-200000) FETCHED
|
||||
- Format support: Quake standard and Valve 220. "Alignment lock in the Shear tool only works in Valve 220 format maps." Face attributes: plane, material, X/Y offset and scale, rotation angle.
|
||||
- Projection: Paraxial = projection axis is the coordinate axis closest to the face normal (can shear slanted faces; "perfect alignment locking is impossible"). Parallel = projects along the face normal, offered only for Valve 220 maps. Shearing needs parallel projection.
|
||||
- UV Lock (separate preference): when enabled, "will attempt to keep vertex UV coordinates the same" during vertex editing/face moves; alignment lock is "precise ... for all brush editing operations".
|
||||
- Scale step default 0.1 per step (0.25 or 0.01 with modifiers); offset step = grid size; angle step 15 deg.
|
||||
- CSG material rule: for CSG operations TrenchBroom "looks for an input face on the same plane and copies its material and attributes", otherwise applies the current material (paraphrase of extraction; no statement on cutter's-vs-target's surface for cut faces beyond this).
|
||||
- Primitive creation (cylinder, cone, UV sphere, arch; scalable circles 12/24/48/96 sides): no text on how they are textured in the part read. Chars 200000-256434 not read.
|
||||
- Valve 220 line syntax and default scale: page 403; remains KNOWLEDGE (see [BS] §A).
|
||||
|
||||
### A3. Unity ProBuilder (docs.unity3d.com/Packages/com.unity.probuilder, SEARCH snippets)
|
||||
- Two UV modes: Auto (default; projects texture, Tiling scale value, "World Space" checkbox to align UVs to the world so same-angle faces line up, UVs "do not stay put when you move the object") and Manual (per vertex/edge). Auto UVs editable only per face; editing an edge/vertex converts to Manual; manual edits do not transfer back.
|
||||
- No ProBuilder text found on texel density, tile-to-metre units, or cylinder/sphere-specific UVs. Pages not opened (workflow-uvs.html, auto-uvs-actions).
|
||||
|
||||
### A4. RealtimeCSG (Unity) SEARCH
|
||||
- Asset Store text: "edit your texturing by dragging, rotating or scaling your material over the brushes". No documentation found on automatic UV method or cut-face UVs. Source-level behaviour of the successor Chisel is already in [BS] §A (per-surface 2x4 UV matrix, evaluated from final vertex positions; cut face = cutter's own base polygon surface, inferred). SabreCSG (comparison) lists per-face UV edit tools; not read.
|
||||
|
||||
### A5. Houdini FETCHED (negative) + SEARCH
|
||||
- Tube and Sphere SOP pages (first 100k chars): no UV text. Sphere SOP: mesh-type sphere meridians meet at the poles; "Unique Points per Pole" controls whether meridians share one pole point or keep their own; "Triangular Poles" turns the degenerate pole quads into triangles.
|
||||
- Houdini Boolean SOP UV/attribute transfer: see [BS] §A (SEARCH only); not extended.
|
||||
|
||||
### A6. OpenCASCADE / CadQuery (BRep parameter spaces) SEARCH
|
||||
- `Geom_CylindricalSurface`: S(U,V) = Loc + R cos(U) X + R sin(U) Y + V Z; U in [0,2pi], periodic; V unbounded; XAxis gives U origin; a seam edge lies at U=0/2pi. (Location of the seam for `BRepPrimAPI_MakeCylinder` is the snippet author's inference from the parametrisation, not stated by a source.)
|
||||
- `Geom_SphericalSurface`: U rotation about main axis, X axis gives U origin; V rotation about YDirection; poles at V limits; topology uses degenerate edges at both poles plus a seam edge joining them (tutorial, Chinese-language aliyun article, snippet). Toroidal: V increases about main axis, U chosen so D1U x D1V points outward; U/V ranges not returned.
|
||||
- CADEX mesher option (SDK docs, snippet): stores UVs "of mesh nodes in parametric space domain of each B-Rep surface"; OCC forum: face-triangulation UVs can fall outside 0-1 and have very large values, fix is to normalise before export.
|
||||
- Onshape/Fusion export (SEARCH): Onshape export presets (Coarse/Medium/Fine/Custom with distance/angular tolerance, max chord length) for glTF/OBJ/3MF; user reports of no `vt` in OBJ export, glTF UVs present but "no consistency in proportions"; Onshape emits a sub-mesh per face. Fusion reports conflict (one says cube exports with UVs, an older one says no texture info). No source for how planar/cylindrical faces are mapped.
|
||||
|
||||
### A7. Property/ID propagation through booleans (cut-face attributes)
|
||||
- Manifold: section C.
|
||||
- libigl `mesh_boolean` (SEARCH, header comments of a mirror at git.inf-cv.uni-jena.de/3DFaces/libigl_Martin): output `J` = "#FC list of indices into [Flist[0];Flist[1];...;Flist[k]] revealing 'birth' facet" (two-mesh overload: indices into [FA;FB]). PyMesh (SEARCH): per-face attribute `source` (0 = first operand) and `source_face` (combined input face index). Property interpolation across new vertices: not described in the snippets. Mirror is old; current upstream unconfirmed.
|
||||
- CGAL Polygon_mesh_processing corefinement (SEARCH): `PMPCorefinementVisitor` concept with face hooks `before_face_copy` / `after_face_copy` (Boolean-specific, 5.1 docs), `before_subface_creations` / `after_subface_created` (face split during corefinement); edge hooks added from 5.3. Default is `Corefinement::Default_visitor`. No official example found propagating a property map through a Boolean.
|
||||
- DarkRadiant subtract and Hammer texture lock: [BS] §A.
|
||||
|
||||
### A8. Not found (curved-primitive questions)
|
||||
- Texel-density keeping across primitives: no source found in any tool above (ProBuilder gives Tiling/World Space only; Blender cap circle scaled to unit-square cell by its own radius, the side strip fixed to 1/segments per face). Cube-sphere/octahedral/torus/sweep sources: none fetched this pass. Blender torus UV code, Valve 220 spec, Blender Geometry Nodes primitive UV text, Houdini UV attribute on primitives: not read.
|
||||
|
||||
## B. Turning SDF microbevels into mesh
|
||||
|
||||
### B1. hg_sdf operators (mercury.sexy/hg_sdf/hg_sdf.glsl) FETCHED
|
||||
- Header: "Stay Lipschitz continuous. That means: don't have any distance gradient larger than 1." "Underestimating distances will happen. That's why calling it a 'distance bound' is more correct." "The invariant is: each fSomething() function returns a correct distance bound."
|
||||
- Operator section: the ops "are designed to actually produce correct distances or distance bounds, unlike popular 'smooth minimum' operators, on the condition that the gradients of the two SDFs are at right angles." "When they are off by more than 30 degrees or so, the Lipschitz condition will no longer hold (i.e. you might get artifacts)." Most take a radius `r`; some check `if ((-a < r) && (-b < r))` to limit influence and cost to a region.
|
||||
- fOpUnionChamfer: `min(min(a,b), (a - r + b)*sqrt(0.5))`, "45-degree chamfered edge (the diagonal of a square of size r)". fOpUnionRound: `u = max(vec2(r-a, r-b), 0); return max(r, min(a,b)) - length(u)`, "quarter-circle". Correction term is zero when a and b are both >= r. fOpUnionSoft: "Similar to fOpUnionRound, but more lipschitz-y at acute angles (and less so at 90 degrees)"; `e = max(r - abs(a-b), 0); min(a,b) - e*e*0.25/r` (same as Dreams soft_min in [BS] §C); deviates from min only where abs(a-b) < r. fOpPipe `length(vec2(a,b)) - r`, "not a boolean operator". fOpEngrave `max(a, (a + r - abs(b))*sqrt(0.5))`. fOpUnionColumns: differs from min only where both a,b < r. fOpUnionStairs: "much less stupid version by paniq". Source states no bound property for Chamfer/Round/Engrave/Columns/Stairs individually; the only stated condition is the right-angle gradient clause above.
|
||||
|
||||
### B2. Smooth-min (Quilez) SEARCH, article body not read
|
||||
- Search summary of iquilezles.org/articles/smin: exponential smin gives the same result regardless of chaining order (smin(a,smin(b,c)) == smin(b,smin(a,c))); polynomial and root variants do not; exponential generalises to n distances. Article's bound/exactness statements: not obtained. [BS] §C already holds the polynomial formula and a Shadertoy comment that smooth-min is not SDF-preserving.
|
||||
- Sphere tracing and smooth union (SEARCH): a source says max and the smooth operators return a lower bound on true distance (never larger, sometimes smaller); a smaller step costs iterations rather than causing overshoot; overshoot arises when gradient exceeds 1. Segment-tracing paper (Galin et al., "Segment Tracing Using Local Lipschitz Bounds", CGF 2020) sets a global Lipschitz bound of 1.45 "to accommodate field compression"; blog (digitalfreepen.com) reports stepping up to ~50% faster than the derived limit before visible artefacts (~3x with Perlin noise added). A shader demo (gamedev.net shaderlab 28) describes polynomial smooth-min as leaving the surface slightly outside the intersection and adding a quadratic correction; gradient magnitude not analysed. Whether polynomial smin of two 1-Lipschitz fields can exceed gradient 1: not found in sources (KNOWLEDGE only).
|
||||
|
||||
### B3. Meshing implicit fields with sharp features
|
||||
- Dual Contouring (Ju, Losasso, Schaefer, Warren, SIGGRAPH 2002) SEARCH: contours a signed grid with Hermite data (exact edge intersections and normals); avoids a separate feature-detection step; vertices at QEF minimiser per cell, made numerically stable; QEF also drives octree simplification. Schaefer-Warren follow-up: "mass points" for rank-deficient QEFs, handling of non-manifold sign configurations. Manifold DC (Schaefer, Ju, Warren): states DC "may generate non-manifold surfaces" (edge shared by >2 polygons; vertex neighbourhood not a disk). Thin features (sheet thinner than a cell): none of the retrieved sources discuss it. PDFs located but not read: cs.wustl.edu/~taoju/research/dualContour.pdf, people.engr.tamu.edu/schaefer/research/dualcontour.pdf, dualsimp_tvcg.pdf, dmc.pdf.
|
||||
- "Dual Contouring of Signed Distance Data" (arXiv 2604.00157, 2026) FETCHED (html): reconstructs from SDF grid values without arbitrary function access or gradients; init: sign-change edge intersections by linear interpolation, normals from trilinear-interpolant gradients, cell vertex at Hermite-point centroid; outer loop re-assigns SDF samples to cells by closest mesh point, optimises vertex with distance term + DC Hermite term, refits Hermite data by PCA; inner loop linearised regularised quadratic; vertex may leave its cell. Reported failure modes: self-intersections and face flips when vertices leave cells; sensitivity to noise (chamfer error worse than some baselines at noise 0.01); isolated dents along feature curves; high Hermite weight unstable (spikes); manifoldness not guaranteed (future work). Fillets and thin features not discussed. Newton projection not used or mentioned.
|
||||
- libfive (SEARCH, secondhand Curv mailing-list post, groups.google.com/g/curv/c/7hMmGQFav7w): libfive's mesher is "mostly manifold dual contouring, which snaps to sharp features and is watertight and manifold, but may contain self-intersections"; uses partial derivatives to place vertices on features, so integrations must supply derivatives. libfive's own docs/Keeter's paper not fetched this pass. Fidget/MPR details (JIT, tapes, autodiff normals) are in [BS] §D.
|
||||
- nTop (docs.ntop.com, SEARCH): "Mesh from Implicit Body" tolerance step-down (start at 30-50% of minimum feature size); sharpening option, 3 iterations recommended start, `Sharpen Extents` to limit region; simplification and remesh options (growth rate 1.2-1.5 for fidelity; span angle and chord height controls); export target closed, edge-manifold, oriented, not self-intersecting; field health checked in a Field Viewer (parallel evenly spaced contours = healthy SDF, "Show Defects" for discontinuities; common field defects from edge-to-edge booleans, "fake zeros" from coincident surfaces, distorted fields from custom equations); booleans of distant bodies share one voxel grid so sharp edges may round off. No fillet/blend-field-specific meshing text found.
|
||||
|
||||
### B4. Manifold smoothing and fillet-like options (manifoldcad.org jsuser Manifold.html + wiki Manifold-Library) FETCHED
|
||||
- `smoothOut(minSharpAngle=52.5, minSmoothness=0)`: edges sharper than minSharpAngle stay sharp; others smoothed to G1; flat faces of >=3 triangles stay flat; "minSmoothness ... The default gives a hard edge, while values > 0 will give a small fillet on these sharp edges"; 1 equals minSharpAngle 180.
|
||||
- `smoothByNormals(normalIdx=0)`: fills halfedgeTangent vectors; geometry unchanged until `refine`/`refineToLength` interpolates; vertices where multiple disagreeing normals exist become sharp edges.
|
||||
- `refine(n)`: splits every edge into n; new vertices coplanar unless halfedgeTangents are present, then moved to the interpolated surface by barycentric coordinates. `refineToLength`, `refineToTolerance` ("All vertices are exactly on the surface, within rounding error").
|
||||
- Wiki: smoothing aims at G1 (G2 explicit non-goal); each edge gets two weighted cubic Bezier control points; sharpening takes half-edges with smoothness 0..1, "a crease is only formed at a smoothness of zero"; "The Boolean works only on flat triangles and ignores control points, so refine before booleaning curved meshes."
|
||||
- `calculateNormals(normalIdx=0, minSharpAngle=52.5)`: fills normal properties; "Flat faces composed of three or more triangles will remain flat"; edges beyond the angle get different normals on each side; 0 gives faceted. Non-zero normalIdx "will not be supported in a future release".
|
||||
|
||||
### B5. Mesh-boolean-then-bevel practice
|
||||
- Blender Bevel modifier (4.1 manual snippets, SEARCH; 5.2 page body not extracted): Harden Normals adjusts bevel-face vertex normals to match surrounding faces so flat neighbours stay flat and the bevel shades smoothly, needs custom split normals (auto-enabled; 2.8x required Auto Smooth); Mark Seam / Mark Sharp keep seam/sharp propagation consistent when bevelling edges that cross; Face Strength (None/New/Affected, older: All) sets strengths (new edge faces Medium, vertex faces Weak, Affected adds Strong on adjacent) for a following Weighted Normals modifier with Face Influence. UV handling of bevel faces: not found.
|
||||
- Hard-surface game workflow (polycount threads and vendor guide, SEARCH; practitioner opinion): one smoothing group + bevels + weighted normals instead of hard-edge splits (fewer split vertices, cleaner normal-map gradients when a normal map is used); one poster found chamfer/support loops on the low poly bake less finely than a high-poly bevel and cost more polygons than hard edges when silhouette is kept; wavy reflections on fake bevels at low texel density; ordering: fix topology and bevel width, then hard/soft boundaries, then weighted normals last; "resolve Boolean intersections before beveling" (duplicate faces give unstable highlights); triangulate late and inspect long diagonals on non-planar faces; custom normals can be lost on FBX import; Blender GSoC 2018 thread: Weighted Normal modifier last in the stack; weighted-normal gradients baked into a low poly can cause a compensating gradient when baking from high poly. Hard Ops/Boxcutter: [BS] §C.
|
||||
- CGAL/other "bevel on a mesh" operators: not searched.
|
||||
|
||||
### B6. Not found
|
||||
- Newton projection onto f=0 for strip vertices as an established mesh-extraction technique: no source surfaced. Edge-strip emission at CSG cut edges from a field: none. Failure modes for overestimating bounds specifically in meshing: only the sphere-tracing material in B2 (rendering, not meshing).
|
||||
|
||||
## C. Manifold: properties and original-ID tracking through booleans
|
||||
|
||||
- Properties (wiki Manifold-Library, manifoldcad.org docs) FETCHED/SEARCH: arbitrary number of float channels per vertex ("merely float values indexed by channel number"); user decides meaning (colour, UV, normal); linearly interpolated across each triangle; vertices and property verts are not one-to-one: "along a Boolean intersection curve each vertex will have different properties on each side"; the design allows "sudden property changes, e.g. at Boolean intersections, without sacrificing manifoldness". Properties of separate input objects "are kept separate" so channel meaning need not agree across inputs. The pages do not state the interpolation rule at the new vertices of a boolean beyond this (FETCHED, negative).
|
||||
- MeshGL fields (manifoldcad.org C++ docs, structmanifold_1_1_mesh_g_l_p.html) FETCHED: `numProp` ">= 3", `vertProperties` flat interleaved, first three always position; `triVerts` CCW from outside; `mergeFromVert`/`mergeToVert` optional (vertices to merge to reconstruct the manifold; same position, possibly different other properties); `runIndex` ("runs of triangles that correspond to a particular input mesh instance", start index divisible by 3); `runOriginalID` ("The OriginalID of the mesh this triangle run came from", several runs may share an ID); `runTransform` (3x4 column-major per run); `faceID` ("Optional: Length NumTri, contains the source face ID this triangle comes from"); `halfedgeTangent` (weighted XYZW for smooth Refine; empty = faceted); `tolerance` ("for mesh simplification"; edges shorter may be collapsed); `Merge()`.
|
||||
- IDs (wiki + jsuser) FETCHED: each Manifold built from scratch gets a unique sequential `originalID`; after booleans each triangle refers back via `originalID` and `faceID`; `faceID` is an input triangle index, and for coplanar triangles "the index of the largest area triangle of that face is used as the faceID for all of those triangles"; each input instance appears as its own triangle run, sorted by originalID. `reserveIDs(n)`: "Returns the first of n sequential new unique mesh IDs for marking sets of triangles that can be looked up after further operations. Assign to Mesh.runOriginalID vector." `asOriginal()`: resets IDs so descendants reference this mesh; "also condenses all coplanar faces in the relation, and collapses those edges." `originalID()` returns the ID for an original, -1 for a product. JS guide for multi-material input: call `reserveIDs()` to give each material a unique originalID and sort materials into triangle runs.
|
||||
- Boolean precision (wiki): no tolerance parameter; "does not account for any tolerance", uses symbolic perturbation; manifold carries internal epsilon; `MeshGL.Merge` takes a `precision` argument.
|
||||
- Header manifold.h (raw, master) FETCHED: confirms `SetProperties(numProp, fn)`, `CalculateNormals`, `Refine*`, `SmoothByNormals`, `OriginalID`, `AsOriginal`, `ReserveIDs`; no behaviour text in the header. Error enum includes `RunIndexWrongLength`, `FaceIDWrongLength`, `PropertiesWrongLength`, `InvalidTangents`.
|
||||
- OpenSCAD: Manifold is the default backend (announcement quoted in SEARCH result; CGAL via `--backend=cgal`); 3MF coloured export reported working on the Manifold backend (Fabbaloo/mailing-list snippets). How OpenSCAD maps colours onto originalID/runOriginalID: not found (OpenSCAD source not read).
|
||||
- Local note: the mapping from a brush to a Manifold originalID would be a user choice of `reserveIDs` per input; nothing in the pages read assigns IDs per brush automatically.
|
||||
|
||||
## Gaps for a follow-up
|
||||
Quilez smin article body (bound/exactness per variant); Valve 220 spec text; Blender torus/cube UV code and Geometry Nodes primitive UV text; Blender bevel manual body and bevel UV behaviour; DC paper body (thin features, QEF limits); libfive docs; sphere/cube-sphere/octahedral/torus/sweep UV sources; texel-density-across-primitives sources; Manifold interpolation rule for properties at new boolean vertices (source code `src/boolean3` / `boolean_result.cpp` unread); OpenSCAD colour-to-originalID mapping; libigl upstream `J`; CGAL property propagation example; Newton-projection-onto-isosurface literature.
|
||||
92
docs/research/sdf-authoring-text-prior-art.md
Normal file
92
docs/research/sdf-authoring-text-prior-art.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# SDF authoring text: prior art
|
||||
|
||||
Fork: campaign `brush-tree`, question `openscad-surface` (map rows "Parametric layer" and "Authoring text", `docs/brush-tree-map.md`; invariants `text-round-trips` and `text-is-a-surface`, `docs/brush-tree-target.md`; GameCult.Geometry.Csg branch `eureka/brush-tree`, commit a7879dad). Gathered by Eyes agent `eyes-bt-sdf-text`, session `self-2026-10-11-ag2`, 2026-10-12. Facts with pointers; lines marked "(inferred)" are not facts from a source.
|
||||
|
||||
Method limits: web pages were read through a summarising fetcher, not raw. Several raw-file fetches returned 404, so items under "Not verified" (section 5) were not read. The mind query for `brush-tree:question:openscad-surface` returned the whole store with no match for that id, so the question text itself was not read; its content is taken from the map and target docs. The commit is in `F:\Projects\GameCult.Geometry.Csg`, not in the CultLib checkout.
|
||||
|
||||
## 0. The local side (what the text must express)
|
||||
|
||||
- `src/field.rs` @ a7879dad: `Add` = `min`, `Subtract` = `d.max(-b)`, `Intersect` = `d.max(b)` (`stream_distance`), all f32 (bevy_math). Primitive distances: oriented box exact; cylinder exact; ellipsoid is a bound; floret arm a max-of-planes bound. No smooth union exists in the file at this commit.
|
||||
- Target needs (`docs/brush-tree-target.md`): primitives, transforms, scoped booleans, smooth unions with a named radius, creases as hard `max`, per-part `field`/`kernel` declaration. Map row Brush: identity "carried in the text as a named argument, plus a human name".
|
||||
- Map row "Authoring text (OpenSCAD-shaped)": not persisted as truth; header names tree key and base revision; parsed back only through the commit primitive; stale base refused.
|
||||
- OpenSCAD has no smooth union. Searched: no native OpenSCAD smooth-union feature or PR was found; mailing-list threads (lists.openscad.org) point to `minkowski()` and hull-based fillets; BOSL2 offers `round_corners`, `offset_sweep`, `rounded_prism` (rounding of paths and prisms, not a field blend; github.com/BelfrySCAD/BOSL2 wiki rounding.scad).
|
||||
|
||||
## 1. Survey
|
||||
|
||||
### libfive
|
||||
- Syntax: Scheme (Guile) `(union a b)`, `(blend-expt a b m)`; Python bindings from the same stdlib; C API; Studio GUI (OpenSCAD-like live coding).
|
||||
- Round trip: shapes evaluate to an expression `Tree`; no documented text print/parse of the tree. The Scheme source is the text.
|
||||
- Smooth: `blend_expt(a,b,m)` ("using exponents"), `blend_expt_unit`, `blend_rough` ("may not preserve gradients"), `blend_difference(a,b,m,o)`. Hard: `union`/`intersection`/`difference` (variadic; difference = intersection with inverse of the union of the rest). Formulas not read (header comments only).
|
||||
- Licence: libfive, stdlib, Python bindings MPL-2.0; Guile bindings and Studio GPL-2.0+. README copyright 2015-2021, no status statement; Guile bindings Linux/macOS only.
|
||||
- Rust: community bindings listed in README, not checked. Vocabulary: large stdlib.
|
||||
- Source: github.com/libfive/libfive README; stdlib Guile csg.scm; libfive_stdlib.h.
|
||||
|
||||
### Fidget (Matthew Keeter)
|
||||
- Syntax: Rust library; Rhai scripts via `fidget-rhai` 0.5.1: `circle(#{ center: [1, 2], radius: 3 }).move(#{ offset: [1, 1] })`, `difference(a, b)`, `union(a, b, c, ...)`, `axes()` returns x,y,z; operators on trees build nodes rather than compute. `fidgetpy` (Python) and a third-party Koto fork exist (README).
|
||||
- Round trip: `Context::from_text` parses a flat node-per-line form (address, op name, operand addresses; `var-x`, `const 1`, `square <addr>`); docs.rs: "loosely defined and only intended for use in quick experiments". `Context` ops: add sub mul div modulo min max atan2 mix compare and or, neg recip abs sqrt square sin cos tan asin acos atan exp ln floor ceil round not rand, `if_nonzero_else`, `deriv`. Canonical tape bytecode exists (README). A Rhai script is a general program (loops, conditionals) evaluated once; no printer from tree back to script is documented.
|
||||
- Smooth: no smooth-min builtin seen in `Context`; the docs.rs rhai page lists only `union`/`difference` and does not mention min/max/blend. Smooth forms would be composed from the math ops. Crease = `max`.
|
||||
- Licence MPL-2.0. Maintenance: sole maintainer, "personal-scale experimental project"; README recommends pinning 0.4.1 to avoid LLM-influenced code; unlikely to merge changes with substantial maintenance burden.
|
||||
- Rust: native (`fidget` on crates.io; JIT aarch64/x86_64; Manifold Dual Contouring meshing). Rhai vocabulary on docs.rs: circle, sphere, move, union, difference, vec2/vec3, sin/cos, constants (partial list).
|
||||
- Source: github.com/mkeeter/fidget; docs.rs/fidget-rhai; docs.rs/fidget Context.
|
||||
|
||||
### Curv
|
||||
- Own pure functional language; shapes are first-class values; GPU render, mesh export STL/OBJ/X3D. `lib.blend`: "14+ blending kernels" (Codeberg README); names not read.
|
||||
- Round trip: not documented. Licence Apache-2.0 (GitHub sidebar). GitHub repo archived 2023-09-27, development moved to Codeberg (codeberg.org/doug-moen/curv, maintained by Tim Ayres; most recent commit shown 2026-09-26). No Rust.
|
||||
|
||||
### fogleman/sdf (Python)
|
||||
- Syntax: `sphere(1) & box(1.5)`, `a | b.k(0.25)`, `union(a, b, k=0.25)`, `.translate((1,2,3))`; `@sdf3` / `@op3` decorators for custom ones.
|
||||
- Round trip: none; the Python expression is the text; no serialisation mentioned.
|
||||
- Smooth (dn.py): union `h=clip(0.5+0.5*(d2-d1)/K,0,1); m=d2+(d1-d2)*h; d=m-K*h*(1-h)`; difference `h=clip(0.5-0.5*(d2+d1)/K,0,1); m=d1+(-d2-d1)*h; d=m+K*h*(1-h)`; intersection `h=clip(0.5-0.5*(d2-d1)/K,0,1); m=d2+(d1-d2)*h; d=m+K*h*(1-h)`. Hard: np.minimum / np.maximum / `max(d1,-d2)`. `K = k or operand._k`, so `k=0` is falsy and falls to the operand's `_k` or the hard form. Separate `blend` is linear `K*d2+(1-K)*d1`, default 0.5.
|
||||
- Vocabulary (about 60 names): 3D primitives sphere box rounded_box wireframe_box torus capsule capped_cylinder rounded_cylinder capped_cone rounded_cone ellipsoid pyramid; 4 Platonics; plane slab cylinder; text image; translate scale rotate orient; union difference intersection and smooth versions; repeat circular_array; blend dilate erode shell elongate twist bend bend_linear bend_radial transition_linear transition_radial wrap_around; extrude extrude_to revolve slice; 2D set.
|
||||
- Licence MIT. 209 commits, 28 open issues, 8 open PRs; last-commit date not shown. No Rust.
|
||||
|
||||
### hg_sdf (Mercury)
|
||||
- GLSL library: primitives, domain folds (`pMod1`, `pModPolar`, ...), booleans in rounded, chamfered, columned and stairs variants: `fOpUnionRound`, `fOpUnionChamfer`, `fOpUnionStairs`, `fOpPipe`, `fOpEngrave`. `fOpUnionSoft` not on the fetched page.
|
||||
- Not a language; no tree. Licence "MIT OR CC-BY-NC-4.0" (page, 2021-07-28 entry). No Rust.
|
||||
|
||||
### Inigo Quilez (distfunctions, smin)
|
||||
- GLSL snippets. From the smin article (via search summary): exponential, power, root, polynomial families; polynomial `h=max(k-|a-b|,0)/k; min(a,b)-h*h*k/4`, equivalent to the clamped-`h` form. "opSmoothUnion" is a library name (Houdini HDK, pyssv); pyssv uses a cubic `h^3*k/6`. Smooth intersection is the negated smin of negations. Code licence not found; distfunctions page fetch returned 404.
|
||||
|
||||
### Shadertoy convention
|
||||
- `float map(vec3 p)` returning `min(...)` or `smin(a,b,k)` copied from IQ; crease by `max`. Hand-written code; no round trip; per-shader licence. (From general knowledge, not fetched.)
|
||||
|
||||
### OpenSCAD and BOSL2
|
||||
- Tree-structured CSG text; OpenSCAD's own `.csg` export prints a CSG tree (general knowledge, not fetched). No smooth operation; see section 0.
|
||||
|
||||
### ImplicitCAD (Haskell)
|
||||
- Haskell DSL plus `extopenscad`, an OpenSCAD dialect with extensions (README): rounded union `union(r=14){...}`; `linear_extrude` accepts `r`; curried functions, `map`. Haskell API: `unionR`, `differenceR`, `intersectR :: ℝ -> [obj] -> obj` (radius first); internal constructors `UnionR`, `DifferenceR`, `IntersectR` in 0.4.1.0 source. README shows rounded union only; rounding formula not read.
|
||||
- Licence AGPL-3.0+ (source headers on Hackage). README status dated 2020-01-26, "very much a work in progress"; hull and minkowski unfinished. No Rust.
|
||||
|
||||
### sdfx (Go)
|
||||
- Go code: `Union3D(sdf ...SDF3)`, `Difference3D(s0, s1)`, `Intersect3D`, `Transform3D(sdf, M44)`; `SetMin(MinFunc)` / `SetMax(MaxFunc)` replace the min and max a boolean uses (default `math.Min`/`math.Max`). Smooth-min type names not found in the visible file. Licence MIT; 850 commits, 12 open PRs; latest commit date not shown. No tree text. No Rust.
|
||||
|
||||
### Blender geometry nodes SDF
|
||||
- SDF Grid Boolean (Intersect, Union, Difference), SDF Fillet, SDF Grid Offset, Mean, Median, Laplacian, Mean Curvature, Mesh to SDF Grid, Grid to Mesh (Volume Nodes manual, 5.1; 5.3 adds Deactivate Voxels and Grid Topology Boolean). Node graphs live in .blend datablocks, scriptable by `bpy`; operate on voxel grids, not closed-form distance. Licence GPL. No smooth-union parameter read on the Boolean node.
|
||||
|
||||
### MagicaCSG, Womp
|
||||
- Closed GUI products; not fetched. Nothing verified.
|
||||
|
||||
## 2. Findings that bear on the fork
|
||||
|
||||
1. No surveyed tool documents a typed field tree printed to a text surface and parsed back as its canonical form. Nearest: Fidget `Context::from_text`, a flat math-node DAG (min, max, sqrt, ...), self-described as experiment-only; it carries no primitives, transforms or named radii.
|
||||
2. libfive, Fidget-Rhai, fogleman, Curv and sdfx authoring surfaces are general programming languages evaluated to a value. A printed typed tree would be a restricted subset of such a language (inferred); none documents a canonical printer.
|
||||
3. Smooth union is a binary operator with a scalar width everywhere: fogleman `a | b.k(k)` / `union(a,b,k=)`, ImplicitCAD `union(r=)` / `unionR r [..]`, hg_sdf `fOpUnionRound(a,b,r)`, libfive `blend_expt(a,b,m)`, IQ `smin(a,b,k)`.
|
||||
4. The blend function differs per tool, so a radius number is not interchangeable: quadratic `k*h*(1-h)` (fogleman, IQ polynomial), cubic `h^3*k/6` (pyssv), exponent-based (libfive), round/chamfer/stairs (hg_sdf). "Named radius" means whatever `src/field.rs` implements.
|
||||
5. Crease is hard `max`/`min` in all of them; no surveyed text language has a separate crease token.
|
||||
6. Precedent for an OpenSCAD-syntax dialect carrying rounded union: ImplicitCAD `extopenscad` `union(r=...)`. README shows no rounded difference or intersection there; Haskell AGPL; 2020 status note.
|
||||
7. None of the surveyed surfaces has a first-class stable-id slot for nodes; named arguments are available in OpenSCAD, Python and Rhai (`#{k: v}` maps) (inferred carrier).
|
||||
|
||||
## 3. Evaluating exactly our distance function (invariant one-distance-per-primitive)
|
||||
|
||||
- Libraries with their own primitives (libfive, Fidget stdlib, fogleman, hg_sdf, sdfx, Curv) compute their own formulas. Our ellipsoid and floret are explicit bounds in `field.rs`; library ellipsoids are different formulas (inferred; fogleman's ellipsoid not read).
|
||||
- Fidget could evaluate our distance only if each primitive is written out in its math nodes (it has sqrt, min, max, abs, mul, compare, if_nonzero_else, exp, ln). Whether its interpreter and JIT backends give bit-equal f32 results to our `bevy_math` code was not examined (inferred risk).
|
||||
- A text front end that only parses into our typed tree and leaves evaluation to `src/field.rs` has one distance by construction (inferred); the other systems then serve as syntax prior art only.
|
||||
- fogleman's smooth forms are plain arithmetic and can be compared term by term with a Rust implementation of the same quadratic form.
|
||||
|
||||
## 4. Rust availability
|
||||
|
||||
- Fidget and `fidget-rhai`: yes (crates.io, MPL-2.0, file-level copyleft). Rhai is itself a Rust embedded scripting engine (used by fidget-rhai). libfive: C++, community Rust bindings listed, unchecked. Others: none. Rust OpenSCAD parser crates were not surveyed.
|
||||
|
||||
## 5. Not verified
|
||||
|
||||
- libfive `blend_expt`/`blend_rough` formulas; Curv `lib.blend` kernel names; sdfx smooth-min type names; IQ code licence; hg_sdf `fOpUnionSoft`; ImplicitCAD rounded difference/intersection syntax and blend formula; full Fidget Rhai function list; whether `fidgetpy` exposes smooth ops; MagicaCSG and Womp text forms; BOSL2 and OpenSCAD licences as fetched; OpenSCAD `.csg` export behaviour; Shadertoy conventions (from general knowledge); the text of `brush-tree:question:openscad-surface`.
|
||||
20
docs/research/shippable-bar/README.md
Normal file
20
docs/research/shippable-bar/README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# What a finished hull part is (operator, 2026-10-10)
|
||||
|
||||
The bar the brush-tree output is judged against. Operator's words:
|
||||
|
||||
> A part is done for me when its topo flow is reasonably clean and unbroken, and I try to unwrap such
|
||||
> that parts of the same object stay together with minimal distortion. Bevels are used to sharpen edges
|
||||
> (sharp shading requires duplicating geo anyway, while a single faced bevel shades flawlessly with
|
||||
> face-weighted normals. See this test render showing a simple bevel, face weighted simple bevel, and
|
||||
> expensive perfect bevel next to each other, and a little test box showing off that and decals for that
|
||||
> Star Citizen effect)
|
||||
|
||||
`operator-bevel-test.webp`, left to right: single-segment bevel with plain normals; the same bevel with
|
||||
face-weighted normals (the target look); a multi-segment "perfect" bevel (too expensive); a test box
|
||||
with face-weighted bevels and decals (decals are the separate panel-decals campaign).
|
||||
|
||||
Measurable reading of the bar:
|
||||
- Topology: clean, unbroken edge flow (quad-dominant loops, few poles, no triangle fans or slivers).
|
||||
- UVs: islands follow the object's parts, kept together, with minimal stretch.
|
||||
- Edges: one-segment bevels on hard edges, shaded with face-weighted normals; no duplicated split
|
||||
geometry for sharpness, no multi-segment bevels.
|
||||
BIN
docs/research/shippable-bar/operator-bevel-test.webp
Normal file
BIN
docs/research/shippable-bar/operator-bevel-test.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
95
docs/research/subd-cages-mixed-hulls-and-open-kernels.md
Normal file
95
docs/research/subd-cages-mixed-hulls-and-open-kernels.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<!-- Serves brush-tree:ruling:representation-per-hull and :no-unownable-tools-in-loop. Eyes pass eyes-subd-hulls, 2026-10-10. -->
|
||||
|
||||
# Eyes facts: subdivision-cage hulls and mixed subd/primitive assets (prior art)
|
||||
|
||||
Serves: brush-tree:ruling:representation-per-hull (InForce, operator, 2026-10-10; supersedes hulls-are-subd-limit-surfaces). Operator words: "No, not every hull, that hull in particular. Any attempt to build it needs to model that subtle interplay of curves in 3D, you can't do that by bashing primitives together. That squatter mech ship, though, I'd build that mostly out of primitives. One size does not fit all." Dexter Quiet = one continuous body with a few mechanical sweeps fused into the center (subd limit surface); squatter mech ship = mostly primitives (CSG). Authoring and lowering must support both paths, per hull or per part.
|
||||
Also serves: brush-tree:ruling:shippable-part-bar (clean unbroken topo flow; part-coherent low-distortion UVs; hard edges by single-segment bevel with face-weighted normals, not split geometry, not multi-segment).
|
||||
Agent: eyes-subd-hulls, session self-2026-10-10-ag2, 2026-10-10. Facts only; no recommendations.
|
||||
|
||||
Tags: FETCHED = page body read this session (WebFetch returns a small-model summary of the page, not the page itself); SEARCH = from search-result summary only; KNOWLEDGE = memory, unverified.
|
||||
|
||||
## 0. Mixing a subd/organic body with primitive/CSG mechanical parts in one asset (added item)
|
||||
|
||||
- Blender (practitioner pipeline, assembled from forum/add-on sources, no official doc): cutters kept in own collection, scale applied, Boolean modifiers live until export; Bevel modifier placed after Boolean (order changes result); bevel weight only on the boolean's edge loop, not the loops in between; guide edges added to steer the boolean solve on curved surfaces; boolean on curved surfaces yields stretched faces and shading streaks; a profile-1 bevel with a spacer loop is used to stop subd shrinking the form; Weighted Normal modifier (Keep Sharp) last in stack; apply booleans/modifiers only once the form is settled; UVs planned after panel layout, seams following panel breaks. [SEARCH https://blenderartists.org/t/boolean-and-bevel-on-curved-surface/1308394 ; https://hardops-manual.readthedocs.io/en/latest/subdivision/ ; https://garagefarm.net/blog/hard-surface-modeling-in-blender]
|
||||
- Blender Bevel modifier has Face Strength + Harden Normals options intended to feed a following Weighted Normals modifier (Face Influence). [FETCHED https://projects.blender.org/blender/blender-manual/raw/branch/main/manual/modeling/modifiers/generate/bevel.rst]
|
||||
- Polycount/ZBrushCentral threads: boolean blockout then rebuild clean topology over it; or cage-first with support loops; booleans only for blockout; boolean+bevel without subd for blocky parts; ZRemesh reported poor on heavy boolean cutouts; match segment counts where round shapes intersect; align operand edges so main-shape support loops are partly built. Jan van den Hemel video "boolean holes to quad topology" converts boolean output to all-quad topology for subd (details not on the page). [SEARCH https://polycount.com/discussion/comment/2729205 ; https://www.zbrushcentral.com/t/booleans-and-retopology-on-hard-surface-in-zbrush/398107 ; FETCHED https://www.blendernation.com/2023/12/14/boolean-holes-to-quad-topology/]
|
||||
- Plasticity (NURBS CAD): no native subdivision modeling found; mesh enters via PolySplines (mesh to editable G2 NURBS, added in 2026.1); xNURBS patching in Studio; Indie tier exports low-res OBJ/STL only; bridges to Blender and Cinema 4D. A Modo plugin (Power SubD-NURBS) exports subd to NURBS CAD formats, "one-to-one" (planar faces) or "smooth" (combined curved patches). No source found describing Plasticity mixing subd + NURBS solids in one document. [SEARCH https://digitalproduction.com/2026/04/21/plasticity-2026-1/ ; https://cgchannel.com/2026/04/plasticity-2026-1-is-out/ ; https://www.plasticity.xyz/studio]. MoI: not searched (gap).
|
||||
- Fusion (T-splines): a Form (T-spline) body must be closed to be combined as a solid; open form needs patch/stitch or Thicken; then Solid > Modify > Combine (Join / Keep Tools); Form tools Weld Vertices, Merge Edge join T-spline bodies at seams. Not confirmed whether Form joins a solid directly without conversion. [SEARCH https://autodesk.com/support/technical/article/caas/sfdcarticles/sfdcarticles/How-to-join-or-combine-bodies-in-Fusion-360.html ; https://forums.autodesk.com/t5/fusion-support-forum/how-to-join-bodies-and-forms-into-one-body/m-p/9148777/highlight/true]
|
||||
- Houdini: Subdivide SOP offers Mantra Catmull-Clark or OpenSubdiv Catmull-Clark; creases via crease attributes; material property "Render polygons as subdivision" subdivides only surfaces with that material (so organic and mechanical parts of one object can differ); SideFX staff: build sharp edges by PolyBevel/PolyExtrude/PolySplit when rendering with subd; nothing found on booleans feeding subd. [SEARCH https://sidefx.com/docs/houdini/render/subdivision.html ; https://www.sidefx.com/forum/post/198241]
|
||||
- ZBrush: Crease PG / polygroup-border creases with per-crease level (crisp for N levels then soften). [SEARCH https://novedge.com/blogs/design-news/zbrush-tip-crease-polygroup-borders-for-predictable-hard-surface-subdivision]
|
||||
- Seam cleanliness for shading/UV between a subd body and primitive inserts: no source found stating a canonical method beyond the practitioner list above (bevel at the junction, weighted normals / harden normals, UV seams on panel breaks, apply late). No source found for two-material/two-mesh split (subd body mesh + separate bevelled primitive meshes) as a convention. [SEARCH, absence]
|
||||
- Local prior art with related content: F:\Projects\aetheria-hull-materials-prior-art.md lines 145-150 (retopo tools, weighted normals + bevel references).
|
||||
|
||||
## 1. Fitting a subd surface to a dense/messy mesh
|
||||
|
||||
- Hoppe, DeRose, Duchamp, Halstead, Jin, McDonald, Schweitzer, Stuetzle, "Piecewise Smooth Surface Reconstruction", SIGGRAPH 1994 pp.295-302. Piecewise smooth subdivision-based surfaces fit to scattered range data; arbitrary topology; recovers creases and corners; unconstrained optimization; three phases (topological type estimation, mesh optimization, piecewise-smooth optimization). [FETCHED abstract https://hhoppe.com/proj/psrecon/ ; phases SEARCH]. PDFs listed, not read: https://graphics.pixar.com/people/derose/publications/PiecewiseSmooth ; https://grail.cs.washington.edu/wp-content/uploads/2015/08/hoppe-1994-pss.pdf
|
||||
- Marinov & Kobbelt, "Optimization methods for scattered data approximation with subdivision surfaces", Graphical Models 67(5) 2005 pp.452-473: fit against the exact limit surface (smooth basis functions per control vertex), closest-point parameterization on the limit (Newton + nonlinear minimization), parameter correction for L2; Solid Modeling 2004 version adds adaptive control-mesh restructuring to bound L-infinity. Also "Automatic Generation of Structure Preserving Multiresolution Models" (CGF 2005) and "A Robust Two-Step Procedure for Quad-Dominant Remeshing" (EG 2006; output coarse enough as Catmull-Clark control mesh). [SEARCH https://www.graphics.rwth-aachen.de/publication/0384/ ; https://diglib.eg.org/handle/10.2312/sm20041382]
|
||||
- Typical recipe in related work: dense mesh -> QEM simplification to coarse base control mesh -> optimize control vertices against distance. [SEARCH]
|
||||
- Ling, Wang, Yan, "Fitting Sharp Features with Loop Subdivision Surfaces", SGP/CGF 27(5) 2008: creases, darts, corners; QEM-simplified control mesh; nonlinear least squares; exact evaluation for Loop with all sharp-feature types; boundary treatment for mechanical objects. Loop (triangles), not Catmull-Clark. [SEARCH https://diglib.eg.org/handle/10.2312/CGF.v27i5pp1383-1391 ; PDF https://i.cs.hku.hk/GraphicsGroup/publications/pdf/sgp08ling.pdf unread]
|
||||
- Lavoue & Dupont, "A framework for quad/triangle subdivision surface fitting: application to mechanical objects", CGF 2007 [SEARCH titles; PDFs https://perso.liris.cnrs.fr/fdupont/publications/2007-CGF.pdf unread; fetch of the 2007-SIGGRAPH PDF returned binary, saved at C:\Users\Meta\.claude\projects\F--Projects-CultLib\1b994b80-e27e-42a8-8b75-ab8cb1dd9c54\tool-results\webfetch-1791668880080-fid2qh.pdf, unread].
|
||||
- Ma, Keates, Jiang, Kosinka: Loop subd fit to dense mesh, control-mesh connectivity from ridge-joined umbilics/ridge crossings; venue/year not confirmed. A 2017 CVM paper adds continuous per-edge sharpness to Loop and fits semi-sharp creases. [SEARCH https://link.springer.com/doi/10.1007/s41095-017-0088-2]
|
||||
- Kloiber & Augsdorfer, "Creating Curvature Adapted Subdivision Control Meshes from Scan Data", GRAPP 2020 pp.149-159: modified quad meshing gives curvature-varying quad-dominant mesh within an error bound, converted to control mesh whose limit approximates the scan. [SEARCH https://www.scitepress.org/Papers/2020/89896 ; PDF unread]
|
||||
- Patent US 12086939: cross field -> quad layout as cage -> optimize vertices to approximate input triangle mesh. [SEARCH https://patents.justia.com/patent/12086939]. UTS "fitmaps" Catmull-Clark construction (2011, closed). [SEARCH]
|
||||
- Neural Subdivision (Liu, Kim, Chaudhuri, Aigerman, Jacobson, SIGGRAPH 2020): learned vertex placement over Loop topology; refines a mesh, does not produce a cage. [SEARCH https://www.dgp.toronto.edu/projects/neural-subdivision]
|
||||
- ASDGen (Eurographics STAG 2025): generates quad meshes by CAD-like operations on a seed mesh, usable as Catmull-Clark control meshes. [SEARCH https://diglib7.eg.org/handle/10.2312/stag20251334 ; direct fetch 403]
|
||||
- Limit evaluation: Stam 1998 exact Catmull-Clark evaluation via eigenbasis, cost near bicubic spline [SEARCH https://courses.cs.umbc.edu/graduate/635/spring13/papers/stam98.pdf]; OpenSubdiv Far::LimitStencilTable evaluates points/derivatives on the limit, recomputable when base vertices change, construction can be costly [SEARCH https://graphics.pixar.com/opensubdiv/docs/far_tutorial_5_3.html]. Rust crate subdiv-kernels 0.1.0 has limit_eval with OpenSubdiv oracle tests [SEARCH https://docs.rs/subdiv-kernels], not examined.
|
||||
- No 2023-2025 paper found that learns Catmull-Clark cage vertices+connectivity end to end. [SEARCH, absence in one query]
|
||||
|
||||
## 2. Quad-dominant remeshing as cage seed
|
||||
|
||||
- Instant Meshes (Jakob, Tarini, Panozzo, Sorkine-Hornung, SIGGRAPH Asia 2015): orientation field then position field; GUI with brush guidance; page does not state output type or CLI. [FETCHED https://github.com/wjakob/instant-meshes]
|
||||
- QuadriFlow (Huang, Zhou, Niessner, Shewchuk, Guibas, SGP 2018 Best Paper): builds on Instant Meshes with network-flow + SAT constraints; ~4x fewer singularities; <10 s/model; 17,791 ShapeNet-subset objects; quad output; abstract cites Catmull-Clark rendering as a motivating application. [FETCHED https://stanford.edu/~jingweih/papers/quadriflow/]. CLI `./quadriflow -i in.obj -o out.obj -f N`, manifold triangle input, pure-quad output [SEARCH https://github.com/aschier/QuadriFlow]. Blender bundles extern/quadriflow.
|
||||
- Blender QuadriFlow bug reports: fails on disconnected triangles (T70608); crash on messy mesh, fixed with manifold/consistent-normals message (T70095); crashes ~40K-900K faces (#105953); scripted loop only works for first and last object (T71871); crash after UI-state script (T92355). [SEARCH https://developer.blender.org/T70608 ; T70095 ; T71871 ; T92355 ; https://projects.blender.org/blender/blender/issues/105953]. NOT FOUND: any source that quadriflow_remesh fails or needs UI under `--background`. One MCP project comment: bpy.ops "can be flaky in background" (informal) [SEARCH https://glama.ai/mcp/servers/@ShirshovDIM/retopoflow_blender_mcp/blob/78358fedc1d6d47808d380d331e176370b479c59/src/blender_handlers/remeshing.py]. bpy.ops.object.quadriflow_remesh signature not captured (API page fetch truncated).
|
||||
- Learned cross fields: NeurCross (arXiv 2405.13745), CrossGen (arXiv 2506.07020). [SEARCH]
|
||||
- No measurement found of any remesher's output as a hard-surface subd cage. Community: Quad Remesher cleaner flow than Instant Meshes, tuned organic, hard-surface varies (secondary; local memo F:\Projects\aetheria-hull-materials-prior-art.md lines 145-149).
|
||||
|
||||
## 3. Hard surface in subd: creases, holding loops, vs one-segment bevel + face-weighted normals
|
||||
|
||||
- Semi-sharp creases: DeRose, Kass, Truong, "Subdivision Surfaces in Character Animation", SIGGRAPH 1998 pp.85-94: sharp rules for a finite number of levels then smooth; creases without extra control vertices; Pixar patent US6489960. [SEARCH https://graphics.pixar.com/library/Geri/paper.pdf, unread]. OpenSubdiv Sdc::Crease: scheme-independent sharpness; semi-sharp decremented 1.0 per level; fractional blend when sharpness decays; Chaikin variant. [SEARCH https://graphics.pixar.com/opensubdiv/docs/doxy_html/a01312.html]. OpenSubdiv prose doc not retrievable (raw github 404; pixar.com redirect).
|
||||
- Blender Subdivision Surface modifier: Levels Viewport/Render; Use Limit Surface (Quality affects crease accuracy); Use Creases (edge weights, Shift-E); UV Smooth (None, Keep Corners, Keep Corners/Junctions, +Concave, Keep Boundaries, All); Boundary Smooth; Apply bakes subdivided geometry. [FETCHED https://projects.blender.org/blender/blender-manual/raw/branch/main/manual/modeling/modifiers/generate/subdivision_surface.rst]
|
||||
- Practitioner: Bevel (Limit Weight) then Weighted Normal (Corner Angle + Face Influence; bevel Face Strength Affected); apply before export since engines import baked custom normals; Polycount guide: thin one-segment bevel, silhouette bevels unweighted; one 80.lv artist uses Bevel+Weighted Normals first, subd modelling as slow fallback; counter-views on LOD cost of support geometry. [SEARCH https://origin.80.lv/articles/hard-surface-modeling-tips-tricks-for-creating-a-vintage-quirky-looking-gun ; https://polycount.com/discussion/234851/trim-bevels-on-mesh-with-weighted-normals]. Blender Weighted Normal manual not retrieved.
|
||||
- Shipping level: ZBrush-style workflows keep detail on top subd level, bake normal map to the lowest-level mesh; quads, support loops at hard features, triangulate at final export. [SEARCH https://novedge.com/blogs/design-news/zbrush-tip-baking-and-exporting-normal-maps-in-zbrush-a-step-by-step-guide]. No source found on a convention of shipping limit-surface-at-level-N with weighted normals versus baking. [SEARCH, absence]. Offline/renderer subdivision from the cage via OpenSubdiv/USD [KNOWLEDGE].
|
||||
|
||||
## 4. Models/tools authoring subd cages
|
||||
|
||||
- MeshGPT (Siddiqui 2023), MeshAnything (arXiv 2406.10163), PolyGen (Nash 2020): autoregressive transformers, triangle meshes. [SEARCH https://arxiv.org/pdf/2406.10163]
|
||||
- QuadGPT (arXiv 2509.21420, ICLR 2026): end-to-end autoregressive quad/quad-dominant meshes from point cloud; 1.1B hourglass transformer; tDPO. QuadLink (arXiv 2605.16813); PolyFlow (arXiv 2606.30673); MeshMosaic (2025). None found emitting subd cages or crease tags. [SEARCH https://arxiv.org/pdf/2509.21420]
|
||||
- LLM to Blender Python: LL3M (arXiv 2508.08228; multi-agent, BlenderRAG; abstract names BMesh, modifiers, shader nodes; no mention of subd) [FETCHED https://arxiv.org/abs/2508.08228]; BlenderLLM (Qwen2.5-Coder-7B, 12k BlendNet pairs, arXiv 2412.14203); CADBench 2026 (arXiv 2605.10873): specialized mesh-to-CAD beat code-generating VLMs; LLaMA-Mesh emits vertices/faces as text. [SEARCH]
|
||||
- Blender Python cages: BMesh edge crease via `bm.edges.layers.crease.verify()`; vertex crease also a layer; edge crease now an attribute (`crease_edge` per search summary, unverified); issue #101401 "Setting edge crease via PyAPI doesn't work" (resolution unread). [SEARCH https://blenderartists.org/t/edge-crease-toggle/611970 ; https://projects.blender.org/blender/blender/issues/101401]. Geometry Nodes Subdivision Surface node (5.2 manual): Edge Crease, Vertex Crease, Limit Surface, UV Smooth, Boundary Smooth; older manuals one Crease input; T103304 edge-crease bug fixed Jan 2023. [SEARCH https://docs.blender.org/manual/en/latest/modeling/geometry_nodes/mesh/operations/subdivision_surface.html]
|
||||
|
||||
## 5. Mechanical sweeps fused into a subd body
|
||||
|
||||
- See section 0 and Polycount items: boolean-then-retopo vs cage-first with support loops. No source found on procedurally merging path sweeps into a Catmull-Clark body as parametric cage operations; ASDGen CAD-ops on a quad seed is nearest. [SEARCH, absence]
|
||||
|
||||
## 6. Plasticity (own section, per operator: "Plasticity is actually really cool")
|
||||
|
||||
- Kernel: Parasolid (booleans, fillets and other modeling ops); file translation via HOOPS Exchange; page says Parasolid also powers SolidWorks, NX, Shapr3D. Developer Nick Kallen. [FETCHED https://www.plasticity.xyz/product ; SEARCH https://en.wikipedia.org/wiki/Plasticity_(software)]
|
||||
- Modeling: NURBS surfaces with CV editing; page claims robust filleting on complex geometry with many aesthetic options; page does not explicitly say "NURBS solids" (Parasolid B-rep solids implied by kernel, not stated). Studio-only: xNURBS surface patching/blending G0/G1/G2 (Windows and Mac, not Linux), PolySplines mesh-to-NURBS G2 conversion, Class-A tools (Square, Align, Explicit Control). [FETCHED product + https://www.plasticity.xyz/studio]
|
||||
- Mesh export (OBJ): topology option Tris / Quads (quads and triangles) / Ngons; Density (single value that auto-sets others); Min width, Max width; Surface plane tolerance (max deviation in cm; docs suggest 0.01 low-poly, 0.0001 high-poly); Surface angle tolerance (radians). The topology setting is a tessellation of the B-rep, i.e. a quad-dominant (quads + triangles) option, not a quad-remesh; quality of that quad output for subd or game use not evidenced. [SEARCH https://doc.plasticity.xyz/plasticity-essentials/export-obj ; page body not fetched]. Free trial exports "low resolution" OBJ/STL only. [FETCHED product]
|
||||
- Import/export formats (per product-page summary, internally inconsistent between its table and prose): trial import OBJ, STL, 3MF, FBX, STEP, PNG/JPG, JSON, SVG; Indie adds X_T, X_B (import and export), STEP, 3MF, SVG export; Studio adds SAT, IGES, 3DM (Rhino) import, DWG/DXF import (Windows). Native `.plasticity` extension; a third-party guide says it holds Parasolid-based solids/surfaces/curves/organization and no parametric history; no official spec found. [FETCHED product ; SEARCH https://www.renderhub.com/guides/3d-file-formats/plasticity (third party)]
|
||||
- Scripting/automation: none found. No API, plugin SDK, scripting language or command-line/batch mode appears in product page, studio page, Wikipedia summary or search. The only programmatic surface found is the Blender bridge's local server (below). [FETCHED/SEARCH, absence]. Plasticity Studio doc pages for any API not fetched (gap).
|
||||
- Blender Bridge add-on: requires Blender 4.1+ (Windows/macOS/Linux) and the Plasticity server enabled (Preferences > Server > Enabled); streams mesh data, no temp files or export dialogs; Live Link toggle pushes every Plasticity edit to Blender; Plasticity groups become Blender collections (scoped by document name); modifiers survive refresh; tolerance, edge flow and polygon density adjustable from a Plasticity panel in Blender; "Merge UV Seams" utility on imported geometry; docs do not state transport protocol, normals/UV/material transfer, or quad-only output. Free with every license; PolySplines (Blender subd to NURBS) is Studio only. Early tests: ~25 s initial load on a complex assembly, 1-3 s live updates. Introduced in Plasticity 1.3 (Oct 2023), then experimental. [FETCHED https://doc.plasticity.xyz/blender/introduction-blender-bridge ; SEARCH https://www.cgchannel.com/2023/10/nick-kallen-ships-plasticity-1-3/ ; https://80.lv/articles/plasticity-1-3-s-blender-bridge-demonstrated/]. Add-on source repo/licence: not found.
|
||||
- Licence and price (facts as stated 2026 on product page): free 30-day trial, 1 machine, no commercial use; Indie $175 plus tax, 1 person, commercial use allowed, up to 2 machines, 12 months updates, perpetual; Studio $299 plus tax, up to 4 machines, 12 months updates and beta access, companies over 10 employees must use Studio (wording ambiguous); Enterprise custom with air-gapped licences; Academic free non-commercial (Indie features). Studio page itself lists no prices and says node-locked, up to 4 machines. [FETCHED product + studio]. Platforms: Windows, macOS, Linux. Plasticity 2026.1 (April 2026) added hidden lines and PolySplines. [SEARCH https://cgchannel.com/2026/04/plasticity-2026-1-is-out/]
|
||||
- Plasticity docs: https://doc.plasticity.xyz/ (only export-obj and Blender bridge pages touched).
|
||||
|
||||
## 6b. Plasticity, short form (operator: "Expensive though. I don't want to rely on code we can't own" -> prior art only)
|
||||
- Does well (per section 6): Parasolid B-rep solids with robust fillets, G2 blends (Studio), a quads/tris/ngons OBJ tessellation option with deflection controls, free Blender live-link. Closed source, commercial licence, no scripting/API found. [FETCHED/SEARCH as in section 6]
|
||||
|
||||
## 7. Open alternatives an agent could drive (solids, fillets, sweeps)
|
||||
|
||||
- OpenCASCADE Technology (OCCT): LGPL-2.1 with the "Open CASCADE Exception 1.0" (distributing object code that uses OCCT headers under own terms with prominent notice) for 6.7.0+; earlier 6.6.0- under OCCT-PL; commercial licence also offered. [SEARCH https://spdx.org/licenses/OCCT-exception-1.0.html ; https://dev.opencascade.org/content/open-cascade-technology-changes-its-license-gnu-lgpl]. Kernel with B-rep booleans, fillets, sweeps, lofts [KNOWLEDGE]. Meshing: BRepMesh_IncrementalMesh with linear and angular deflection (plus interior variants); tracker statements that it does not guarantee tolerance, and a maintainer saying it is designed for visualization facets and is "definitely not a good choice" for regular tessellation; a 7.8.0 regression report of meshing failure on a cone (deflections 0.01/0.5). Example presets coarse 0.5/1.0, medium 0.1/0.5, fine 0.02/0.2. Output triangles only. [SEARCH https://dev.opencascade.org/doc/overview/html/occt_user_guides__mesh.html ; https://dev.opencascade.org/content/mesh-triangulation-quality ; https://cad.pyvista.org/examples/06_advanced/custom_tessellation.html ; https://tracker.dev.opencascade.org/view.php?id=33691]. Scripting: C++ API; Python via OCP wrapper (used by CadQuery and build123d) [SEARCH].
|
||||
- CadQuery: Apache-2.0; OCCT-based; lofts, sweeps, fillets; exports STEP/STL/AMF/3MF; glTF export only for assemblies (single solid must be wrapped); tessellation tolerance (linear 0.1, angular 0.1 default) shown on AMF/3MF exporters, unconfirmed for glTF. Python scripting API (Algebra/fluent). [SEARCH https://cadquery.readthedocs.io/en/stable/importexport.html ; https://pypi.org/project/cadquery/0.5.1]
|
||||
- build123d: Apache-2.0; built on Open Cascade, derived from portions of CadQuery; fillet and chamfer in README examples; extrude/sweep/loft/revolve/boolean per docs; multi-section sweep may fail, loft suggested as alternative; docs advise fillets late in design, 2D fillets fail less than 3D; export_step, export_stl, import_svg on README (glTF/3MF not on README page); Algebra Mode and Builder Mode Python API, Python 3.11-3.14, typed; ~3.4k stars, 3,314 commits, "under active development". [FETCHED https://github.com/gumyr/build123d ; SEARCH https://build123d.readthedocs.io/en/stable/tips.html]
|
||||
- truck (ricosjp, Rust): Apache-2.0; B-rep with NURBS; truck-shapeops booleans; truck-meshalgo tessellation; truck-stepio crate; truck-js WASM wrapper; wgpu rendering; fillets not mentioned on the page; docs reference v0.6.x; no latest-commit date on captured page. ~1.6k stars, 35 open issues. [FETCHED https://github.com/ricosjp/truck]
|
||||
- Fornjot (Rust): 0BSD; README says "This project has been shut down. Its goals were never reached."; repo archived June 19, 2026; "early-stage and experimental", unsuited for real-world use; no fillet/sweep/boolean listed; fj-export names 3MF. [FETCHED https://github.com/hannobraun/fornjot]
|
||||
- Manifold (elalish): Apache-2.0; guaranteed-manifold mesh booleans; bindings C++, C, Python (manifold3d), JS/TS (WASM, manifold-3d), Rust (manifold-csg external; native manifold-rust port), C#, others external; arbitrary vertex properties and original-ID tracking; README recommends 3MF, or glTF with EXT_mesh_manifold, over STL; refinement functions for smooth interpolation keeping flat faces flat with sharp or small-radius edges. No fillet feature. [FETCHED https://github.com/elalish/manifold]. API header (3.0): SmoothOut(minSharpAngle=52.5, minSmoothness=0) (older docs default 60), Refine, RefineToLength, RefineToTolerance, SmoothByNormals; Smooth(MeshGL, sharpenedEdges) with per-halfedge Smoothness 0 (sharp) to 1 (smooth), halfedge index = 3*triangle + local; without tangents refined triangles stay coplanar. Constructors include extrude/revolve/CrossSection per ManifoldCAD docs [KNOWLEDGE, not on fetched README; unverified]. [SEARCH https://manifoldcad.org/docs/html/manifold_8h_source.html ; https://manifoldcad.org/docs/html/group___structs.html]. Python keyword names not confirmed.
|
||||
- libfive: library, C API and Python bindings MPL-2.0; Guile bindings and Studio GPL-2.0+; implicit f-rep modeling; meshing described as watertight, manifold, hierarchical, feature-preserving triangle meshes (algorithm not named on page); fillets/blends not mentioned on page (blend/smooth-union functions in stdlib [KNOWLEDGE, unverified]); copyright 2015-2021, 103 open issues, third-party Rust/Unity/Lisp bindings. [FETCHED https://github.com/libfive/libfive]
|
||||
- Blender (GPL, scripts carry GPL notices) tools: Bevel modifier and bmesh.ops.bevel (bevel tool, modifier and bmesh operator refactored to matching parameters; modifier API: width, angle_limit, limit_method, affect, segments; per-edge weights); Curve to Mesh node (profile swept along a curve) in Geometry Nodes; Subdivision Surface modifier/node with creases (section 3, 4); Boolean modifier; Skin and Solidify modifiers (Skin does not work through Geometry Nodes per a forum thread; Solidify has a reported bug on curve-derived geometry). Headless: `blender --background [file] --python script.py`, `--factory-startup`, `--` passes args. Output is polygon meshes with quads/ngons possible, custom normals, UVs; this is the only item here producing game-ready UV/normal data natively. [SEARCH https://docs.blender.org/api/5.3/bpy.types.BevelModifier.html ; https://projects.blender.org/archive/blender-archive/src/commit/24b47c00eaabe12b5262b59a185da154a477bd98/release/scripts/templates_py/background_job.py ; https://blenderartists.org/t/geometry-nodes-convert-curve-to-mesh-to-work-with-skin-modifier/1605796]. bmesh.ops.bevel argument names not fetched.
|
||||
- Cross-cutting: none of the OCCT front ends or Manifold yields quads from a B-rep or UVs/face-weighted normals directly on the sources read; UV/normal handling for OCCT meshes not found. [SEARCH, absence]
|
||||
|
||||
## Gaps
|
||||
- Unread full texts: Hoppe 1994, Ling 2008, Lavoue-Dupont 2007, Kloiber 2020, DeRose 1998, QuadriFlow body, QuadGPT body, ASDGen (403).
|
||||
- OpenSubdiv prose docs and Blender Weighted Normal manual not retrieved. MoI3D not searched. No Plasticity subd+NURBS single-document workflow found.
|
||||
- Blender quadriflow_remesh signature and background-mode failure: no corroboration; no local Blender run made.
|
||||
- No evidence on quad remesher quality as hard-surface cage; none on limit-surface-at-level-N shipping convention; none on a standard seam method between subd body and primitive inserts.
|
||||
- Search-summary titles/venues (esp. Ma et al.) need confirming before citation.
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
# 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`.
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
target
|
||||
Cargo.lock
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
[package]
|
||||
name = "trucktorture"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
truck-modeling = { git = "https://github.com/ricosjp/truck", rev = "88ed005249e5e3a6b07f62425399435905cd3ab6" }
|
||||
truck-shapeops = { git = "https://github.com/ricosjp/truck", rev = "88ed005249e5e3a6b07f62425399435905cd3ab6" }
|
||||
truck-meshalgo = { git = "https://github.com/ricosjp/truck", rev = "88ed005249e5e3a6b07f62425399435905cd3ab6" }
|
||||
truck-stepio = { git = "https://github.com/ricosjp/truck", rev = "88ed005249e5e3a6b07f62425399435905cd3ab6" }
|
||||
serde_json = "1"
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,151 +0,0 @@
|
|||
"""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}))
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
// 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"),
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue