diff --git a/docs/gallery/assets/street-lantern-hero.webp b/docs/gallery/assets/street-lantern-hero.webp index 238836a..b378093 100644 Binary files a/docs/gallery/assets/street-lantern-hero.webp and b/docs/gallery/assets/street-lantern-hero.webp differ diff --git a/docs/gallery/assets/water-trough-hero.webp b/docs/gallery/assets/water-trough-hero.webp index e76f7cd..39c58dc 100644 Binary files a/docs/gallery/assets/water-trough-hero.webp and b/docs/gallery/assets/water-trough-hero.webp differ diff --git a/docs/gallery/grindstone/index.html b/docs/gallery/grindstone/index.html index ace8172..c83a0eb 100644 --- a/docs/gallery/grindstone/index.html +++ b/docs/gallery/grindstone/index.html @@ -1022,7 +1022,7 @@
A procedural hanging street lantern through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
-witnesses Recomputed: 3520 tris, three materials with 24 glass and 94 brass faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.403×0.892×1.404 m, LOD ratios in band, convex collider 84 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+witnesses Recomputed: 3338 tris, three materials with 1530 metal, 24 glass and 209 brass faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.300×0.786×1.287 m, zmin 0, hygiene 0, brace BVH gap 0, hanger-roof gap −1 mm, post 0.100×1.120 m plumb 0, LOD ratios in band (5.2 COLLAPSE 2 tris leaner on LOD2), convex collider 78 tris, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z 16; --float-brace 17; --sink-arm 18; --rake-post 19.
View exampleA procedural watertight water trough through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
-witnesses Recomputed: 2484 tris, three materials with 192 metal and 6 water faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.152×0.568×0.509 m, LOD ratios in band (5.2 COLLAPSE 8 tris leaner on LOD2), convex collider 32 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+witnesses Recomputed: 3700 tris, three materials with 1674 wood, 156 metal and 29 water faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.093×0.552×0.504 m, zmin 0, four shoes planted, U end-cap rim span 5 mm, strap BVH gap 0, water-hull gap 2.6 mm, hull 1.045×0.466 m, LOD ratios in band, convex collider 64 tris, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-legs 16; --box-ends 17; --float-strap 18; --narrow-hull 19.
View example
Rendered headless by the example itself — click to zoom.
-blender --background --python showcase/street-lantern/street_lantern.py --
A showcase piece, not an example. Procedural hanging street lantern (stepped iron foot, tapered octagonal post, brass collars, braced arm, muntined cage with amber panes, pyramidal brass roof and finial) 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 hanging street lantern (stepped iron foot, tapered octagonal post, brass collars, braced arm, muntined cage with amber panes, square pyramidal brass roof and finial) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
+The arm axis, drop length, and roof stack are one closed-form chain: the roof peak stays below the arm, the hanger is the arm's far station, and the brace is an oriented box between a post-radius station and an arm station. The square pyramid is lofted on the cage axes, not a 4-gon cone. Cage posts and rails share a named overlap (CAGE_JOINT); glass sits behind the muntins by PANE_REBATE. Collars and the arm root evaluate post_radius_at(z).
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: 0.30 m square foot, 1.12 m octagonal post (0.10 m across at the base), 0.50 m arm, 0.24 × 0.30 m cage; outer AABB 0.300 × 0.786 × 1.287 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 | 3400–3650 | 3520 / 3520 / 3520 | | 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 3 distinct, ≥8 glass faces, ≥8 brass faces | 3 slots, 24 glass, 94 brass | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.403, 0.892, 1.404) m ± 0.01 | (0.4032, 0.8916, 1.4041), zmin 0 | | Collider tris | ≤ 110 | 84 | | Export | written, size > 0 | 254552 / 254552 / 254536 bytes |
DECIMATE COLLAPSE triangle counts are not guaranteed identical across series — the gate is a ratio band, not an exact count. This mesh happened to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. glTF byte size differs by a few bytes across series.
--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 | 3000–3800 | 3338 / 3338 / 3338 | | LOD1 ratio | 0.32–0.62 of base | 0.4997 / 0.4997 / 0.4997 | | LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2193 | | Materials | exactly 3 distinct; ≥200 metal, ≥8 glass, ≥24 brass | 3 slots; 1530 / 24 / 209 | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.300, 0.786, 1.287) m ± 0.01 | (0.3000, 0.7860, 1.2874), zmin 0 | | Collider tris | ≤ 120 | 78 | | Export | written, size > 0 | 250980 / 250980 / 250964 bytes |
Base triangles dropped from 3520 to 3338 in the quality pass: the open 4-gon cone roof became a closed square pyramid, cage crossings stopped sharing vertices, and a pane rebate replaced coplanar glass.
+DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is 2 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 16 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 | | Post plan | 0.100 m across × 1.120 m high ± (0.02, 0.04) | 0.1000 × 1.1200 | | Post plumb (top vs bottom XY) | ≤ 0.008 m | 0.00000 |
| Axis | Declared | Measured (all three) | | --- | --- | --- | | Brace-to-arm BVH gap | ≤ 0.006 m | 0.00000 | | Arm underside vs roof peak | ≥ 0.008 m clearance | 0.05300 | | Hanger-to-roof BVH gap | ≤ 0.004 m | −0.00100 |
+Each violates one named budget. All six 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-brace | brace-to-arm BVH gap | 17 | | --sink-arm | arm-over-roof clearance | 18 | | --rake-post | post plumb | 19 |
--sink-arm lowers only the arm. Hang-station hang_z stays at post top so the cage does not follow, which is what lets the clearance budget fire.
blender --background --python street_lantern.py --
blender --background --python street_lantern.py -- --skip-decimate
+blender --background --python street_lantern.py -- --stray-vert
+blender --background --python street_lantern.py -- --lift-z
+blender --background --python street_lantern.py -- --float-brace
+blender --background --python street_lantern.py -- --sink-arm
+blender --background --python street_lantern.py -- --rake-post
blender --background --python street_lantern.py -- --output lantern.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 glass/brass 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 a face-count floor missed | | 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 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, z-fight | | 16 | Not grounded: bounding box zmin off 0 | | 17 | Joint fit: brace-to-arm gap (--float-brace) | | 18 | Seat: arm-roof clearance or hanger-roof gap (--sink-arm) | | 19 | Post plumb or post size off the stated real-world size (--rake-post) |
Rendered headless by the example itself — click to zoom.
-blender --background --python showcase/water-trough/water_trough.py --
A showcase piece, not an example. Procedural staved water trough on a timber stand (watertight U-hull, plank end-caps, contained water, iron straps, trestle legs) 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 staved water trough on a timber stand (watertight U-hull, U end-caps, contained water, iron straps, trestle legs) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
+The hull, ends, straps and water all sample the same YZ arc. End caps are U-boards the staves tenon into, not a bounding-box slab around the U. Straps sit at STRAP_X, offset from LEG_X, so the trestle does not punch through the iron. Stretchers span the inner faces of the legs. --round-band is not used; --float-strap is the seat falsifier.
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.08 m tray length, 0.44 m across the U, 0.50 m overall height; outer AABB 1.093 × 0.552 × 0.504 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 | 2320–2650 | 2484 / 2484 / 2484 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2166 | | Materials | exactly 3 distinct, ≥24 metal and ≥6 water faces | 3 slots, 192 metal, 6 water | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.152, 0.568, 0.509) m ± 0.01 | (1.1520, 0.5680, 0.5086), zmin 0 | | Collider tris | ≤ 80 | 32 | | Export | written, size > 0 | 180748 / 180748 / 180728 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 20 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 | 3300–4000 | 3700 / 3700 / 3700 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.2200 | | Materials | exactly 3 distinct; ≥24 wood, ≥24 metal, ≥6 water | 3 slots; 1674 / 156 / 29 | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.093, 0.552, 0.504) m ± 0.01 | (1.0932, 0.5520, 0.5040), zmin 0 | | Collider tris | ≤ 80 | 64 | | Export | written, size > 0 | 268840 / 268840 / 268824 bytes |
Base triangles rose from 2484 to 3700 in the quality pass: box end slabs and a single extruded U became ten jittered staves, U end-caps, continuous straps offset from the legs, and stretchers that meet the inner faces.
+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. Stave-width jitter uses closed-form sin(i), no RNG. Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a gated axis.
The hull-size budget (exit 19) runs before the outer AABB budget (exit 8) so --narrow-hull is not swallowed by a moved bounding box.
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 supports: 4 shoes | each zmin ≤ 0.001 | 4, shoe_z 0.00000 | | Hull plan | 1.08 m × 0.44 m ± 0.08 | 1.0448 × 0.4661 |
| Axis | Declared | Measured (all three) | | --- | --- | --- | | End-cap rim Z-span | ≤ 0.080 m | 0.0050 | | Strap-to-hull BVH gap | ≤ 0.008 m | 0.00000 | | Water-to-hull BVH gap | ≤ 0.008 m | 0.00260 |
+Each violates one named budget. All seven 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-legs | named shoe supports at Z=0 | 16 | | --box-ends | end-cap rim span | 17 | | --float-strap | strap-to-hull gap | 18 | | --narrow-hull | hull plan vs stated size | 19 |
--short-legs lifts the shoes only, so AABB zmin stays 0 (the legs still plant) and the named-support gate is what fires.
blender --background --python water_trough.py --
blender --background --python water_trough.py -- --skip-decimate
+blender --background --python water_trough.py -- --stray-vert
+blender --background --python water_trough.py -- --lift-z
+blender --background --python water_trough.py -- --short-legs
+blender --background --python water_trough.py -- --box-ends
+blender --background --python water_trough.py -- --float-strap
+blender --background --python water_trough.py -- --narrow-hull
blender --background --python water_trough.py -- --output trough.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/metal/water 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 a face-count floor missed | | 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 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, z-fight | | 16 | Not grounded: bounding box zmin off 0, or a named shoe floats | | 17 | Joint fit: end-cap U-rim span (--box-ends) | | 18 | Seat: strap or water BVH gap (--float-strap) | | 19 | Hull length or width off the stated real-world size (--narrow-hull) |
"""Game-ready wooden water trough — a showcase piece, not an example.
Asserts budget conformance of a procedural staved trough on a timber
-stand (watertight U-hull, plank end-caps, contained water, iron straps,
-trestle legs) after composing shipped pipeline pieces: bmesh
-construction, UVs, three materials, high-to-low normal bake, LOD chain,
-convex collider, Unity glTF export.
+stand (U-staves and U end-caps from one radius function, contained
+water, iron straps lofted on that same host, trestle legs) after
+composing shipped pipeline pieces: bmesh construction, UVs, three
+materials, high-to-low normal bake, LOD chain, convex collider, Unity
+glTF export.
+
+The hull, ends, straps and water all sample the same YZ arc. End caps
+are U-boards the staves tenon into, not a bounding-box slab around the
+U. Legs run from a hull-outer station to a shoe at Z=0.
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.
+They are not API-contract witnesses. Each falsifier violates one named
+budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh
+hygiene, ``--lift-z`` grounded zmin, ``--short-legs`` named shoe
+supports, ``--box-ends`` end-cap U-fit, ``--float-strap`` strap seat,
+``--narrow-hull`` hull real-world size.
-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. Stave seams use closed-form ``sin(i)``. 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 water_trough.py --
blender --background --python water_trough.py -- --skip-decimate
@@ -312,6 +338,7 @@ Source
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)
@@ -322,15 +349,34 @@ Source
TRAY_L = 1.08
TRAY_R = 0.22
-STAVE_T = 0.030
-N_STAVES = 12
-A_SPAN = 1.38
-END_T = 0.050
+STAVE_T = 0.032
+N_STAVES = 10
+A_SPAN = 1.20
+END_T = 0.044
+END_OVERHANG = 0.010
+STAVE_GAP = 0.008
+STRAP_W = 0.028
+STRAP_T = 0.008
+STRAP_BITE = 0.008
+SHOE_H = 0.028
+SHOE_XY = (0.058, 0.052)
+LEG_X = TRAY_L * 0.28
+STRAP_X = TRAY_L * 0.18
+LEG_TOP_Y = 0.090
+LEG_BOT_Y = 0.250
+STRETCHER_Z = 0.115
+STRETCHER_T = 0.028
+LEG_W = 0.048
+WATER_GAP = 0.004
+WATER_FILL = 0.68
BBOX_TOL = 0.01
-OUTER_SIZE = (1.152, 0.568, 0.509)
-BASE_TRIS_MIN = 2320
-BASE_TRIS_MAX = 2650
+OUTER_SIZE = (1.093, 0.552, 0.504)
+HULL_SIZE = (1.08, 0.44)
+HULL_SIZE_TOL = (0.08, 0.08)
+NARROW_HULL_R = 0.12
+BASE_TRIS_MIN = 3300
+BASE_TRIS_MAX = 4000
LOD1_RATIO_MIN = 0.32
LOD1_RATIO_MAX = 0.62
LOD2_RATIO_MIN = 0.10
@@ -347,6 +393,19 @@ Source
WOOD_FACES_MIN = 24
WATER_FACES_MIN = 6
+ZMIN_EPS = 1e-4
+DOUBLES_EPS = 1e-5
+AREA_EPS = 1e-10
+ZFIGHT_EPS = 1e-4
+ZFIGHT_COS = 0.998
+LIFT_Z = 0.05
+SHOE_COUNT = 4
+SHOE_ZMIN_MAX = 1e-3
+END_RIM_SPAN_MAX = 0.080
+STRAP_GAP_MAX = 0.008
+WATER_CONTACT_MAX = 0.008
+PLUMB_MAX = 0.010
+
WOOD_IDX = 0
METAL_IDX = 1
WATER_IDX = 2
@@ -426,6 +485,11 @@ Source
return Vector((x, radius * math.sin(a), zc - radius * math.cos(a)))
+def hull_z_at_y(y, radius, zc):
+ y = max(-radius + 1e-6, min(radius - 1e-6, y))
+ return zc - math.sqrt(radius * radius - y * y)
+
+
def add_u_shell(bm, x0, x1, r_in, r_out, n_seg, zc, a_span, mat_idx):
in0, out0, in1, out1 = [], [], [], []
for i in range(n_seg + 1):
@@ -445,60 +509,63 @@ Source
return collected
-def add_end_plank(bm, x_end, toward, radius, zc, a_span, thick, mat_idx):
- y_span = 2.0 * radius * math.sin(a_span) + 0.046
- z_lo = zc - radius - 0.008
- z_hi = zc - radius * math.cos(a_span) + 0.036
+def add_stave(bm, a0, a1, x0, x1, r_in, r_out, zc, mat_idx):
+ def ring(x):
+ return [
+ bm.verts.new(_arc_point(a0, r_out, x, zc)),
+ bm.verts.new(_arc_point(a1, r_out, x, zc)),
+ bm.verts.new(_arc_point(a1, r_in, x, zc)),
+ bm.verts.new(_arc_point(a0, r_in, x, zc)),
+ ]
+
+ r0 = ring(x0)
+ r1 = ring(x1)
+ _face(bm, (r0[0], r0[1], r1[1], r1[0]), mat_idx)
+ _face(bm, (r0[2], r0[3], r1[3], r1[2]), mat_idx)
+ _face(bm, (r0[1], r0[2], r1[2], r1[1]), mat_idx)
+ _face(bm, (r0[3], r0[0], r1[0], r1[3]), mat_idx)
+ _face(bm, (r0[0], r0[3], r0[2], r0[1]), mat_idx)
+ _face(bm, (r1[0], r1[1], r1[2], r1[3]), mat_idx)
+ return r0 + r1
+
+
+def add_box_end(bm, x_mid, radius, zc, a_span, thick, mat_idx):
+ y_span = 2.0 * radius * math.sin(a_span)
+ z_lo = zc - radius
+ z_hi = zc - radius * math.cos(a_span)
z_mid = 0.5 * (z_lo + z_hi)
- x_mid = x_end + toward * (thick * 0.5 - 0.014)
- return add_box(
+ verts = add_box(
bm,
(x_mid, 0.0, z_mid),
(thick, y_span, z_hi - z_lo),
mat_idx,
)
+ edges = list({e for v in verts for e in v.link_edges})
+ bmesh.ops.subdivide_edges(bm, edges=edges, cuts=6, use_grid_fill=True)
+ return verts
-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_u_water(bm, x0, x1, r, n_seg, zc, z_water, mat_idx):
+ ca = max(-1.0, min(1.0, (zc - z_water) / r))
+ a_wl = math.acos(ca)
+ ring0, ring1 = [], []
+ for i in range(n_seg + 1):
+ a = -a_wl + 2.0 * a_wl * i / n_seg
+ ring0.append(bm.verts.new(_arc_point(a, r, x0, zc)))
+ ring1.append(bm.verts.new(_arc_point(a, r, x1, zc)))
+ collected = ring0 + ring1
+ for i in range(n_seg):
+ _face(bm, (ring0[i], ring0[i + 1], ring1[i + 1], ring1[i]), mat_idx)
+ _face(bm, (ring0[0], ring1[0], ring1[n_seg], ring0[n_seg]), mat_idx)
+ _face(bm, tuple(reversed(ring0)), mat_idx)
+ _face(bm, tuple(ring1), mat_idx)
+ return collected
-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,
- segments=segments,
- radius1=radius1,
- radius2=radius2,
- 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 triangulate_ngons(bm):
+ faces = [f for f in bm.faces if len(f.verts) > 4]
+ if faces:
+ bmesh.ops.triangulate(bm, faces=faces)
def pack_uvs(bm, margin=0.08):
@@ -544,54 +611,86 @@ Source
)
-def build_trough_mesh(name, bevel_offset, bevel_segments):
+def build_trough_mesh(
+ name,
+ bevel_offset,
+ bevel_segments,
+ box_ends=False,
+ float_strap=False,
+ short_legs=False,
+ narrow_hull=False,
+):
bm = bmesh.new()
try:
wood = []
- metal = []
- zc = 0.30 + TRAY_R
- cx = 0.0
- r_in = TRAY_R
- r_out = TRAY_R + STAVE_T
- x0 = cx - TRAY_L / 2.0
- x1 = cx + TRAY_L / 2.0
- wood.extend(
- add_u_shell(bm, x0, x1, r_in, r_out, N_STAVES, zc, A_SPAN, WOOD_IDX)
- )
- wood.extend(add_end_plank(bm, x0, -1.0, r_out, zc, A_SPAN, END_T, WOOD_IDX))
- wood.extend(add_end_plank(bm, x1, 1.0, r_out, zc, A_SPAN, END_T, WOOD_IDX))
+ tray_r = NARROW_HULL_R if narrow_hull else TRAY_R
+ zc = SHOE_H + 0.335 + TRAY_R
+ r_in = tray_r
+ r_out = tray_r + STAVE_T
+ x_cap_l0 = -TRAY_L / 2.0 - END_T * 0.15
+ x_cap_l1 = -TRAY_L / 2.0 + END_T * 0.85
+ x_cap_r0 = TRAY_L / 2.0 - END_T * 0.85
+ x_cap_r1 = TRAY_L / 2.0 + END_T * 0.15
+ x_stave_0 = -TRAY_L / 2.0 + END_T * 0.40
+ x_stave_1 = TRAY_L / 2.0 - END_T * 0.40
+
+ span = 2.0 * A_SPAN
+ usable = span - STAVE_GAP * N_STAVES
+ width = usable / N_STAVES
+ a = -A_SPAN + STAVE_GAP * 0.5
+ for i in range(N_STAVES):
+ jitter = 0.0035 * math.sin(i * 1.7 + 0.4)
+ a0 = a
+ a1 = a + width + jitter
+ wood.extend(
+ add_stave(bm, a0, a1, x_stave_0, x_stave_1, r_in, r_out, zc, WOOD_IDX)
+ )
+ a = a1 + STAVE_GAP
- for a_lip in (-A_SPAN, A_SPAN):
+ if not box_ends:
+ r_cap_in = r_in - 0.006
+ r_cap_out = r_out + END_OVERHANG
wood.extend(
- add_box(
- bm,
- _arc_point(a_lip, r_out, cx, zc),
- (TRAY_L - 0.04, 0.038, 0.032),
- WOOD_IDX,
- euler=(a_lip, 0.0, 0.0),
+ add_u_shell(
+ bm, x_cap_l0, x_cap_l1, r_cap_in, r_cap_out, N_STAVES, zc, A_SPAN, WOOD_IDX
+ )
+ )
+ wood.extend(
+ add_u_shell(
+ bm, x_cap_r0, x_cap_r1, r_cap_in, r_cap_out, N_STAVES, zc, A_SPAN, WOOD_IDX
)
)
- for sx in (-TRAY_L * 0.30, TRAY_L * 0.30):
+ top_z = hull_z_at_y(LEG_TOP_Y, r_out, zc)
+ bot_z = SHOE_H * 0.55
+ shoe_mid = SHOE_H / 2.0
+ if short_legs:
+ shoe_mid += 0.045
+ for sx in (-LEG_X, LEG_X):
for ysign in (-1.0, 1.0):
- top = (sx, ysign * 0.11, zc - TRAY_R - 0.04)
- bot = (sx, ysign * 0.26, 0.024)
- wood.extend(
- add_oriented_box(bm, top, bot, (0.050, 0.036), WOOD_IDX)
- )
+ top_pt = Vector((sx, ysign * LEG_TOP_Y, top_z))
+ bot_pt = Vector((sx, ysign * LEG_BOT_Y, bot_z))
+ direction = (top_pt - bot_pt).normalized()
+ top_pt = top_pt + direction * (STAVE_T * 0.35)
+ wood.extend(add_oriented_box(bm, top_pt, bot_pt, (LEG_W, 0.034), WOOD_IDX))
+ t_st = (STRETCHER_Z - bot_z) / max(top_z - bot_z, 1e-6)
+ y_leg = LEG_BOT_Y + t_st * (LEG_TOP_Y - LEG_BOT_Y)
+ # Inner face of the diagonal leg, not through its volume.
+ side_len = 2.0 * (y_leg - 0.022)
wood.extend(
add_box(
bm,
- (sx, 0.0, 0.115),
- (0.044, 0.40, 0.030),
+ (sx, 0.0, STRETCHER_Z),
+ (0.042, side_len, STRETCHER_T),
WOOD_IDX,
)
)
+ long_len = 2.0 * LEG_X - LEG_W
wood.extend(
add_box(
bm,
- (0.0, 0.0, 0.115),
- (TRAY_L * 0.62, 0.044, 0.030),
+ (0.0, 0.0, STRETCHER_Z),
+ (long_len, 0.042, STRETCHER_T),
WOOD_IDX,
)
)
@@ -610,40 +709,54 @@ Source
for f in ret.get("faces") or []:
f.material_index = WOOD_IDX
- r_strap = r_out + 0.006
- for sx in (-TRAY_L * 0.28, TRAY_L * 0.28):
- n = 14
- pts = []
- for k in range(n + 1):
- a = -A_SPAN + 2.0 * A_SPAN * k / n
- pts.append(_arc_point(a, r_strap, cx + sx, zc))
- for p0, p1 in zip(pts, pts[1:]):
- metal.extend(
- add_oriented_box(bm, p0, p1, (0.022, 0.011), METAL_IDX)
- )
- for sx in (-TRAY_L * 0.30, TRAY_L * 0.30):
+ if box_ends:
+ add_box_end(
+ bm, 0.5 * (x_cap_l0 + x_cap_l1), r_out, zc, A_SPAN, END_T, WOOD_IDX
+ )
+ add_box_end(
+ bm, 0.5 * (x_cap_r0 + x_cap_r1), r_out, zc, A_SPAN, END_T, WOOD_IDX
+ )
+
+ r_strap_in = r_out + 0.040 if float_strap else r_out - STRAP_BITE
+ r_strap_out = r_strap_in + STRAP_T
+ for sx in (-STRAP_X, STRAP_X):
+ add_u_shell(
+ bm,
+ sx - STRAP_W / 2.0,
+ sx + STRAP_W / 2.0,
+ r_strap_in,
+ r_strap_out,
+ 16,
+ zc,
+ A_SPAN,
+ METAL_IDX,
+ )
+ for sx in (-LEG_X, LEG_X):
for ysign in (-1.0, 1.0):
- metal.extend(
- add_box(
- bm,
- (sx, ysign * 0.26, 0.012),
- (0.056, 0.048, 0.024),
- METAL_IDX,
- )
+ add_box(
+ bm,
+ (sx, ysign * LEG_BOT_Y, shoe_mid),
+ (SHOE_XY[0], SHOE_XY[1], SHOE_H),
+ METAL_IDX,
)
z_bot = zc - r_in
z_rim = zc - r_in * math.cos(A_SPAN)
- z_water = z_bot + 0.70 * (z_rim - z_bot)
- ca = (zc - z_water) / r_in
- half_w = r_in * math.sqrt(max(0.0, 1.0 - ca * ca)) * 0.97
- add_box(
+ z_water = z_bot + WATER_FILL * (z_rim - z_bot)
+ add_u_water(
bm,
- (cx, 0.0, z_water),
- (TRAY_L - 2.0 * END_T - 0.08, 2.0 * half_w, 0.016),
+ x_cap_l1 - 0.010,
+ x_cap_r0 + 0.010,
+ r_in - WATER_GAP,
+ N_STAVES,
+ zc,
+ z_water,
WATER_IDX,
)
+ triangulate_ngons(bm)
+ bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-5)
+ bmesh.ops.dissolve_degenerate(bm, dist=1e-6)
xs = [v.co.x for v in bm.verts]
ys = [v.co.y for v in bm.verts]
zs = [v.co.z for v in bm.verts]
@@ -673,13 +786,26 @@ Source
return out
-def principled(name, color, metallic, roughness):
+def principled(name, color, metallic, roughness, roughness_var=0.0):
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 roughness_var > 0.0:
+ noise = nt.nodes.new("ShaderNodeTexNoise")
+ noise.inputs["Scale"].default_value = 11.0
+ ramp = nt.nodes.new("ShaderNodeValToRGB")
+ lo = max(0.08, roughness - roughness_var)
+ hi = min(0.95, roughness + roughness_var)
+ ramp.color_ramp.elements[0].position = 0.28
+ ramp.color_ramp.elements[0].color = (lo, lo, lo, 1.0)
+ ramp.color_ramp.elements[1].position = 0.72
+ ramp.color_ramp.elements[1].color = (hi, hi, hi, 1.0)
+ nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
+ nt.links.new(ramp.outputs["Color"], bsdf.inputs["Roughness"])
return mat
@@ -726,6 +852,246 @@ Source
return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+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):
+ # Combinatorics match examples/mesh-hygiene-audit.audit (copied, not imported).
+ 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)
+ bm.verts.ensure_lookup_table()
+ bm.edges.ensure_lookup_table()
+ 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 None:
+ continue
+ best = min(best, hit[3])
+ for face in bm_a.faces:
+ hit = tree.find_nearest(face.calc_center_median())
+ if hit[0] is None:
+ continue
+ best = min(best, hit[3])
+ return best
+ finally:
+ bm_a.free()
+ bm_b.free()
+
+
+def min_vert_gap(me, ga, gb):
+ pa = [me.vertices[i].co for i in ga]
+ pb = [me.vertices[i].co for i in gb]
+ step_a = max(1, len(pa) // 80)
+ step_b = max(1, len(pb) // 80)
+ best = 1e9
+ for a in pa[::step_a]:
+ for b in pb[::step_b]:
+ d = (a - b).length
+ if d < best:
+ best = d
+ return best
+
+
+def support_audit(me):
+ shoes = []
+ for group in shells(me):
+ if mat_of(me, group) != METAL_IDX:
+ continue
+ a = shell_aabb(me, group)
+ dz = a[5] - a[2]
+ dx = a[3] - a[0]
+ dy = a[4] - a[1]
+ cz = 0.5 * (a[2] + a[5])
+ if cz > 0.08 or dz > 0.06:
+ continue
+ if max(dx, dy) < 0.03:
+ continue
+ shoes.append(a)
+ zmin = min((a[2] for a in shoes), default=99.0)
+ return {"shoes": len(shoes), "shoe_z": zmin}
+
+
+def joint_audit(me):
+ groups = shells(me)
+ boxes = [(g, shell_aabb(me, g), mat_of(me, g)) for g in groups]
+ staves = []
+ ends = []
+ straps = []
+ waters = []
+ for g, a, mat in boxes:
+ dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2]
+ cx = 0.5 * (a[0] + a[3])
+ if mat == WOOD_IDX and dx > TRAY_L * 0.5:
+ staves.append((g, a))
+ continue
+ if mat == WOOD_IDX and dx < END_T * 3.5 and dy > 0.25 and abs(cx) > TRAY_L * 0.35:
+ ends.append((g, a))
+ continue
+ if mat == METAL_IDX and dy > 0.15 and dz > 0.10:
+ straps.append((g, a))
+ continue
+ if mat == WATER_IDX:
+ waters.append((g, a))
+ hulls = staves
+ rim_span = 0.0
+ for g, a in ends:
+ pts = [me.vertices[i].co for i in g]
+ ymax = max(abs(p.y) for p in pts)
+ rim = [p for p in pts if abs(abs(p.y) - ymax) < 0.025]
+ if rim:
+ span = max(p.z for p in rim) - min(p.z for p in rim)
+ if span > rim_span:
+ rim_span = span
+ strap_gap = 99.0
+ if straps and hulls:
+ strap_gap = min(shell_bvh_gap(me, s[0], h[0]) for s in straps for h in hulls)
+ water_gap = 99.0
+ if waters and hulls:
+ water_gap = min(shell_bvh_gap(me, w[0], h[0]) for w in waters for h in hulls)
+ hull_xy = (0.0, 0.0)
+ if staves:
+ xs = [v for _g, a in staves for v in (a[0], a[3])]
+ ys = [v for _g, a in staves for v in (a[1], a[4])]
+ hull_xy = (max(xs) - min(xs), max(ys) - min(ys))
+ return {
+ "parts": len(groups),
+ "hulls": len(hulls),
+ "ends": len(ends),
+ "straps": len(straps),
+ "waters": len(waters),
+ "rim_span": rim_span,
+ "strap_gap": strap_gap,
+ "water_gap": water_gap,
+ "hull_xy": hull_xy,
+ }
+
+
+def add_stray_vert(me):
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(me)
+ bm.verts.new((0.0, 0.0, 0.25))
+ bm.to_mesh(me)
+ me.update()
+ finally:
+ bm.free()
+
+
def make_lod(obj, name, ratio, skip_decimate):
mesh = obj.data.copy()
lod = bpy.data.objects.new(name, mesh)
@@ -812,16 +1178,47 @@ Source
)
-def check(skip_decimate):
+def check(
+ skip_decimate,
+ lift_z=False,
+ stray_vert=False,
+ box_ends=False,
+ float_strap=False,
+ short_legs=False,
+ narrow_hull=False,
+):
bpy.ops.wm.read_factory_settings(use_empty=True)
- low = build_trough_mesh("TroughLow", bevel_offset=0.005, bevel_segments=2)
- high = build_trough_mesh("TroughHigh", bevel_offset=0.005, bevel_segments=4)
- wood = principled("TroughWood", (0.40, 0.22, 0.08, 1.0), 0.0, 0.58)
- metal = principled("TroughIron", (0.10, 0.105, 0.12, 1.0), 1.0, 0.30)
+ low = build_trough_mesh(
+ "TroughLow",
+ bevel_offset=0.004,
+ bevel_segments=2,
+ box_ends=box_ends,
+ float_strap=float_strap,
+ short_legs=short_legs,
+ narrow_hull=narrow_hull,
+ )
+ high = build_trough_mesh(
+ "TroughHigh",
+ bevel_offset=0.004,
+ bevel_segments=4,
+ box_ends=box_ends,
+ float_strap=float_strap,
+ short_legs=short_legs,
+ narrow_hull=narrow_hull,
+ )
+ wood = principled("TroughWood", (0.40, 0.22, 0.08, 1.0), 0.0, 0.58, roughness_var=0.10)
+ metal = principled("TroughIron", (0.10, 0.105, 0.12, 1.0), 1.0, 0.32, roughness_var=0.08)
water = principled("TroughWater", (0.06, 0.16, 0.18, 1.0), 0.0, 0.08)
assign_slots(low, wood, metal, water)
assign_slots(high, wood, metal, water)
+ 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("trough mesh did not build", 3), None, None, None, None, None
@@ -838,6 +1235,10 @@ Source
size_x = bb[3] - bb[0]
size_y = bb[4] - bb[1]
size_z = bb[5] - bb[2]
+ hyg = hygiene_audit(low.data)
+ zf = zfight_pairs(low.data)
+ sup = support_audit(low.data)
+ jnt = joint_audit(low.data)
img, tex = setup_bake_image(low, wood)
if img is None:
@@ -883,6 +1284,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 shoes={sup['shoes']} shoe_z={sup['shoe_z']:.5f} "
+ f"rim_span={jnt['rim_span']:.4f} strap_gap={jnt['strap_gap']:.5f} "
+ f"water_gap={jnt['water_gap']:.5f} hull_xy={jnt['hull_xy']} "
+ f"ends={jnt['ends']} straps={jnt['straps']}"
+ )
if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
return fail(
@@ -919,6 +1331,12 @@ Source
f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
7,
), None, None, None, None, None
+ hx, hy = jnt["hull_xy"]
+ if abs(hx - HULL_SIZE[0]) > HULL_SIZE_TOL[0] or abs(hy - HULL_SIZE[1]) > HULL_SIZE_TOL[1]:
+ return fail(
+ f"hull size ({hx:.4f},{hy:.4f}) off {HULL_SIZE}",
+ 19,
+ ), None, None, None, None, None
if (
abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
@@ -952,6 +1370,46 @@ 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
+ if (
+ bb[2] > ZMIN_EPS
+ or sup["shoes"] != SHOE_COUNT
+ or sup["shoe_z"] > SHOE_ZMIN_MAX
+ ):
+ return fail(
+ f"grounded zmin={bb[2]:.5f} shoes={sup['shoes']} "
+ f"shoe_z={sup['shoe_z']:.5f}",
+ 16,
+ ), None, None, None, None, None
+ if jnt["ends"] < 2 or jnt["rim_span"] > END_RIM_SPAN_MAX:
+ return fail(
+ f"end rim span {jnt['rim_span']:.4f} ends={jnt['ends']}",
+ 17,
+ ), None, None, None, None, None
+ if jnt["straps"] < 2 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["water_gap"] > WATER_CONTACT_MAX:
+ return fail(
+ f"water-hull gap {jnt['water_gap']:.5f}",
+ 18,
+ ), None, None, None, None, None
return 0, low, high, wood, tex, collider
@@ -1065,14 +1523,24 @@ 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("--short-legs", action="store_true")
+ p.add_argument("--box-ends", action="store_true")
+ p.add_argument("--float-strap", action="store_true")
+ p.add_argument("--narrow-hull", 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,
+ box_ends=args.box_ends,
+ float_strap=args.float_strap,
+ short_legs=args.short_legs,
+ narrow_hull=args.narrow_hull,
+ )
if code:
return code
if args.output:
diff --git a/showcase/README.md b/showcase/README.md
index 2a4395a..615a944 100644
--- a/showcase/README.md
+++ b/showcase/README.md
@@ -50,7 +50,13 @@ entry in `showcase/gallery.json`, and a rendered still.
flat fan cap are coplanar and close-centred by construction, and
counting those makes the budget unsatisfiable rather than meaningful.
Multi-body props do **not** require Euler characteristic 2 — that is a
- single-shell contract. Pairs of parts meant to touch assert a BVH
+ single-shell contract. Two boxes that share a coplanar face z-fight;
+ two that share a vertex weld into one shell. Seat a joint with a named
+ overlap or rebate computed from the host (a cage post into its rail, a
+ glass pane behind its muntin, a plinth step into its base) rather than
+ hoping the numbers happen not to coincide. A hang-station versus
+ arm-station split lets a contact falsifier move one member without
+ dragging the rest of the assembly. Pairs of parts meant to touch assert a BVH
surface gap below a named epsilon (vert-vert is the wrong metric for
thin straps and collars). A spanning board whose only vertices sit at
the far ends will report a false gap to a mid-span knuckle; put a
diff --git a/showcase/gallery.json b/showcase/gallery.json
index bdaac1f..f2fd6f0 100644
--- a/showcase/gallery.json
+++ b/showcase/gallery.json
@@ -69,7 +69,7 @@
"name": "street-lantern",
"dir": "showcase/street-lantern",
"teaches": "A procedural hanging street lantern through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
- "witnessesFix": "Recomputed: 3520 tris, three materials with 24 glass and 94 brass faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.403×0.892×1.404 m, LOD ratios in band, convex collider 84 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
+ "witnessesFix": "Recomputed: 3338 tris, three materials with 1530 metal, 24 glass and 209 brass faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.300×0.786×1.287 m, zmin 0, hygiene 0, brace BVH gap 0, hanger-roof gap −1 mm, post 0.100×1.120 m plumb 0, LOD ratios in band (5.2 COLLAPSE 2 tris leaner on LOD2), convex collider 78 tris, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z 16; --float-brace 17; --sink-arm 18; --rake-post 19.",
"hero": "docs/gallery/assets/street-lantern-hero.webp",
"preview": "showcase/street-lantern/preview.webp",
"tags": [
@@ -177,7 +177,7 @@
"name": "water-trough",
"dir": "showcase/water-trough",
"teaches": "A procedural watertight water trough through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
- "witnessesFix": "Recomputed: 2484 tris, three materials with 192 metal and 6 water faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.152×0.568×0.509 m, LOD ratios in band (5.2 COLLAPSE 8 tris leaner on LOD2), convex collider 32 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
+ "witnessesFix": "Recomputed: 3700 tris, three materials with 1674 wood, 156 metal and 29 water faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.093×0.552×0.504 m, zmin 0, four shoes planted, U end-cap rim span 5 mm, strap BVH gap 0, water-hull gap 2.6 mm, hull 1.045×0.466 m, LOD ratios in band, convex collider 64 tris, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-legs 16; --box-ends 17; --float-strap 18; --narrow-hull 19.",
"hero": "docs/gallery/assets/water-trough-hero.webp",
"preview": "showcase/water-trough/preview.webp",
"tags": [
diff --git a/showcase/street-lantern/README.md b/showcase/street-lantern/README.md
index 58957a1..9589adc 100644
--- a/showcase/street-lantern/README.md
+++ b/showcase/street-lantern/README.md
@@ -2,9 +2,17 @@
A showcase piece, not an example. Procedural hanging street lantern
(stepped iron foot, tapered octagonal post, brass collars, braced arm,
-muntined cage with amber panes, pyramidal brass roof and finial) then
-the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake,
-LOD chain, convex collider, Unity glTF export.
+muntined cage with amber panes, square pyramidal brass roof and finial)
+then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal
+bake, LOD chain, convex collider, Unity glTF export.
+
+The arm axis, drop length, and roof stack are one closed-form chain: the
+roof peak stays below the arm, the hanger is the arm's far station, and
+the brace is an oriented box between a post-radius station and an arm
+station. The square pyramid is lofted on the cage axes, not a 4-gon cone.
+Cage posts and rails share a named overlap (`CAGE_JOINT`); glass sits
+behind the muntins by `PANE_REBATE`. Collars and the arm root evaluate
+`post_radius_at(z)`.
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 +22,12 @@ 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.30 m square foot, 1.12 m octagonal post (0.10 m across
+at the base), 0.50 m arm, 0.24 × 0.30 m cage; outer AABB
+0.300 × 0.786 × 1.287 m.
## Budgets
@@ -22,38 +36,87 @@ materials, UVs, evaluated LOD, collider, or export file.
| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) |
| --- | --- | --- |
-| Base triangles | 3400–3650 | 3520 / 3520 / 3520 |
-| 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 3 distinct, ≥8 glass faces, ≥8 brass faces | 3 slots, 24 glass, 94 brass |
+| Base triangles | 3000–3800 | 3338 / 3338 / 3338 |
+| LOD1 ratio | 0.32–0.62 of base | 0.4997 / 0.4997 / 0.4997 |
+| LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2193 |
+| Materials | exactly 3 distinct; ≥200 metal, ≥8 glass, ≥24 brass | 3 slots; 1530 / 24 / 209 |
| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
-| Outer AABB | (0.403, 0.892, 1.404) m ± 0.01 | (0.4032, 0.8916, 1.4041), zmin 0 |
-| Collider tris | ≤ 110 | 84 |
-| Export | written, size > 0 | 254552 / 254552 / 254536 bytes |
+| Outer AABB | (0.300, 0.786, 1.287) m ± 0.01 | (0.3000, 0.7860, 1.2874), zmin 0 |
+| Collider tris | ≤ 120 | 78 |
+| Export | written, size > 0 | 250980 / 250980 / 250964 bytes |
+
+Base triangles dropped from **3520 to 3338** in the quality pass: the
+open 4-gon cone roof became a closed square pyramid, cage crossings
+stopped sharing vertices, and a pane rebate replaced coplanar glass.
+
+DECIMATE COLLAPSE triangle counts are **not** identical across series —
+5.2.1 is 2 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 16 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 |
+| Post plan | 0.100 m across × 1.120 m high ± (0.02, 0.04) | 0.1000 × 1.1200 |
+| Post plumb (top vs bottom XY) | ≤ 0.008 m | 0.00000 |
+
+### Joint fit and seat
+
+| Axis | Declared | Measured (all three) |
+| --- | --- | --- |
+| Brace-to-arm BVH gap | ≤ 0.006 m | 0.00000 |
+| Arm underside vs roof peak | ≥ 0.008 m clearance | 0.05300 |
+| Hanger-to-roof BVH gap | ≤ 0.004 m | −0.00100 |
-DECIMATE COLLAPSE triangle counts are **not** guaranteed identical across
-series — the gate is a ratio band, not an exact count. This mesh happened
-to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate
-is `has_data` plus operator `FINISHED`, not byte-identity. Construction
-uses no RNG. glTF byte size differs by a few bytes across series.
+### Falsifiers
+
+Each violates one named budget. All six 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-brace` | brace-to-arm BVH gap | 17 |
+| `--sink-arm` | arm-over-roof clearance | 18 |
+| `--rake-post` | post plumb | 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.
+`--sink-arm` lowers only the arm. Hang-station `hang_z` stays at post
+top so the cage does not follow, which is what lets the clearance budget
+fire.
## Run
```bash
blender --background --python street_lantern.py --
blender --background --python street_lantern.py -- --skip-decimate
+blender --background --python street_lantern.py -- --stray-vert
+blender --background --python street_lantern.py -- --lift-z
+blender --background --python street_lantern.py -- --float-brace
+blender --background --python street_lantern.py -- --sink-arm
+blender --background --python street_lantern.py -- --rake-post
blender --background --python street_lantern.py -- --output lantern.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 |
| --- | --- |
@@ -62,7 +125,7 @@ File-local. `9` is a valid check code. `10` is reserved for
| 2 | argparse / usage |
| 3 | Mesh did not build / no UV layer |
| 4 | Base triangle count outside range |
-| 5 | Material count ≠ 3 distinct slots, or glass/brass faces missing |
+| 5 | Material count ≠ 3 distinct slots, or a face-count floor missed |
| 6 | UVs outside 0..1 |
| 7 | UV AABB overlap above tolerance |
| 8 | World AABB off declared outer size |
@@ -72,3 +135,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 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, z-fight |
+| 16 | Not grounded: bounding box `zmin` off 0 |
+| 17 | Joint fit: brace-to-arm gap (`--float-brace`) |
+| 18 | Seat: arm-roof clearance or hanger-roof gap (`--sink-arm`) |
+| 19 | Post plumb or post size off the stated real-world size (`--rake-post`) |
diff --git a/showcase/street-lantern/preview.webp b/showcase/street-lantern/preview.webp
index c7ba133..b378093 100644
Binary files a/showcase/street-lantern/preview.webp and b/showcase/street-lantern/preview.webp differ
diff --git a/showcase/street-lantern/street_lantern.py b/showcase/street-lantern/street_lantern.py
index d012138..63d302d 100644
--- a/showcase/street-lantern/street_lantern.py
+++ b/showcase/street-lantern/street_lantern.py
@@ -4,9 +4,16 @@
shipped pipeline pieces: bmesh construction, UVs, three materials,
high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
+The arm axis, drop length, and roof stack are one closed-form chain: the
+roof peak stays below the arm, the hanger is the arm's far station, and
+the brace is an oriented box between a post-radius station and an arm
+station. The square pyramid is lofted on the cage axes, not a 4-gon cone.
+
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.
+They are not API-contract witnesses. Each falsifier violates one named
+budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh
+hygiene, ``--lift-z`` grounded zmin, ``--float-brace`` brace-to-arm
+joint, ``--sink-arm`` arm-over-roof clearance, ``--rake-post`` post plumb.
No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
are not byte-identical across Blender versions — the LOD gate is a
@@ -26,6 +33,7 @@
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;
@@ -37,25 +45,52 @@
sys.dont_write_bytecode = True
import gallery_framing # noqa: E402
-FOOT_XY = 0.34
-FOOT_H = 0.085
-POST_R = 0.046
-POST_H = 1.18
-ARM_LEN = 0.52
-ARM_R = 0.028
-CAGE_W = 0.28
-CAGE_H = 0.34
-FRAME = 0.026
-PANE_T = 0.008
-ROOF_H = 0.12
-FINIAL_H = 0.07
-DROP_H = 0.07
-MUNTIN = 0.012
+# Stepped plinth. One body at Z=0 — no coplanar corner pads.
+FOOT_BASE = 0.30
+FOOT_BASE_H = 0.042
+FOOT_STEP = 0.22
+FOOT_STEP_H = 0.038
+# Step sits into the base so the two boxes do not share a coplanar face.
+STEP_SINK = 0.002
+FOOT_H = FOOT_BASE_H + FOOT_STEP_H - STEP_SINK
+
+POST_SIDES = 8
+POST_R_BOT = 0.050
+POST_R_TOP = 0.042
+POST_H = 1.12
+COLLAR_H = 0.036
+COLLAR_T = 0.016
+CAP_H = 0.050
+CAP_R = POST_R_TOP + 0.028
+POST_FINIAL_H = 0.070
+
+ARM_R = 0.022
+ARM_LEN = 0.50
+BRACE_T = 0.024
+BRACE_POST_DROP = 0.155
+BRACE_ARM_FRAC = 0.46
+
+CAGE_W = 0.24
+CAGE_H = 0.30
+FRAME = 0.022
+MUNTIN = 0.010
+PANE_T = 0.006
+PANE_REBATE = 0.003
+# Rails seat into posts without sharing vertex positions.
+CAGE_JOINT = 0.0015
+ROOF_H = 0.095
+FINIAL_H = 0.050
+EAVE = 0.016
+# Roof stack plus a gap so the arm passes *over* the cage, never through it.
+DROP_H = ARM_R + ROOF_H + FINIAL_H + 0.024
BBOX_TOL = 0.01
-OUTER_SIZE = (0.403, 0.892, 1.404)
-BASE_TRIS_MIN = 3400
-BASE_TRIS_MAX = 3650
+# Fitted after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (0.300, 0.786, 1.287)
+POST_SIZE = (POST_R_BOT * 2.0, POST_H)
+POST_SIZE_TOL = (0.02, 0.04)
+BASE_TRIS_MIN = 3000
+BASE_TRIS_MAX = 3800
LOD1_RATIO_MIN = 0.32
LOD1_RATIO_MAX = 0.62
LOD2_RATIO_MIN = 0.10
@@ -65,11 +100,25 @@
MATERIAL_COUNT = 3
UV_EPS = 1e-4
UV_OVERLAP_MAX = 1e-5
-COLLIDER_TRIS_MAX = 110
+COLLIDER_TRIS_MAX = 120
BAKE_RES = 256
CAGE_EXTRUSION = 0.06
GLASS_FACES_MIN = 8
-BRASS_FACES_MIN = 8
+BRASS_FACES_MIN = 24
+METAL_FACES_MIN = 200
+
+ZMIN_EPS = 1e-4
+DOUBLES_EPS = 1e-5
+AREA_EPS = 1e-10
+ZFIGHT_EPS = 1e-4
+ZFIGHT_COS = 0.998
+LIFT_Z = 0.05
+BRACE_GAP_MAX = 0.006
+ARM_ROOF_CLEAR_MIN = 0.008
+HANGER_ROOF_GAP_MAX = 0.004
+PLUMB_MAX = 0.008
+SINK_ARM = 0.14
+RAKE = math.radians(8.0)
METAL_IDX = 0
GLASS_IDX = 1
@@ -102,6 +151,12 @@ def evaluated_triangle_count(obj):
eval_obj.to_mesh_clear()
+def post_radius_at(z):
+ t = (z - FOOT_H) / POST_H
+ t = max(0.0, min(1.0, t))
+ return POST_R_BOT + t * (POST_R_TOP - POST_R_BOT)
+
+
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,17 +171,35 @@ 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=True,
segments=segments,
radius1=radius1,
radius2=radius2,
depth=depth,
)
- verts = geo["verts"]
+ verts = list(geo["verts"])
rot = Euler(euler).to_matrix()
origin = Vector(loc)
for v in verts:
@@ -137,6 +210,32 @@ def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.
return verts
+def add_square_pyramid(bm, loc, half, height, mat_idx):
+ """Square pyramid whose base edges are parallel to X/Y — the cage axes."""
+ origin = Vector(loc)
+ z0 = origin.z - height * 0.5
+ z1 = origin.z + height * 0.5
+ corners = [
+ bm.verts.new((origin.x - half, origin.y - half, z0)),
+ bm.verts.new((origin.x + half, origin.y - half, z0)),
+ bm.verts.new((origin.x + half, origin.y + half, z0)),
+ bm.verts.new((origin.x - half, origin.y + half, z0)),
+ ]
+ apex = bm.verts.new((origin.x, origin.y, z1))
+ for i in range(4):
+ face = bm.faces.new((corners[i], corners[(i + 1) % 4], apex))
+ face.material_index = mat_idx
+ base = bm.faces.new(tuple(reversed(corners)))
+ base.material_index = mat_idx
+ return corners + [apex]
+
+
+def triangulate_ngons(bm):
+ faces = [f for f in bm.faces if len(f.verts) > 4]
+ if faces:
+ bmesh.ops.triangulate(bm, faces=faces)
+
+
def pack_uvs(bm, margin=0.08):
uv = bm.loops.layers.uv.new("UVMap")
faces = list(bm.faces)
@@ -180,44 +279,51 @@ def pack_uvs(bm, margin=0.08):
)
-def build_lantern_mesh(name, bevel_offset, bevel_segments):
+def build_lantern_mesh(
+ name,
+ bevel_offset,
+ bevel_segments,
+ float_brace=False,
+ sink_arm=False,
+ rake_post=False,
+):
bm = bmesh.new()
try:
bevel_verts = []
post_z0 = FOOT_H
post_top = post_z0 + POST_H
- arm_z = post_top - 0.02
+ hang_z = post_top
+ arm_z = post_top
+ if sink_arm:
+ arm_z -= SINK_ARM
cage_y = ARM_LEN
- cage_top = arm_z - DROP_H
+ cage_top = hang_z - DROP_H
cage_bot = cage_top - CAGE_H
cage_mid = 0.5 * (cage_top + cage_bot)
+ post_euler = (RAKE, 0.0, 0.0) if rake_post else (0.0, 0.0, 0.0)
bevel_verts.extend(
add_box(
bm,
- (0.0, 0.0, FOOT_H / 2.0),
- (FOOT_XY, FOOT_XY, FOOT_H),
+ (0.0, 0.0, FOOT_BASE_H / 2.0),
+ (FOOT_BASE, FOOT_BASE, FOOT_BASE_H),
+ METAL_IDX,
+ )
+ )
+ step_z0 = FOOT_BASE_H - STEP_SINK
+ bevel_verts.extend(
+ add_box(
+ bm,
+ (0.0, 0.0, step_z0 + FOOT_STEP_H / 2.0),
+ (FOOT_STEP, FOOT_STEP, FOOT_STEP_H),
METAL_IDX,
)
)
- pad = 0.07
- pad_h = 0.022
- hx = FOOT_XY / 2.0 - pad / 2.0 - 0.012
- for sxn in (-1.0, 1.0):
- for syn in (-1.0, 1.0):
- bevel_verts.extend(
- add_box(
- bm,
- (sxn * hx, syn * hx, pad_h / 2.0),
- (pad, pad, pad_h),
- METAL_IDX,
- )
- )
add_cone(
bm,
- (0.0, 0.0, FOOT_H + 0.028),
- 0.12,
- 0.10,
+ (0.0, 0.0, post_z0 + 0.030),
+ 0.100,
+ 0.072,
0.056,
12,
BRASS_IDX,
@@ -225,202 +331,193 @@ def build_lantern_mesh(name, bevel_offset, bevel_segments):
add_cone(
bm,
(0.0, 0.0, post_z0 + POST_H / 2.0),
- POST_R,
- POST_R * 0.86,
+ POST_R_BOT,
+ POST_R_TOP,
POST_H,
- 8,
+ POST_SIDES,
METAL_IDX,
+ euler=post_euler,
)
+ collar_z = post_z0 + POST_H * 0.36
+ collar_r = post_radius_at(collar_z) + COLLAR_T
add_cone(
bm,
- (0.0, 0.0, post_z0 + POST_H * 0.36),
- POST_R + 0.028,
- POST_R + 0.028,
- 0.042,
+ (0.0, 0.0, collar_z),
+ collar_r,
+ collar_r,
+ COLLAR_H,
12,
BRASS_IDX,
)
add_cone(
bm,
(0.0, 0.0, post_top),
- 0.078,
- 0.078,
- 0.052,
+ CAP_R,
+ CAP_R,
+ CAP_H,
12,
BRASS_IDX,
)
add_cone(
bm,
- (0.0, ARM_LEN / 2.0, arm_z),
- ARM_R,
- ARM_R,
- ARM_LEN + 0.06,
- 8,
- METAL_IDX,
- euler=(math.radians(90.0), 0.0, 0.0),
- )
- bevel_verts.extend(
- add_box(
- bm,
- (0.0, 0.18, arm_z - 0.10),
- (0.032, 0.28, 0.032),
- METAL_IDX,
- euler=(math.radians(-36.0), 0.0, 0.0),
- )
- )
- add_cone(
- bm,
- (0.0, 0.0, post_top + 0.055),
- 0.032,
- 0.006,
- 0.08,
+ (0.0, 0.0, post_top + CAP_H / 2.0 + POST_FINIAL_H * 0.42),
+ 0.028,
+ 0.005,
+ POST_FINIAL_H,
8,
BRASS_IDX,
)
+
+ arm_y0 = post_radius_at(arm_z) - 0.008
+ arm_y1 = cage_y
add_cone(
bm,
- (0.0, cage_y, arm_z - DROP_H / 2.0),
- 0.016,
- 0.016,
- DROP_H + 0.03,
+ (0.0, 0.5 * (arm_y0 + arm_y1), arm_z),
+ ARM_R,
+ ARM_R,
+ arm_y1 - arm_y0,
8,
METAL_IDX,
+ euler=(math.radians(90.0), 0.0, 0.0),
)
- eave = CAGE_W + 0.04
- bevel_verts.extend(
- add_box(
- bm,
- (0.0, cage_y, cage_bot - 0.010),
- (eave, eave, 0.020),
- METAL_IDX,
- )
- )
- bevel_verts.extend(
- add_box(
+
+ brace_z = arm_z - BRACE_POST_DROP
+ post_r = post_radius_at(brace_z)
+ p_post = Vector((0.0, post_r + BRACE_T * 0.35, brace_z))
+ p_arm = Vector((0.0, ARM_LEN * BRACE_ARM_FRAC, arm_z - ARM_R - BRACE_T * 0.20))
+ if float_brace:
+ p_arm = p_post + Vector((0.0, 0.11, -0.06))
+ bevel_verts.extend(add_oriented_box(bm, p_post, p_arm, (BRACE_T, BRACE_T), METAL_IDX))
+
+ apex_z = cage_top - 0.001 + ROOF_H
+ stub_bot = apex_z + FINIAL_H * 0.70
+ stub_top = arm_z - ARM_R + 0.006
+ if stub_top - stub_bot > 0.008:
+ add_cone(
bm,
- (0.0, cage_y, cage_top + 0.010),
- (eave, eave, 0.020),
+ (0.0, cage_y, 0.5 * (stub_top + stub_bot)),
+ 0.011,
+ 0.011,
+ stub_top - stub_bot,
+ 8,
METAL_IDX,
)
- )
+
hw = CAGE_W / 2.0 - FRAME / 2.0
+ post_h = CAGE_H - 2.0 * FRAME + 2.0 * CAGE_JOINT
for sxn in (-1.0, 1.0):
for syn in (-1.0, 1.0):
bevel_verts.extend(
add_box(
bm,
(sxn * hw, cage_y + syn * hw, cage_mid),
- (FRAME, FRAME, CAGE_H),
+ (FRAME, FRAME, post_h),
METAL_IDX,
)
)
- rail_z = (cage_bot + FRAME / 2.0, cage_mid, cage_top - FRAME / 2.0)
- for z in rail_z:
+ rail_len = CAGE_W - 2.0 * FRAME + 2.0 * CAGE_JOINT
+ for z in (cage_bot + FRAME / 2.0, cage_mid, cage_top - FRAME / 2.0):
for sign in (-1.0, 1.0):
bevel_verts.extend(
add_box(
bm,
- (0.0, cage_y + sign * (CAGE_W / 2.0 - FRAME / 2.0), z),
- (CAGE_W - 2.0 * FRAME, FRAME, FRAME),
+ (0.0, cage_y + sign * hw, z),
+ (rail_len, FRAME, FRAME),
METAL_IDX,
)
)
bevel_verts.extend(
add_box(
bm,
- (sign * (CAGE_W / 2.0 - FRAME / 2.0), cage_y, z),
- (FRAME, CAGE_W - 2.0 * FRAME, FRAME),
+ (sign * hw, cage_y, z),
+ (FRAME, rail_len, FRAME),
METAL_IDX,
)
)
+ opening = (CAGE_H - 3.0 * FRAME) / 2.0
+ muntin_h = opening + 2.0 * CAGE_JOINT
+ lower_z = cage_bot + FRAME + opening / 2.0
+ upper_z = cage_top - FRAME - opening / 2.0
for sign in (-1.0, 1.0):
- bevel_verts.extend(
- add_box(
- bm,
- (0.0, cage_y + sign * (CAGE_W / 2.0 - FRAME / 2.0), cage_mid),
- (MUNTIN, FRAME * 0.85, CAGE_H - 2.0 * FRAME),
- METAL_IDX,
+ for z in (lower_z, upper_z):
+ bevel_verts.extend(
+ add_box(
+ bm,
+ (0.0, cage_y + sign * hw, z),
+ (MUNTIN, FRAME * 0.80, muntin_h),
+ METAL_IDX,
+ )
)
- )
- bevel_verts.extend(
- add_box(
- bm,
- (sign * (CAGE_W / 2.0 - FRAME / 2.0), cage_y, cage_mid),
- (FRAME * 0.85, MUNTIN, CAGE_H - 2.0 * FRAME),
- METAL_IDX,
+ bevel_verts.extend(
+ add_box(
+ bm,
+ (sign * hw, cage_y, z),
+ (FRAME * 0.80, MUNTIN, muntin_h),
+ METAL_IDX,
+ )
)
- )
- inset = FRAME + 0.001
- open_w = CAGE_W - 2.0 * inset
- open_h = CAGE_H - 2.0 * inset
+ open_w = CAGE_W - 2.0 * FRAME - 0.004
+ open_h = CAGE_H - 2.0 * FRAME - 0.004
+ pane_inset = CAGE_W / 2.0 - FRAME - PANE_T / 2.0 - PANE_REBATE
add_box(
bm,
- (0.0, cage_y + CAGE_W / 2.0 - PANE_T / 2.0 - 0.001, cage_mid),
+ (0.0, cage_y + pane_inset, cage_mid),
(open_w, PANE_T, open_h),
GLASS_IDX,
)
add_box(
bm,
- (0.0, cage_y - CAGE_W / 2.0 + PANE_T / 2.0 + 0.001, cage_mid),
+ (0.0, cage_y - pane_inset, cage_mid),
(open_w, PANE_T, open_h),
GLASS_IDX,
)
add_box(
bm,
- (CAGE_W / 2.0 - PANE_T / 2.0 - 0.001, cage_y, cage_mid),
+ (pane_inset, cage_y, cage_mid),
(PANE_T, open_w, open_h),
GLASS_IDX,
)
add_box(
bm,
- (-CAGE_W / 2.0 + PANE_T / 2.0 + 0.001, cage_y, cage_mid),
+ (-pane_inset, cage_y, cage_mid),
(PANE_T, open_w, open_h),
GLASS_IDX,
)
+
add_cone(
bm,
- (0.0, cage_y, cage_bot + 0.045),
- 0.028,
- 0.022,
- 0.055,
+ (0.0, cage_y, cage_bot + 0.040),
+ 0.024,
+ 0.018,
+ 0.048,
8,
BRASS_IDX,
)
- bevel_verts.extend(
- add_box(
- bm,
- (0.0, cage_y, cage_top + 0.022),
- (CAGE_W + 0.08, CAGE_W + 0.08, 0.016),
- BRASS_IDX,
- )
- )
- roof_z = cage_top + 0.030 + ROOF_H / 2.0
- add_cone(
+ roof_half = CAGE_W / 2.0 + EAVE
+ add_square_pyramid(
bm,
- (0.0, cage_y, roof_z),
- CAGE_W * 0.72,
- 0.014,
+ Vector((0.0, cage_y, cage_top - 0.001 + ROOF_H / 2.0)),
+ roof_half,
ROOF_H,
- 4,
BRASS_IDX,
)
add_cone(
bm,
- (0.0, cage_y, cage_top + 0.030 + ROOF_H + FINIAL_H * 0.40),
- 0.016,
- 0.016,
- FINIAL_H * 0.55,
+ (0.0, cage_y, apex_z + FINIAL_H * 0.22),
+ 0.014,
+ 0.014,
+ FINIAL_H * 0.44,
8,
BRASS_IDX,
)
add_cone(
bm,
- (0.0, cage_y, cage_top + 0.030 + ROOF_H + FINIAL_H * 0.88),
- 0.024,
- 0.004,
- FINIAL_H * 0.50,
+ (0.0, cage_y, apex_z + FINIAL_H * 0.62),
+ 0.020,
+ 0.006,
+ FINIAL_H * 0.40,
8,
BRASS_IDX,
)
@@ -437,6 +534,9 @@ def build_lantern_mesh(name, bevel_offset, bevel_segments):
clamp_overlap=True,
)
+ bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-5)
+ bmesh.ops.dissolve_degenerate(bm, dist=1e-6)
+ triangulate_ngons(bm)
xs = [v.co.x for v in bm.verts]
ys = [v.co.y for v in bm.verts]
zs = [v.co.z for v in bm.verts]
@@ -453,21 +553,20 @@ def build_lantern_mesh(name, bevel_offset, bevel_segments):
pack_uvs(bm)
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
for face in bm.faces:
- face.smooth = face.material_index != GLASS_IDX
+ face.smooth = face.material_index == GLASS_IDX
for edge in bm.edges:
mats = {f.material_index for f in edge.link_faces}
if GLASS_IDX in mats:
edge.smooth = False
+ elif edge.is_manifold and len(edge.link_faces) == 2:
+ edge.smooth = edge.calc_face_angle() < math.radians(35.0)
else:
- 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
+ edge.smooth = False
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
me.update()
for poly in me.polygons:
- poly.use_smooth = poly.material_index != GLASS_IDX
+ poly.use_smooth = poly.material_index == GLASS_IDX
finally:
bm.free()
obj = bpy.data.objects.new(name, me)
@@ -475,10 +574,11 @@ def build_lantern_mesh(name, bevel_offset, bevel_segments):
return obj
-def principled(name, color, metallic, roughness, emission=None):
+def principled(name, color, metallic, roughness, emission=None, roughness_var=0.0):
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
@@ -491,6 +591,18 @@ def principled(name, color, metallic, roughness, emission=None):
bsdf.inputs["Emission"].default_value = ecol
if "Emission Strength" in bsdf.inputs:
bsdf.inputs["Emission Strength"].default_value = strength
+ if roughness_var > 0.0:
+ noise = nt.nodes.new("ShaderNodeTexNoise")
+ noise.inputs["Scale"].default_value = 14.0
+ ramp = nt.nodes.new("ShaderNodeValToRGB")
+ lo = max(0.05, roughness - roughness_var)
+ hi = min(0.95, roughness + roughness_var)
+ ramp.color_ramp.elements[0].position = 0.30
+ ramp.color_ramp.elements[0].color = (lo, lo, lo, 1.0)
+ ramp.color_ramp.elements[1].position = 0.70
+ ramp.color_ramp.elements[1].color = (hi, hi, hi, 1.0)
+ nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
+ nt.links.new(ramp.outputs["Color"], bsdf.inputs["Roughness"])
return mat
@@ -539,6 +651,248 @@ def uv_stats(mesh):
return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+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):
+ # Combinatorics match examples/mesh-hygiene-audit.audit (copied, not imported).
+ 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)
+ bm.verts.ensure_lookup_table()
+ bm.edges.ensure_lookup_table()
+ 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):
+ """Nearest surface distance between two shells. Vert-vert misses mid-face seats."""
+ 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 None:
+ continue
+ best = min(best, hit[3])
+ for face in bm_a.faces:
+ hit = tree.find_nearest(face.calc_center_median())
+ if hit[0] is None:
+ continue
+ best = min(best, hit[3])
+ return best
+ finally:
+ bm_a.free()
+ bm_b.free()
+
+
+def joint_audit(me):
+ """Brace reaches the arm; roof stays under the arm."""
+ groups = shells(me)
+ boxes = [(g, shell_aabb(me, g), mat_of(me, g)) for g in groups]
+ arms = []
+ braces = []
+ roofs = []
+ hangers = []
+ posts = []
+ for g, a, mat in boxes:
+ dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2]
+ cy = 0.5 * (a[1] + a[4])
+ cz = 0.5 * (a[2] + a[5])
+ if mat == METAL_IDX and dy > ARM_LEN * 0.55 and dz < ARM_R * 4.0 and dx < ARM_R * 4.0:
+ arms.append((g, a))
+ continue
+ if mat == METAL_IDX and dz > POST_H * 0.7 and dx < POST_R_BOT * 3.0 and dy < POST_R_BOT * 3.0:
+ posts.append((g, a))
+ continue
+ if mat == METAL_IDX and dz > 0.10 and dy > 0.10 and dx < 0.08 and cz > 0.9:
+ braces.append((g, a))
+ continue
+ if mat == BRASS_IDX and dx > CAGE_W * 0.6 and dy > CAGE_W * 0.6 and cz > 0.9:
+ roofs.append((g, a))
+ if (
+ mat == BRASS_IDX
+ and dx < 0.06
+ and dy < 0.06
+ and cz > 0.9
+ ):
+ hangers.append((g, a))
+ brace_gap = 99.0
+ if arms and braces:
+ brace_gap = min(shell_bvh_gap(me, b[0], a[0]) for b in braces for a in arms)
+ roof_zmax = max((a[5] for _g, a in roofs), default=0.0)
+ arm_zmin = min((a[2] for _g, a in arms), default=99.0)
+ hanger_zmin = min((a[2] for _g, a in hangers), default=99.0)
+ return {
+ "parts": len(groups),
+ "arms": len(arms),
+ "braces": len(braces),
+ "roofs": len(roofs),
+ "posts": len(posts),
+ "hangers": len(hangers),
+ "brace_gap": brace_gap,
+ "arm_roof_clear": arm_zmin - roof_zmax,
+ "hanger_roof_gap": hanger_zmin - roof_zmax,
+ }
+
+
+def plumb_audit(me):
+ """XY centroid of the post's bottom slab vs top slab."""
+ drifts = []
+ for group in shells(me):
+ if mat_of(me, group) != METAL_IDX:
+ continue
+ a = shell_aabb(me, group)
+ dz = a[5] - a[2]
+ dx = a[3] - a[0]
+ dy = a[4] - a[1]
+ if dz < POST_H * 0.7 or dx > POST_R_BOT * 3.0 or dy > POST_R_BOT * 3.0:
+ continue
+ pts = [me.vertices[i].co for i in group]
+ zcut_lo = a[2] + 0.08 * dz
+ zcut_hi = a[5] - 0.08 * dz
+ lo = [p for p in pts if p.z <= zcut_lo]
+ hi = [p for p in pts if p.z >= zcut_hi]
+ if len(lo) < 3 or len(hi) < 3:
+ continue
+ c_lo = Vector((sum(p.x for p in lo) / len(lo), sum(p.y for p in lo) / len(lo)))
+ c_hi = Vector((sum(p.x for p in hi) / len(hi), sum(p.y for p in hi) / len(hi)))
+ drifts.append((c_hi - c_lo).length)
+ return max(drifts) if drifts else 99.0
+
+
+def post_size(me):
+ for group in shells(me):
+ if mat_of(me, group) != METAL_IDX:
+ continue
+ a = shell_aabb(me, group)
+ dz = a[5] - a[2]
+ dx = a[3] - a[0]
+ dy = a[4] - a[1]
+ if dz < POST_H * 0.7 or dx > POST_R_BOT * 3.0 or dy > POST_R_BOT * 3.0:
+ continue
+ return max(dx, dy), dz
+ return 0.0, 0.0
+
+
+def add_stray_vert(me):
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(me)
+ bm.verts.new((0.0, 0.0, FOOT_H + POST_H * 0.4))
+ bm.to_mesh(me)
+ me.update()
+ finally:
+ bm.free()
+
+
def make_lod(obj, name, ratio, skip_decimate):
mesh = obj.data.copy()
lod = bpy.data.objects.new(name, mesh)
@@ -631,22 +985,50 @@ def export_unity(path, objects):
)
-def check(skip_decimate):
+def check(
+ skip_decimate,
+ lift_z=False,
+ stray_vert=False,
+ float_brace=False,
+ sink_arm=False,
+ rake_post=False,
+):
bpy.ops.wm.read_factory_settings(use_empty=True)
- low = build_lantern_mesh("LanternLow", bevel_offset=0.004, bevel_segments=2)
- high = build_lantern_mesh("LanternHigh", bevel_offset=0.004, bevel_segments=4)
- metal = principled("LanternMetal", (0.045, 0.048, 0.055, 1.0), 0.88, 0.34)
+ low = build_lantern_mesh(
+ "LanternLow",
+ bevel_offset=0.004,
+ bevel_segments=2,
+ float_brace=float_brace,
+ sink_arm=sink_arm,
+ rake_post=rake_post,
+ )
+ high = build_lantern_mesh(
+ "LanternHigh",
+ bevel_offset=0.004,
+ bevel_segments=4,
+ float_brace=float_brace,
+ sink_arm=sink_arm,
+ rake_post=rake_post,
+ )
+ metal = principled("LanternMetal", (0.045, 0.048, 0.055, 1.0), 0.88, 0.34, roughness_var=0.08)
glass = principled(
"LanternGlass",
(0.62, 0.32, 0.08, 1.0),
0.0,
0.22,
- emission=((0.85, 0.42, 0.10, 1.0), 0.35),
+ emission=((0.85, 0.42, 0.10, 1.0), 0.45),
)
- brass = principled("LanternBrass", (0.72, 0.46, 0.14, 1.0), 1.0, 0.28)
+ brass = principled("LanternBrass", (0.72, 0.46, 0.14, 1.0), 1.0, 0.28, roughness_var=0.07)
assign_slots(low, metal, glass, brass)
assign_slots(high, metal, glass, brass)
+ 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("lantern mesh did not build", 3), None, None, None, None, None
@@ -663,6 +1045,11 @@ def check(skip_decimate):
size_x = bb[3] - bb[0]
size_y = bb[4] - bb[1]
size_z = bb[5] - bb[2]
+ hyg = hygiene_audit(low.data)
+ zf = zfight_pairs(low.data)
+ jnt = joint_audit(low.data)
+ plumb = plumb_audit(low.data)
+ pxy, pz = post_size(low.data)
img, tex = setup_bake_image(low, metal)
if img is None:
@@ -693,9 +1080,7 @@ 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}"
- )
+ 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}"
@@ -712,6 +1097,19 @@ 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 brace_gap={jnt['brace_gap']:.5f} "
+ f"arm_roof_clear={jnt['arm_roof_clear']:.5f} "
+ f"hanger_roof_gap={jnt['hanger_roof_gap']:.5f} "
+ f"plumb={plumb:.5f} post_size=({pxy:.4f},{pz:.4f}) "
+ f"parts={jnt['parts']} arms={jnt['arms']} braces={jnt['braces']} "
+ f"roofs={jnt['roofs']} hangers={jnt['hangers']}"
+ )
if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
return fail(
@@ -723,6 +1121,11 @@ def check(skip_decimate):
f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
5,
), None, None, None, None, None
+ if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+ return fail(
+ f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
+ 5,
+ ), None, None, None, None, None
if idx_counts.get(GLASS_IDX, 0) < GLASS_FACES_MIN:
return fail(
f"glass faces {idx_counts.get(GLASS_IDX, 0)} < {GLASS_FACES_MIN}",
@@ -776,6 +1179,47 @@ 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
+ if bb[2] > ZMIN_EPS:
+ return fail(f"grounded zmin={bb[2]:.5f}", 16), None, None, None, None, None
+ if jnt["braces"] < 1 or jnt["brace_gap"] > BRACE_GAP_MAX:
+ return fail(
+ f"brace gap {jnt['brace_gap']:.5f} braces={jnt['braces']}",
+ 17,
+ ), None, None, None, None, None
+ if jnt["arm_roof_clear"] < ARM_ROOF_CLEAR_MIN:
+ return fail(
+ f"arm-roof clearance {jnt['arm_roof_clear']:.5f}",
+ 18,
+ ), None, None, None, None, None
+ if jnt["hangers"] < 1 or jnt["hanger_roof_gap"] > HANGER_ROOF_GAP_MAX:
+ return fail(
+ f"hanger-roof gap {jnt['hanger_roof_gap']:.5f} hangers={jnt['hangers']}",
+ 18,
+ ), None, None, None, None, None
+ if (
+ plumb > PLUMB_MAX
+ or abs(pxy - POST_SIZE[0]) > POST_SIZE_TOL[0]
+ or abs(pz - POST_SIZE[1]) > POST_SIZE_TOL[1]
+ ):
+ return fail(
+ f"plumb {plumb:.5f} post_size=({pxy:.4f},{pz:.4f})",
+ 19,
+ ), None, None, None, None, None
return 0, low, high, metal, tex, collider
@@ -846,7 +1290,7 @@ def light(name, loc, energy, size, col, rot):
cam.location = (2.55, -3.70, 1.85)
scene.collection.objects.link(cam)
aim = bpy.data.objects.new("Aim", None)
- aim.location = (0.0, 0.0, 0.78)
+ aim.location = (0.0, 0.0, 0.72)
scene.collection.objects.link(aim)
con = cam.constraints.new("TRACK_TO")
con.target = aim
@@ -889,14 +1333,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-brace", action="store_true")
+ p.add_argument("--sink-arm", action="store_true")
+ p.add_argument("--rake-post", action="store_true")
args = p.parse_args(argv)
- code, low, _high, metal, tex, _col = check(args.skip_decimate)
+ code, low, _high, metal, tex, _col = check(
+ args.skip_decimate,
+ lift_z=args.lift_z,
+ stray_vert=args.stray_vert,
+ float_brace=args.float_brace,
+ sink_arm=args.sink_arm,
+ rake_post=args.rake_post,
+ )
if code:
return code
if args.output:
diff --git a/showcase/water-trough/README.md b/showcase/water-trough/README.md
index 51b873b..41a2738 100644
--- a/showcase/water-trough/README.md
+++ b/showcase/water-trough/README.md
@@ -1,10 +1,16 @@
# Water trough
A showcase piece, not an example. Procedural staved water trough on a
-timber stand (watertight U-hull, plank end-caps, contained water, iron
+timber stand (watertight U-hull, U end-caps, contained water, iron
straps, trestle legs) then the shipped pipeline: unique-cell UVs, Cycles
high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
+The hull, ends, straps and water all sample the same YZ arc. End caps
+are U-boards the staves tenon into, not a bounding-box slab around the
+U. Straps sit at `STRAP_X`, offset from `LEG_X`, so the trestle does not
+punch through the iron. Stretchers span the inner faces of the legs.
+`--round-band` is not used; `--float-strap` is the seat falsifier.
+
It asserts **budget conformance** of the generated result. It does not
witness an API contract. "It rendered without error" is not a check.
@@ -13,6 +19,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.08 m tray length, 0.44 m across the U, 0.50 m overall
+height; outer AABB 1.093 × 0.552 × 0.504 m.
## Budgets
@@ -21,38 +32,93 @@ materials, UVs, evaluated LOD, collider, or export file.
| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) |
| --- | --- | --- |
-| Base triangles | 2320–2650 | 2484 / 2484 / 2484 |
+| Base triangles | 3300–4000 | 3700 / 3700 / 3700 |
| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 |
-| LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2166 |
-| Materials | exactly 3 distinct, ≥24 metal and ≥6 water faces | 3 slots, 192 metal, 6 water |
+| LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.2200 |
+| Materials | exactly 3 distinct; ≥24 wood, ≥24 metal, ≥6 water | 3 slots; 1674 / 156 / 29 |
| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
-| Outer AABB | (1.152, 0.568, 0.509) m ± 0.01 | (1.1520, 0.5680, 0.5086), zmin 0 |
-| Collider tris | ≤ 80 | 32 |
-| Export | written, size > 0 | 180748 / 180748 / 180728 bytes |
+| Outer AABB | (1.093, 0.552, 0.504) m ± 0.01 | (1.0932, 0.5520, 0.5040), zmin 0 |
+| Collider tris | ≤ 80 | 64 |
+| Export | written, size > 0 | 268840 / 268840 / 268824 bytes |
+
+Base triangles rose from **2484 to 3700** in the quality pass: box end
+slabs and a single extruded U became ten jittered staves, U end-caps,
+continuous straps offset from the legs, and stretchers that meet the
+inner faces.
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 20 B on 5.2.1 (glTF serializer), not a gated axis.
+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. Stave-width jitter uses closed-form `sin(i)`, no RNG.
+Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a
+gated axis.
+
+The hull-size budget (exit 19) runs **before** the outer AABB budget
+(exit 8) so `--narrow-hull` is not swallowed by a moved bounding box.
+
+### 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 supports: 4 shoes | each `zmin` ≤ 0.001 | 4, shoe_z 0.00000 |
+| Hull plan | 1.08 m × 0.44 m ± 0.08 | 1.0448 × 0.4661 |
+
+### Joint fit and seat
+
+| Axis | Declared | Measured (all three) |
+| --- | --- | --- |
+| End-cap rim Z-span | ≤ 0.080 m | 0.0050 |
+| Strap-to-hull BVH gap | ≤ 0.008 m | 0.00000 |
+| Water-to-hull BVH gap | ≤ 0.008 m | 0.00260 |
+
+### Falsifiers
+
+Each violates one named budget. All seven 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-legs` | named shoe supports at Z=0 | 16 |
+| `--box-ends` | end-cap rim span | 17 |
+| `--float-strap` | strap-to-hull gap | 18 |
+| `--narrow-hull` | hull plan vs stated size | 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.
+`--short-legs` lifts the shoes only, so AABB `zmin` stays 0 (the legs
+still plant) and the named-support gate is what fires.
## Run
```bash
blender --background --python water_trough.py --
blender --background --python water_trough.py -- --skip-decimate
+blender --background --python water_trough.py -- --stray-vert
+blender --background --python water_trough.py -- --lift-z
+blender --background --python water_trough.py -- --short-legs
+blender --background --python water_trough.py -- --box-ends
+blender --background --python water_trough.py -- --float-strap
+blender --background --python water_trough.py -- --narrow-hull
blender --background --python water_trough.py -- --output trough.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 |
| --- | --- |
@@ -61,7 +127,7 @@ File-local. `9` is a valid check code. `10` is reserved for
| 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/metal/water faces missing |
+| 5 | Material count ≠ 3 distinct slots, or a face-count floor missed |
| 6 | UVs outside 0..1 |
| 7 | UV AABB overlap above tolerance |
| 8 | World AABB off declared outer size |
@@ -71,3 +137,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 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, z-fight |
+| 16 | Not grounded: bounding box `zmin` off 0, or a named shoe floats |
+| 17 | Joint fit: end-cap U-rim span (`--box-ends`) |
+| 18 | Seat: strap or water BVH gap (`--float-strap`) |
+| 19 | Hull length or width off the stated real-world size (`--narrow-hull`) |
diff --git a/showcase/water-trough/preview.webp b/showcase/water-trough/preview.webp
index f2dab32..39c58dc 100644
Binary files a/showcase/water-trough/preview.webp and b/showcase/water-trough/preview.webp differ
diff --git a/showcase/water-trough/water_trough.py b/showcase/water-trough/water_trough.py
index b42b5f6..510ca6f 100644
--- a/showcase/water-trough/water_trough.py
+++ b/showcase/water-trough/water_trough.py
@@ -1,18 +1,26 @@
"""Game-ready wooden water trough — a showcase piece, not an example.
Asserts budget conformance of a procedural staved trough on a timber
-stand (watertight U-hull, plank end-caps, contained water, iron straps,
-trestle legs) after composing shipped pipeline pieces: bmesh
-construction, UVs, three materials, high-to-low normal bake, LOD chain,
-convex collider, Unity glTF export.
+stand (U-staves and U end-caps from one radius function, contained
+water, iron straps lofted on that same host, trestle legs) after
+composing shipped pipeline pieces: bmesh construction, UVs, three
+materials, high-to-low normal bake, LOD chain, convex collider, Unity
+glTF export.
+
+The hull, ends, straps and water all sample the same YZ arc. End caps
+are U-boards the staves tenon into, not a bounding-box slab around the
+U. Legs run from a hull-outer station to a shoe at Z=0.
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.
+They are not API-contract witnesses. Each falsifier violates one named
+budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh
+hygiene, ``--lift-z`` grounded zmin, ``--short-legs`` named shoe
+supports, ``--box-ends`` end-cap U-fit, ``--float-strap`` strap seat,
+``--narrow-hull`` hull real-world size.
-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. Stave seams use closed-form ``sin(i)``. 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 water_trough.py --
blender --background --python water_trough.py -- --skip-decimate
@@ -28,6 +36,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)
@@ -38,15 +47,34 @@
TRAY_L = 1.08
TRAY_R = 0.22
-STAVE_T = 0.030
-N_STAVES = 12
-A_SPAN = 1.38
-END_T = 0.050
+STAVE_T = 0.032
+N_STAVES = 10
+A_SPAN = 1.20
+END_T = 0.044
+END_OVERHANG = 0.010
+STAVE_GAP = 0.008
+STRAP_W = 0.028
+STRAP_T = 0.008
+STRAP_BITE = 0.008
+SHOE_H = 0.028
+SHOE_XY = (0.058, 0.052)
+LEG_X = TRAY_L * 0.28
+STRAP_X = TRAY_L * 0.18
+LEG_TOP_Y = 0.090
+LEG_BOT_Y = 0.250
+STRETCHER_Z = 0.115
+STRETCHER_T = 0.028
+LEG_W = 0.048
+WATER_GAP = 0.004
+WATER_FILL = 0.68
BBOX_TOL = 0.01
-OUTER_SIZE = (1.152, 0.568, 0.509)
-BASE_TRIS_MIN = 2320
-BASE_TRIS_MAX = 2650
+OUTER_SIZE = (1.093, 0.552, 0.504)
+HULL_SIZE = (1.08, 0.44)
+HULL_SIZE_TOL = (0.08, 0.08)
+NARROW_HULL_R = 0.12
+BASE_TRIS_MIN = 3300
+BASE_TRIS_MAX = 4000
LOD1_RATIO_MIN = 0.32
LOD1_RATIO_MAX = 0.62
LOD2_RATIO_MIN = 0.10
@@ -63,6 +91,19 @@
WOOD_FACES_MIN = 24
WATER_FACES_MIN = 6
+ZMIN_EPS = 1e-4
+DOUBLES_EPS = 1e-5
+AREA_EPS = 1e-10
+ZFIGHT_EPS = 1e-4
+ZFIGHT_COS = 0.998
+LIFT_Z = 0.05
+SHOE_COUNT = 4
+SHOE_ZMIN_MAX = 1e-3
+END_RIM_SPAN_MAX = 0.080
+STRAP_GAP_MAX = 0.008
+WATER_CONTACT_MAX = 0.008
+PLUMB_MAX = 0.010
+
WOOD_IDX = 0
METAL_IDX = 1
WATER_IDX = 2
@@ -142,6 +183,11 @@ def _arc_point(a, radius, x, zc):
return Vector((x, radius * math.sin(a), zc - radius * math.cos(a)))
+def hull_z_at_y(y, radius, zc):
+ y = max(-radius + 1e-6, min(radius - 1e-6, y))
+ return zc - math.sqrt(radius * radius - y * y)
+
+
def add_u_shell(bm, x0, x1, r_in, r_out, n_seg, zc, a_span, mat_idx):
in0, out0, in1, out1 = [], [], [], []
for i in range(n_seg + 1):
@@ -161,60 +207,63 @@ def add_u_shell(bm, x0, x1, r_in, r_out, n_seg, zc, a_span, mat_idx):
return collected
-def add_end_plank(bm, x_end, toward, radius, zc, a_span, thick, mat_idx):
- y_span = 2.0 * radius * math.sin(a_span) + 0.046
- z_lo = zc - radius - 0.008
- z_hi = zc - radius * math.cos(a_span) + 0.036
+def add_stave(bm, a0, a1, x0, x1, r_in, r_out, zc, mat_idx):
+ def ring(x):
+ return [
+ bm.verts.new(_arc_point(a0, r_out, x, zc)),
+ bm.verts.new(_arc_point(a1, r_out, x, zc)),
+ bm.verts.new(_arc_point(a1, r_in, x, zc)),
+ bm.verts.new(_arc_point(a0, r_in, x, zc)),
+ ]
+
+ r0 = ring(x0)
+ r1 = ring(x1)
+ _face(bm, (r0[0], r0[1], r1[1], r1[0]), mat_idx)
+ _face(bm, (r0[2], r0[3], r1[3], r1[2]), mat_idx)
+ _face(bm, (r0[1], r0[2], r1[2], r1[1]), mat_idx)
+ _face(bm, (r0[3], r0[0], r1[0], r1[3]), mat_idx)
+ _face(bm, (r0[0], r0[3], r0[2], r0[1]), mat_idx)
+ _face(bm, (r1[0], r1[1], r1[2], r1[3]), mat_idx)
+ return r0 + r1
+
+
+def add_box_end(bm, x_mid, radius, zc, a_span, thick, mat_idx):
+ y_span = 2.0 * radius * math.sin(a_span)
+ z_lo = zc - radius
+ z_hi = zc - radius * math.cos(a_span)
z_mid = 0.5 * (z_lo + z_hi)
- x_mid = x_end + toward * (thick * 0.5 - 0.014)
- return add_box(
+ verts = add_box(
bm,
(x_mid, 0.0, z_mid),
(thick, y_span, z_hi - z_lo),
mat_idx,
)
+ edges = list({e for v in verts for e in v.link_edges})
+ bmesh.ops.subdivide_edges(bm, edges=edges, cuts=6, use_grid_fill=True)
+ return verts
-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_u_water(bm, x0, x1, r, n_seg, zc, z_water, mat_idx):
+ ca = max(-1.0, min(1.0, (zc - z_water) / r))
+ a_wl = math.acos(ca)
+ ring0, ring1 = [], []
+ for i in range(n_seg + 1):
+ a = -a_wl + 2.0 * a_wl * i / n_seg
+ ring0.append(bm.verts.new(_arc_point(a, r, x0, zc)))
+ ring1.append(bm.verts.new(_arc_point(a, r, x1, zc)))
+ collected = ring0 + ring1
+ for i in range(n_seg):
+ _face(bm, (ring0[i], ring0[i + 1], ring1[i + 1], ring1[i]), mat_idx)
+ _face(bm, (ring0[0], ring1[0], ring1[n_seg], ring0[n_seg]), mat_idx)
+ _face(bm, tuple(reversed(ring0)), mat_idx)
+ _face(bm, tuple(ring1), mat_idx)
+ return collected
-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,
- segments=segments,
- radius1=radius1,
- radius2=radius2,
- 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 triangulate_ngons(bm):
+ faces = [f for f in bm.faces if len(f.verts) > 4]
+ if faces:
+ bmesh.ops.triangulate(bm, faces=faces)
def pack_uvs(bm, margin=0.08):
@@ -260,54 +309,86 @@ def pack_uvs(bm, margin=0.08):
)
-def build_trough_mesh(name, bevel_offset, bevel_segments):
+def build_trough_mesh(
+ name,
+ bevel_offset,
+ bevel_segments,
+ box_ends=False,
+ float_strap=False,
+ short_legs=False,
+ narrow_hull=False,
+):
bm = bmesh.new()
try:
wood = []
- metal = []
- zc = 0.30 + TRAY_R
- cx = 0.0
- r_in = TRAY_R
- r_out = TRAY_R + STAVE_T
- x0 = cx - TRAY_L / 2.0
- x1 = cx + TRAY_L / 2.0
- wood.extend(
- add_u_shell(bm, x0, x1, r_in, r_out, N_STAVES, zc, A_SPAN, WOOD_IDX)
- )
- wood.extend(add_end_plank(bm, x0, -1.0, r_out, zc, A_SPAN, END_T, WOOD_IDX))
- wood.extend(add_end_plank(bm, x1, 1.0, r_out, zc, A_SPAN, END_T, WOOD_IDX))
+ tray_r = NARROW_HULL_R if narrow_hull else TRAY_R
+ zc = SHOE_H + 0.335 + TRAY_R
+ r_in = tray_r
+ r_out = tray_r + STAVE_T
+ x_cap_l0 = -TRAY_L / 2.0 - END_T * 0.15
+ x_cap_l1 = -TRAY_L / 2.0 + END_T * 0.85
+ x_cap_r0 = TRAY_L / 2.0 - END_T * 0.85
+ x_cap_r1 = TRAY_L / 2.0 + END_T * 0.15
+ x_stave_0 = -TRAY_L / 2.0 + END_T * 0.40
+ x_stave_1 = TRAY_L / 2.0 - END_T * 0.40
+
+ span = 2.0 * A_SPAN
+ usable = span - STAVE_GAP * N_STAVES
+ width = usable / N_STAVES
+ a = -A_SPAN + STAVE_GAP * 0.5
+ for i in range(N_STAVES):
+ jitter = 0.0035 * math.sin(i * 1.7 + 0.4)
+ a0 = a
+ a1 = a + width + jitter
+ wood.extend(
+ add_stave(bm, a0, a1, x_stave_0, x_stave_1, r_in, r_out, zc, WOOD_IDX)
+ )
+ a = a1 + STAVE_GAP
- for a_lip in (-A_SPAN, A_SPAN):
+ if not box_ends:
+ r_cap_in = r_in - 0.006
+ r_cap_out = r_out + END_OVERHANG
wood.extend(
- add_box(
- bm,
- _arc_point(a_lip, r_out, cx, zc),
- (TRAY_L - 0.04, 0.038, 0.032),
- WOOD_IDX,
- euler=(a_lip, 0.0, 0.0),
+ add_u_shell(
+ bm, x_cap_l0, x_cap_l1, r_cap_in, r_cap_out, N_STAVES, zc, A_SPAN, WOOD_IDX
+ )
+ )
+ wood.extend(
+ add_u_shell(
+ bm, x_cap_r0, x_cap_r1, r_cap_in, r_cap_out, N_STAVES, zc, A_SPAN, WOOD_IDX
)
)
- for sx in (-TRAY_L * 0.30, TRAY_L * 0.30):
+ top_z = hull_z_at_y(LEG_TOP_Y, r_out, zc)
+ bot_z = SHOE_H * 0.55
+ shoe_mid = SHOE_H / 2.0
+ if short_legs:
+ shoe_mid += 0.045
+ for sx in (-LEG_X, LEG_X):
for ysign in (-1.0, 1.0):
- top = (sx, ysign * 0.11, zc - TRAY_R - 0.04)
- bot = (sx, ysign * 0.26, 0.024)
- wood.extend(
- add_oriented_box(bm, top, bot, (0.050, 0.036), WOOD_IDX)
- )
+ top_pt = Vector((sx, ysign * LEG_TOP_Y, top_z))
+ bot_pt = Vector((sx, ysign * LEG_BOT_Y, bot_z))
+ direction = (top_pt - bot_pt).normalized()
+ top_pt = top_pt + direction * (STAVE_T * 0.35)
+ wood.extend(add_oriented_box(bm, top_pt, bot_pt, (LEG_W, 0.034), WOOD_IDX))
+ t_st = (STRETCHER_Z - bot_z) / max(top_z - bot_z, 1e-6)
+ y_leg = LEG_BOT_Y + t_st * (LEG_TOP_Y - LEG_BOT_Y)
+ # Inner face of the diagonal leg, not through its volume.
+ side_len = 2.0 * (y_leg - 0.022)
wood.extend(
add_box(
bm,
- (sx, 0.0, 0.115),
- (0.044, 0.40, 0.030),
+ (sx, 0.0, STRETCHER_Z),
+ (0.042, side_len, STRETCHER_T),
WOOD_IDX,
)
)
+ long_len = 2.0 * LEG_X - LEG_W
wood.extend(
add_box(
bm,
- (0.0, 0.0, 0.115),
- (TRAY_L * 0.62, 0.044, 0.030),
+ (0.0, 0.0, STRETCHER_Z),
+ (long_len, 0.042, STRETCHER_T),
WOOD_IDX,
)
)
@@ -326,40 +407,54 @@ def build_trough_mesh(name, bevel_offset, bevel_segments):
for f in ret.get("faces") or []:
f.material_index = WOOD_IDX
- r_strap = r_out + 0.006
- for sx in (-TRAY_L * 0.28, TRAY_L * 0.28):
- n = 14
- pts = []
- for k in range(n + 1):
- a = -A_SPAN + 2.0 * A_SPAN * k / n
- pts.append(_arc_point(a, r_strap, cx + sx, zc))
- for p0, p1 in zip(pts, pts[1:]):
- metal.extend(
- add_oriented_box(bm, p0, p1, (0.022, 0.011), METAL_IDX)
- )
- for sx in (-TRAY_L * 0.30, TRAY_L * 0.30):
+ if box_ends:
+ add_box_end(
+ bm, 0.5 * (x_cap_l0 + x_cap_l1), r_out, zc, A_SPAN, END_T, WOOD_IDX
+ )
+ add_box_end(
+ bm, 0.5 * (x_cap_r0 + x_cap_r1), r_out, zc, A_SPAN, END_T, WOOD_IDX
+ )
+
+ r_strap_in = r_out + 0.040 if float_strap else r_out - STRAP_BITE
+ r_strap_out = r_strap_in + STRAP_T
+ for sx in (-STRAP_X, STRAP_X):
+ add_u_shell(
+ bm,
+ sx - STRAP_W / 2.0,
+ sx + STRAP_W / 2.0,
+ r_strap_in,
+ r_strap_out,
+ 16,
+ zc,
+ A_SPAN,
+ METAL_IDX,
+ )
+ for sx in (-LEG_X, LEG_X):
for ysign in (-1.0, 1.0):
- metal.extend(
- add_box(
- bm,
- (sx, ysign * 0.26, 0.012),
- (0.056, 0.048, 0.024),
- METAL_IDX,
- )
+ add_box(
+ bm,
+ (sx, ysign * LEG_BOT_Y, shoe_mid),
+ (SHOE_XY[0], SHOE_XY[1], SHOE_H),
+ METAL_IDX,
)
z_bot = zc - r_in
z_rim = zc - r_in * math.cos(A_SPAN)
- z_water = z_bot + 0.70 * (z_rim - z_bot)
- ca = (zc - z_water) / r_in
- half_w = r_in * math.sqrt(max(0.0, 1.0 - ca * ca)) * 0.97
- add_box(
+ z_water = z_bot + WATER_FILL * (z_rim - z_bot)
+ add_u_water(
bm,
- (cx, 0.0, z_water),
- (TRAY_L - 2.0 * END_T - 0.08, 2.0 * half_w, 0.016),
+ x_cap_l1 - 0.010,
+ x_cap_r0 + 0.010,
+ r_in - WATER_GAP,
+ N_STAVES,
+ zc,
+ z_water,
WATER_IDX,
)
+ triangulate_ngons(bm)
+ bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-5)
+ bmesh.ops.dissolve_degenerate(bm, dist=1e-6)
xs = [v.co.x for v in bm.verts]
ys = [v.co.y for v in bm.verts]
zs = [v.co.z for v in bm.verts]
@@ -389,13 +484,26 @@ def build_trough_mesh(name, bevel_offset, bevel_segments):
return out
-def principled(name, color, metallic, roughness):
+def principled(name, color, metallic, roughness, roughness_var=0.0):
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 roughness_var > 0.0:
+ noise = nt.nodes.new("ShaderNodeTexNoise")
+ noise.inputs["Scale"].default_value = 11.0
+ ramp = nt.nodes.new("ShaderNodeValToRGB")
+ lo = max(0.08, roughness - roughness_var)
+ hi = min(0.95, roughness + roughness_var)
+ ramp.color_ramp.elements[0].position = 0.28
+ ramp.color_ramp.elements[0].color = (lo, lo, lo, 1.0)
+ ramp.color_ramp.elements[1].position = 0.72
+ ramp.color_ramp.elements[1].color = (hi, hi, hi, 1.0)
+ nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
+ nt.links.new(ramp.outputs["Color"], bsdf.inputs["Roughness"])
return mat
@@ -442,6 +550,246 @@ def uv_stats(mesh):
return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+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):
+ # Combinatorics match examples/mesh-hygiene-audit.audit (copied, not imported).
+ 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)
+ bm.verts.ensure_lookup_table()
+ bm.edges.ensure_lookup_table()
+ 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 None:
+ continue
+ best = min(best, hit[3])
+ for face in bm_a.faces:
+ hit = tree.find_nearest(face.calc_center_median())
+ if hit[0] is None:
+ continue
+ best = min(best, hit[3])
+ return best
+ finally:
+ bm_a.free()
+ bm_b.free()
+
+
+def min_vert_gap(me, ga, gb):
+ pa = [me.vertices[i].co for i in ga]
+ pb = [me.vertices[i].co for i in gb]
+ step_a = max(1, len(pa) // 80)
+ step_b = max(1, len(pb) // 80)
+ best = 1e9
+ for a in pa[::step_a]:
+ for b in pb[::step_b]:
+ d = (a - b).length
+ if d < best:
+ best = d
+ return best
+
+
+def support_audit(me):
+ shoes = []
+ for group in shells(me):
+ if mat_of(me, group) != METAL_IDX:
+ continue
+ a = shell_aabb(me, group)
+ dz = a[5] - a[2]
+ dx = a[3] - a[0]
+ dy = a[4] - a[1]
+ cz = 0.5 * (a[2] + a[5])
+ if cz > 0.08 or dz > 0.06:
+ continue
+ if max(dx, dy) < 0.03:
+ continue
+ shoes.append(a)
+ zmin = min((a[2] for a in shoes), default=99.0)
+ return {"shoes": len(shoes), "shoe_z": zmin}
+
+
+def joint_audit(me):
+ groups = shells(me)
+ boxes = [(g, shell_aabb(me, g), mat_of(me, g)) for g in groups]
+ staves = []
+ ends = []
+ straps = []
+ waters = []
+ for g, a, mat in boxes:
+ dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2]
+ cx = 0.5 * (a[0] + a[3])
+ if mat == WOOD_IDX and dx > TRAY_L * 0.5:
+ staves.append((g, a))
+ continue
+ if mat == WOOD_IDX and dx < END_T * 3.5 and dy > 0.25 and abs(cx) > TRAY_L * 0.35:
+ ends.append((g, a))
+ continue
+ if mat == METAL_IDX and dy > 0.15 and dz > 0.10:
+ straps.append((g, a))
+ continue
+ if mat == WATER_IDX:
+ waters.append((g, a))
+ hulls = staves
+ rim_span = 0.0
+ for g, a in ends:
+ pts = [me.vertices[i].co for i in g]
+ ymax = max(abs(p.y) for p in pts)
+ rim = [p for p in pts if abs(abs(p.y) - ymax) < 0.025]
+ if rim:
+ span = max(p.z for p in rim) - min(p.z for p in rim)
+ if span > rim_span:
+ rim_span = span
+ strap_gap = 99.0
+ if straps and hulls:
+ strap_gap = min(shell_bvh_gap(me, s[0], h[0]) for s in straps for h in hulls)
+ water_gap = 99.0
+ if waters and hulls:
+ water_gap = min(shell_bvh_gap(me, w[0], h[0]) for w in waters for h in hulls)
+ hull_xy = (0.0, 0.0)
+ if staves:
+ xs = [v for _g, a in staves for v in (a[0], a[3])]
+ ys = [v for _g, a in staves for v in (a[1], a[4])]
+ hull_xy = (max(xs) - min(xs), max(ys) - min(ys))
+ return {
+ "parts": len(groups),
+ "hulls": len(hulls),
+ "ends": len(ends),
+ "straps": len(straps),
+ "waters": len(waters),
+ "rim_span": rim_span,
+ "strap_gap": strap_gap,
+ "water_gap": water_gap,
+ "hull_xy": hull_xy,
+ }
+
+
+def add_stray_vert(me):
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(me)
+ bm.verts.new((0.0, 0.0, 0.25))
+ bm.to_mesh(me)
+ me.update()
+ finally:
+ bm.free()
+
+
def make_lod(obj, name, ratio, skip_decimate):
mesh = obj.data.copy()
lod = bpy.data.objects.new(name, mesh)
@@ -528,16 +876,47 @@ def export_unity(path, objects):
)
-def check(skip_decimate):
+def check(
+ skip_decimate,
+ lift_z=False,
+ stray_vert=False,
+ box_ends=False,
+ float_strap=False,
+ short_legs=False,
+ narrow_hull=False,
+):
bpy.ops.wm.read_factory_settings(use_empty=True)
- low = build_trough_mesh("TroughLow", bevel_offset=0.005, bevel_segments=2)
- high = build_trough_mesh("TroughHigh", bevel_offset=0.005, bevel_segments=4)
- wood = principled("TroughWood", (0.40, 0.22, 0.08, 1.0), 0.0, 0.58)
- metal = principled("TroughIron", (0.10, 0.105, 0.12, 1.0), 1.0, 0.30)
+ low = build_trough_mesh(
+ "TroughLow",
+ bevel_offset=0.004,
+ bevel_segments=2,
+ box_ends=box_ends,
+ float_strap=float_strap,
+ short_legs=short_legs,
+ narrow_hull=narrow_hull,
+ )
+ high = build_trough_mesh(
+ "TroughHigh",
+ bevel_offset=0.004,
+ bevel_segments=4,
+ box_ends=box_ends,
+ float_strap=float_strap,
+ short_legs=short_legs,
+ narrow_hull=narrow_hull,
+ )
+ wood = principled("TroughWood", (0.40, 0.22, 0.08, 1.0), 0.0, 0.58, roughness_var=0.10)
+ metal = principled("TroughIron", (0.10, 0.105, 0.12, 1.0), 1.0, 0.32, roughness_var=0.08)
water = principled("TroughWater", (0.06, 0.16, 0.18, 1.0), 0.0, 0.08)
assign_slots(low, wood, metal, water)
assign_slots(high, wood, metal, water)
+ 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("trough mesh did not build", 3), None, None, None, None, None
@@ -554,6 +933,10 @@ def check(skip_decimate):
size_x = bb[3] - bb[0]
size_y = bb[4] - bb[1]
size_z = bb[5] - bb[2]
+ hyg = hygiene_audit(low.data)
+ zf = zfight_pairs(low.data)
+ sup = support_audit(low.data)
+ jnt = joint_audit(low.data)
img, tex = setup_bake_image(low, wood)
if img is None:
@@ -599,6 +982,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 shoes={sup['shoes']} shoe_z={sup['shoe_z']:.5f} "
+ f"rim_span={jnt['rim_span']:.4f} strap_gap={jnt['strap_gap']:.5f} "
+ f"water_gap={jnt['water_gap']:.5f} hull_xy={jnt['hull_xy']} "
+ f"ends={jnt['ends']} straps={jnt['straps']}"
+ )
if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
return fail(
@@ -635,6 +1029,12 @@ def check(skip_decimate):
f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
7,
), None, None, None, None, None
+ hx, hy = jnt["hull_xy"]
+ if abs(hx - HULL_SIZE[0]) > HULL_SIZE_TOL[0] or abs(hy - HULL_SIZE[1]) > HULL_SIZE_TOL[1]:
+ return fail(
+ f"hull size ({hx:.4f},{hy:.4f}) off {HULL_SIZE}",
+ 19,
+ ), None, None, None, None, None
if (
abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
@@ -668,6 +1068,46 @@ 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
+ if (
+ bb[2] > ZMIN_EPS
+ or sup["shoes"] != SHOE_COUNT
+ or sup["shoe_z"] > SHOE_ZMIN_MAX
+ ):
+ return fail(
+ f"grounded zmin={bb[2]:.5f} shoes={sup['shoes']} "
+ f"shoe_z={sup['shoe_z']:.5f}",
+ 16,
+ ), None, None, None, None, None
+ if jnt["ends"] < 2 or jnt["rim_span"] > END_RIM_SPAN_MAX:
+ return fail(
+ f"end rim span {jnt['rim_span']:.4f} ends={jnt['ends']}",
+ 17,
+ ), None, None, None, None, None
+ if jnt["straps"] < 2 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["water_gap"] > WATER_CONTACT_MAX:
+ return fail(
+ f"water-hull gap {jnt['water_gap']:.5f}",
+ 18,
+ ), None, None, None, None, None
return 0, low, high, wood, tex, collider
@@ -781,14 +1221,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-legs", action="store_true")
+ p.add_argument("--box-ends", action="store_true")
+ p.add_argument("--float-strap", action="store_true")
+ p.add_argument("--narrow-hull", 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,
+ box_ends=args.box_ends,
+ float_strap=args.float_strap,
+ short_legs=args.short_legs,
+ narrow_hull=args.narrow_hull,
+ )
if code:
return code
if args.output: