diff --git a/README.md b/README.md index bc8fc3d7..392862c5 100644 --- a/README.md +++ b/README.md @@ -143,13 +143,13 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`hand-pump`](showcase/hand-pump/) — procedural cast-iron village hand pump through the same pipeline. Falsifier `--skip-decimate` exits 9. -[`signpost`](showcase/signpost/) — procedural timber fingerboard signpost through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`signpost`](showcase/signpost/) — procedural timber fingerboard signpost through the same pipeline. Falsifiers `--skip-decimate` (9), `--stray-vert` (15), `--yaw-boards` (19). [`chopping-block`](showcase/chopping-block/) — procedural hooped timber stump with an embedded axe through the same pipeline. Falsifier `--skip-decimate` exits 9. [`wooden-bucket`](showcase/wooden-bucket/) — procedural coopered pail with a round bail through the ear rings through the same pipeline. Falsifier `--float-handle` exits 17. -[`wall-torch`](showcase/wall-torch/) — procedural wall-mounted torch sconce with a stone plaque, iron bracket, and flame through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`wall-torch`](showcase/wall-torch/) — procedural wall-mounted torch sconce with a stone plaque, iron bracket, and flame through the same pipeline. Falsifiers `--skip-decimate` (9), `--stray-vert` (15), `--float-arm` (17). [`tavern-stool`](showcase/tavern-stool/) — procedural tavern stool with turned legs, stretchers, and iron ferrules through the same pipeline. Falsifier `--skip-decimate` exits 9. diff --git a/docs/gallery/assets/signpost-hero.webp b/docs/gallery/assets/signpost-hero.webp index b2727e05..c8c1ef4e 100644 Binary files a/docs/gallery/assets/signpost-hero.webp and b/docs/gallery/assets/signpost-hero.webp differ diff --git a/docs/gallery/assets/signpost-preview.webp b/docs/gallery/assets/signpost-preview.webp new file mode 100644 index 00000000..3724acb3 Binary files /dev/null and b/docs/gallery/assets/signpost-preview.webp differ diff --git a/docs/gallery/assets/wall-torch-hero.webp b/docs/gallery/assets/wall-torch-hero.webp index cb700f3c..a790e58b 100644 Binary files a/docs/gallery/assets/wall-torch-hero.webp and b/docs/gallery/assets/wall-torch-hero.webp differ diff --git a/docs/gallery/assets/wall-torch-preview.webp b/docs/gallery/assets/wall-torch-preview.webp new file mode 100644 index 00000000..8b4c01f8 Binary files /dev/null and b/docs/gallery/assets/wall-torch-preview.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 9e644bee..9a6a8ed0 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -1090,7 +1090,7 @@
A procedural timber fingerboard signpost through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
-witnesses Recomputed: 504 tris, three materials with 66 metal and 24 board faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.368×0.201×1.566 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 56 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+witnesses Recomputed: 376 tris, three materials with 108 metal, 22 board and 60 wood faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.260×0.142×1.551 m at zmin 0, LOD ratios in band (5.2 COLLAPSE leaner on LOD1/LOD2), convex collider 52 tris, non-empty glTF. Hygiene all zero. Board-to-post gap 5 mm, strap gap 3 mm, board AABB Y 28 mm. --skip-decimate exits 9; --stray-vert 15; --lift-z / --short-shoe 16; --gap-board 17; --float-strap 18; --yaw-boards 19.
View exampleA procedural wall-mounted torch sconce with a dressed stone plaque, iron bracket, wooden haft, and emissive flame through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
-witnesses Recomputed: 848 tris, four materials with 162 stone, 134 metal, and 44 flame faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.384×0.360×0.670 m, LOD ratios in band (5.2 COLLAPSE 8 tris leaner on LOD2), convex collider 92 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+witnesses Recomputed: 1528 tris, four materials with 540 stone, 228 metal and 78 flame faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.304×0.293×0.424 m at zmin 0, LOD ratios in band, convex collider 60 tris, non-empty glTF. Hygiene all zero. Arm-to-cup gap 0.87 mm, plate-to-plaque gap 4 mm, plaque width 0.280 m. --skip-decimate exits 9; --stray-vert 15; --lift-z 16; --float-arm 17; --float-plate 18; --skinny-plaque 19.
View example
Rendered headless by the example itself — click to zoom.
-blender --background --python showcase/signpost/signpost.py --
A showcase piece, not an example. Procedural timber fingerboard signpost (square post, collar and pyramidal cap, two painted planks with diamond tips, iron shoe and straps) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
+A showcase piece, not an example. Procedural timber fingerboard signpost (square post, pyramidal cap from the post half plus overhang, two axis-aligned painted boards with wedges cut from the board section, iron shoe collar and U-wrap straps) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
+Boards are yaw 0. Finger tips are wedges whose base is the board's own thickness×height, bitten into the board so the base face is inside it. The cap is added after the post bevel. The post stays on the origin. Hygiene family 15–19: --stray-vert, --lift-z / --short-shoe, --gap-board, --float-strap, --yaw-boards.
It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.
-Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied, not imported as a package).
Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied, not imported as a package). Hygiene combinatorics match examples/mesh-hygiene-audit (copied, not imported).
Intended size: 1.48 m post, boards 0.62 m and 0.54 m, 0.118 m post stock; outer AABB 1.260 × 0.142 × 1.551 m.
Declared as named constants; every gate recomputes from the mesh, materials, UVs, evaluated LOD, collider, or export file.
-| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 430–580 | 504 / 504 / 504 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2183 / 0.2183 / 0.1746 | | Materials | exactly 3 distinct, ≥24 metal and ≥12 board faces | 3 slots, 66 metal, 24 board | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.368, 0.201, 1.566) m ± 0.01 | (1.3679, 0.2014, 1.5660), zmin 0 | | Collider tris | ≤ 140 | 56 | | Export | written, size > 0 | 45164 / 45164 / 45152 bytes |
DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 12 B on 5.2.1 (glTF serializer), not a gated axis.
--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates.
| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 320–480 | 376 / 376 / 376 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4681 | | LOD2 ratio | 0.10–0.35 of base | 0.2181 / 0.2181 / 0.1649 | | Materials | exactly 3 distinct, ≥24 metal, ≥12 board, ≥12 wood | 3 slots, 108 metal, 22 board, 60 wood | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.260, 0.142, 1.551) m ± 0.015 | (1.2600, 0.1420, 1.5510), zmin 0 | | Collider tris | ≤ 180 | 52 | | Export | written, size > 0 | 35688 / 35688 / 35676 bytes |
Base triangles dropped from 504 to 376 in the quality pass: glued diamond cubes and a solid shoe block became wedges and a four-plate collar. Outer AABB tightened from 1.368 × 0.201 × 1.566 m.
+DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is leaner on LOD1 and LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 12 B on 5.2.1 (glTF serializer), not a gated axis.
Recomputed from the generated mesh, not asserted about the script.
+| Axis | Declared | Measured (all three) | | --- | --- | --- | | Non-manifold edges | 0 | 0 | | Loose verts / edges | 0 / 0 | 0 / 0 | | Doubles merged at 1e-5 | 0 | 0 | | Zero-area faces | 0 | 0 | | N-gons | 0 | 0 | | Coplanar disjoint face pairs | 0 | 0 | | Grounded: zmin | within 1e-4 of 0 | 0.0000 | | Named shoe plates | ≥1, each zmin ≤ 0.002 | 4, 0.00000 |
| Axis | Declared | Measured (all three) | | --- | --- | --- | | Board-to-post BVH gap | ≤ 0.008 m | 0.00500 | | Strap-to-post BVH gap | ≤ 0.008 m | 0.00300 | | Board AABB Y (axis-aligned) | ≤ 0.055 m | 0.0280 |
+Each violates one named budget. All were run on 4.5.11, 5.1.2 and 5.2.1 and returned the same code on each.
+| Flag | Budget violated | Exit | | --- | --- | --- | | --skip-decimate | LOD1 ratio band | 9 | | --stray-vert | loose vertex count is 0 | 15 | | --lift-z | bounding box zmin is 0 | 16 | | --short-shoe | named shoes plant at z=0 | 16 | | --gap-board | board-to-post BVH gap | 17 | | --float-strap | strap-to-post BVH gap | 18 | | --yaw-boards | board AABB Y (orthogonal) | 19 |
--short-shoe lifts only the iron shoe plates. After zmin snap the post is the AABB zmin, so the AABB gate would still pass; the named shoe stations fail. --gap-board moves the board inner station outside the post. --float-strap adds Y offset to the U-wrap so it no longer bites. --yaw-boards restores a 0.12 rad yaw; the AABB secondary extent exceeds the thickness bound.
blender --background --python signpost.py --
blender --background --python signpost.py -- --skip-decimate
+blender --background --python signpost.py -- --stray-vert
+blender --background --python signpost.py -- --lift-z
+blender --background --python signpost.py -- --short-shoe
+blender --background --python signpost.py -- --gap-board
+blender --background --python signpost.py -- --float-strap
+blender --background --python signpost.py -- --yaw-boards
blender --background --python signpost.py -- --output signpost.png
-Smoke does not pass --output or --skip-decimate.
Smoke passes no flags.
File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path.
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 3 distinct slots, or wood/board/metal faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file |
File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path. 15–19 are the hygiene and joint-fit family.
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 3 distinct slots, or wood/board/metal faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file | | 15 | Hygiene (--stray-vert lands here) | | 16 | Grounded zmin / named shoes (--lift-z, --short-shoe) | | 17 | Board-to-post gap (--gap-board) | | 18 | Strap-to-post gap (--float-strap) | | 19 | Board yaw / AABB Y (--yaw-boards) |
Rendered headless by the example itself — click to zoom.
-blender --background --python showcase/wall-torch/wall_torch.py --
A showcase piece, not an example. Procedural wall-mounted torch sconce (dressed stone plaque, iron bracket and cup, wooden haft, emissive flame) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
+A showcase piece, not an example. Procedural wall-mounted torch sconce (running-bond dressed stone plaque, iron plate and arm from named stations, bowl cup, wooden haft, emissive flame) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
+The plaque back sits on the wall plane Y=0 with zmin at 0. The iron arm is a tube from the plate station to the cup wall, not a pair of square bars aimed at a funnel. The bowl opens upward and holds the haft. Hygiene family 15–19: --stray-vert, --lift-z, --float-arm, --float-plate, --skinny-plaque.
It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.
-Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, procedural-materials-and-shaders, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied, not imported as a package).
Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, procedural-materials-and-shaders, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied, not imported as a package). Hygiene combinatorics match examples/mesh-hygiene-audit (copied, not imported).
Intended size: 0.28 m plaque width, 0.40 m plaque height, bowl projecting 0.24 m from the wall; outer AABB 0.304 × 0.293 × 0.424 m.
Declared as named constants; every gate recomputes from the mesh, materials, UVs, evaluated LOD, collider, or export file.
-| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 800–920 | 848 / 848 / 848 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2193 / 0.2193 / 0.2099 | | Materials | exactly 4 distinct, ≥24 stone, ≥24 metal, ≥8 flame | 4 slots, 162 stone, 134 metal, 44 flame | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.384, 0.360, 0.670) m ± 0.01 | (0.3840, 0.3600, 0.6700), zmin 0.140 | | Collider tris | ≤ 120 | 92 | | Export | written, size > 0 | 69060 / 69060 / 69048 bytes |
DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is 8 tris leaner on LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 12 B on 5.2.1 (glTF serializer), not a gated axis.
--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates.
| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 1400–1700 | 1528 / 1528 / 1528 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2199 | | Materials | exactly 4 distinct, ≥24 stone, ≥24 metal, ≥8 flame | 4 slots, 540 stone, 228 metal, 78 flame | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.304, 0.293, 0.424) m ± 0.015 | (0.3040, 0.2930, 0.4240), zmin 0 | | Collider tris | ≤ 180 | 60 | | Export | written, size > 0 | 124608 / 124608 / 124584 bytes |
Base triangles rose from 848 to 1528 in the quality pass: one mailbox slab became four courses of running-bond ashlar plus a round arm and a triangulated bowl. Outer AABB tightened from 0.384 × 0.360 × 0.670 m (and zmin 0.140) to 0.304 × 0.293 × 0.424 m at zmin 0.
+DECIMATE COLLAPSE triangle counts are not identical across series. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 24 B on 5.2.1 (glTF serializer), not a gated axis.
Recomputed from the generated mesh, not asserted about the script.
+| Axis | Declared | Measured (all three) | | --- | --- | --- | | Non-manifold edges | 0 | 0 | | Loose verts / edges | 0 / 0 | 0 / 0 | | Doubles merged at 1e-5 | 0 | 0 | | Zero-area faces | 0 | 0 | | N-gons | 0 | 0 | | Coplanar disjoint face pairs | 0 | 0 | | Grounded: zmin | within 1e-4 of 0 | 0.0000 |
| Axis | Declared | Measured (all three) | | --- | --- | --- | | Arm-to-cup BVH gap | ≤ 0.008 m | 0.00087 | | Plate-to-plaque BVH gap | ≤ 0.008 m | 0.00400 | | Plaque width vs WALL_W | ± 0.04 m | 0.2800 vs 0.280 |
Each violates one named budget. All were run on 4.5.11, 5.1.2 and 5.2.1 and returned the same code on each.
+| Flag | Budget violated | Exit | | --- | --- | --- | | --skip-decimate | LOD1 ratio band | 9 | | --stray-vert | loose vertex count is 0 | 15 | | --lift-z | bounding box zmin is 0 | 16 | | --float-arm | arm-to-cup BVH gap | 17 | | --float-plate | plate-to-plaque BVH gap | 18 | | --skinny-plaque | plaque width vs WALL_W | 19 |
--float-arm shortens the tube to 55 % of the plate-to-cup station vector; the cup stays put. --float-plate moves only the hang plate off the plaque; the arm still reaches the cup so 17 still passes. --skinny-plaque scales the back plaque and courses to 0.55 · WALL_W.
blender --background --python wall_torch.py --
blender --background --python wall_torch.py -- --skip-decimate
+blender --background --python wall_torch.py -- --stray-vert
+blender --background --python wall_torch.py -- --lift-z
+blender --background --python wall_torch.py -- --float-arm
+blender --background --python wall_torch.py -- --float-plate
+blender --background --python wall_torch.py -- --skinny-plaque
blender --background --python wall_torch.py -- --output torch.png
-Smoke does not pass --output or --skip-decimate.
Smoke passes no flags.
File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path.
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 4 distinct slots, or stone/metal/flame faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file |
File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path. 15–19 are the hygiene and joint-fit family.
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 4 distinct slots, or stone/metal/flame faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file | | 15 | Hygiene (--stray-vert lands here) | | 16 | Grounded zmin (--lift-z) | | 17 | Arm-to-cup gap (--float-arm) | | 18 | Plate-to-plaque gap (--float-plate) | | 19 | Plaque width (--skinny-plaque) |
"""Game-ready wall torch sconce — a showcase piece, not an example.
-Asserts budget conformance of a procedural wall-mounted torch after composing
-shipped pipeline pieces: bmesh construction, UVs, four materials, high-to-low
-normal bake, LOD chain, convex collider, Unity glTF export.
+Asserts budget conformance of a procedural wall-mounted torch after
+composing shipped pipeline pieces: bmesh construction, UVs, four
+materials, high-to-low normal bake, LOD chain, convex collider,
+Unity glTF export.
-Budgets are declared below and recomputed from the generated result.
-They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
-DECIMATE stage so the LOD-ratio budget fails.
+The plaque back sits on the wall plane Y=0 with zmin at 0. The iron
+arm is a tube between the plate station and the cup station. The
+bowl opens upward and holds the haft. Hygiene family 15–19:
+``--stray-vert``, ``--lift-z``, ``--float-arm``, ``--float-plate``,
+``--skinny-plaque``.
-No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
-are not byte-identical across Blender versions — the LOD gate is a
-ratio band, not an exact count.
+No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle
+counts are not byte-identical across Blender versions — the LOD
+gate is a ratio band, not an exact count.
blender --background --python wall_torch.py --
blender --background --python wall_torch.py -- --skip-decimate
@@ -310,10 +329,8 @@ Source
import bmesh
import bpy
from mathutils import Euler, Vector
+from mathutils.bvhtree import BVHTree
-# Showcase lives at repo-root/showcase/, not under examples/. The framing
-# helper is the repo's only shared import and lives next to the examples;
-# resolve the repo root so we do not move gallery_framing.py.
_REPO = os.path.abspath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
)
@@ -321,15 +338,34 @@ Source
sys.dont_write_bytecode = True
import gallery_framing # noqa: E402
-WALL_W = 0.36
-WALL_D = 0.10
-WALL_H = 0.58
-BBOX_TOL = 0.01
-# Fitted after locking geometry. Recomputed from bound_box.
-OUTER_SIZE = (0.384, 0.360, 0.670)
-
-BASE_TRIS_MIN = 800
-BASE_TRIS_MAX = 920
+WALL_W = 0.28
+WALL_D = 0.070
+WALL_H = 0.40
+CORNICE_H = 0.028
+GROUT = 0.007
+NROWS = 4
+STONE_D = 0.020
+STONE_BITE = 0.004
+PLATE_Z = 0.22
+PLATE_R = 0.055
+PLATE_T = 0.016
+CUP_Y = 0.24
+CUP_Z = 0.20
+CUP_H = 0.052
+CUP_R0 = 0.024
+CUP_R1 = 0.050
+ARM_R = 0.011
+ARM_BITE = 0.012
+HAFT_R = 0.016
+LIFT_Z = 0.05
+FLOAT_ARM = 0.55
+FLOAT_PLATE = 0.08
+SKINNY = 0.55
+
+BBOX_TOL = 0.015
+OUTER_SIZE = (0.304, 0.293, 0.424)
+BASE_TRIS_MIN = 1400
+BASE_TRIS_MAX = 1700
LOD1_RATIO_MIN = 0.32
LOD1_RATIO_MAX = 0.62
LOD2_RATIO_MIN = 0.10
@@ -339,7 +375,7 @@ Source
MATERIAL_COUNT = 4
UV_EPS = 1e-4
UV_OVERLAP_MAX = 1e-5
-COLLIDER_TRIS_MAX = 120
+COLLIDER_TRIS_MAX = 180
BAKE_RES = 256
CAGE_EXTRUSION = 0.06
STONE_FACES_MIN = 24
@@ -351,6 +387,15 @@ Source
WOOD_IDX = 2
FLAME_IDX = 3
+DOUBLES_EPS = 1e-5
+AREA_EPS = 1e-10
+ZMIN_EPS = 1e-4
+ZFIGHT_EPS = 0.002
+ZFIGHT_COS = 0.98
+ARM_GAP_MAX = 0.008
+PLATE_GAP_MAX = 0.008
+PLAQUE_W_TOL = 0.04
+
def eevee_engine_id():
return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
@@ -367,7 +412,6 @@ Source
def evaluated_triangle_count(obj):
- # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
depsgraph = bpy.context.evaluated_depsgraph_get()
eval_obj = obj.evaluated_get(depsgraph)
eval_mesh = eval_obj.to_mesh()
@@ -392,29 +436,11 @@ Source
return verts
-def add_oriented_box(bm, a, b, scale_xy, mat_idx):
- a = Vector(a)
- b = Vector(b)
- delta = b - a
- length = delta.length
- if length < 1e-8:
- return []
- quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
- eul = quat.to_euler("XYZ")
- return add_box(
- bm,
- ((a + b) * 0.5),
- (scale_xy[0], scale_xy[1], length),
- mat_idx,
- euler=(eul.x, eul.y, eul.z),
- )
-
-
def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
geo = bmesh.ops.create_cone(
bm,
cap_ends=True,
- cap_tris=False,
+ cap_tris=segments > 4,
segments=segments,
radius1=radius1,
radius2=radius2,
@@ -431,38 +457,25 @@ Source
return verts
-def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
- return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler)
-
-
-def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)):
- n_major = 12
- n_minor = 6
- rings = []
- for i in range(n_major):
- u = i * (2.0 * math.pi / n_major)
- ring = []
- for j in range(n_minor):
- v = j * (2.0 * math.pi / n_minor)
- x = (major + minor * math.cos(v)) * math.cos(u)
- y = (major + minor * math.cos(v)) * math.sin(u)
- z = minor * math.sin(v)
- ring.append(bm.verts.new((x, y, z)))
- rings.append(ring)
- for i in range(n_major):
- i2 = (i + 1) % n_major
- for j in range(n_minor):
- j2 = (j + 1) % n_minor
- face = bm.faces.new(
- (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2])
- )
- face.material_index = mat_idx
- verts = [v for ring in rings for v in ring]
- rot = Euler(euler).to_matrix()
- origin = Vector(loc)
- for v in verts:
- v.co = rot @ v.co + origin
- return verts
+def add_cyl_between(bm, a, b, radius, segments, mat_idx):
+ a = Vector(a)
+ b = Vector(b)
+ delta = b - a
+ length = delta.length
+ if length < 1e-8:
+ return []
+ quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+ eul = quat.to_euler("XYZ")
+ return add_cone(
+ bm,
+ (a + b) * 0.5,
+ radius,
+ radius,
+ length,
+ segments,
+ mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
def pack_uvs(bm, margin=0.08):
@@ -508,138 +521,133 @@ Source
)
-def build_torch_mesh(name, bevel_offset, bevel_segments):
+def build_torch_mesh(
+ name,
+ bevel_offset,
+ bevel_segments,
+ float_arm=False,
+ float_plate=False,
+ skinny_plaque=False,
+):
bm = bmesh.new()
- metal_faces = set()
- flame_faces = set()
try:
- # One dressed plaque — a tile grid reads as bathroom masonry.
- add_box(
- bm,
- (0.0, 0.0, WALL_H / 2.0),
- (WALL_W, WALL_D, WALL_H),
- STONE_IDX,
- )
+ plaque_w = WALL_W * SKINNY if skinny_plaque else WALL_W
add_box(
bm,
- (0.0, WALL_D / 2.0 + 0.010, WALL_H / 2.0),
- (WALL_W - 0.048, 0.022, WALL_H - 0.070),
+ (0.0, WALL_D / 2.0, WALL_H / 2.0),
+ (plaque_w, WALL_D, WALL_H),
STONE_IDX,
)
add_box(
bm,
- (0.0, 0.0, WALL_H + 0.014),
- (WALL_W + 0.024, WALL_D + 0.024, 0.030),
+ (0.0, WALL_D / 2.0 + 0.006, WALL_H + CORNICE_H / 2.0 - 0.004),
+ (plaque_w + 0.024, WALL_D + 0.018, CORNICE_H),
STONE_IDX,
)
- plate_y = WALL_D / 2.0 + 0.024
- plate_z = 0.30
- before = set(bm.faces)
- add_box(bm, (0.0, plate_y, plate_z), (0.14, 0.018, 0.20), METAL_IDX)
- add_box(bm, (0.0, plate_y, plate_z + 0.078), (0.18, 0.014, 0.028), METAL_IDX)
- add_box(bm, (0.0, plate_y, plate_z - 0.078), (0.18, 0.014, 0.028), METAL_IDX)
- add_rim(
- bm,
- (0.0, plate_y + 0.020, plate_z + 0.016),
- 0.048,
- 0.009,
- METAL_IDX,
- euler=(math.radians(90.0), 0.0, 0.0),
- )
- arm_y0 = plate_y + 0.012
- arm_y1 = plate_y + 0.15
- cup_z = 0.26
- add_oriented_box(
- bm,
- (0.032, arm_y0, plate_z - 0.018),
- (0.020, arm_y1, cup_z + 0.036),
- (0.018, 0.018),
- METAL_IDX,
- )
- add_oriented_box(
- bm,
- (-0.032, arm_y0, plate_z - 0.018),
- (-0.020, arm_y1, cup_z + 0.036),
- (0.018, 0.018),
- METAL_IDX,
- )
+ course_h = (WALL_H - GROUT * (NROWS + 1)) / NROWS
+ face_y = WALL_D - STONE_BITE + STONE_D / 2.0
+ inner = plaque_w - 2.0 * GROUT
+ for row in range(NROWS):
+ zc = GROUT + row * (course_h + GROUT) + course_h / 2.0
+ fracs = (0.58, 0.42) if row % 2 == 0 else (0.40, 0.60)
+ x = -plaque_w / 2.0 + GROUT
+ for frac in fracs:
+ w = inner * frac - GROUT * 0.5
+ cx = x + w / 2.0
+ add_box(
+ bm,
+ (cx, face_y, zc),
+ (max(w, 0.04), STONE_D, course_h - 0.001),
+ STONE_IDX,
+ )
+ x += w + GROUT
+
+ plate_y = WALL_D - 0.004 + PLATE_T / 2.0
+ if float_plate:
+ plate_y += FLOAT_PLATE
add_cone(
bm,
- (0.0, arm_y1 + 0.012, cup_z),
- 0.062,
- 0.034,
- 0.062,
- 14,
- METAL_IDX,
- )
- add_cylinder(
- bm,
- (0.0, arm_y1 + 0.012, cup_z - 0.042),
- 0.054,
- 0.014,
- 14,
+ (0.0, plate_y, PLATE_Z),
+ PLATE_R,
+ PLATE_R,
+ PLATE_T,
+ 12,
METAL_IDX,
+ euler=(math.pi / 2.0, 0.0, 0.0),
)
- metal_faces.update(set(bm.faces) - before)
+ for sx, sz in ((-0.032, 0.028), (0.032, 0.028), (-0.032, -0.028), (0.032, -0.028)):
+ add_cone(
+ bm,
+ (sx, plate_y + PLATE_T / 2.0 + 0.004, PLATE_Z + sz),
+ 0.006,
+ 0.006,
+ 0.010,
+ 8,
+ METAL_IDX,
+ euler=(math.pi / 2.0, 0.0, 0.0),
+ )
- stick_z0 = cup_z - 0.018
- stick_z1 = 0.50
- add_cylinder(
+ plate_st = Vector((0.0, plate_y, PLATE_Z))
+ cup_st = Vector((0.0, CUP_Y - CUP_R1 + ARM_BITE, CUP_Z))
+ arm_end = cup_st
+ if float_arm:
+ arm_end = plate_st + (cup_st - plate_st) * FLOAT_ARM
+ add_cyl_between(bm, plate_st, arm_end, ARM_R, 8, METAL_IDX)
+
+ add_cone(
bm,
- (0.0, arm_y1 + 0.012, (stick_z0 + stick_z1) / 2.0),
- 0.026,
- stick_z1 - stick_z0,
+ (0.0, CUP_Y, CUP_Z),
+ CUP_R0,
+ CUP_R1,
+ CUP_H,
12,
- WOOD_IDX,
+ METAL_IDX,
)
add_cone(
bm,
- (0.0, arm_y1 + 0.012, 0.46),
- 0.050,
- 0.030,
- 0.12,
+ (0.0, CUP_Y, CUP_Z - CUP_H / 2.0 - 0.006),
+ CUP_R0 + 0.004,
+ CUP_R0 + 0.004,
+ 0.010,
12,
- WOOD_IDX,
+ METAL_IDX,
)
- before = set(bm.faces)
- flame_y = arm_y1 + 0.012
- add_cone(bm, (0.0, flame_y, 0.56), 0.048, 0.006, 0.22, 10, FLAME_IDX)
+ cup_rim = CUP_Z + CUP_H / 2.0
+ haft_z0 = CUP_Z - CUP_H / 2.0 + 0.006
+ haft_z1 = cup_rim + 0.026
add_cone(
bm,
- (0.018, flame_y - 0.010, 0.545),
- 0.030,
- 0.004,
- 0.16,
- 9,
- FLAME_IDX,
- euler=(0.0, math.radians(16.0), 0.0),
+ (0.0, CUP_Y, (haft_z0 + haft_z1) / 2.0),
+ HAFT_R,
+ HAFT_R,
+ haft_z1 - haft_z0,
+ 10,
+ WOOD_IDX,
)
+
+ add_cone(bm, (0.0, CUP_Y, cup_rim + 0.038), 0.034, 0.004, 0.078, 10, FLAME_IDX)
add_cone(
bm,
- (-0.016, flame_y + 0.012, 0.54),
- 0.028,
- 0.004,
- 0.15,
- 9,
+ (0.012, CUP_Y - 0.008, cup_rim + 0.028),
+ 0.022,
+ 0.003,
+ 0.058,
+ 8,
FLAME_IDX,
- euler=(math.radians(-12.0), math.radians(-14.0), 0.0),
+ euler=(0.0, math.radians(14.0), 0.0),
)
add_cone(
bm,
- (0.006, flame_y + 0.004, 0.58),
+ (-0.010, CUP_Y + 0.006, cup_rim + 0.024),
0.018,
0.003,
- 0.12,
+ 0.050,
8,
FLAME_IDX,
- euler=(math.radians(8.0), math.radians(6.0), 0.0),
+ euler=(math.radians(-10.0), math.radians(-12.0), 0.0),
)
- flame_faces.update(set(bm.faces) - before)
-
- for v in bm.verts:
- v.co.z += 0.14
if bevel_offset > 0.0:
stone_edges = [
@@ -647,7 +655,7 @@ Source
if any(f.material_index == STONE_IDX for f in e.link_faces)
]
if stone_edges:
- bmesh.ops.bevel(
+ ret = bmesh.ops.bevel(
bm,
geom=stone_edges,
offset=bevel_offset,
@@ -656,25 +664,25 @@ Source
affect="EDGES",
clamp_overlap=True,
)
+ for f in ret.get("faces") or []:
+ f.material_index = STONE_IDX
+
+ zs = [v.co.z for v in bm.verts]
+ zmin = min(zs)
+ for v in bm.verts:
+ v.co.z -= zmin
+ if v.co.z < 0.0:
+ v.co.z = 0.0
pack_uvs(bm)
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
for face in bm.faces:
- face.smooth = face.material_index != STONE_IDX
- for edge in bm.edges:
- edge.smooth = True
- if edge.is_manifold and len(edge.link_faces) == 2:
- if edge.calc_face_angle() > math.radians(35.0):
- edge.smooth = False
- for f in metal_faces:
- if f.is_valid:
- f.material_index = METAL_IDX
- for f in flame_faces:
- if f.is_valid:
- f.material_index = FLAME_IDX
+ face.smooth = face.material_index in (METAL_IDX, FLAME_IDX)
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
me.update()
+ for poly in me.polygons:
+ poly.use_smooth = poly.material_index in (METAL_IDX, FLAME_IDX)
finally:
bm.free()
obj = bpy.data.objects.new(name, me)
@@ -682,10 +690,20 @@ Source
return obj
-def principled(name, color, metallic, roughness, emission=0.0, emission_color=None):
+def principled(
+ name,
+ color,
+ metallic,
+ roughness,
+ emission=0.0,
+ emission_color=None,
+ noise_scale=0.0,
+ wear=None,
+):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
- bsdf = mat.node_tree.nodes["Principled BSDF"]
+ nt = mat.node_tree
+ bsdf = nt.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = color
bsdf.inputs["Metallic"].default_value = metallic
bsdf.inputs["Roughness"].default_value = roughness
@@ -696,28 +714,36 @@ Source
bsdf.inputs["Emission Strength"].default_value = emission
elif "Emission" in bsdf.inputs:
bsdf.inputs["Emission"].default_value = ecol
+ if noise_scale > 0.0 and wear is not None:
+ tex = nt.nodes.new("ShaderNodeTexNoise")
+ tex.inputs["Scale"].default_value = noise_scale
+ tex.inputs["Detail"].default_value = 6.0
+ mix = nt.nodes.new("ShaderNodeMix")
+ mix.data_type = "RGBA"
+ mix.inputs["A"].default_value = color
+ mix.inputs["B"].default_value = wear
+ fac = mix.inputs.get("Factor") or mix.inputs.get("Fac")
+ nt.links.new(tex.outputs["Fac"], fac)
+ nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"])
return mat
def assign_slots(obj, stone, metal, wood, flame):
mats = obj.data.materials
- slots = (stone, metal, wood, flame)
- if len(mats) == 0:
- for s in slots:
- mats.append(s)
- return
- for i, s in enumerate(slots):
+ wanted = (stone, metal, wood, flame)
+ for i, mat in enumerate(wanted):
if i < len(mats):
- mats[i] = s
+ mats[i] = mat
else:
- mats.append(s)
+ mats.append(mat)
def world_bbox(obj):
- corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
- xs = [c.x for c in corners]
- ys = [c.y for c in corners]
- zs = [c.z for c in corners]
+ mat = obj.matrix_world
+ pts = [mat @ v.co for v in obj.data.vertices]
+ xs = [p.x for p in pts]
+ ys = [p.y for p in pts]
+ zs = [p.z for p in pts]
return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
@@ -759,7 +785,6 @@ Source
def convex_hull_collider(obj, name):
- # Duplicated from snippets/convex_hull_collider.py (not a package).
mesh = bpy.data.meshes.new(name)
bm = bmesh.new()
try:
@@ -781,6 +806,180 @@ Source
return collider
+def face_area(me, poly):
+ vs = [me.vertices[i].co for i in poly.vertices]
+ if len(vs) < 3:
+ return 0.0
+ v0 = vs[0]
+ area = 0.0
+ for i in range(1, len(vs) - 1):
+ area += (vs[i] - v0).cross(vs[i + 1] - v0).length * 0.5
+ return area
+
+
+def hygiene_audit(me):
+ nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons)
+ ngons = sum(1 for p in me.polygons if len(p.vertices) > 4)
+ zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS)
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(me)
+ loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0)
+ loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0)
+ nonman = sum(1 for e in bm.edges if not e.is_manifold)
+ ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS)
+ doubles = len(ret.get("targetmap") or {})
+ finally:
+ bm.free()
+ return {
+ "nv": nv, "ne": ne, "nf": nf, "ngons": ngons,
+ "loose_v": loose_v, "loose_e": loose_e, "nonman": nonman,
+ "zero_area": zero_area, "doubles": doubles, "euler": nv - ne + nf,
+ }
+
+
+def zfight_pairs(me):
+ data = [
+ (p.center.copy(), p.normal.copy(), frozenset(p.vertices))
+ for p in me.polygons
+ ]
+ eps2 = ZFIGHT_EPS * ZFIGHT_EPS
+ count = 0
+ for i in range(len(data)):
+ ci, ni, vi = data[i]
+ for j in range(i + 1, len(data)):
+ cj, nj, vj = data[j]
+ if (cj - ci).length_squared > eps2:
+ continue
+ if abs(ni.dot(nj)) <= ZFIGHT_COS:
+ continue
+ if vi & vj:
+ continue
+ count += 1
+ return count
+
+
+def shells(me):
+ neighbors = [[] for _ in range(len(me.vertices))]
+ for edge in me.edges:
+ a, b = edge.vertices
+ neighbors[a].append(b)
+ neighbors[b].append(a)
+ seen = [False] * len(me.vertices)
+ groups = []
+ for start in range(len(me.vertices)):
+ if seen[start]:
+ continue
+ seen[start] = True
+ stack = [start]
+ group = []
+ while stack:
+ current = stack.pop()
+ group.append(current)
+ for nxt in neighbors[current]:
+ if not seen[nxt]:
+ seen[nxt] = True
+ stack.append(nxt)
+ groups.append(group)
+ return groups
+
+
+def shell_aabb(me, group):
+ pts = [me.vertices[i].co for i in group]
+ return (
+ min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts),
+ max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts),
+ )
+
+
+def mat_of(me, group):
+ member = set(group)
+ for poly in me.polygons:
+ if all(i in member for i in poly.vertices):
+ return poly.material_index
+ return None
+
+
+def shell_bvh_gap(me, ga, gb):
+ bm_a = bmesh.new()
+ bm_b = bmesh.new()
+ try:
+ bm_a.from_mesh(me)
+ bm_b.from_mesh(me)
+ keep_a, keep_b = set(ga), set(gb)
+ drop_a = [f for f in bm_a.faces if not all(v.index in keep_a for v in f.verts)]
+ drop_b = [f for f in bm_b.faces if not all(v.index in keep_b for v in f.verts)]
+ if drop_a:
+ bmesh.ops.delete(bm_a, geom=drop_a, context="FACES")
+ if drop_b:
+ bmesh.ops.delete(bm_b, geom=drop_b, context="FACES")
+ if not bm_a.faces or not bm_b.faces:
+ return 1e9
+ tree = BVHTree.FromBMesh(bm_b)
+ best = 1e9
+ for v in bm_a.verts:
+ hit = tree.find_nearest(v.co)
+ if hit[0] is not None:
+ best = min(best, hit[3])
+ for f in bm_a.faces:
+ hit = tree.find_nearest(f.calc_center_median())
+ if hit[0] is not None:
+ best = min(best, hit[3])
+ return best
+ finally:
+ bm_a.free()
+ bm_b.free()
+
+
+def add_stray_vert(me):
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(me)
+ bm.verts.new((0.0, CUP_Y, PLATE_Z))
+ bm.to_mesh(me)
+ me.update()
+ finally:
+ bm.free()
+
+
+def joint_audit(me):
+ groups = shells(me)
+ plaques = []
+ plates = []
+ cups = []
+ arms = []
+ for g in groups:
+ a = shell_aabb(me, g)
+ dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2]
+ mat = mat_of(me, g)
+ if mat == STONE_IDX and dz > 0.20 and dx > 0.12:
+ plaques.append((g, a))
+ elif mat == METAL_IDX and dy <= 0.04 and dx >= 0.08 and dz >= 0.08:
+ plates.append((g, a))
+ elif mat == METAL_IDX and a[4] > CUP_Y - 0.05 and dx > 0.05 and dz < 0.14:
+ cups.append((g, a))
+ elif mat == METAL_IDX and dy > dx and dy > dz:
+ arms.append((g, a))
+ arm_gap = 1e9
+ for ag, _ in arms:
+ for cg, _ in cups:
+ arm_gap = min(arm_gap, shell_bvh_gap(me, ag, cg))
+ plate_gap = 1e9
+ for pg, _ in plates:
+ for qg, _ in plaques:
+ plate_gap = min(plate_gap, shell_bvh_gap(me, pg, qg))
+ plaque_w = max((a[3] - a[0] for _, a in plaques), default=0.0)
+ return {
+ "plaques": len(plaques),
+ "plates": len(plates),
+ "cups": len(cups),
+ "arms": len(arms),
+ "arm_gap": arm_gap if arms and cups else 1e9,
+ "plate_gap": plate_gap if plates and plaques else 1e9,
+ "plaque_w": plaque_w,
+ }
+
+
def setup_bake_image(obj, target_mat, size=BAKE_RES):
if not obj.data.uv_layers:
return None, None
@@ -835,13 +1034,34 @@ Source
)
-def check(skip_decimate):
+def check(
+ skip_decimate,
+ lift_z=False,
+ stray_vert=False,
+ float_arm=False,
+ float_plate=False,
+ skinny_plaque=False,
+):
bpy.ops.wm.read_factory_settings(use_empty=True)
- low = build_torch_mesh("TorchLow", bevel_offset=0.004, bevel_segments=2)
- high = build_torch_mesh("TorchHigh", bevel_offset=0.004, bevel_segments=4)
- stone = principled("TorchStone", (0.36, 0.34, 0.30, 1.0), 0.0, 0.72)
- metal = principled("TorchMetal", (0.14, 0.12, 0.10, 1.0), 0.88, 0.38)
- wood = principled("TorchWood", (0.22, 0.11, 0.05, 1.0), 0.0, 0.64)
+ kw = dict(
+ float_arm=float_arm,
+ float_plate=float_plate,
+ skinny_plaque=skinny_plaque,
+ )
+ low = build_torch_mesh("TorchLow", bevel_offset=0.004, bevel_segments=2, **kw)
+ high = build_torch_mesh("TorchHigh", bevel_offset=0.004, bevel_segments=4, **kw)
+ stone = principled(
+ "TorchStone", (0.36, 0.34, 0.30, 1.0), 0.0, 0.72,
+ noise_scale=12.0, wear=(0.28, 0.26, 0.22, 1.0),
+ )
+ metal = principled(
+ "TorchMetal", (0.14, 0.12, 0.10, 1.0), 0.88, 0.38,
+ noise_scale=18.0, wear=(0.22, 0.16, 0.10, 1.0),
+ )
+ wood = principled(
+ "TorchWood", (0.22, 0.11, 0.05, 1.0), 0.0, 0.64,
+ noise_scale=14.0, wear=(0.12, 0.06, 0.03, 1.0),
+ )
flame = principled(
"TorchFlame",
(1.0, 0.28, 0.04, 1.0),
@@ -853,6 +1073,13 @@ Source
assign_slots(low, stone, metal, wood, flame)
assign_slots(high, stone, metal, wood, flame)
+ if stray_vert:
+ add_stray_vert(low.data)
+ if lift_z:
+ for v in low.data.vertices:
+ v.co.z += LIFT_Z
+ low.data.update()
+
if low.data is None or len(low.data.polygons) < 6:
return fail("torch mesh did not build", 3), None, None, None, None, None
@@ -883,7 +1110,7 @@ Source
r1 = lod1_tris / base_tris if base_tris else 0.0
r2 = lod2_tris / base_tris if base_tris else 0.0
- collider_src = build_torch_mesh("TorchColSrc", bevel_offset=0.0, bevel_segments=1)
+ collider_src = build_torch_mesh("TorchColSrc", bevel_offset=0.0, bevel_segments=1, **kw)
collider = convex_hull_collider(collider_src, "TorchCollider")
bpy.data.objects.remove(collider_src, do_unlink=True)
col_tris = triangle_count(collider.data)
@@ -897,9 +1124,11 @@ Source
export_unity(export_path, [low, collider])
export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
- print(
- f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}"
- )
+ hyg = hygiene_audit(low.data)
+ zf = zfight_pairs(low.data)
+ jnt = joint_audit(low.data)
+
+ print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
print(
f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
@@ -916,6 +1145,17 @@ Source
f"measured collider_tris={col_tris} bake={bake_result} "
f"bake_has_data={img.has_data} export_bytes={export_size}"
)
+ print(
+ f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} "
+ f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} "
+ f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}"
+ )
+ print(
+ f"measured plaques={jnt['plaques']} plates={jnt['plates']} "
+ f"cups={jnt['cups']} arms={jnt['arms']} "
+ f"arm_gap={jnt['arm_gap']:.5f} plate_gap={jnt['plate_gap']:.5f} "
+ f"plaque_w={jnt['plaque_w']:.4f}"
+ )
if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
return fail(
@@ -952,6 +1192,24 @@ Source
f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
7,
), None, None, None, None, None
+ if bb[2] > ZMIN_EPS:
+ return fail(f"grounded zmin={bb[2]:.5f}", 16), None, None, None, None, None
+ if jnt["arms"] < 1 or jnt["cups"] < 1 or jnt["arm_gap"] > ARM_GAP_MAX:
+ return fail(
+ f"arm-cup gap {jnt['arm_gap']:.5f} arms={jnt['arms']} cups={jnt['cups']}",
+ 17,
+ ), None, None, None, None, None
+ if jnt["plates"] < 1 or jnt["plaques"] < 1 or jnt["plate_gap"] > PLATE_GAP_MAX:
+ return fail(
+ f"plate-plaque gap {jnt['plate_gap']:.5f} plates={jnt['plates']} "
+ f"plaques={jnt['plaques']}",
+ 18,
+ ), None, None, None, None, None
+ if abs(jnt["plaque_w"] - WALL_W) > PLAQUE_W_TOL:
+ return fail(
+ f"plaque width {jnt['plaque_w']:.4f} off {WALL_W}",
+ 19,
+ ), None, None, None, None, None
if (
abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
@@ -985,6 +1243,16 @@ Source
), None, None, None, None, None
if export_size <= 0:
return fail("export file missing or empty", 13), None, None, None, None, None
+ if (
+ hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"]
+ or hyg["doubles"] or hyg["ngons"] or zf
+ ):
+ return fail(
+ f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} "
+ f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} "
+ f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}",
+ 15,
+ ), None, None, None, None, None
return 0, low, high, stone, tex, collider
@@ -1005,8 +1273,7 @@ Source
ob.hide_render = True
ob.hide_viewport = True
- # Sconce is built on +Y; camera sits in -Y, so yaw 180 so the flame faces us.
- low.rotation_euler.z = math.radians(142.0)
+ low.rotation_euler.z = math.radians(128.0)
floor_me = bpy.data.meshes.new("Floor")
bm = bmesh.new()
@@ -1048,16 +1315,15 @@ Source
light("Key", (-3.6, -5.0, 5.4), 640.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
light("Fill", (5.0, -3.4, 2.4), 42.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
light("Wedge", (2.2, 4.0, 3.8), 560.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
- # Small warm key on the flame so emission reads in EEVEE stills.
- light("FlameKick", (0.4, 1.2, 1.1), 90.0, 0.6, (1.0, 0.55, 0.22), (40, 0, 160))
+ light("FlameKick", (0.4, 1.2, 0.8), 90.0, 0.6, (1.0, 0.55, 0.22), (40, 0, 160))
cam_data = bpy.data.cameras.new("Cam")
- cam_data.lens = 50.0
+ cam_data.lens = 55.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (1.10, -1.70, 0.64)
+ cam.location = (-0.95, -1.15, 0.36)
scene.collection.objects.link(cam)
aim = bpy.data.objects.new("Aim", None)
- aim.location = (0.0, 0.06, 0.48)
+ aim.location = (0.02, 0.06, 0.21)
scene.collection.objects.link(aim)
con = cam.constraints.new("TRACK_TO")
con.target = aim
@@ -1100,14 +1366,22 @@ Source
p = argparse.ArgumentParser()
p.add_argument("--output", default=None)
p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
- p.add_argument(
- "--skip-decimate",
- action="store_true",
- help="falsification: skip the LOD DECIMATE stage",
- )
+ p.add_argument("--skip-decimate", action="store_true")
+ p.add_argument("--stray-vert", action="store_true")
+ p.add_argument("--lift-z", action="store_true")
+ p.add_argument("--float-arm", action="store_true")
+ p.add_argument("--float-plate", action="store_true")
+ p.add_argument("--skinny-plaque", action="store_true")
args = p.parse_args(argv)
- code, low, _high, stone, tex, _col = check(args.skip_decimate)
+ code, low, _high, stone, tex, _col = check(
+ args.skip_decimate,
+ lift_z=args.lift_z,
+ stray_vert=args.stray_vert,
+ float_arm=args.float_arm,
+ float_plate=args.float_plate,
+ skinny_plaque=args.skinny_plaque,
+ )
if code:
return code
if args.output:
diff --git a/showcase/README.md b/showcase/README.md
index f9dbeb84..39401186 100644
--- a/showcase/README.md
+++ b/showcase/README.md
@@ -101,6 +101,16 @@ entry in `showcase/gallery.json`, and a rendered still.
scatter is stones, not open crates; a per-shell face floor is the
budget that catches a cube leftover (exit 19). `--poke-rock` skips
the clamp and over-bites; `--float-rocks` seats above the host.
+- **Orthogonal members (exit 19).** Boards, planks or arms that should
+ sit on a world axis assert their AABB secondary extent stays within a
+ named bound of the member thickness. A closed-form yaw of 0.12 rad
+ reads as an accident, not weathering. `--yaw-boards` is the
+ falsifier. Fingerboard tips are a wedge whose base is the board's own
+ thickness×height, bitten into the board so the base face is inside
+ it — not a 45° cube of a different size glued on the end.
+- **Wall-mount zmin (exit 16).** A wall sconce plants the plaque bottom
+ at Z=0. Lifting every vert by a hanging height so the asset floats in
+ its own space is not the same as placing it on a wall in the engine.
- **Plumb and real-world size (exit 19).** Assert that the axis of a
turned or lofted body is vertical, by comparing the XY centroid of a
bottom slab against a top slab — not the exact `zmin` and `zmax`
diff --git a/showcase/gallery.json b/showcase/gallery.json
index 158a1a46..682f2630 100644
--- a/showcase/gallery.json
+++ b/showcase/gallery.json
@@ -225,7 +225,7 @@
"name": "signpost",
"dir": "showcase/signpost",
"teaches": "A procedural timber fingerboard signpost through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
- "witnessesFix": "Recomputed: 504 tris, three materials with 66 metal and 24 board faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.368×0.201×1.566 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 56 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
+ "witnessesFix": "Recomputed: 376 tris, three materials with 108 metal, 22 board and 60 wood faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.260×0.142×1.551 m at zmin 0, LOD ratios in band (5.2 COLLAPSE leaner on LOD1/LOD2), convex collider 52 tris, non-empty glTF. Hygiene all zero. Board-to-post gap 5 mm, strap gap 3 mm, board AABB Y 28 mm. --skip-decimate exits 9; --stray-vert 15; --lift-z / --short-shoe 16; --gap-board 17; --float-strap 18; --yaw-boards 19.",
"hero": "docs/gallery/assets/signpost-hero.webp",
"preview": "showcase/signpost/preview.webp",
"tags": [
@@ -261,7 +261,7 @@
"name": "wall-torch",
"dir": "showcase/wall-torch",
"teaches": "A procedural wall-mounted torch sconce with a dressed stone plaque, iron bracket, wooden haft, and emissive flame through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
- "witnessesFix": "Recomputed: 848 tris, four materials with 162 stone, 134 metal, and 44 flame faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.384×0.360×0.670 m, LOD ratios in band (5.2 COLLAPSE 8 tris leaner on LOD2), convex collider 92 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
+ "witnessesFix": "Recomputed: 1528 tris, four materials with 540 stone, 228 metal and 78 flame faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.304×0.293×0.424 m at zmin 0, LOD ratios in band, convex collider 60 tris, non-empty glTF. Hygiene all zero. Arm-to-cup gap 0.87 mm, plate-to-plaque gap 4 mm, plaque width 0.280 m. --skip-decimate exits 9; --stray-vert 15; --lift-z 16; --float-arm 17; --float-plate 18; --skinny-plaque 19.",
"hero": "docs/gallery/assets/wall-torch-hero.webp",
"preview": "showcase/wall-torch/preview.webp",
"tags": [
diff --git a/showcase/signpost/README.md b/showcase/signpost/README.md
index 6a66060f..20f024ef 100644
--- a/showcase/signpost/README.md
+++ b/showcase/signpost/README.md
@@ -1,10 +1,17 @@
# Signpost
A showcase piece, not an example. Procedural timber fingerboard
-signpost (square post, collar and pyramidal cap, two painted planks
-with diamond tips, iron shoe and straps) then the shipped pipeline:
-unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex
-collider, Unity glTF export.
+signpost (square post, pyramidal cap from the post half plus overhang,
+two axis-aligned painted boards with wedges cut from the board
+section, iron shoe collar and U-wrap straps) then the shipped
+pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain,
+convex collider, Unity glTF export.
+
+Boards are yaw 0. Finger tips are wedges whose base is the board's own
+thickness×height, bitten into the board so the base face is inside it.
+The cap is added after the post bevel. The post stays on the origin.
+Hygiene family 15–19: `--stray-vert`, `--lift-z` / `--short-shoe`,
+`--gap-board`, `--float-strap`, `--yaw-boards`.
It asserts **budget conformance** of the generated result. It does not
witness an API contract. "It rendered without error" is not a check.
@@ -14,6 +21,11 @@ witness an API contract. "It rendered without error" is not a check.
`bake_normal_high_to_low.py`, `setup_bake_target_image.py`,
`lod_chain.py` / `decimate_to_budget.py`, `convex_hull_collider.py`,
`export_preset_unity.py` (helpers copied, not imported as a package).
+Hygiene combinatorics match `examples/mesh-hygiene-audit` (copied, not
+imported).
+
+Intended size: 1.48 m post, boards 0.62 m and 0.54 m, 0.118 m post
+stock; outer AABB 1.260 × 0.142 × 1.551 m.
## Budgets
@@ -22,39 +34,92 @@ materials, UVs, evaluated LOD, collider, or export file.
| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) |
| --- | --- | --- |
-| Base triangles | 430–580 | 504 / 504 / 504 |
-| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 |
-| LOD2 ratio | 0.10–0.35 of base | 0.2183 / 0.2183 / 0.1746 |
-| Materials | exactly 3 distinct, ≥24 metal and ≥12 board faces | 3 slots, 66 metal, 24 board |
+| Base triangles | 320–480 | 376 / 376 / 376 |
+| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4681 |
+| LOD2 ratio | 0.10–0.35 of base | 0.2181 / 0.2181 / 0.1649 |
+| Materials | exactly 3 distinct, ≥24 metal, ≥12 board, ≥12 wood | 3 slots, 108 metal, 22 board, 60 wood |
| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
-| Outer AABB | (1.368, 0.201, 1.566) m ± 0.01 | (1.3679, 0.2014, 1.5660), zmin 0 |
-| Collider tris | ≤ 140 | 56 |
-| Export | written, size > 0 | 45164 / 45164 / 45152 bytes |
+| Outer AABB | (1.260, 0.142, 1.551) m ± 0.015 | (1.2600, 0.1420, 1.5510), zmin 0 |
+| Collider tris | ≤ 180 | 52 |
+| Export | written, size > 0 | 35688 / 35688 / 35676 bytes |
+
+Base triangles dropped from **504 to 376** in the quality pass: glued
+diamond cubes and a solid shoe block became wedges and a four-plate
+collar. Outer AABB tightened from 1.368 × 0.201 × 1.566 m.
DECIMATE COLLAPSE triangle counts are **not** identical across series —
-5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an
+5.2.1 is leaner on LOD1 and LOD2. The gate is a ratio band, not an
exact count. Bake pixels are stochastic; the gate is `has_data` plus
operator `FINISHED`, not byte-identity. Construction uses no RNG.
Export byte counts differ by 12 B on 5.2.1 (glTF serializer), not a
gated axis.
-`--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and
-exit 9 fires. That is the named budget the falsifier violates.
+### Hygiene
+
+Recomputed from the generated mesh, not asserted about the script.
+
+| Axis | Declared | Measured (all three) |
+| --- | --- | --- |
+| Non-manifold edges | 0 | 0 |
+| Loose verts / edges | 0 / 0 | 0 / 0 |
+| Doubles merged at 1e-5 | 0 | 0 |
+| Zero-area faces | 0 | 0 |
+| N-gons | 0 | 0 |
+| Coplanar disjoint face pairs | 0 | 0 |
+| Grounded: `zmin` | within 1e-4 of 0 | 0.0000 |
+| Named shoe plates | ≥1, each `zmin` ≤ 0.002 | 4, 0.00000 |
+
+### Joint fit and orthogonal boards
+
+| Axis | Declared | Measured (all three) |
+| --- | --- | --- |
+| Board-to-post BVH gap | ≤ 0.008 m | 0.00500 |
+| Strap-to-post BVH gap | ≤ 0.008 m | 0.00300 |
+| Board AABB Y (axis-aligned) | ≤ 0.055 m | 0.0280 |
+
+### Falsifiers
+
+Each violates one named budget. All were run on 4.5.11, 5.1.2 and 5.2.1
+and returned the same code on each.
+
+| Flag | Budget violated | Exit |
+| --- | --- | --- |
+| `--skip-decimate` | LOD1 ratio band | 9 |
+| `--stray-vert` | loose vertex count is 0 | 15 |
+| `--lift-z` | bounding box `zmin` is 0 | 16 |
+| `--short-shoe` | named shoes plant at z=0 | 16 |
+| `--gap-board` | board-to-post BVH gap | 17 |
+| `--float-strap` | strap-to-post BVH gap | 18 |
+| `--yaw-boards` | board AABB Y (orthogonal) | 19 |
+
+`--short-shoe` lifts only the iron shoe plates. After zmin snap the
+post is the AABB `zmin`, so the AABB gate would still pass; the named
+shoe stations fail. `--gap-board` moves the board inner station outside
+the post. `--float-strap` adds Y offset to the U-wrap so it no longer
+bites. `--yaw-boards` restores a 0.12 rad yaw; the AABB secondary
+extent exceeds the thickness bound.
## Run
```bash
blender --background --python signpost.py --
blender --background --python signpost.py -- --skip-decimate
+blender --background --python signpost.py -- --stray-vert
+blender --background --python signpost.py -- --lift-z
+blender --background --python signpost.py -- --short-shoe
+blender --background --python signpost.py -- --gap-board
+blender --background --python signpost.py -- --float-strap
+blender --background --python signpost.py -- --yaw-boards
blender --background --python signpost.py -- --output signpost.png
```
-Smoke does not pass `--output` or `--skip-decimate`.
+Smoke passes no flags.
## Exit codes
File-local. `9` is a valid check code. `10` is reserved for
-`gallery_framing.check_framing` on the `--output` path.
+`gallery_framing.check_framing` on the `--output` path. `15`–`19` are the
+hygiene and joint-fit family.
| Code | Meaning |
| --- | --- |
@@ -73,3 +138,8 @@ File-local. `9` is a valid check code. `10` is reserved for
| 12 | Bake did not finish or image has no data |
| 13 | Export file missing or empty |
| 14 | `--output` produced no file |
+| 15 | Hygiene (`--stray-vert` lands here) |
+| 16 | Grounded zmin / named shoes (`--lift-z`, `--short-shoe`) |
+| 17 | Board-to-post gap (`--gap-board`) |
+| 18 | Strap-to-post gap (`--float-strap`) |
+| 19 | Board yaw / AABB Y (`--yaw-boards`) |
diff --git a/showcase/signpost/preview.webp b/showcase/signpost/preview.webp
index 266e72f1..c8c1ef4e 100644
Binary files a/showcase/signpost/preview.webp and b/showcase/signpost/preview.webp differ
diff --git a/showcase/signpost/signpost.py b/showcase/signpost/signpost.py
index 82e6702f..27d53062 100644
--- a/showcase/signpost/signpost.py
+++ b/showcase/signpost/signpost.py
@@ -5,9 +5,11 @@
pipeline pieces: bmesh construction, UVs, three materials, high-to-low
normal bake, LOD chain, convex collider, Unity glTF export.
-Budgets are declared below and recomputed from the generated result.
-They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
-DECIMATE stage so the LOD-ratio budget fails.
+Boards are axis-aligned. Finger tips are wedges whose base is the
+board's own section, not glued cubes. The cap is a 4-gon pyramid from
+the post half plus overhang, added after bevel. The post stays on the
+origin. Hygiene family 15–19: ``--stray-vert``, ``--short-shoe``,
+``--gap-board``, ``--float-strap``, ``--yaw-boards``.
No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
are not byte-identical across Blender versions — the LOD gate is a
@@ -27,6 +29,7 @@
import bmesh
import bpy
from mathutils import Euler, Vector
+from mathutils.bvhtree import BVHTree
_REPO = os.path.abspath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
@@ -37,18 +40,29 @@
POST_W = 0.118
POST_H = 1.48
-CAP_H = 0.058
-SHOE_H = 0.048
-SHOE_SCALE = 1.34
+CAP_H = 0.075
+CAP_OVERHANG = 0.012
+SHOE_H = 0.042
+IRON_T = 0.014
+COLLAR_BITE = 0.004
+TENON = 0.005
BOARD_T = 0.028
-BOARD_H = 0.112
-BOARD_A = (1.24, 0.62, 1.0, 0.12)
-BOARD_B = (1.00, 0.54, -1.0, -0.18)
-
-BBOX_TOL = 0.01
-OUTER_SIZE = (1.368, 0.201, 1.566)
-BASE_TRIS_MIN = 430
-BASE_TRIS_MAX = 580
+BOARD_H_A = 0.112
+BOARD_H_B = 0.100
+BOARD_A = (1.24, 0.62, 1.0)
+BOARD_B = (1.00, 0.54, -1.0)
+TIP_LEN = 0.070
+TIP_BITE = 0.004
+BAND_Z = 0.22
+LIFT_SHOE = 0.08
+GAP_BOARD = 0.12
+FLOAT_STRAP = 0.06
+YAW_FAIL = 0.12
+
+BBOX_TOL = 0.015
+OUTER_SIZE = (1.260, 0.142, 1.551)
+BASE_TRIS_MIN = 320
+BASE_TRIS_MAX = 480
LOD1_RATIO_MIN = 0.32
LOD1_RATIO_MAX = 0.62
LOD2_RATIO_MIN = 0.10
@@ -58,7 +72,7 @@
MATERIAL_COUNT = 3
UV_EPS = 1e-4
UV_OVERLAP_MAX = 1e-5
-COLLIDER_TRIS_MAX = 140
+COLLIDER_TRIS_MAX = 180
BAKE_RES = 256
CAGE_EXTRUSION = 0.08
METAL_FACES_MIN = 24
@@ -69,6 +83,17 @@
BOARD_IDX = 1
METAL_IDX = 2
+DOUBLES_EPS = 1e-5
+AREA_EPS = 1e-10
+ZMIN_EPS = 1e-4
+ZFIGHT_EPS = 0.002
+ZFIGHT_COS = 0.98
+SHOE_COUNT = 1
+SHOE_ZMIN_MAX = 0.002
+BOARD_GAP_MAX = 0.008
+STRAP_GAP_MAX = 0.008
+BOARD_DY_MAX = 0.055
+
def eevee_engine_id():
return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
@@ -95,13 +120,6 @@ def evaluated_triangle_count(obj):
eval_obj.to_mesh_clear()
-def deselect_all():
- for ob in list(bpy.context.view_layer.objects):
- if ob is None:
- continue
- ob.select_set(False)
-
-
def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
geo = bmesh.ops.create_cube(bm, size=1.0)
verts = geo["verts"]
@@ -116,50 +134,11 @@ def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
return verts
-def add_oriented_box(bm, a, b, scale_xy, mat_idx):
- a = Vector(a)
- b = Vector(b)
- delta = b - a
- length = delta.length
- if length < 1e-8:
- return []
- quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
- eul = quat.to_euler("XYZ")
- return add_box(
- bm,
- ((a + b) * 0.5),
- (scale_xy[0], scale_xy[1], length),
- mat_idx,
- euler=(eul.x, eul.y, eul.z),
- )
-
-
-def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
- geo = bmesh.ops.create_cone(
- bm,
- cap_ends=True,
- cap_tris=False,
- segments=segments,
- radius1=radius,
- radius2=radius,
- depth=depth,
- )
- verts = geo["verts"]
- rot = Euler(euler).to_matrix()
- origin = Vector(loc)
- for v in verts:
- v.co = rot @ v.co + origin
- faces = {f for v in verts for f in v.link_faces}
- for f in faces:
- f.material_index = mat_idx
- return verts
-
-
def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
geo = bmesh.ops.create_cone(
bm,
cap_ends=True,
- cap_tris=False,
+ cap_tris=segments > 4,
segments=segments,
radius1=radius1,
radius2=radius2,
@@ -176,44 +155,49 @@ def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.
return verts
-def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)):
- n_major = 14
- n_minor = 7
- rings = []
- for i in range(n_major):
- u = i * (2.0 * math.pi / n_major)
- ring = []
- for j in range(n_minor):
- v = j * (2.0 * math.pi / n_minor)
- x = (major + minor * math.cos(v)) * math.cos(u)
- y = (major + minor * math.cos(v)) * math.sin(u)
- z = minor * math.sin(v)
- ring.append(bm.verts.new((x, y, z)))
- rings.append(ring)
- bm.verts.ensure_lookup_table()
- for i in range(n_major):
- i2 = (i + 1) % n_major
- for j in range(n_minor):
- j2 = (j + 1) % n_minor
- face = bm.faces.new(
- (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2])
- )
- face.material_index = mat_idx
- verts = [v for ring in rings for v in ring]
- rot = Euler(euler).to_matrix()
- origin = Vector(loc)
- for v in verts:
- v.co = rot @ v.co + origin
- return verts
+def add_collar(bm, z, half, t, h, mat_idx, closed=True, omit_x_sign=None, extra_y=0.0):
+ loc_y = half - COLLAR_BITE + t * 0.5 + extra_y
+ add_box(bm, (0.0, loc_y, z), (2.0 * half + t * 0.55, t, h), mat_idx)
+ add_box(bm, (0.0, -loc_y, z), (2.0 * half + t * 0.55, t, h), mat_idx)
+ loc_x = half - COLLAR_BITE + t * 0.5 + extra_y
+ for sx in (-1.0, 1.0):
+ if omit_x_sign is not None and sx == omit_x_sign:
+ continue
+ if not closed:
+ continue
+ add_box(
+ bm,
+ (sx * loc_x, 0.0, z),
+ (t, 2.0 * half + t * 0.15, h),
+ mat_idx,
+ )
-def add_square_band(bm, z, half, t, h, mat_idx):
- metal = []
- metal.extend(add_box(bm, (0.0, half + t / 2.0, z), (2.0 * half + 2.0 * t, t, h), mat_idx))
- metal.extend(add_box(bm, (0.0, -(half + t / 2.0), z), (2.0 * half + 2.0 * t, t, h), mat_idx))
- metal.extend(add_box(bm, (half + t / 2.0, 0.0, z), (t, 2.0 * half, h), mat_idx))
- metal.extend(add_box(bm, (-(half + t / 2.0), 0.0, z), (t, 2.0 * half, h), mat_idx))
- return metal
+def add_wedge_tip(bm, origin, sign, yaw, height, thickness, mat_idx):
+ rot = Euler((0.0, 0.0, yaw)).to_matrix()
+ hy = thickness * 0.5
+ hz = height * 0.5
+ sx = 1.0 if sign > 0.0 else -1.0
+ local = (
+ (0.0, -hy, -hz),
+ (0.0, hy, -hz),
+ (0.0, hy, hz),
+ (0.0, -hy, hz),
+ (sx * TIP_LEN, -hy, 0.0),
+ (sx * TIP_LEN, hy, 0.0),
+ )
+ verts = [bm.verts.new(rot @ Vector(p) + Vector(origin)) for p in local]
+ faces = (
+ (verts[0], verts[1], verts[2], verts[3]),
+ (verts[0], verts[4], verts[5], verts[1]),
+ (verts[3], verts[2], verts[5], verts[4]),
+ (verts[0], verts[3], verts[4]),
+ (verts[1], verts[5], verts[2]),
+ )
+ for fverts in faces:
+ face = bm.faces.new(fverts)
+ face.material_index = mat_idx
+ return verts
def pack_uvs(bm, margin=0.08):
@@ -259,14 +243,19 @@ def pack_uvs(bm, margin=0.08):
)
-def build_signpost_mesh(name, bevel_offset, bevel_segments):
+def build_signpost_mesh(
+ name,
+ bevel_offset,
+ bevel_segments,
+ short_shoe=False,
+ gap_board=False,
+ float_strap=False,
+ yaw_boards=False,
+):
bm = bmesh.new()
try:
wood = []
- boards = []
- metal = []
half = POST_W / 2.0
-
wood.extend(
add_box(
bm,
@@ -275,26 +264,6 @@ def build_signpost_mesh(name, bevel_offset, bevel_segments):
WOOD_IDX,
)
)
- wood.extend(
- add_box(
- bm,
- (0.0, 0.0, POST_H + 0.014),
- (POST_W * 1.16, POST_W * 1.16, 0.028),
- WOOD_IDX,
- )
- )
- wood.extend(
- add_cone(
- bm,
- (0.0, 0.0, POST_H + 0.028 + CAP_H / 2.0),
- POST_W * 0.72,
- 0.008,
- CAP_H,
- 4,
- WOOD_IDX,
- euler=(0.0, 0.0, math.pi / 4.0),
- )
- )
if bevel_offset > 0.0:
edges = list({e for v in wood for e in v.link_edges})
@@ -310,76 +279,66 @@ def build_signpost_mesh(name, bevel_offset, bevel_segments):
for f in ret.get("faces") or []:
f.material_index = WOOD_IDX
- for z, length, sign, yaw in (BOARD_A, BOARD_B):
- eul = Euler((0.0, 0.0, yaw))
- rot = eul.to_matrix()
- inner = Vector((sign * (half - 0.016), 0.0, z))
- center = inner + rot @ Vector((sign * length / 2.0, 0.0, 0.0))
- boards.extend(
- add_box(
- bm,
- (center.x, center.y, center.z),
- (length, BOARD_T, BOARD_H),
- BOARD_IDX,
- euler=(0.0, 0.0, yaw),
- )
- )
- tip = inner + rot @ Vector((sign * (length + 0.004), 0.0, 0.0))
- boards.extend(
- add_box(
- bm,
- (tip.x, tip.y, tip.z),
- (0.088, BOARD_T, 0.088),
- BOARD_IDX,
- euler=(0.0, math.pi / 4.0, yaw),
- )
- )
- t = 0.012
- h = BOARD_H + 0.024
- metal.extend(
- add_box(
- bm,
- (sign * (half + t / 2.0), 0.0, z),
- (t, POST_W * 0.94, h),
- METAL_IDX,
- )
- )
- metal.extend(
- add_box(
- bm,
- (0.0, half + t / 2.0, z),
- (POST_W * 0.94, t, h),
- METAL_IDX,
- )
- )
- metal.extend(
- add_box(
- bm,
- (0.0, -(half + t / 2.0), z),
- (POST_W * 0.94, t, h),
- METAL_IDX,
- )
- )
+ cap_half = half + CAP_OVERHANG
+ add_cone(
+ bm,
+ (0.0, 0.0, POST_H + CAP_H / 2.0 - 0.004),
+ cap_half * math.sqrt(2.0),
+ 0.018,
+ CAP_H,
+ 4,
+ WOOD_IDX,
+ euler=(0.0, 0.0, math.pi / 4.0),
+ )
- metal.extend(
+ yaw = YAW_FAIL if yaw_boards else 0.0
+ extra = GAP_BOARD if gap_board else 0.0
+ strap_extra = FLOAT_STRAP if float_strap else 0.0
+ specs = (
+ (BOARD_A[0], BOARD_A[1], BOARD_A[2], BOARD_H_A, BOARD_T),
+ (BOARD_B[0], BOARD_B[1], BOARD_B[2], BOARD_H_B, BOARD_T * 0.92),
+ )
+ for z, length, sign, bh, bt in specs:
+ inner = sign * (half - TENON + extra)
+ rect_len = length - TIP_LEN
+ center_x = inner + sign * rect_len / 2.0
add_box(
bm,
- (0.0, 0.0, SHOE_H / 2.0),
- (POST_W * SHOE_SCALE, POST_W * SHOE_SCALE, SHOE_H),
+ (center_x, 0.0, z),
+ (rect_len, bt, bh),
+ BOARD_IDX,
+ euler=(0.0, 0.0, sign * yaw),
+ )
+ outer = inner + sign * rect_len - sign * TIP_BITE
+ add_wedge_tip(
+ bm,
+ (outer, 0.0, z),
+ sign,
+ sign * yaw,
+ bh,
+ bt,
+ BOARD_IDX,
+ )
+ add_collar(
+ bm,
+ z,
+ half,
+ IRON_T,
+ bh + 0.020,
METAL_IDX,
+ closed=True,
+ omit_x_sign=sign,
+ extra_y=strap_extra,
)
- )
- metal.extend(add_square_band(bm, 0.22, half, 0.012, 0.028, METAL_IDX))
- xs = [v.co.x for v in bm.verts]
- ys = [v.co.y for v in bm.verts]
+ shoe_z = SHOE_H / 2.0 + (LIFT_SHOE if short_shoe else 0.0)
+ add_collar(bm, shoe_z, half, IRON_T, SHOE_H, METAL_IDX, closed=True)
+ add_collar(bm, BAND_Z, half, IRON_T, 0.028, METAL_IDX, closed=True)
+ add_collar(bm, 0.46, half, IRON_T, 0.028, METAL_IDX, closed=True)
+
zs = [v.co.z for v in bm.verts]
- rcx = 0.5 * (min(xs) + max(xs))
- rcy = 0.5 * (min(ys) + max(ys))
zmin = min(zs)
for v in bm.verts:
- v.co.x -= rcx
- v.co.y -= rcy
v.co.z -= zmin
if v.co.z < 0.0:
v.co.z = 0.0
@@ -400,13 +359,25 @@ def build_signpost_mesh(name, bevel_offset, bevel_segments):
return out
-def principled(name, color, metallic, roughness):
+def principled(name, color, metallic, roughness, noise_scale=0.0, wear=None):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
- bsdf = mat.node_tree.nodes["Principled BSDF"]
+ nt = mat.node_tree
+ bsdf = nt.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = color
bsdf.inputs["Metallic"].default_value = metallic
bsdf.inputs["Roughness"].default_value = roughness
+ if noise_scale > 0.0 and wear is not None:
+ tex = nt.nodes.new("ShaderNodeTexNoise")
+ tex.inputs["Scale"].default_value = noise_scale
+ tex.inputs["Detail"].default_value = 6.0
+ mix = nt.nodes.new("ShaderNodeMix")
+ mix.data_type = "RGBA"
+ mix.inputs["A"].default_value = color
+ mix.inputs["B"].default_value = wear
+ fac = mix.inputs.get("Factor") or mix.inputs.get("Fac")
+ nt.links.new(tex.outputs["Fac"], fac)
+ nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"])
return mat
@@ -421,10 +392,11 @@ def assign_slots(obj, wood, board, metal):
def world_bbox(obj):
- corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
- xs = [c.x for c in corners]
- ys = [c.y for c in corners]
- zs = [c.z for c in corners]
+ mat = obj.matrix_world
+ pts = [mat @ v.co for v in obj.data.vertices]
+ xs = [p.x for p in pts]
+ ys = [p.y for p in pts]
+ zs = [p.z for p in pts]
return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
@@ -487,10 +459,192 @@ def convex_hull_collider(obj, name):
return collider
+def face_area(me, poly):
+ vs = [me.vertices[i].co for i in poly.vertices]
+ if len(vs) < 3:
+ return 0.0
+ v0 = vs[0]
+ area = 0.0
+ for i in range(1, len(vs) - 1):
+ area += (vs[i] - v0).cross(vs[i + 1] - v0).length * 0.5
+ return area
+
+
+def hygiene_audit(me):
+ nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons)
+ ngons = sum(1 for p in me.polygons if len(p.vertices) > 4)
+ zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS)
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(me)
+ loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0)
+ loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0)
+ nonman = sum(1 for e in bm.edges if not e.is_manifold)
+ ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS)
+ doubles = len(ret.get("targetmap") or {})
+ finally:
+ bm.free()
+ return {
+ "nv": nv, "ne": ne, "nf": nf, "ngons": ngons,
+ "loose_v": loose_v, "loose_e": loose_e, "nonman": nonman,
+ "zero_area": zero_area, "doubles": doubles, "euler": nv - ne + nf,
+ }
+
+
+def zfight_pairs(me):
+ data = [
+ (p.center.copy(), p.normal.copy(), frozenset(p.vertices))
+ for p in me.polygons
+ ]
+ eps2 = ZFIGHT_EPS * ZFIGHT_EPS
+ count = 0
+ for i in range(len(data)):
+ ci, ni, vi = data[i]
+ for j in range(i + 1, len(data)):
+ cj, nj, vj = data[j]
+ if (cj - ci).length_squared > eps2:
+ continue
+ if abs(ni.dot(nj)) <= ZFIGHT_COS:
+ continue
+ if vi & vj:
+ continue
+ count += 1
+ return count
+
+
+def shells(me):
+ neighbors = [[] for _ in range(len(me.vertices))]
+ for edge in me.edges:
+ a, b = edge.vertices
+ neighbors[a].append(b)
+ neighbors[b].append(a)
+ seen = [False] * len(me.vertices)
+ groups = []
+ for start in range(len(me.vertices)):
+ if seen[start]:
+ continue
+ seen[start] = True
+ stack = [start]
+ group = []
+ while stack:
+ current = stack.pop()
+ group.append(current)
+ for nxt in neighbors[current]:
+ if not seen[nxt]:
+ seen[nxt] = True
+ stack.append(nxt)
+ groups.append(group)
+ return groups
+
+
+def shell_aabb(me, group):
+ pts = [me.vertices[i].co for i in group]
+ return (
+ min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts),
+ max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts),
+ )
+
+
+def mat_of(me, group):
+ member = set(group)
+ for poly in me.polygons:
+ if all(i in member for i in poly.vertices):
+ return poly.material_index
+ return None
+
+
+def shell_bvh_gap(me, ga, gb):
+ bm_a = bmesh.new()
+ bm_b = bmesh.new()
+ try:
+ bm_a.from_mesh(me)
+ bm_b.from_mesh(me)
+ keep_a, keep_b = set(ga), set(gb)
+ drop_a = [f for f in bm_a.faces if not all(v.index in keep_a for v in f.verts)]
+ drop_b = [f for f in bm_b.faces if not all(v.index in keep_b for v in f.verts)]
+ if drop_a:
+ bmesh.ops.delete(bm_a, geom=drop_a, context="FACES")
+ if drop_b:
+ bmesh.ops.delete(bm_b, geom=drop_b, context="FACES")
+ if not bm_a.faces or not bm_b.faces:
+ return 1e9
+ tree = BVHTree.FromBMesh(bm_b)
+ best = 1e9
+ for v in bm_a.verts:
+ hit = tree.find_nearest(v.co)
+ if hit[0] is not None:
+ best = min(best, hit[3])
+ for f in bm_a.faces:
+ hit = tree.find_nearest(f.calc_center_median())
+ if hit[0] is not None:
+ best = min(best, hit[3])
+ return best
+ finally:
+ bm_a.free()
+ bm_b.free()
+
+
+def add_stray_vert(me):
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(me)
+ bm.verts.new((0.0, 0.0, POST_H * 0.55))
+ bm.to_mesh(me)
+ me.update()
+ finally:
+ bm.free()
+
+
+def joint_audit(me):
+ groups = shells(me)
+ posts = []
+ boards = []
+ shoes = []
+ straps = []
+ for g in groups:
+ a = shell_aabb(me, g)
+ dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2]
+ mat = mat_of(me, g)
+ if mat == WOOD_IDX and dz > 0.80 and dx < 0.22 and dy < 0.22:
+ posts.append((g, a))
+ elif mat == BOARD_IDX and dx > 0.25:
+ boards.append((g, a))
+ elif mat == METAL_IDX and a[2] < 0.08 and dz < 0.08:
+ shoes.append((g, a))
+ elif mat == METAL_IDX and a[2] > 0.70:
+ straps.append((g, a))
+ board_gap = 0.0
+ for bg, _ in boards:
+ best = 1e9
+ for pg, _ in posts:
+ best = min(best, shell_bvh_gap(me, bg, pg))
+ if best < 1e8:
+ board_gap = max(board_gap, best)
+ strap_gap = 0.0
+ for sg, _ in straps:
+ best = 1e9
+ for pg, _ in posts:
+ best = min(best, shell_bvh_gap(me, sg, pg))
+ if best < 1e8:
+ strap_gap = max(strap_gap, best)
+ board_dy = max((a[4] - a[1] for _, a in boards), default=0.0)
+ shoe_z = min((a[2] for _, a in shoes), default=1.0)
+ return {
+ "posts": len(posts),
+ "boards": len(boards),
+ "shoes": len(shoes),
+ "straps": len(straps),
+ "board_gap": board_gap if boards and posts else 1e9,
+ "strap_gap": strap_gap if straps and posts else 1e9,
+ "board_dy": board_dy,
+ "shoe_z": shoe_z,
+ }
+
+
def setup_bake_image(obj, target_mat, size=BAKE_RES):
if not obj.data.uv_layers:
return None, None
- img = bpy.data.images.new("SignpostNrm", size, size, alpha=True, float_buffer=False)
+ img = bpy.data.images.new("SignNrm", size, size, alpha=True, float_buffer=False)
img.colorspace_settings.name = "Non-Color"
nodes = target_mat.node_tree.nodes
tex = nodes.new("ShaderNodeTexImage")
@@ -507,7 +661,8 @@ def bake_normal(high, low):
scene.cycles.device = "CPU"
scene.cycles.samples = 1
scene.cycles.use_denoising = False
- deselect_all()
+ for ob in bpy.context.view_layer.objects:
+ ob.select_set(False)
high.select_set(True)
low.select_set(True)
bpy.context.view_layer.objects.active = low
@@ -525,7 +680,8 @@ def bake_normal(high, low):
def export_unity(path, objects):
- deselect_all()
+ for ob in bpy.context.view_layer.objects:
+ ob.select_set(False)
for ob in objects:
ob.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
@@ -539,16 +695,46 @@ def export_unity(path, objects):
)
-def check(skip_decimate):
+def check(
+ skip_decimate,
+ lift_z=False,
+ stray_vert=False,
+ short_shoe=False,
+ gap_board=False,
+ float_strap=False,
+ yaw_boards=False,
+):
bpy.ops.wm.read_factory_settings(use_empty=True)
- low = build_signpost_mesh("SignpostLow", bevel_offset=0.004, bevel_segments=2)
- high = build_signpost_mesh("SignpostHigh", bevel_offset=0.004, bevel_segments=4)
- wood = principled("SignpostWood", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62)
- board = principled("SignpostBoard", (0.76, 0.58, 0.22, 1.0), 0.0, 0.52)
- metal = principled("SignpostIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38)
+ kw = dict(
+ short_shoe=short_shoe,
+ gap_board=gap_board,
+ float_strap=float_strap,
+ yaw_boards=yaw_boards,
+ )
+ low = build_signpost_mesh("SignpostLow", bevel_offset=0.004, bevel_segments=2, **kw)
+ high = build_signpost_mesh("SignpostHigh", bevel_offset=0.004, bevel_segments=4, **kw)
+ wood = principled(
+ "SignpostWood", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62,
+ noise_scale=14.0, wear=(0.22, 0.10, 0.04, 1.0),
+ )
+ board = principled(
+ "SignpostBoard", (0.76, 0.58, 0.22, 1.0), 0.0, 0.52,
+ noise_scale=10.0, wear=(0.55, 0.38, 0.14, 1.0),
+ )
+ metal = principled(
+ "SignpostIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38,
+ noise_scale=18.0, wear=(0.22, 0.18, 0.14, 1.0),
+ )
assign_slots(low, wood, board, metal)
assign_slots(high, wood, board, metal)
+ if stray_vert:
+ add_stray_vert(low.data)
+ if lift_z:
+ for v in low.data.vertices:
+ v.co.z += 0.05
+ low.data.update()
+
if low.data is None or len(low.data.polygons) < 6:
return fail("signpost mesh did not build", 3), None, None, None, None, None
@@ -580,7 +766,7 @@ def check(skip_decimate):
r2 = lod2_tris / base_tris if base_tris else 0.0
collider_src = build_signpost_mesh(
- "SignpostColSrc", bevel_offset=0.0, bevel_segments=1
+ "SignpostColSrc", bevel_offset=0.0, bevel_segments=1, **kw
)
collider = convex_hull_collider(collider_src, "SignpostCollider")
bpy.data.objects.remove(collider_src, do_unlink=True)
@@ -595,6 +781,10 @@ def check(skip_decimate):
export_unity(export_path, [low, collider])
export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+ hyg = hygiene_audit(low.data)
+ zf = zfight_pairs(low.data)
+ jnt = joint_audit(low.data)
+
print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
print(
f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
@@ -612,6 +802,17 @@ def check(skip_decimate):
f"measured collider_tris={col_tris} bake={bake_result} "
f"bake_has_data={img.has_data} export_bytes={export_size}"
)
+ print(
+ f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} "
+ f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} "
+ f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}"
+ )
+ print(
+ f"measured posts={jnt['posts']} boards={jnt['boards']} "
+ f"shoes={jnt['shoes']} straps={jnt['straps']} "
+ f"board_gap={jnt['board_gap']:.5f} strap_gap={jnt['strap_gap']:.5f} "
+ f"board_dy={jnt['board_dy']:.4f} shoe_z={jnt['shoe_z']:.5f}"
+ )
if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
return fail(
@@ -648,6 +849,32 @@ def check(skip_decimate):
f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
7,
), None, None, None, None, None
+ if (
+ bb[2] > ZMIN_EPS
+ or jnt["shoes"] < SHOE_COUNT
+ or jnt["shoe_z"] > SHOE_ZMIN_MAX
+ ):
+ return fail(
+ f"grounded zmin={bb[2]:.5f} shoes={jnt['shoes']} "
+ f"shoe_z={jnt['shoe_z']:.5f}",
+ 16,
+ ), None, None, None, None, None
+ if jnt["boards"] < 2 or jnt["posts"] < 1 or jnt["board_gap"] > BOARD_GAP_MAX:
+ return fail(
+ f"board gap {jnt['board_gap']:.5f} boards={jnt['boards']} "
+ f"posts={jnt['posts']}",
+ 17,
+ ), None, None, None, None, None
+ if jnt["straps"] < 1 or jnt["strap_gap"] > STRAP_GAP_MAX:
+ return fail(
+ f"strap gap {jnt['strap_gap']:.5f} straps={jnt['straps']}",
+ 18,
+ ), None, None, None, None, None
+ if jnt["board_dy"] > BOARD_DY_MAX:
+ return fail(
+ f"board dy {jnt['board_dy']:.4f} > {BOARD_DY_MAX}",
+ 19,
+ ), None, None, None, None, None
if (
abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
@@ -681,6 +908,16 @@ def check(skip_decimate):
), None, None, None, None, None
if export_size <= 0:
return fail("export file missing or empty", 13), None, None, None, None, None
+ if (
+ hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"]
+ or hyg["doubles"] or hyg["ngons"] or zf
+ ):
+ return fail(
+ f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} "
+ f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} "
+ f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}",
+ 15,
+ ), None, None, None, None, None
return 0, low, high, wood, tex, collider
@@ -702,7 +939,6 @@ def render_still(low, wood, tex, path, engine):
ob.hide_viewport = True
low.rotation_euler.z = math.radians(-12.0)
- low.rotation_euler.x = math.radians(0.0)
floor_me = bpy.data.meshes.new("Floor")
bm = bmesh.new()
@@ -794,14 +1030,24 @@ def main():
p = argparse.ArgumentParser()
p.add_argument("--output", default=None)
p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
- p.add_argument(
- "--skip-decimate",
- action="store_true",
- help="falsification: skip the LOD DECIMATE stage",
- )
+ p.add_argument("--skip-decimate", action="store_true")
+ p.add_argument("--stray-vert", action="store_true")
+ p.add_argument("--lift-z", action="store_true")
+ p.add_argument("--short-shoe", action="store_true")
+ p.add_argument("--gap-board", action="store_true")
+ p.add_argument("--float-strap", action="store_true")
+ p.add_argument("--yaw-boards", action="store_true")
args = p.parse_args(argv)
- code, low, _high, wood, tex, _col = check(args.skip_decimate)
+ code, low, _high, wood, tex, _col = check(
+ args.skip_decimate,
+ lift_z=args.lift_z,
+ stray_vert=args.stray_vert,
+ short_shoe=args.short_shoe,
+ gap_board=args.gap_board,
+ float_strap=args.float_strap,
+ yaw_boards=args.yaw_boards,
+ )
if code:
return code
if args.output:
diff --git a/showcase/wall-torch/README.md b/showcase/wall-torch/README.md
index 44a11a12..1c2193a7 100644
--- a/showcase/wall-torch/README.md
+++ b/showcase/wall-torch/README.md
@@ -1,9 +1,16 @@
# Wall torch
A showcase piece, not an example. Procedural wall-mounted torch sconce
-(dressed stone plaque, iron bracket and cup, wooden haft, emissive flame)
-then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal
-bake, LOD chain, convex collider, Unity glTF export.
+(running-bond dressed stone plaque, iron plate and arm from named
+stations, bowl cup, wooden haft, emissive flame) then the shipped
+pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain,
+convex collider, Unity glTF export.
+
+The plaque back sits on the wall plane Y=0 with zmin at 0. The iron
+arm is a tube from the plate station to the cup wall, not a pair of
+square bars aimed at a funnel. The bowl opens upward and holds the
+haft. Hygiene family 15–19: `--stray-vert`, `--lift-z`, `--float-arm`,
+`--float-plate`, `--skinny-plaque`.
It asserts **budget conformance** of the generated result. It does not
witness an API contract. "It rendered without error" is not a check.
@@ -14,6 +21,11 @@ witness an API contract. "It rendered without error" is not a check.
`bake_normal_high_to_low.py`, `setup_bake_target_image.py`,
`lod_chain.py` / `decimate_to_budget.py`, `convex_hull_collider.py`,
`export_preset_unity.py` (helpers copied, not imported as a package).
+Hygiene combinatorics match `examples/mesh-hygiene-audit` (copied, not
+imported).
+
+Intended size: 0.28 m plaque width, 0.40 m plaque height, bowl
+projecting 0.24 m from the wall; outer AABB 0.304 × 0.293 × 0.424 m.
## Budgets
@@ -22,38 +34,87 @@ materials, UVs, evaluated LOD, collider, or export file.
| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) |
| --- | --- | --- |
-| Base triangles | 800–920 | 848 / 848 / 848 |
+| Base triangles | 1400–1700 | 1528 / 1528 / 1528 |
| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 |
-| LOD2 ratio | 0.10–0.35 of base | 0.2193 / 0.2193 / 0.2099 |
-| Materials | exactly 4 distinct, ≥24 stone, ≥24 metal, ≥8 flame | 4 slots, 162 stone, 134 metal, 44 flame |
+| LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2199 |
+| Materials | exactly 4 distinct, ≥24 stone, ≥24 metal, ≥8 flame | 4 slots, 540 stone, 228 metal, 78 flame |
| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
-| Outer AABB | (0.384, 0.360, 0.670) m ± 0.01 | (0.3840, 0.3600, 0.6700), zmin 0.140 |
-| Collider tris | ≤ 120 | 92 |
-| Export | written, size > 0 | 69060 / 69060 / 69048 bytes |
+| Outer AABB | (0.304, 0.293, 0.424) m ± 0.015 | (0.3040, 0.2930, 0.4240), zmin 0 |
+| Collider tris | ≤ 180 | 60 |
+| Export | written, size > 0 | 124608 / 124608 / 124584 bytes |
+
+Base triangles rose from **848 to 1528** in the quality pass: one
+mailbox slab became four courses of running-bond ashlar plus a round
+arm and a triangulated bowl. Outer AABB tightened from 0.384 × 0.360 ×
+0.670 m (and zmin 0.140) to 0.304 × 0.293 × 0.424 m at zmin 0.
+
+DECIMATE COLLAPSE triangle counts are **not** identical across series.
+The gate is a ratio band, not an exact count. Bake pixels are
+stochastic; the gate is `has_data` plus operator `FINISHED`, not
+byte-identity. Construction uses no RNG. Export byte counts differ by
+24 B on 5.2.1 (glTF serializer), not a gated axis.
+
+### Hygiene
+
+Recomputed from the generated mesh, not asserted about the script.
+
+| Axis | Declared | Measured (all three) |
+| --- | --- | --- |
+| Non-manifold edges | 0 | 0 |
+| Loose verts / edges | 0 / 0 | 0 / 0 |
+| Doubles merged at 1e-5 | 0 | 0 |
+| Zero-area faces | 0 | 0 |
+| N-gons | 0 | 0 |
+| Coplanar disjoint face pairs | 0 | 0 |
+| Grounded: `zmin` | within 1e-4 of 0 | 0.0000 |
+
+### Joint fit and plaque size
+
+| Axis | Declared | Measured (all three) |
+| --- | --- | --- |
+| Arm-to-cup BVH gap | ≤ 0.008 m | 0.00087 |
+| Plate-to-plaque BVH gap | ≤ 0.008 m | 0.00400 |
+| Plaque width vs `WALL_W` | ± 0.04 m | 0.2800 vs 0.280 |
-DECIMATE COLLAPSE triangle counts are **not** identical across series —
-5.2.1 is 8 tris leaner on LOD2. The gate is a ratio band, not an exact
-count. Bake pixels are stochastic; the gate is `has_data` plus operator
-`FINISHED`, not byte-identity. Construction uses no RNG. Export byte
-counts differ by 12 B on 5.2.1 (glTF serializer), not a gated axis.
+### Falsifiers
+
+Each violates one named budget. All were run on 4.5.11, 5.1.2 and 5.2.1
+and returned the same code on each.
+
+| Flag | Budget violated | Exit |
+| --- | --- | --- |
+| `--skip-decimate` | LOD1 ratio band | 9 |
+| `--stray-vert` | loose vertex count is 0 | 15 |
+| `--lift-z` | bounding box `zmin` is 0 | 16 |
+| `--float-arm` | arm-to-cup BVH gap | 17 |
+| `--float-plate` | plate-to-plaque BVH gap | 18 |
+| `--skinny-plaque` | plaque width vs `WALL_W` | 19 |
-`--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and
-exit 9 fires. That is the named budget the falsifier violates.
+`--float-arm` shortens the tube to 55 % of the plate-to-cup station
+vector; the cup stays put. `--float-plate` moves only the hang plate
+off the plaque; the arm still reaches the cup so 17 still passes.
+`--skinny-plaque` scales the back plaque and courses to 0.55 · `WALL_W`.
## Run
```bash
blender --background --python wall_torch.py --
blender --background --python wall_torch.py -- --skip-decimate
+blender --background --python wall_torch.py -- --stray-vert
+blender --background --python wall_torch.py -- --lift-z
+blender --background --python wall_torch.py -- --float-arm
+blender --background --python wall_torch.py -- --float-plate
+blender --background --python wall_torch.py -- --skinny-plaque
blender --background --python wall_torch.py -- --output torch.png
```
-Smoke does not pass `--output` or `--skip-decimate`.
+Smoke passes no flags.
## Exit codes
File-local. `9` is a valid check code. `10` is reserved for
-`gallery_framing.check_framing` on the `--output` path.
+`gallery_framing.check_framing` on the `--output` path. `15`–`19` are the
+hygiene and joint-fit family.
| Code | Meaning |
| --- | --- |
@@ -72,3 +133,8 @@ File-local. `9` is a valid check code. `10` is reserved for
| 12 | Bake did not finish or image has no data |
| 13 | Export file missing or empty |
| 14 | `--output` produced no file |
+| 15 | Hygiene (`--stray-vert` lands here) |
+| 16 | Grounded zmin (`--lift-z`) |
+| 17 | Arm-to-cup gap (`--float-arm`) |
+| 18 | Plate-to-plaque gap (`--float-plate`) |
+| 19 | Plaque width (`--skinny-plaque`) |
diff --git a/showcase/wall-torch/preview.webp b/showcase/wall-torch/preview.webp
index 5e0d8556..a790e58b 100644
Binary files a/showcase/wall-torch/preview.webp and b/showcase/wall-torch/preview.webp differ
diff --git a/showcase/wall-torch/wall_torch.py b/showcase/wall-torch/wall_torch.py
index 6ba38108..da5b4cf3 100644
--- a/showcase/wall-torch/wall_torch.py
+++ b/showcase/wall-torch/wall_torch.py
@@ -1,16 +1,19 @@
"""Game-ready wall torch sconce — a showcase piece, not an example.
-Asserts budget conformance of a procedural wall-mounted torch after composing
-shipped pipeline pieces: bmesh construction, UVs, four materials, high-to-low
-normal bake, LOD chain, convex collider, Unity glTF export.
+Asserts budget conformance of a procedural wall-mounted torch after
+composing shipped pipeline pieces: bmesh construction, UVs, four
+materials, high-to-low normal bake, LOD chain, convex collider,
+Unity glTF export.
-Budgets are declared below and recomputed from the generated result.
-They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
-DECIMATE stage so the LOD-ratio budget fails.
+The plaque back sits on the wall plane Y=0 with zmin at 0. The iron
+arm is a tube between the plate station and the cup station. The
+bowl opens upward and holds the haft. Hygiene family 15–19:
+``--stray-vert``, ``--lift-z``, ``--float-arm``, ``--float-plate``,
+``--skinny-plaque``.
-No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
-are not byte-identical across Blender versions — the LOD gate is a
-ratio band, not an exact count.
+No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle
+counts are not byte-identical across Blender versions — the LOD
+gate is a ratio band, not an exact count.
blender --background --python wall_torch.py --
blender --background --python wall_torch.py -- --skip-decimate
@@ -26,10 +29,8 @@
import bmesh
import bpy
from mathutils import Euler, Vector
+from mathutils.bvhtree import BVHTree
-# Showcase lives at repo-root/showcase/, not under examples/. The framing
-# helper is the repo's only shared import and lives next to the examples;
-# resolve the repo root so we do not move gallery_framing.py.
_REPO = os.path.abspath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
)
@@ -37,15 +38,34 @@
sys.dont_write_bytecode = True
import gallery_framing # noqa: E402
-WALL_W = 0.36
-WALL_D = 0.10
-WALL_H = 0.58
-BBOX_TOL = 0.01
-# Fitted after locking geometry. Recomputed from bound_box.
-OUTER_SIZE = (0.384, 0.360, 0.670)
-
-BASE_TRIS_MIN = 800
-BASE_TRIS_MAX = 920
+WALL_W = 0.28
+WALL_D = 0.070
+WALL_H = 0.40
+CORNICE_H = 0.028
+GROUT = 0.007
+NROWS = 4
+STONE_D = 0.020
+STONE_BITE = 0.004
+PLATE_Z = 0.22
+PLATE_R = 0.055
+PLATE_T = 0.016
+CUP_Y = 0.24
+CUP_Z = 0.20
+CUP_H = 0.052
+CUP_R0 = 0.024
+CUP_R1 = 0.050
+ARM_R = 0.011
+ARM_BITE = 0.012
+HAFT_R = 0.016
+LIFT_Z = 0.05
+FLOAT_ARM = 0.55
+FLOAT_PLATE = 0.08
+SKINNY = 0.55
+
+BBOX_TOL = 0.015
+OUTER_SIZE = (0.304, 0.293, 0.424)
+BASE_TRIS_MIN = 1400
+BASE_TRIS_MAX = 1700
LOD1_RATIO_MIN = 0.32
LOD1_RATIO_MAX = 0.62
LOD2_RATIO_MIN = 0.10
@@ -55,7 +75,7 @@
MATERIAL_COUNT = 4
UV_EPS = 1e-4
UV_OVERLAP_MAX = 1e-5
-COLLIDER_TRIS_MAX = 120
+COLLIDER_TRIS_MAX = 180
BAKE_RES = 256
CAGE_EXTRUSION = 0.06
STONE_FACES_MIN = 24
@@ -67,6 +87,15 @@
WOOD_IDX = 2
FLAME_IDX = 3
+DOUBLES_EPS = 1e-5
+AREA_EPS = 1e-10
+ZMIN_EPS = 1e-4
+ZFIGHT_EPS = 0.002
+ZFIGHT_COS = 0.98
+ARM_GAP_MAX = 0.008
+PLATE_GAP_MAX = 0.008
+PLAQUE_W_TOL = 0.04
+
def eevee_engine_id():
return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
@@ -83,7 +112,6 @@ def triangle_count(mesh):
def evaluated_triangle_count(obj):
- # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
depsgraph = bpy.context.evaluated_depsgraph_get()
eval_obj = obj.evaluated_get(depsgraph)
eval_mesh = eval_obj.to_mesh()
@@ -108,29 +136,11 @@ def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
return verts
-def add_oriented_box(bm, a, b, scale_xy, mat_idx):
- a = Vector(a)
- b = Vector(b)
- delta = b - a
- length = delta.length
- if length < 1e-8:
- return []
- quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
- eul = quat.to_euler("XYZ")
- return add_box(
- bm,
- ((a + b) * 0.5),
- (scale_xy[0], scale_xy[1], length),
- mat_idx,
- euler=(eul.x, eul.y, eul.z),
- )
-
-
def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
geo = bmesh.ops.create_cone(
bm,
cap_ends=True,
- cap_tris=False,
+ cap_tris=segments > 4,
segments=segments,
radius1=radius1,
radius2=radius2,
@@ -147,38 +157,25 @@ def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.
return verts
-def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
- return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler)
-
-
-def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)):
- n_major = 12
- n_minor = 6
- rings = []
- for i in range(n_major):
- u = i * (2.0 * math.pi / n_major)
- ring = []
- for j in range(n_minor):
- v = j * (2.0 * math.pi / n_minor)
- x = (major + minor * math.cos(v)) * math.cos(u)
- y = (major + minor * math.cos(v)) * math.sin(u)
- z = minor * math.sin(v)
- ring.append(bm.verts.new((x, y, z)))
- rings.append(ring)
- for i in range(n_major):
- i2 = (i + 1) % n_major
- for j in range(n_minor):
- j2 = (j + 1) % n_minor
- face = bm.faces.new(
- (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2])
- )
- face.material_index = mat_idx
- verts = [v for ring in rings for v in ring]
- rot = Euler(euler).to_matrix()
- origin = Vector(loc)
- for v in verts:
- v.co = rot @ v.co + origin
- return verts
+def add_cyl_between(bm, a, b, radius, segments, mat_idx):
+ a = Vector(a)
+ b = Vector(b)
+ delta = b - a
+ length = delta.length
+ if length < 1e-8:
+ return []
+ quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+ eul = quat.to_euler("XYZ")
+ return add_cone(
+ bm,
+ (a + b) * 0.5,
+ radius,
+ radius,
+ length,
+ segments,
+ mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
def pack_uvs(bm, margin=0.08):
@@ -224,138 +221,133 @@ def pack_uvs(bm, margin=0.08):
)
-def build_torch_mesh(name, bevel_offset, bevel_segments):
+def build_torch_mesh(
+ name,
+ bevel_offset,
+ bevel_segments,
+ float_arm=False,
+ float_plate=False,
+ skinny_plaque=False,
+):
bm = bmesh.new()
- metal_faces = set()
- flame_faces = set()
try:
- # One dressed plaque — a tile grid reads as bathroom masonry.
- add_box(
- bm,
- (0.0, 0.0, WALL_H / 2.0),
- (WALL_W, WALL_D, WALL_H),
- STONE_IDX,
- )
+ plaque_w = WALL_W * SKINNY if skinny_plaque else WALL_W
add_box(
bm,
- (0.0, WALL_D / 2.0 + 0.010, WALL_H / 2.0),
- (WALL_W - 0.048, 0.022, WALL_H - 0.070),
+ (0.0, WALL_D / 2.0, WALL_H / 2.0),
+ (plaque_w, WALL_D, WALL_H),
STONE_IDX,
)
add_box(
bm,
- (0.0, 0.0, WALL_H + 0.014),
- (WALL_W + 0.024, WALL_D + 0.024, 0.030),
+ (0.0, WALL_D / 2.0 + 0.006, WALL_H + CORNICE_H / 2.0 - 0.004),
+ (plaque_w + 0.024, WALL_D + 0.018, CORNICE_H),
STONE_IDX,
)
- plate_y = WALL_D / 2.0 + 0.024
- plate_z = 0.30
- before = set(bm.faces)
- add_box(bm, (0.0, plate_y, plate_z), (0.14, 0.018, 0.20), METAL_IDX)
- add_box(bm, (0.0, plate_y, plate_z + 0.078), (0.18, 0.014, 0.028), METAL_IDX)
- add_box(bm, (0.0, plate_y, plate_z - 0.078), (0.18, 0.014, 0.028), METAL_IDX)
- add_rim(
- bm,
- (0.0, plate_y + 0.020, plate_z + 0.016),
- 0.048,
- 0.009,
- METAL_IDX,
- euler=(math.radians(90.0), 0.0, 0.0),
- )
- arm_y0 = plate_y + 0.012
- arm_y1 = plate_y + 0.15
- cup_z = 0.26
- add_oriented_box(
- bm,
- (0.032, arm_y0, plate_z - 0.018),
- (0.020, arm_y1, cup_z + 0.036),
- (0.018, 0.018),
- METAL_IDX,
- )
- add_oriented_box(
- bm,
- (-0.032, arm_y0, plate_z - 0.018),
- (-0.020, arm_y1, cup_z + 0.036),
- (0.018, 0.018),
- METAL_IDX,
- )
+ course_h = (WALL_H - GROUT * (NROWS + 1)) / NROWS
+ face_y = WALL_D - STONE_BITE + STONE_D / 2.0
+ inner = plaque_w - 2.0 * GROUT
+ for row in range(NROWS):
+ zc = GROUT + row * (course_h + GROUT) + course_h / 2.0
+ fracs = (0.58, 0.42) if row % 2 == 0 else (0.40, 0.60)
+ x = -plaque_w / 2.0 + GROUT
+ for frac in fracs:
+ w = inner * frac - GROUT * 0.5
+ cx = x + w / 2.0
+ add_box(
+ bm,
+ (cx, face_y, zc),
+ (max(w, 0.04), STONE_D, course_h - 0.001),
+ STONE_IDX,
+ )
+ x += w + GROUT
+
+ plate_y = WALL_D - 0.004 + PLATE_T / 2.0
+ if float_plate:
+ plate_y += FLOAT_PLATE
add_cone(
bm,
- (0.0, arm_y1 + 0.012, cup_z),
- 0.062,
- 0.034,
- 0.062,
- 14,
- METAL_IDX,
- )
- add_cylinder(
- bm,
- (0.0, arm_y1 + 0.012, cup_z - 0.042),
- 0.054,
- 0.014,
- 14,
+ (0.0, plate_y, PLATE_Z),
+ PLATE_R,
+ PLATE_R,
+ PLATE_T,
+ 12,
METAL_IDX,
+ euler=(math.pi / 2.0, 0.0, 0.0),
)
- metal_faces.update(set(bm.faces) - before)
+ for sx, sz in ((-0.032, 0.028), (0.032, 0.028), (-0.032, -0.028), (0.032, -0.028)):
+ add_cone(
+ bm,
+ (sx, plate_y + PLATE_T / 2.0 + 0.004, PLATE_Z + sz),
+ 0.006,
+ 0.006,
+ 0.010,
+ 8,
+ METAL_IDX,
+ euler=(math.pi / 2.0, 0.0, 0.0),
+ )
+
+ plate_st = Vector((0.0, plate_y, PLATE_Z))
+ cup_st = Vector((0.0, CUP_Y - CUP_R1 + ARM_BITE, CUP_Z))
+ arm_end = cup_st
+ if float_arm:
+ arm_end = plate_st + (cup_st - plate_st) * FLOAT_ARM
+ add_cyl_between(bm, plate_st, arm_end, ARM_R, 8, METAL_IDX)
- stick_z0 = cup_z - 0.018
- stick_z1 = 0.50
- add_cylinder(
+ add_cone(
bm,
- (0.0, arm_y1 + 0.012, (stick_z0 + stick_z1) / 2.0),
- 0.026,
- stick_z1 - stick_z0,
+ (0.0, CUP_Y, CUP_Z),
+ CUP_R0,
+ CUP_R1,
+ CUP_H,
12,
- WOOD_IDX,
+ METAL_IDX,
)
add_cone(
bm,
- (0.0, arm_y1 + 0.012, 0.46),
- 0.050,
- 0.030,
- 0.12,
+ (0.0, CUP_Y, CUP_Z - CUP_H / 2.0 - 0.006),
+ CUP_R0 + 0.004,
+ CUP_R0 + 0.004,
+ 0.010,
12,
- WOOD_IDX,
+ METAL_IDX,
)
- before = set(bm.faces)
- flame_y = arm_y1 + 0.012
- add_cone(bm, (0.0, flame_y, 0.56), 0.048, 0.006, 0.22, 10, FLAME_IDX)
+ cup_rim = CUP_Z + CUP_H / 2.0
+ haft_z0 = CUP_Z - CUP_H / 2.0 + 0.006
+ haft_z1 = cup_rim + 0.026
add_cone(
bm,
- (0.018, flame_y - 0.010, 0.545),
- 0.030,
- 0.004,
- 0.16,
- 9,
- FLAME_IDX,
- euler=(0.0, math.radians(16.0), 0.0),
+ (0.0, CUP_Y, (haft_z0 + haft_z1) / 2.0),
+ HAFT_R,
+ HAFT_R,
+ haft_z1 - haft_z0,
+ 10,
+ WOOD_IDX,
)
+
+ add_cone(bm, (0.0, CUP_Y, cup_rim + 0.038), 0.034, 0.004, 0.078, 10, FLAME_IDX)
add_cone(
bm,
- (-0.016, flame_y + 0.012, 0.54),
- 0.028,
- 0.004,
- 0.15,
- 9,
+ (0.012, CUP_Y - 0.008, cup_rim + 0.028),
+ 0.022,
+ 0.003,
+ 0.058,
+ 8,
FLAME_IDX,
- euler=(math.radians(-12.0), math.radians(-14.0), 0.0),
+ euler=(0.0, math.radians(14.0), 0.0),
)
add_cone(
bm,
- (0.006, flame_y + 0.004, 0.58),
+ (-0.010, CUP_Y + 0.006, cup_rim + 0.024),
0.018,
0.003,
- 0.12,
+ 0.050,
8,
FLAME_IDX,
- euler=(math.radians(8.0), math.radians(6.0), 0.0),
+ euler=(math.radians(-10.0), math.radians(-12.0), 0.0),
)
- flame_faces.update(set(bm.faces) - before)
-
- for v in bm.verts:
- v.co.z += 0.14
if bevel_offset > 0.0:
stone_edges = [
@@ -363,7 +355,7 @@ def build_torch_mesh(name, bevel_offset, bevel_segments):
if any(f.material_index == STONE_IDX for f in e.link_faces)
]
if stone_edges:
- bmesh.ops.bevel(
+ ret = bmesh.ops.bevel(
bm,
geom=stone_edges,
offset=bevel_offset,
@@ -372,25 +364,25 @@ def build_torch_mesh(name, bevel_offset, bevel_segments):
affect="EDGES",
clamp_overlap=True,
)
+ for f in ret.get("faces") or []:
+ f.material_index = STONE_IDX
+
+ zs = [v.co.z for v in bm.verts]
+ zmin = min(zs)
+ for v in bm.verts:
+ v.co.z -= zmin
+ if v.co.z < 0.0:
+ v.co.z = 0.0
pack_uvs(bm)
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
for face in bm.faces:
- face.smooth = face.material_index != STONE_IDX
- for edge in bm.edges:
- edge.smooth = True
- if edge.is_manifold and len(edge.link_faces) == 2:
- if edge.calc_face_angle() > math.radians(35.0):
- edge.smooth = False
- for f in metal_faces:
- if f.is_valid:
- f.material_index = METAL_IDX
- for f in flame_faces:
- if f.is_valid:
- f.material_index = FLAME_IDX
+ face.smooth = face.material_index in (METAL_IDX, FLAME_IDX)
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
me.update()
+ for poly in me.polygons:
+ poly.use_smooth = poly.material_index in (METAL_IDX, FLAME_IDX)
finally:
bm.free()
obj = bpy.data.objects.new(name, me)
@@ -398,10 +390,20 @@ def build_torch_mesh(name, bevel_offset, bevel_segments):
return obj
-def principled(name, color, metallic, roughness, emission=0.0, emission_color=None):
+def principled(
+ name,
+ color,
+ metallic,
+ roughness,
+ emission=0.0,
+ emission_color=None,
+ noise_scale=0.0,
+ wear=None,
+):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
- bsdf = mat.node_tree.nodes["Principled BSDF"]
+ nt = mat.node_tree
+ bsdf = nt.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = color
bsdf.inputs["Metallic"].default_value = metallic
bsdf.inputs["Roughness"].default_value = roughness
@@ -412,28 +414,36 @@ def principled(name, color, metallic, roughness, emission=0.0, emission_color=No
bsdf.inputs["Emission Strength"].default_value = emission
elif "Emission" in bsdf.inputs:
bsdf.inputs["Emission"].default_value = ecol
+ if noise_scale > 0.0 and wear is not None:
+ tex = nt.nodes.new("ShaderNodeTexNoise")
+ tex.inputs["Scale"].default_value = noise_scale
+ tex.inputs["Detail"].default_value = 6.0
+ mix = nt.nodes.new("ShaderNodeMix")
+ mix.data_type = "RGBA"
+ mix.inputs["A"].default_value = color
+ mix.inputs["B"].default_value = wear
+ fac = mix.inputs.get("Factor") or mix.inputs.get("Fac")
+ nt.links.new(tex.outputs["Fac"], fac)
+ nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"])
return mat
def assign_slots(obj, stone, metal, wood, flame):
mats = obj.data.materials
- slots = (stone, metal, wood, flame)
- if len(mats) == 0:
- for s in slots:
- mats.append(s)
- return
- for i, s in enumerate(slots):
+ wanted = (stone, metal, wood, flame)
+ for i, mat in enumerate(wanted):
if i < len(mats):
- mats[i] = s
+ mats[i] = mat
else:
- mats.append(s)
+ mats.append(mat)
def world_bbox(obj):
- corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
- xs = [c.x for c in corners]
- ys = [c.y for c in corners]
- zs = [c.z for c in corners]
+ mat = obj.matrix_world
+ pts = [mat @ v.co for v in obj.data.vertices]
+ xs = [p.x for p in pts]
+ ys = [p.y for p in pts]
+ zs = [p.z for p in pts]
return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
@@ -475,7 +485,6 @@ def make_lod(obj, name, ratio, skip_decimate):
def convex_hull_collider(obj, name):
- # Duplicated from snippets/convex_hull_collider.py (not a package).
mesh = bpy.data.meshes.new(name)
bm = bmesh.new()
try:
@@ -497,6 +506,180 @@ def convex_hull_collider(obj, name):
return collider
+def face_area(me, poly):
+ vs = [me.vertices[i].co for i in poly.vertices]
+ if len(vs) < 3:
+ return 0.0
+ v0 = vs[0]
+ area = 0.0
+ for i in range(1, len(vs) - 1):
+ area += (vs[i] - v0).cross(vs[i + 1] - v0).length * 0.5
+ return area
+
+
+def hygiene_audit(me):
+ nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons)
+ ngons = sum(1 for p in me.polygons if len(p.vertices) > 4)
+ zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS)
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(me)
+ loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0)
+ loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0)
+ nonman = sum(1 for e in bm.edges if not e.is_manifold)
+ ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS)
+ doubles = len(ret.get("targetmap") or {})
+ finally:
+ bm.free()
+ return {
+ "nv": nv, "ne": ne, "nf": nf, "ngons": ngons,
+ "loose_v": loose_v, "loose_e": loose_e, "nonman": nonman,
+ "zero_area": zero_area, "doubles": doubles, "euler": nv - ne + nf,
+ }
+
+
+def zfight_pairs(me):
+ data = [
+ (p.center.copy(), p.normal.copy(), frozenset(p.vertices))
+ for p in me.polygons
+ ]
+ eps2 = ZFIGHT_EPS * ZFIGHT_EPS
+ count = 0
+ for i in range(len(data)):
+ ci, ni, vi = data[i]
+ for j in range(i + 1, len(data)):
+ cj, nj, vj = data[j]
+ if (cj - ci).length_squared > eps2:
+ continue
+ if abs(ni.dot(nj)) <= ZFIGHT_COS:
+ continue
+ if vi & vj:
+ continue
+ count += 1
+ return count
+
+
+def shells(me):
+ neighbors = [[] for _ in range(len(me.vertices))]
+ for edge in me.edges:
+ a, b = edge.vertices
+ neighbors[a].append(b)
+ neighbors[b].append(a)
+ seen = [False] * len(me.vertices)
+ groups = []
+ for start in range(len(me.vertices)):
+ if seen[start]:
+ continue
+ seen[start] = True
+ stack = [start]
+ group = []
+ while stack:
+ current = stack.pop()
+ group.append(current)
+ for nxt in neighbors[current]:
+ if not seen[nxt]:
+ seen[nxt] = True
+ stack.append(nxt)
+ groups.append(group)
+ return groups
+
+
+def shell_aabb(me, group):
+ pts = [me.vertices[i].co for i in group]
+ return (
+ min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts),
+ max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts),
+ )
+
+
+def mat_of(me, group):
+ member = set(group)
+ for poly in me.polygons:
+ if all(i in member for i in poly.vertices):
+ return poly.material_index
+ return None
+
+
+def shell_bvh_gap(me, ga, gb):
+ bm_a = bmesh.new()
+ bm_b = bmesh.new()
+ try:
+ bm_a.from_mesh(me)
+ bm_b.from_mesh(me)
+ keep_a, keep_b = set(ga), set(gb)
+ drop_a = [f for f in bm_a.faces if not all(v.index in keep_a for v in f.verts)]
+ drop_b = [f for f in bm_b.faces if not all(v.index in keep_b for v in f.verts)]
+ if drop_a:
+ bmesh.ops.delete(bm_a, geom=drop_a, context="FACES")
+ if drop_b:
+ bmesh.ops.delete(bm_b, geom=drop_b, context="FACES")
+ if not bm_a.faces or not bm_b.faces:
+ return 1e9
+ tree = BVHTree.FromBMesh(bm_b)
+ best = 1e9
+ for v in bm_a.verts:
+ hit = tree.find_nearest(v.co)
+ if hit[0] is not None:
+ best = min(best, hit[3])
+ for f in bm_a.faces:
+ hit = tree.find_nearest(f.calc_center_median())
+ if hit[0] is not None:
+ best = min(best, hit[3])
+ return best
+ finally:
+ bm_a.free()
+ bm_b.free()
+
+
+def add_stray_vert(me):
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(me)
+ bm.verts.new((0.0, CUP_Y, PLATE_Z))
+ bm.to_mesh(me)
+ me.update()
+ finally:
+ bm.free()
+
+
+def joint_audit(me):
+ groups = shells(me)
+ plaques = []
+ plates = []
+ cups = []
+ arms = []
+ for g in groups:
+ a = shell_aabb(me, g)
+ dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2]
+ mat = mat_of(me, g)
+ if mat == STONE_IDX and dz > 0.20 and dx > 0.12:
+ plaques.append((g, a))
+ elif mat == METAL_IDX and dy <= 0.04 and dx >= 0.08 and dz >= 0.08:
+ plates.append((g, a))
+ elif mat == METAL_IDX and a[4] > CUP_Y - 0.05 and dx > 0.05 and dz < 0.14:
+ cups.append((g, a))
+ elif mat == METAL_IDX and dy > dx and dy > dz:
+ arms.append((g, a))
+ arm_gap = 1e9
+ for ag, _ in arms:
+ for cg, _ in cups:
+ arm_gap = min(arm_gap, shell_bvh_gap(me, ag, cg))
+ plate_gap = 1e9
+ for pg, _ in plates:
+ for qg, _ in plaques:
+ plate_gap = min(plate_gap, shell_bvh_gap(me, pg, qg))
+ plaque_w = max((a[3] - a[0] for _, a in plaques), default=0.0)
+ return {
+ "plaques": len(plaques),
+ "plates": len(plates),
+ "cups": len(cups),
+ "arms": len(arms),
+ "arm_gap": arm_gap if arms and cups else 1e9,
+ "plate_gap": plate_gap if plates and plaques else 1e9,
+ "plaque_w": plaque_w,
+ }
+
+
def setup_bake_image(obj, target_mat, size=BAKE_RES):
if not obj.data.uv_layers:
return None, None
@@ -551,13 +734,34 @@ def export_unity(path, objects):
)
-def check(skip_decimate):
+def check(
+ skip_decimate,
+ lift_z=False,
+ stray_vert=False,
+ float_arm=False,
+ float_plate=False,
+ skinny_plaque=False,
+):
bpy.ops.wm.read_factory_settings(use_empty=True)
- low = build_torch_mesh("TorchLow", bevel_offset=0.004, bevel_segments=2)
- high = build_torch_mesh("TorchHigh", bevel_offset=0.004, bevel_segments=4)
- stone = principled("TorchStone", (0.36, 0.34, 0.30, 1.0), 0.0, 0.72)
- metal = principled("TorchMetal", (0.14, 0.12, 0.10, 1.0), 0.88, 0.38)
- wood = principled("TorchWood", (0.22, 0.11, 0.05, 1.0), 0.0, 0.64)
+ kw = dict(
+ float_arm=float_arm,
+ float_plate=float_plate,
+ skinny_plaque=skinny_plaque,
+ )
+ low = build_torch_mesh("TorchLow", bevel_offset=0.004, bevel_segments=2, **kw)
+ high = build_torch_mesh("TorchHigh", bevel_offset=0.004, bevel_segments=4, **kw)
+ stone = principled(
+ "TorchStone", (0.36, 0.34, 0.30, 1.0), 0.0, 0.72,
+ noise_scale=12.0, wear=(0.28, 0.26, 0.22, 1.0),
+ )
+ metal = principled(
+ "TorchMetal", (0.14, 0.12, 0.10, 1.0), 0.88, 0.38,
+ noise_scale=18.0, wear=(0.22, 0.16, 0.10, 1.0),
+ )
+ wood = principled(
+ "TorchWood", (0.22, 0.11, 0.05, 1.0), 0.0, 0.64,
+ noise_scale=14.0, wear=(0.12, 0.06, 0.03, 1.0),
+ )
flame = principled(
"TorchFlame",
(1.0, 0.28, 0.04, 1.0),
@@ -569,6 +773,13 @@ def check(skip_decimate):
assign_slots(low, stone, metal, wood, flame)
assign_slots(high, stone, metal, wood, flame)
+ if stray_vert:
+ add_stray_vert(low.data)
+ if lift_z:
+ for v in low.data.vertices:
+ v.co.z += LIFT_Z
+ low.data.update()
+
if low.data is None or len(low.data.polygons) < 6:
return fail("torch mesh did not build", 3), None, None, None, None, None
@@ -599,7 +810,7 @@ def check(skip_decimate):
r1 = lod1_tris / base_tris if base_tris else 0.0
r2 = lod2_tris / base_tris if base_tris else 0.0
- collider_src = build_torch_mesh("TorchColSrc", bevel_offset=0.0, bevel_segments=1)
+ collider_src = build_torch_mesh("TorchColSrc", bevel_offset=0.0, bevel_segments=1, **kw)
collider = convex_hull_collider(collider_src, "TorchCollider")
bpy.data.objects.remove(collider_src, do_unlink=True)
col_tris = triangle_count(collider.data)
@@ -613,9 +824,11 @@ def check(skip_decimate):
export_unity(export_path, [low, collider])
export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
- print(
- f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}"
- )
+ hyg = hygiene_audit(low.data)
+ zf = zfight_pairs(low.data)
+ jnt = joint_audit(low.data)
+
+ print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
print(
f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
@@ -632,6 +845,17 @@ def check(skip_decimate):
f"measured collider_tris={col_tris} bake={bake_result} "
f"bake_has_data={img.has_data} export_bytes={export_size}"
)
+ print(
+ f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} "
+ f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} "
+ f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}"
+ )
+ print(
+ f"measured plaques={jnt['plaques']} plates={jnt['plates']} "
+ f"cups={jnt['cups']} arms={jnt['arms']} "
+ f"arm_gap={jnt['arm_gap']:.5f} plate_gap={jnt['plate_gap']:.5f} "
+ f"plaque_w={jnt['plaque_w']:.4f}"
+ )
if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
return fail(
@@ -668,6 +892,24 @@ def check(skip_decimate):
f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
7,
), None, None, None, None, None
+ if bb[2] > ZMIN_EPS:
+ return fail(f"grounded zmin={bb[2]:.5f}", 16), None, None, None, None, None
+ if jnt["arms"] < 1 or jnt["cups"] < 1 or jnt["arm_gap"] > ARM_GAP_MAX:
+ return fail(
+ f"arm-cup gap {jnt['arm_gap']:.5f} arms={jnt['arms']} cups={jnt['cups']}",
+ 17,
+ ), None, None, None, None, None
+ if jnt["plates"] < 1 or jnt["plaques"] < 1 or jnt["plate_gap"] > PLATE_GAP_MAX:
+ return fail(
+ f"plate-plaque gap {jnt['plate_gap']:.5f} plates={jnt['plates']} "
+ f"plaques={jnt['plaques']}",
+ 18,
+ ), None, None, None, None, None
+ if abs(jnt["plaque_w"] - WALL_W) > PLAQUE_W_TOL:
+ return fail(
+ f"plaque width {jnt['plaque_w']:.4f} off {WALL_W}",
+ 19,
+ ), None, None, None, None, None
if (
abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
@@ -701,6 +943,16 @@ def check(skip_decimate):
), None, None, None, None, None
if export_size <= 0:
return fail("export file missing or empty", 13), None, None, None, None, None
+ if (
+ hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"]
+ or hyg["doubles"] or hyg["ngons"] or zf
+ ):
+ return fail(
+ f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} "
+ f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} "
+ f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}",
+ 15,
+ ), None, None, None, None, None
return 0, low, high, stone, tex, collider
@@ -721,8 +973,7 @@ def render_still(low, stone, tex, path, engine):
ob.hide_render = True
ob.hide_viewport = True
- # Sconce is built on +Y; camera sits in -Y, so yaw 180 so the flame faces us.
- low.rotation_euler.z = math.radians(142.0)
+ low.rotation_euler.z = math.radians(128.0)
floor_me = bpy.data.meshes.new("Floor")
bm = bmesh.new()
@@ -764,16 +1015,15 @@ def light(name, loc, energy, size, col, rot):
light("Key", (-3.6, -5.0, 5.4), 640.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
light("Fill", (5.0, -3.4, 2.4), 42.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
light("Wedge", (2.2, 4.0, 3.8), 560.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
- # Small warm key on the flame so emission reads in EEVEE stills.
- light("FlameKick", (0.4, 1.2, 1.1), 90.0, 0.6, (1.0, 0.55, 0.22), (40, 0, 160))
+ light("FlameKick", (0.4, 1.2, 0.8), 90.0, 0.6, (1.0, 0.55, 0.22), (40, 0, 160))
cam_data = bpy.data.cameras.new("Cam")
- cam_data.lens = 50.0
+ cam_data.lens = 55.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (1.10, -1.70, 0.64)
+ cam.location = (-0.95, -1.15, 0.36)
scene.collection.objects.link(cam)
aim = bpy.data.objects.new("Aim", None)
- aim.location = (0.0, 0.06, 0.48)
+ aim.location = (0.02, 0.06, 0.21)
scene.collection.objects.link(aim)
con = cam.constraints.new("TRACK_TO")
con.target = aim
@@ -816,14 +1066,22 @@ def main():
p = argparse.ArgumentParser()
p.add_argument("--output", default=None)
p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
- p.add_argument(
- "--skip-decimate",
- action="store_true",
- help="falsification: skip the LOD DECIMATE stage",
- )
+ p.add_argument("--skip-decimate", action="store_true")
+ p.add_argument("--stray-vert", action="store_true")
+ p.add_argument("--lift-z", action="store_true")
+ p.add_argument("--float-arm", action="store_true")
+ p.add_argument("--float-plate", action="store_true")
+ p.add_argument("--skinny-plaque", action="store_true")
args = p.parse_args(argv)
- code, low, _high, stone, tex, _col = check(args.skip_decimate)
+ code, low, _high, stone, tex, _col = check(
+ args.skip_decimate,
+ lift_z=args.lift_z,
+ stray_vert=args.stray_vert,
+ float_arm=args.float_arm,
+ float_plate=args.float_plate,
+ skinny_plaque=args.skinny_plaque,
+ )
if code:
return code
if args.output: