- pipeline/dump_bird.mjs: imports web/js/world/magpie.js UNMODIFIED (bare 'three' resolved to the repo's vendored build through a node resolve hook) and dumps birdGeometry() — so the A/B is against Lane B's actual bird, not a port. MEASURED 182 tris / 216 verts. --sim walks a player past a territory on magpie.js's own clock: drawn 54.1% of frames, and OF THOSE perched 80.5% / swooping 7.7% / returning 11.8%. - pipeline/bird_to_glb.py: wraps the dump as a GLB in E's frame (head +Z, +Y up) so the identical render_views.py rig shoots both; --fold applies the perch pose (x x 0.42). glb_stat re-measures 182. - pipeline/render_views.py --noemit: strips emission from BOTH candidates. normalize.py's emissiveFactor 0.28 is albedo-MODULATED; a vertex-coloured mesh cannot express that in glTF and Blender writes a flat 0.28 that lifts a black bird to grey. Off both sides or the picture lies. - pipeline/view_sheet.py --ab: the strip as a GRID (one line per candidate, stacked) plus two linear motion-blur blocks at 25% and 100% of the bird's width, and a wrapped notes footer. - docs/shots/laneE/r39_magpie_ab.png — the sheet Fable rules on. Recommendation in LANE_E_NOTES; picture first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
97 lines
4.5 KiB
Python
97 lines
4.5 KiB
Python
"""PROCITY Lane E — bird_to_glb.py (R39, the magpie A/B)
|
||
|
||
Turn `pipeline/dump_bird.mjs`'s dump of LANE B'S OWN `birdGeometry()` into a GLB, so the identical
|
||
`render_views.py` camera rig that shot E's tinted 894-tri GLB can shoot B's 182-tri procedural bird
|
||
from the identical angles under the identical light. Rendering the two through different pipelines
|
||
would make the picture arguable; this makes it decisive.
|
||
|
||
BL=/Applications/Blender.app/Contents/MacOS/Blender
|
||
"$BL" --background --python pipeline/bird_to_glb.py -- IN.json OUT.glb [--fold]
|
||
|
||
`--fold` applies magpie.js's perch pose (`place(..., folded=true)` scales the instance X by 0.42),
|
||
because B's bird has TWO silhouettes off one geometry and E's GLB has one.
|
||
|
||
Vertex colours ride as COLOR_0 and are wired straight into Base Color, matching
|
||
`MeshStandardMaterial({ vertexColors: true, roughness: 0.62, metalness: 0, side: DoubleSide })`.
|
||
Normals are the ones three.js computed, imported as custom split normals — a Blender-recomputed
|
||
normal would shade the sphere differently from the browser.
|
||
"""
|
||
import bpy, sys, json
|
||
|
||
ARGV = sys.argv[sys.argv.index("--") + 1:]
|
||
SRC, OUT = ARGV[0], ARGV[1]
|
||
FOLD = "--fold" in ARGV
|
||
FOLD_X = 0.42 # magpie.js: the perched instance is squashed in X, wings in
|
||
|
||
d = json.load(open(SRC))
|
||
pos, nrm, col, idx = d["position"], d["normal"], d["color"], d["index"]
|
||
nv = len(pos) // 3
|
||
|
||
# ── FRAME. three: +Y up, nose along −Z. E's published magpie GLB: +Y up, HEAD AT +Z (that is what
|
||
# render_views.py's rig assumes — "glTF +Z head -> Blender -Y"). Blender's glTF exporter with
|
||
# export_yup writes glTF = (Bx, Bz, −By). So to land B's bird in E's frame we want
|
||
# glTF = (−xt, yt, −zt) (a 180° yaw, det = +1 ⇒ winding preserved)
|
||
# which means the Blender vertex must be (−xt, zt, yt). Without this the two rows of the sheet
|
||
# would be shot from opposite ends of the bird and the comparison would be worthless.
|
||
def to_blender(x, y, z):
|
||
return (-x, z, y)
|
||
|
||
fx = FOLD_X if FOLD else 1.0
|
||
verts = [to_blender(pos[i * 3] * fx, pos[i * 3 + 1], pos[i * 3 + 2]) for i in range(nv)]
|
||
faces = [tuple(idx[i:i + 3]) for i in range(0, len(idx), 3)] if idx else \
|
||
[(i, i + 1, i + 2) for i in range(0, nv, 3)]
|
||
|
||
for o in list(bpy.data.objects):
|
||
bpy.data.objects.remove(o, do_unlink=True)
|
||
|
||
me = bpy.data.meshes.new("bird")
|
||
me.from_pydata(verts, [], faces)
|
||
me.update()
|
||
ob = bpy.data.objects.new("bird", me)
|
||
bpy.context.collection.objects.link(ob)
|
||
|
||
# three's own normals, per corner (folding in X inverts nothing: 0.42 > 0, so only a rescale)
|
||
if nrm:
|
||
loops = []
|
||
for p in me.polygons:
|
||
for vi in p.vertices:
|
||
loops.append(to_blender(nrm[vi * 3], nrm[vi * 3 + 1], nrm[vi * 3 + 2]))
|
||
try:
|
||
me.normals_split_custom_set(loops)
|
||
except Exception as e: # never let shading cosmetics kill the export
|
||
print("custom normals skipped:", e)
|
||
|
||
# COLOR_0 — the whole argument for B's bird is that the white is IN the vertices
|
||
if col:
|
||
ca = me.color_attributes.new(name="Col", type='FLOAT_COLOR', domain='POINT')
|
||
for i in range(nv):
|
||
ca.data[i].color = (col[i * 3], col[i * 3 + 1], col[i * 3 + 2], 1.0)
|
||
me.color_attributes.active_color = ca
|
||
me.attributes.active_color = ca
|
||
|
||
mat = bpy.data.materials.new("birdMat")
|
||
mat.use_nodes = True
|
||
mat.use_backface_culling = False # three: side = DoubleSide
|
||
nt = mat.node_tree
|
||
bsdf = nt.nodes["Principled BSDF"]
|
||
bsdf.inputs["Roughness"].default_value = 0.62
|
||
bsdf.inputs["Metallic"].default_value = 0.0
|
||
if col:
|
||
cattr = nt.nodes.new("ShaderNodeVertexColor")
|
||
cattr.layer_name = "Col"
|
||
nt.links.new(cattr.outputs["Color"], bsdf.inputs["Base Color"])
|
||
# FAIRNESS. E's published magpie carries normalize.py's emissiveFactor 0.28 WITH an
|
||
# emissiveTexture, i.e. 0.28 × its own albedo — a self-lit copy of its own markings. Handing B's
|
||
# bird the identical treatment (0.28 × its vertex colour) is the only way the two rows of the
|
||
# sheet are lit the same; without it the render flatters E by construction and the ruling is
|
||
# worthless. `--noemit` drops it, for the ?noassets-honest version of B's material.
|
||
if "--noemit" not in ARGV:
|
||
nt.links.new(cattr.outputs["Color"], bsdf.inputs["Emission Color"])
|
||
bsdf.inputs["Emission Strength"].default_value = 0.28
|
||
me.materials.append(mat)
|
||
|
||
bpy.ops.export_scene.gltf(filepath=OUT, export_format='GLB',
|
||
export_apply=True, export_yup=True,
|
||
export_normals=True, export_vertex_color='ACTIVE')
|
||
print(f"WROTE {OUT} tris={len(faces)} verts={nv} folded={FOLD}")
|