Skip to content

Commit 1812640

Browse files
TMHSDigitalclaude
andcommitted
fix(examples): seat the triangulate-tangents buckler on the floor
The display stand hung 0.49 m above the floor with nothing under it, and nothing held the buckler's 20-degree backward lean. The stand now rests on the floor, the rim's lowest evaluated vertex bites 1 cm into its top, and a kickstand strut runs from the buckler's back (found by a local-space ray) to the floor. The camera and aim ride with the derived lift. Render path only; exit codes are unchanged on 4.5.11, 5.1.2 and 5.2.1. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
1 parent b9e6aa7 commit 1812640

6 files changed

Lines changed: 124 additions & 47 deletions

File tree

5.44 KB
Loading
15.8 KB
Loading

‎docs/gallery/triangulate-tangents/index.html‎

Lines changed: 63 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@
1616
<meta property="og:image" content="https://tmhsdigital.github.io/Blender-Developer-Tools/gallery/assets/triangulate-tangents-hero.webp" />
1717
<meta property="og:image:width" content="1280" />
1818
<meta property="og:image:height" content="720" />
19-
<meta property="og:image:alt" content="A round silver buckler on a stand, its face brushed in circular grooves with an anisotropic highlight." />
19+
<meta property="og:image:alt" content="A round silver buckler leaning back on a low stand, its face brushed in circular grooves with an anisotropic highlight." />
2020
<meta property="og:site_name" content="Blender Developer Tools" />
2121
<meta name="twitter:card" content="summary_large_image" />
2222
<meta name="twitter:title" content="triangulate-tangents — Examples — Blender Developer Tools" />
2323
<meta name="twitter:description" content="A machined buckler verifying the tangent-space contract a game engine&#x27;s normal mapping depends on. Deterministic triangulation, unit orthogonal tangent frames, and the edge/UV-delta formula matching mikktspace within welding tolerance." />
2424
<meta name="twitter:image" content="https://tmhsdigital.github.io/Blender-Developer-Tools/gallery/assets/triangulate-tangents-hero.webp" />
25-
<meta name="twitter:image:alt" content="A round silver buckler on a stand, its face brushed in circular grooves with an anisotropic highlight." />
25+
<meta name="twitter:image:alt" content="A round silver buckler leaning back on a low stand, its face brushed in circular grooves with an anisotropic highlight." />
2626
<style>
2727
/* fonts are deployed by the landing build (docs/fonts/) */
2828
@font-face { font-family: 'Barlow Condensed'; font-weight: 600; font-display: swap;
@@ -268,8 +268,8 @@ <h1>triangulate-tangents</h1>
268268
<p>A machined buckler verifying the tangent-space contract a game engine&#x27;s normal mapping depends on. Deterministic triangulation, unit orthogonal tangent frames, and the edge/UV-delta formula matching mikktspace within welding tolerance.</p>
269269
</header>
270270
<main id="main">
271-
<button class="detail-hero" id="heroZoom" type="button" aria-label="View full size: A round silver buckler on a stand, its face brushed in circular grooves with an anisotropic highlight.">
272-
<img src="../assets/triangulate-tangents-hero.webp" alt="A round silver buckler on a stand, its face brushed in circular grooves with an anisotropic highlight." width="1280" height="720" />
271+
<button class="detail-hero" id="heroZoom" type="button" aria-label="View full size: A round silver buckler leaning back on a low stand, its face brushed in circular grooves with an anisotropic highlight.">
272+
<img src="../assets/triangulate-tangents-hero.webp" alt="A round silver buckler leaning back on a low stand, its face brushed in circular grooves with an anisotropic highlight." width="1280" height="720" />
273273
</button>
274274
<p class="zoom-hint">Rendered headless by the example itself — click to zoom.</p>
275275
<div class="callout"><span class="tag">witnesses</span> calc_tangents aborts on any ngon; planar UVs on a cylindrical wall collapse tangents onto normals; a MeshUVLoopLayer handle held across calc_tangents dangles on 4.5 (471 phantom flips, silent exit 0) while the mikktspace math is byte-identical on both versions.</div>
@@ -284,7 +284,7 @@ <h1>triangulate-tangents</h1>
284284
<ul><li><strong>Deterministic triangulation and the engine basis.</strong> <code>calc_loop_triangles</code> yields the closed-form count (two tris per quad + the cap fan, 720), and every loop tangent is unit length (err 1.3e-07) and exactly orthogonal to its loop normal (err 6.0e-08), with the bitangent exactly <code>bitangent_sign * (normal x tangent)</code> (err 0.0). One ngon anywhere in the mesh and <code>calc_tangents</code> <strong>aborts the whole call</strong> — the back cap is an explicit fan for exactly that reason.</li><li><strong>The tangent frame follows the UVs.</strong> On smooth-field triangles the per-loop tangents match the independently derived edge/UV-delta formula within mikktspace&#x27;s vertex-welding tolerance (measured 2.3e-06, tol 0.15); a flipped frame inside a smooth field is never legal (0 measured). At UV seams the frame orientation is implementation-defined — 42 seam flips, 9 at chart seams, identical on both versions — and planar UVs on a cylindrical wall are degenerate: they collapse the tangent onto the normal (dot 0.998, measured and fixed by unwrapping strips).</li><li><strong>The reference-lifetime hazard (the real divergence).</strong> A <code>MeshUVLoopLayer</code> handle held across <code>calc_tangents()</code> dangles: on Blender 4.5 reads through it return tangent floats, not UVs — measured while authoring as a phantom <strong>471 flipped frames and 385 phantom seam positions, exit 0, a silent wrong answer</strong>. On 5.1 the same stale read survives by memory-layout luck. The mikktspace math itself is byte-identical on 4.5.11 and 5.1.2; the apparent version divergence was entirely the corrupt handle. Never hold layer handles across CustomData-reallocating calls — re-fetch by name.</li></ul>
285285
<p><strong>What each check catches on failure:</strong> a remeshed asset breaking the triangulation count (probe: dropped profile ring, exit 3, 624 vs 720); a wrong tangent formula in the independent derivation (probe: swapped du/dv, exit 7, weld 1.459); and the stale-handle read (probe: no re-fetch — corrupt measurements on 4.5, correct values on 5.1 by luck).</p>
286286
<p><strong>Version witness:</strong> mikktspace output is identical on Blender 4.5.11 LTS and 5.1.2 (same weld deviations, same seam flips). The <code>calc_tangents(uvmap=)</code> signature is stable. The lifetime hazard above is the only behavioral split, and it corrupts measurements rather than raising.</p>
287-
<p>The render shows the buckler on its cradle: brushed grooves circulating the boss with the anisotropic sweep riding them — the circulating tangent field made visible.</p>
287+
<p>The render shows the buckler on its cradle: brushed grooves circulating the boss with the anisotropic sweep riding them — the circulating tangent field made visible. The staging is derived from the posed mesh: the stand rests on the floor, the rim&#x27;s lowest vertex sits 1 cm into its top, and a kickstand strut runs from the buckler&#x27;s back (found by a local-space ray) to the floor behind it.</p>
288288
<h2>Run</h2>
289289
<pre><code># Cheap correctness check (no render) — the CI check:
290290
blender --background --python triangulate_tangents.py --
@@ -359,6 +359,13 @@ <h2>Source</h2>
359359
BTN_TOL = <span class="n">1e-4</span> <span class="c"># bitangent == sign * (n x t)</span>
360360
UV_TOL = <span class="n">1e-6</span> <span class="c"># re-fetched UV layer vs authored closed form</span>
361361

362+
<span class="c"># render staging only (not part of the check)</span>
363+
STAND_H = <span class="n">0.12</span> <span class="c"># display stand height, resting on the floor</span>
364+
SEAT_BITE = <span class="n">0.01</span> <span class="c"># rim&#x27;s lowest vertex sunk into the stand top</span>
365+
STRUT_V = <span class="n">0.25</span> <span class="c"># local v where the kickstand meets the back</span>
366+
STRUT_BITE = <span class="n">0.02</span> <span class="c"># strut ends buried in the back face / floor</span>
367+
STRUT_LEAN = <span class="n">0.45</span> <span class="c"># horizontal run per metre of strut height</span>
368+
362369
<span class="c"># dome profile: (r, z) rings from center out, then the raised rim lip</span>
363370
PROFILE = [(<span class="n">0.02</span>, <span class="n">0.34</span>), (<span class="n">0.30</span>, <span class="n">0.30</span>), (<span class="n">0.55</span>, <span class="n">0.22</span>), (<span class="n">0.78</span>, <span class="n">0.12</span>),
364371
(<span class="n">0.95</span>, <span class="n">0.05</span>), (<span class="n">1.00</span>, <span class="n">0.05</span>)]
@@ -620,28 +627,56 @@ <h2>Source</h2>
620627
nt.links.new(bump.outputs[<span class="s">&quot;Normal&quot;</span>], bsdf.inputs[<span class="s">&quot;Normal&quot;</span>])
621628
obj.data.materials.append(mat)
622629

623-
<span class="c"># presentation: the buckler leaning back on a low display stand</span>
624-
obj.location = (<span class="n">0.0</span>, <span class="n">0.0</span>, <span class="n">1.55</span>)
625-
obj.rotation_euler = (math.radians(<span class="n">70</span>), <span class="n">0.0</span>, math.radians(-<span class="n">12</span>))
626-
stand_me = bpy.data.meshes.new(<span class="s">&quot;Stand&quot;</span>)
627-
bm = bmesh.new()
628-
<span class="k">try</span>:
629-
bmesh.ops.create_cube(bm, size=<span class="n">1.0</span>,
630-
matrix=mathutils.Matrix.Diagonal((<span class="n">0.9</span>, <span class="n">0.5</span>, <span class="n">0.12</span>, <span class="n">1.0</span>)))
631-
bm.to_mesh(stand_me)
632-
<span class="k">finally</span>:
633-
bm.free()
630+
<span class="c"># presentation: the buckler leaning back on a low display stand, held by</span>
631+
<span class="c"># a kickstand strut behind it. Every contact is derived from the posed</span>
632+
<span class="c"># mesh: the stand sits on the floor, the rim&#x27;s lowest vertex bites into</span>
633+
<span class="c"># the stand&#x27;s top, and the strut runs from the buckler&#x27;s back to the floor.</span>
634+
yaw = math.radians(-<span class="n">12</span>)
635+
obj.rotation_euler = (math.radians(<span class="n">70</span>), <span class="n">0.0</span>, yaw)
636+
obj.location = (<span class="n">0.0</span>, <span class="n">0.0</span>, <span class="n">0.0</span>)
637+
bpy.context.view_layer.update()
638+
low = min((obj.matrix_world @ v.co <span class="k">for</span> v <span class="k">in</span> obj.data.vertices), key=<span class="k">lambda</span> p: p.z)
639+
lift = STAND_H - SEAT_BITE - low.z
640+
obj.location = (<span class="n">0.0</span>, <span class="n">0.0</span>, lift)
641+
bpy.context.view_layer.update()
642+
mw = obj.matrix_world
643+
low.z += lift
644+
634645
smat = bpy.data.materials.new(<span class="s">&quot;StandMetal&quot;</span>)
635646
smat.use_nodes = <span class="k">True</span>
636647
sb = smat.node_tree.nodes[<span class="s">&quot;Principled BSDF&quot;</span>]
637648
sb.inputs[<span class="s">&quot;Base Color&quot;</span>].default_value = (<span class="n">0.09</span>, <span class="n">0.10</span>, <span class="n">0.12</span>, <span class="n">1.0</span>)
638649
sb.inputs[<span class="s">&quot;Metallic&quot;</span>].default_value = <span class="n">0.85</span>
639650
sb.inputs[<span class="s">&quot;Roughness&quot;</span>].default_value = <span class="n">0.45</span>
640-
stand_me.materials.append(smat)
641-
stand = bpy.data.objects.new(<span class="s">&quot;Stand&quot;</span>, stand_me)
642-
<span class="c"># under the leaning rim&#x27;s contact patch, so the cradle visibly carries it</span>
643-
stand.location = (<span class="n">0.0</span>, -<span class="n">0.34</span>, <span class="n">0.55</span>)
644-
scene.collection.objects.link(stand)
651+
652+
<span class="k">def</span> box(name, dims, loc, rot):
653+
me = bpy.data.meshes.new(name)
654+
bm = bmesh.new()
655+
<span class="k">try</span>:
656+
bmesh.ops.create_cube(bm, size=<span class="n">1.0</span>,
657+
matrix=mathutils.Matrix.Diagonal((*dims, <span class="n">1.0</span>)))
658+
bm.to_mesh(me)
659+
<span class="k">finally</span>:
660+
bm.free()
661+
me.materials.append(smat)
662+
ob = bpy.data.objects.new(name, me)
663+
ob.location = loc
664+
ob.rotation_euler = rot
665+
scene.collection.objects.link(ob)
666+
<span class="k">return</span> ob
667+
668+
<span class="c"># the stand: on the floor, centred under the rim&#x27;s contact vertex</span>
669+
box(<span class="s">&quot;Stand&quot;</span>, (<span class="n">0.9</span>, <span class="n">0.5</span>, STAND_H), (low.x, low.y, STAND_H / <span class="n">2</span>), (<span class="n">0.0</span>, <span class="n">0.0</span>, yaw))
670+
<span class="c"># the strut: its top buried in the buckler&#x27;s back face (found by a</span>
671+
<span class="c"># local-space ray), its foot bitten into the floor behind</span>
672+
hit = obj.ray_cast((<span class="n">0.0</span>, STRUT_V, -<span class="n">1.0</span>), (<span class="n">0.0</span>, <span class="n">0.0</span>, <span class="n">1.0</span>))[<span class="n">1</span>]
673+
back_n = (mw.to_3x3() @ mathutils.Vector((<span class="n">0.0</span>, <span class="n">0.0</span>, -<span class="n">1.0</span>))).normalized()
674+
top = mw @ hit - back_n * STRUT_BITE
675+
out = mathutils.Vector((back_n.x, back_n.y, <span class="n">0.0</span>)).normalized()
676+
foot = mathutils.Vector((top.x, top.y, -STRUT_BITE)) + out * top.z * STRUT_LEAN
677+
axis = top - foot
678+
box(<span class="s">&quot;Strut&quot;</span>, (<span class="n">0.08</span>, <span class="n">0.04</span>, axis.length), (top + foot) / <span class="n">2</span>,
679+
axis.to_track_quat(<span class="s">&#x27;Z&#x27;</span>, <span class="s">&#x27;Y&#x27;</span>).to_euler())
645680

646681
floor_me = bpy.data.meshes.new(<span class="s">&quot;Floor&quot;</span>)
647682
bm = bmesh.new()
@@ -690,10 +725,12 @@ <h2>Source</h2>
690725
<span class="c"># Reframed: the old (0.6,-8.8,2.8) left the buckler at 0.334 fill adrift</span>
691726
<span class="c"># in the stage; moved in ~2.2x so the brushed-steel tangent sweep fills</span>
692727
<span class="c"># the frame — the grooves are the tangent-field evidence.</span>
693-
cam.location = (<span class="n">0.6</span>, -<span class="n">6.35</span>, <span class="n">2.62</span>)
728+
<span class="c"># Seated on the floor (was hung 0.49 m up): camera and aim ride with the</span>
729+
<span class="c"># buckler&#x27;s derived lift, so the composition is unchanged.</span>
730+
cam.location = (<span class="n">0.6</span>, -<span class="n">6.35</span>, lift + <span class="n">1.07</span>)
694731
scene.collection.objects.link(cam)
695732
target = bpy.data.objects.new(<span class="s">&quot;Aim&quot;</span>, <span class="k">None</span>)
696-
target.location = (<span class="n">0.0</span>, <span class="n">0.0</span>, <span class="n">1.42</span>)
733+
target.location = (<span class="n">0.0</span>, <span class="n">0.0</span>, lift - <span class="n">0.13</span>)
697734
scene.collection.objects.link(target)
698735
con = cam.constraints.new(<span class="s">&#x27;TRACK_TO&#x27;</span>)
699736
con.target = target
@@ -715,11 +752,11 @@ <h2>Source</h2>
715752
scene.view_settings.view_transform = <span class="s">&#x27;Standard&#x27;</span>
716753
<span class="c"># Layer 1 framing gate (silhouette matte) — exit 10 on violation, before</span>
717754
<span class="c"># the beauty render so a defective composition ships no artifact.</span>
718-
stand_ob = scene.objects.get(<span class="s">&quot;Stand&quot;</span>)
755+
props = [scene.objects[n] <span class="k">for</span> n <span class="k">in</span> (<span class="s">&quot;Stand&quot;</span>, <span class="s">&quot;Strut&quot;</span>)]
719756
fcode = gallery_framing.check_framing(
720757
scene, cam,
721758
hero=[obj],
722-
elements=[obj] + ([stand_ob] <span class="k">if</span> stand_ob <span class="k">else</span> []),
759+
elements=[obj] + props,
723760
stage=[floor, wall],
724761
)
725762
<span class="k">if</span> fcode:
@@ -771,7 +808,7 @@ <h2>Source</h2>
771808
</main>
772809
<dialog class="lightbox" id="lightbox" aria-label="Full-size render">
773810
<button class="lightbox-close" type="button" autofocus>Close</button>
774-
<img src="../assets/triangulate-tangents-hero.webp" alt="A round silver buckler on a stand, its face brushed in circular grooves with an anisotropic highlight." />
811+
<img src="../assets/triangulate-tangents-hero.webp" alt="A round silver buckler leaning back on a low stand, its face brushed in circular grooves with an anisotropic highlight." />
775812
</dialog>
776813
<footer>
777814
<div class="statusbar">

‎examples/triangulate-tangents/README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ and it corrupts measurements rather than raising.
5151

5252
The render shows the buckler on its cradle: brushed grooves circulating the
5353
boss with the anisotropic sweep riding them — the circulating tangent field
54-
made visible.
54+
made visible. The staging is derived from the posed mesh: the stand rests on
55+
the floor, the rim's lowest vertex sits 1 cm into its top, and a kickstand
56+
strut runs from the buckler's back (found by a local-space ray) to the floor
57+
behind it.
5558

5659
## Run
5760

6.06 KB
Loading

0 commit comments

Comments
 (0)