refactor(tools): group the sixty scripts by what they talk to

One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:

  Tools/unreal/    commandlets and the two map builders, with ue_common.py and ue_placement.py as the
                   shared imports (they need the engine)
  Tools/blender/   builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
                   (they need the Blender Flatpak)
  Tools/review/    the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
                   build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
                   visual_targets.json
  Tools/gen/       image and texture generation: the ComfyUI driver, deterministic texture writers,
                   reference fetching
  Tools/input/     OS-level input injection
  root             _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/

Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.

51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.

Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
This commit is contained in:
zwitschi committed 2026-11-10 12:40:30 +01:00
1 parent f681d83736
commit 4d27109668
168 files changed
+799 -407

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Render review screenshots straight out of Blender.
blender -b "Art/blender/vehicle/hot hatch.blend" \
--python Tools/blender/blender_screenshots.py -- \
--label vehicle --mode both --out Screenshots/blender
Two modes, because two questions get asked about a Blender asset:
* **objects** - one render per mesh object, isolated. This is how a system split is
checked: if a panel lands in the wrong group, it shows up as the only thing in its
own picture.
* **scene** - four turntable angles of everything together, for silhouette and
proportion against the in-game shot.
Outputs go beside the in-game captures (`Screenshots/blender/`), one folder layer
below, so `Tools/review/build_comparison_md.py` can pair them and nothing has to be copied by
hand. Every render is framed from the scene's own bounds rather than a hand-placed
camera, so adding geometry does not silently crop the shot.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import bpy
from mathutils import Vector
RESOLUTIONS = (1280, 720)
PARTS: list[str] = []
def log(message: str) -> None:
PARTS.append(message)
print(f"[shots] {message}")
def scene_bounds(objects):
low = [1e9, 1e9, 1e9]
high = [-1e9, -1e9, -1e9]
for obj in objects:
for corner in obj.bound_box:
world = obj.matrix_world @ Vector(corner)
for axis in range(3):
low[axis] = min(low[axis], world[axis])
high[axis] = max(high[axis], world[axis])
return Vector(low), Vector(high)
def setup_scene():
scene = bpy.context.scene
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x, scene.render.resolution_y = RESOLUTIONS
scene.render.film_transparent = False
world = bpy.data.worlds.new("shots") if not bpy.data.worlds else bpy.data.worlds[0]
scene.world = world
world.use_nodes = True
world.node_tree.nodes["Background"].inputs[0].default_value = (0.55, 0.58, 0.62, 1.0)
world.node_tree.nodes["Background"].inputs[1].default_value = 0.8
if not any(o.type == "LIGHT" for o in bpy.data.objects):
bpy.ops.object.light_add(type="SUN", location=(6.0, -6.0, 12.0))
bpy.context.active_object.data.energy = 4.0
def frame_camera(low, high, azimuth_deg, elevation_deg=26.0, padding=1.9):
centre = (low + high) * 0.5
size = max((high - low).length, 0.5)
distance = size * padding
azimuth = math.radians(azimuth_deg)
elevation = math.radians(elevation_deg)
offset = Vector((
math.cos(elevation) * math.cos(azimuth),
math.cos(elevation) * math.sin(azimuth),
math.sin(elevation),
)) * distance
bpy.ops.object.camera_add(location=centre + offset)
camera = bpy.context.active_object
camera.data.lens = 50.0
direction = (centre - camera.location).normalized()
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
bpy.context.scene.camera = camera
return camera
def render(path):
bpy.context.scene.render.filepath = path
bpy.ops.render.render(write_still=True)
return os.path.exists(path)
def render_scene_angles(meshes, out_dir, label, angles=(35, 125, 215, 305)):
written = []
low, high = scene_bounds(meshes)
for azimuth in angles:
camera = frame_camera(low, high, azimuth)
path = os.path.join(out_dir, f"{label}-angle-{azimuth:03d}.png")
if render(path):
written.append(path)
log(f"scene angle {azimuth}: {path}")
bpy.data.objects.remove(camera, do_unlink=True)
return written
def render_objects(meshes, out_dir, label):
written = []
hidden = []
for target in meshes:
for other in meshes:
if other is target:
continue
if other.hide_render:
continue
other.hide_render = True
hidden.append(other)
low, high = scene_bounds([target])
camera = frame_camera(low, high, 40.0)
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in target.name)
path = os.path.join(out_dir, f"{label}-object-{safe}.png")
if render(path):
written.append(path)
tris = sum(len(p.vertices) - 2 for p in target.data.polygons)
log(f"object {target.name}: {tris} triangles -> {path}")
bpy.data.objects.remove(camera, do_unlink=True)
for other in hidden:
other.hide_render = False
hidden = []
return written
def main(argv=None):
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--label", required=True, help="prefix for the file names, e.g. 'vehicle'")
ap.add_argument("--mode", choices=("objects", "scene", "both"), default="both")
ap.add_argument("--out", default="Screenshots/blender")
ap.add_argument("--report", default=None)
args = ap.parse_args(argv)
out_dir = os.path.abspath(args.out)
os.makedirs(out_dir, exist_ok=True)
setup_scene()
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
if not meshes:
print("[shots] no meshes in the scene", file=sys.stderr)
return 1
log(f"{len(meshes)} mesh objects in {bpy.data.filepath}")
written = []
if args.mode in ("scene", "both"):
written += render_scene_angles(meshes, out_dir, args.label)
if args.mode in ("objects", "both"):
written += render_objects(meshes, out_dir, args.label)
report = {"blend": bpy.data.filepath, "label": args.label, "mode": args.mode,
"outputs": written, "log": PARTS}
if args.report:
with open(os.path.abspath(args.report), "w") as handle:
json.dump(report, handle, indent=2)
log(f"{len(written)} screenshots written to {out_dir}")
return 0 if written else 1
if __name__ == "__main__":
raise SystemExit(main())
+433
View File
@@ -0,0 +1,433 @@
#!/usr/bin/env python3
"""Author the basic character animation set (0.2.0): idle and walk.
Three clips on the MPFB standard rig, sampled from parametric cycles rather than
hand-keyed poses, so the loop is closed by construction and every key can be
re-derived from the numbers below:
GigLine_Idle 60 frames @ 30 fps — breathing sway, feet planted
GigLine_Walk 30 frames @ 30 fps — one full stride, left and right contact
blender -b Art/blender/character/character-2026-11-07.blend \
--python Tools/blender/character_anim_build.py -- \
--out /tmp/gigline/character-anim.fbx \
--report /tmp/gigline/character-anim-report.json
Rotation axes are **measured, not assumed**. A probe on the actual rig rotated
`upperleg01.L` by 0.5 rad on each axis and read the resulting `foot.L` head:
X -> foot Y -0.022 -> +0.362 (fore/aft swing: the axis used here)
Y -> twist along the leg, foot barely moves
Z -> foot swings sideways into the other leg
The arm probe gives the same answer for `upperarm01`, so limbs swing on local X
and positive X sends the limb backwards. Everything else (spine lean, head nod)
uses small X deltas for the same reason.
The rig is `pelvis.L → upperleg01 → upperleg02 → lowerleg01 → lowerleg02 → foot`,
all deform bones, no constraints, so a plain pose-bone rotation deforms the mesh.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
FPS = 30
CLIPS = {"Idle": 60, "Walk": 30}
# Stride amplitude in radians at the hip. A human walk swings the thigh about
# 25-30 deg forward and 15-20 back; 0.45 rad (~26 deg) is a normal-pace stride.
HIP = 0.45
# Knee flexion during swing, measured from the photographs of a normal gait.
KNEE = 0.55
# Arm swing counter-phases the leg; a relaxed walk swings about 20 deg.
ARM = 0.32
ELBOW = 0.18
PARTS: list[dict] = []
# The .blend stores the raw A-pose bind: `mpfb_character_export.py --pose idle`
# bakes the arms down only in the exported FBX, not in the file. A clip that
# animates only swing therefore leaves the arms out in an A — which is exactly how
# the first third-person render looked. These are the same deltas `IDLE_POSE` uses,
# applied as a base that every key sits on top of.
# Finger curl, in radians per joint. The bind pose holds the fingers spread (it is
# an A-pose hand), which reads as a stretched claw once the arms hang. Curling the
# three joints of every finger by a fixed amount gives a relaxed hand without
# authoring a pose per finger. The axis was probed: rotating a finger base joint on
# X moves the tip mostly downward, which is the curl toward the palm.
FINGER_CURL = (0.28, 0.34, 0.22)
PARTS: list[dict] = []
def derive_arm_rotation(arm, target_dir=(0.12, 0.0, -1.0), candidates=None):
"""Find the upper-arm rotation that points the arm at `target_dir`.
The 0.2.0 value (0.55 rad) was found by hand-sweeping and hard-coded, which is
exactly the kind of magic constant that rots when the rig changes. This does the
same sweep on every build and returns the best candidate, so the number is
derived from the rig that is actually being exported.
"""
from mathutils import Euler, Vector
candidates = candidates or [0.35 + 0.05 * i for i in range(11)]
target = Vector(target_dir).normalized()
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="POSE")
upper = arm.pose.bones["upperarm01.L"]
upper.rotation_mode = "XYZ"
best, best_dot = 0.55, -2.0
for z in candidates:
upper.rotation_euler = Euler((0.0, 0.0, -z))
bpy.context.view_layer.update()
direction = (upper.matrix.to_3x3() @ Vector((0.0, 1.0, 0.0))).normalized()
dot = direction.dot(target)
if dot > best_dot:
best, best_dot = z, dot
upper.rotation_euler = Euler((0.0, 0.0, 0.0))
bpy.ops.object.mode_set(mode="OBJECT")
log(f"arm rotation derived: {best:.2f} rad (direction dot {best_dot:.3f})")
return best
PARTS: list[dict] = []
# The .blend stores the raw A-pose bind: `mpfb_character_export.py --pose idle`
# bakes the arms down only in the exported FBX, not in the file. A clip that
# animates only swing therefore leaves the arms out in an A — which is exactly how
# the first third-person render looked. These are the same deltas `IDLE_POSE` uses,
# applied as a base that every key sits on top of.
# Values are **measured, not inherited**. The rig's A bind already points the upper
# arm (0.65, 0.01, -0.76), i.e. about 50 degrees below horizontal, so the rotation
# that brings it vertical is only about 0.55 rad - not the 1.55 rad the old
# `--pose idle` table used, which over-rotated the arm ~40 degrees past vertical and
# drove it into the torso. Sweeping the rotation in Blender gave:
# z=-0.55 -> arm (0.16, -0.02, -0.99) down, slightly clear of the body
# z=-0.65 -> arm (0.06, -0.02, -1.00) straight down, touching the hip
# z=-0.75 -> arm (-0.04, -0.03, -1.00) past vertical, into the body
# 0.55 is the value used.
BASE = {
"upperarm01.L": (0.0, 0.0, -0.55),
"upperarm01.R": (0.0, 0.0, 0.55),
"lowerarm01.L": (0.0, 0.0, -0.12),
"lowerarm01.R": (0.0, 0.0, 0.12),
}
def log(msg):
PARTS.append(msg)
print(f"[anim] {msg}")
HAIR_MATERIAL = "M_Character_Hair"
def add_hair_cap(arm, mesh):
"""Add a short-hair cap conforming to the head, skinned to the head bone.
The character exported from MPFB is bald, and a bald head reads as a mannequin
before anything else does. A cap plus a strand texture is enough at review
distance; hair cards and a groomed cut are a later pass.
"""
head = arm.data.bones.get("head")
if head is None or mesh is None:
log("hair: no head bone or no mesh - skipped")
return None
centre = (head.head_local + head.tail_local) * 0.5
radius = max(0.085, (head.tail_local - head.head_local).length * 0.62)
bpy.ops.mesh.primitive_uv_sphere_add(segments=24, ring_count=14, radius=radius,
location=(centre.x, centre.y, centre.z + 0.012))
cap = bpy.context.active_object
cap.name = "HairCap"
# Keep the upper hemisphere plus a little: a full sphere reads as a helmet.
cut_z = centre.z - radius * 0.12
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.bisect(plane_co=(0.0, 0.0, cut_z), plane_no=(0.0, 0.0, 1.0),
clear_inner=True, use_fill=False)
bpy.ops.object.mode_set(mode="OBJECT")
# Slightly ovoid, wider than deep, sitting back on the skull.
cap.scale = (1.06, 1.10, 0.92)
hair_mat = bpy.data.materials.get(HAIR_MATERIAL) or bpy.data.materials.new(HAIR_MATERIAL)
hair_mat.use_nodes = True
bsdf = hair_mat.node_tree.nodes.get("Principled BSDF")
if bsdf:
bsdf.inputs["Base Color"].default_value = (0.055, 0.038, 0.030, 1.0)
bsdf.inputs["Roughness"].default_value = 0.42
cap.data.materials.append(hair_mat)
bpy.ops.object.select_all(action="DESELECT")
cap.select_set(True)
mesh.select_set(True)
bpy.context.view_layer.objects.active = mesh
bpy.ops.object.join()
log(f"hair: cap added at head centre, radius {radius:.3f} m, material {HAIR_MATERIAL}")
return mesh
def clear_animation(arm):
"""Drop any pre-existing actions so a rerun does not stack clips."""
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
if arm.animation_data:
arm.animation_data_clear()
def pose_bones(arm):
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="POSE")
for pb in arm.pose.bones:
pb.rotation_mode = "XYZ"
return arm.pose.bones
def apply_base_pose(arm):
"""Bake the arms-down pose into the REST pose before authoring any clip.
The first two attempts added the arms-down as an offset inside every key. That
is not what the export sees: the exporter writes clip data relative to the rest
pose, so an offset that lives only in the keys arrives in Unreal as a small
delta around an A-posed bind - the body stood in an A with a ±20 degree arm
swing, which is exactly what the Unreal pose probe measured. Baking the pose
into the rest pose first is the same trick `mpfb_character_export.py --pose
idle` uses, and it makes bind and clips agree.
"""
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="POSE")
for name, eul in BASE.items():
bone = arm.pose.bones.get(name)
if bone is None:
continue
bone.rotation_mode = "XYZ"
bone.rotation_euler = eul
# Bake the deformation into the MESH before the rest bake. Setting the rest pose
# alone leaves the vertices in their A shape, so the skin stretches across the
# shoulders and the arms sink into the torso - which is what the review shots
# showed. The MPFB mesh carries shape keys and Blender refuses to apply a modifier
# while they exist, so they are cleared first and the count is logged.
mesh = next((o for o in bpy.data.objects
if o.type == "MESH" and any(m.type == "ARMATURE" and m.object == arm
for m in o.modifiers)), None)
keys = 0
if mesh is not None:
if mesh.data.shape_keys:
keys = len(mesh.data.shape_keys.key_blocks) - 1
bpy.ops.object.mode_set(mode="OBJECT")
bpy.context.view_layer.objects.active = mesh
bpy.ops.object.select_all(action="DESELECT")
mesh.select_set(True)
if mesh.data.shape_keys:
bpy.ops.object.shape_key_remove(all=True)
for mod in list(mesh.modifiers):
if mod.type == "ARMATURE" and mod.object == arm:
bpy.ops.object.modifier_apply(modifier=mod.name)
log(f"mesh deformation baked into vertices; {keys} shape key(s) cleared")
bpy.ops.object.select_all(action="DESELECT")
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="POSE")
bpy.ops.pose.armature_apply()
bpy.ops.object.mode_set(mode="OBJECT")
if mesh is not None:
added = mesh.modifiers.new(name="Armature", type="ARMATURE")
added.object = arm
log("mesh re-bound to the new rest pose")
log("base pose applied to rest: arms down before any clip is authored")
BASE = {}
def fill_base(arm):
"""Populate the standing base: derived arm angle plus the finger curl."""
arm_z = derive_arm_rotation(arm)
BASE.update({
"upperarm01.L": (0.0, 0.0, -arm_z),
"upperarm01.R": (0.0, 0.0, arm_z),
"lowerarm01.L": (0.0, 0.0, -0.12),
"lowerarm01.R": (0.0, 0.0, 0.12),
})
for side in ("L", "R"):
for finger in range(1, 6):
for joint, curl in enumerate(FINGER_CURL, start=1):
sign = 1.0 if side == "L" else -1.0
BASE[f"finger{finger}-{joint}.{side}"] = (sign * curl, 0.0, 0.0)
log(f"standing base: {len(BASE)} bones (arm {arm_z:.2f} rad, "
f"finger curl {FINGER_CURL})")
def key_euler(pb, frame, x=0.0, y=0.0, z=0.0):
"""Key a pose-bone rotation, offset by the standing base.
The bind is left alone: the MPFB mesh carries shape keys, and every attempt to
re-bind it (clear keys, bake the armature modifier, set rest) either failed or
left Unreal showing the old bind. Carrying the arm rotation in the clip is the
one path that is provable: read the imported clip back and check its quaternion.
"""
bx, by, bz = BASE.get(pb.name, (0.0, 0.0, 0.0))
pb.rotation_euler = (bx + x, by + y, bz + z)
pb.keyframe_insert(data_path="rotation_euler", frame=frame)
def clip_idle(arm, frames):
"""Breathing sway: spine and head counter-rotate, arms drift, feet planted."""
bones = pose_bones(arm)
action = bpy.data.actions.new("GigLine_Idle")
arm.animation_data_create()
arm.animation_data.action = action
for frame in range(1, frames + 1):
t = 2.0 * math.pi * (frame - 1) / frames
breathe = math.sin(t)
# Chest lifts and settles; the head counter-rotates so the gaze stays level.
key_euler(bones["spine03"], frame, x=-0.020 * breathe)
key_euler(bones["spine05"], frame, x=-0.012 * breathe)
key_euler(bones["neck01"], frame, x=0.010 * breathe)
key_euler(bones["head"], frame, x=0.008 * breathe)
# Arms hang and drift a little; nothing swings.
key_euler(bones["upperarm01.L"], frame, x=0.020 * breathe)
key_euler(bones["upperarm01.R"], frame, x=-0.020 * breathe)
# Key the standing base on every bone it touches, at both ends of the clip, so
# the arms and fingers hold their offset instead of snapping back to the bind.
for name in BASE:
bone = bones.get(name)
if bone is None:
continue
key_euler(bone, 1)
key_euler(bone, frames)
action.use_fake_user = True
log(f"Idle: {frames} frames, spine/neck/head/arms, feet planted")
return action
def clip_walk(arm, frames):
"""One full stride: left contact at frame 1, right contact at the half-cycle."""
bones = pose_bones(arm)
action = bpy.data.actions.new("GigLine_Walk")
arm.animation_data.action = action
for frame in range(1, frames + 1):
t = 2.0 * math.pi * (frame - 1) / frames
# cos, not sin: the clip starts at maximum stride. A sine starts and crosses
# zero twice per cycle, and the review capture kept landing on those passing
# frames - legs together, which reads as a still. The cycle is unchanged.
swing = math.cos(t)
# Hip swing, opposite phase per leg; negative X is forward.
key_euler(bones["upperleg01.L"], frame, x=-HIP * swing)
key_euler(bones["upperleg01.R"], frame, x=HIP * swing)
# The knee only flexes while the leg is swinging forward, so it is driven
# by the half-rectified swing rather than the raw sine.
knee_l = KNEE * max(0.0, math.sin(t + math.pi))
knee_r = KNEE * max(0.0, math.sin(t))
key_euler(bones["lowerleg01.L"], frame, x=knee_l)
key_euler(bones["lowerleg01.R"], frame, x=knee_r)
# Ankles keep the foot roughly level: counter the hip plus half the knee.
key_euler(bones["foot.L"], frame, x=0.25 * HIP * swing - 0.35 * knee_l)
key_euler(bones["foot.R"], frame, x=-0.25 * HIP * swing - 0.35 * knee_r)
# Arms counter-phase the legs, elbows carry a constant relaxed bend.
key_euler(bones["upperarm01.L"], frame, x=ARM * swing)
key_euler(bones["upperarm01.R"], frame, x=-ARM * swing)
key_euler(bones["lowerarm01.L"], frame, x=ELBOW + 0.10 * swing)
key_euler(bones["lowerarm01.R"], frame, x=ELBOW - 0.10 * swing)
# Torso counter-rotation: the shoulders turn against the hips.
key_euler(bones["spine04"], frame, z=0.08 * swing)
key_euler(bones["neck01"], frame, z=-0.04 * swing)
for name in BASE:
bone = bones.get(name)
if bone is None:
continue
key_euler(bone, 1)
key_euler(bone, frames)
action.use_fake_user = True
log(f"Walk: {frames} frames, stride {HIP:.2f} rad hip / {KNEE:.2f} knee / {ARM:.2f} arm")
return action
def main(argv=None) -> int:
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/character-anim.fbx")
ap.add_argument("--report", default=None)
# Hair is built but not exported yet: Unreal's import collapses the second
# material slot, so the cap renders with the skin material and reads as a bald
# skull. Off until the hair is a separate asset (see TODO.md).
ap.add_argument("--hair", action="store_true")
args = ap.parse_args(argv)
arm = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None)
if arm is None:
raise SystemExit("no armature in the .blend")
mesh = next((o for o in bpy.data.objects if o.type == "MESH"), None)
log(f"rig: {arm.name}, {len(arm.pose.bones)} bones; mesh {mesh.name if mesh else 'none'} "
f"{len(mesh.data.vertices) if mesh else 0} verts")
bpy.context.scene.render.fps = FPS
clear_animation(arm)
fill_base(arm)
idle = clip_idle(arm, CLIPS["Idle"])
walk = clip_walk(arm, CLIPS["Walk"])
# Hair before export so the cap is part of the skinned mesh; a separate asset
# would need its own bone attachment and a second material assignment.
verts_before = len(mesh.data.vertices) if mesh else 0
if args.hair:
add_hair_cap(arm, mesh)
else:
log("hair: skipped (--hair to include; blocked on material slots, see TODO.md)")
if mesh and len(mesh.data.vertices) > verts_before:
group = mesh.vertex_groups.get("head") or mesh.vertex_groups.new(name="head")
group.add(list(range(verts_before, len(mesh.data.vertices))), 1.0, "REPLACE")
log(f"hair: {len(mesh.data.vertices) - verts_before} vertices weighted to 'head'")
bpy.ops.object.mode_set(mode="OBJECT")
# Export armature + mesh together: the preset's character flags carry the
# bake_anim settings that make Unreal create AnimSequences.
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
if mesh:
mesh.select_set(True)
bpy.context.view_layer.objects.active = arm
os.makedirs(os.path.dirname(args.out), exist_ok=True)
fbx_preset.export_character(args.out, selection=True)
log(f"exported {args.out}")
if args.report:
with open(args.report, "w", encoding="utf-8") as handle:
json.dump({
"fps": FPS,
"rig": arm.name,
"bones": len(arm.pose.bones),
"clips": [
{"action": idle.name, "frames": CLIPS["Idle"]},
{"action": walk.name, "frames": CLIPS["Walk"]},
],
"axis_evidence": "probe: upperleg01.L X 0.5 rad moves foot.L "
"Y -0.022 -> +0.362; Y twists, Z abducts",
"amplitudes_rad": {"hip": HIP, "knee": KNEE, "arm": ARM, "elbow": ELBOW},
"export": os.path.basename(args.out),
"log": PARTS,
}, handle, indent=2)
print(f"[anim] report -> {args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""Build the character's clothing as separate skinned meshes.
The `character-artist` skill's clothing workflow, followed literally: duplicate body
faces for the clothing base, solidify for cloth thickness, and keep each piece a
separate mesh so it can be swapped or re-materialled on its own.
blender -b Art/blender/character/character-2026-11-07.blend \
--python Tools/blender/character_clothing_build.py -- \
--out /tmp/gigline/clothing --report /tmp/gigline/clothing.json
Outputs one FBX per garment (`tee`, `jeans`, `shoes`), each carrying the character
armature so Unreal imports it as a skeletal mesh with the body's skeleton.
Selection is by **dominant vertex group**, not by height band alone: a z-range band
would swallow the arms hanging at the sides, and the arms have to stay bare below the
cap sleeves. Each garment lists the bones whose weights it accepts.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
GARMENTS = (
{
"name": "tee",
"asset": "SM_Char_Cloth_Tee",
"bones": ["spine01", "spine02", "spine03", "spine04", "spine05",
"clavicle.L", "clavicle.R", "shoulder01.L", "shoulder01.R",
"upperarm01.L", "upperarm01.R", "breast.L", "breast.R",
"neck01", "neck02", "pelvis.L", "pelvis.R"],
"z_min": 0.90, "z_max": 1.44,
"x_max": 0.255, # trims the bare arms out of the tee
"use_bones": False,
"thickness": 0.020,
"colour": (0.020, 0.032, 0.075, 1.0), # navy cotton
"roughness": 0.78,
},
{
"name": "jeans",
"asset": "SM_Char_Cloth_Jeans",
"bones": ["pelvis.L", "pelvis.R", "upperleg01.L", "upperleg01.R",
"upperleg02.L", "upperleg02.R", "lowerleg01.L", "lowerleg01.R",
"lowerleg02.L", "lowerleg02.R", "spine01"],
"z_min": 0.06, "z_max": 1.06,
"x_max": 0.32,
"use_bones": False,
"thickness": 0.020,
"colour": (0.032, 0.055, 0.105, 1.0), # dark denim
"roughness": 0.62,
},
{
"name": "shoes",
"asset": "SM_Char_Cloth_Shoes",
"bones": ["foot.L", "foot.R", "toe1-1.L", "toe1-1.R",
"lowerleg02.L", "lowerleg02.R"],
"z_min": -0.05, "z_max": 0.16,
"x_max": 0.40,
"use_bones": False,
"thickness": 0.012,
"colour": (0.72, 0.71, 0.68, 1.0), # off-white canvas
"roughness": 0.55,
},
)
PARTS: list[str] = []
def log(message: str) -> None:
PARTS.append(message)
print(f"[cloth] {message}")
# MPFB ships helper geometry in the same mesh, weighted 1.0 to groups called
# 'Left', 'Right', 'HelperGeometry' and 'JointCubes'. Those dominate the raw weights,
# so the first build selected zero faces: every vertex's strongest group was a helper.
_HELPER_PREFIXES = ("helper", "joint", "Helper", "Joint")
_HELPER_NAMES = {"Left", "Right", "Mid", "body", "HelperGeometry", "JointCubes"}
def is_deform_bone(name: str) -> bool:
return bool(name) and not name.startswith(_HELPER_PREFIXES) and name not in _HELPER_NAMES
def dominant_groups(mesh) -> dict[int, str]:
"""Vertex index -> its strongest *deforming bone* group, ignoring helpers."""
index_to_name = {g.index: g.name for g in mesh.vertex_groups}
result = {}
for vertex in mesh.data.vertices:
candidates = [(g.weight, index_to_name.get(g.group, "")) for g in vertex.groups]
candidates = [(w, n) for w, n in candidates if is_deform_bone(n)]
if not candidates:
continue
result[vertex.index] = max(candidates)[1]
return result
def build_garment(body, arm, spec, out_dir):
bpy.ops.object.select_all(action="DESELECT")
body.select_set(True)
bpy.context.view_layer.objects.active = body
bpy.ops.object.duplicate()
garment = bpy.context.active_object
garment.name = spec["asset"]
allowed = set(spec["bones"])
dominant = dominant_groups(body)
mesh = garment.data
# Filter with bmesh instead of edit-mode selection. Setting polygon.select in
# object mode and then entering edit mode re-derives selection from the vertices,
# which were still all selected - the delete wiped the whole garment every time.
import bmesh
bm = bmesh.new()
bm.from_mesh(mesh)
doomed = []
for face in bm.faces:
centre_z = sum(v.co.z for v in face.verts) / len(face.verts)
groups = [dominant.get(v.index, "") for v in face.verts]
centre_x = abs(sum(v.co.x for v in face.verts) / len(face.verts))
keep = (spec["z_min"] <= centre_z <= spec["z_max"]
and centre_x <= spec.get("x_max", 10.0)
and (not spec.get("use_bones") or any(g in allowed for g in groups)))
face.tag = keep
if not keep:
doomed.append(face)
bmesh.ops.delete(bm, geom=doomed, context="FACES")
bm.to_mesh(mesh)
bm.free()
mesh.update()
log(f"{spec['asset']}: kept {len(mesh.polygons)} faces")
if not mesh.polygons:
log(f"{spec['asset']}: no faces selected - check the bone list and z range")
return None
# The body carries MPFB shape keys, and Blender refuses to apply a modifier to a
# mesh that has any. A garment does not need the morph targets.
if garment.data.shape_keys:
bpy.ops.object.shape_key_remove(all=True)
# Cloth thickness: solidify outward so the garment sits over the body rather
# than inside it.
solidify = garment.modifiers.new(name="Cloth", type="SOLIDIFY")
solidify.thickness = spec["thickness"]
solidify.offset = 1.0
bpy.ops.object.modifier_apply(modifier=solidify.name)
# Push the shell out along its own normals. Solidify alone left the body visible
# through the garment at every convex patch - shoulders, hips, knees.
import bmesh
bm = bmesh.new()
bm.from_mesh(mesh)
bm.normal_update()
for vertex in bm.verts:
vertex.co += vertex.normal * spec.get("fatten", 0.008)
bm.to_mesh(mesh)
bm.free()
mesh.update()
log(f"{spec['asset']}: fattened by {spec.get('fatten', 0.008):.3f} m")
material = bpy.data.materials.get(f"M_Char_{spec['name']}") \
or bpy.data.materials.new(f"M_Char_{spec['name']}")
material.use_nodes = True
bsdf = material.node_tree.nodes.get("Principled BSDF")
if bsdf:
bsdf.inputs["Base Color"].default_value = spec["colour"]
bsdf.inputs["Roughness"].default_value = spec["roughness"]
garment.data.materials.clear()
garment.data.materials.append(material)
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
garment.select_set(True)
bpy.context.view_layer.objects.active = arm
path = os.path.join(out_dir, f"{spec['name']}.fbx")
fbx_preset.export_character(path, selection=True)
stats = {
"asset": spec["asset"],
"file": path,
"vertices": len(garment.data.vertices),
"triangles": sum(len(p.vertices) - 2 for p in garment.data.polygons),
"thickness_m": spec["thickness"],
"bones": spec["bones"],
"z_range": [spec["z_min"], spec["z_max"]],
"colour": spec["colour"],
"roughness": spec["roughness"],
}
log(f"{spec['asset']}: {stats['vertices']} verts, {stats['triangles']} tris -> {path}")
return stats
def main(argv=None) -> int:
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/clothing")
ap.add_argument("--report", default=None)
args = ap.parse_args(argv)
arm = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None)
body = next((o for o in bpy.data.objects if o.type == "MESH" and o.parent == arm), None)
if arm is None or body is None:
raise SystemExit("need the character armature and its body mesh")
log(f"body {body.name}: {len(body.data.vertices)} verts")
os.makedirs(args.out, exist_ok=True)
built = []
for spec in GARMENTS:
stats = build_garment(body, arm, spec, args.out)
if stats:
built.append(stats)
if args.report:
with open(args.report, "w") as handle:
json.dump({"garments": built, "log": PARTS}, handle, indent=2)
print(f"[cloth] report -> {args.report}")
return 0 if len(built) == len(GARMENTS) else 1
if __name__ == "__main__":
raise SystemExit(main())
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Build the hair as its own skinned mesh — hair cards over a scalp cap.
The `character-artist` skill is explicit that hair for a game character is a card
system and that swappable parts stay separate meshes. Joining hair into the body mesh
was tried first and failed for an engine reason: Unreal collapses the second material
slot on a skeletal-mesh import, so the cap rendered with the skin material. A separate
object sidesteps that and is what the skill calls for anyway.
blender -b Art/blender/character/character-2026-11-07.blend \
--python Tools/blender/character_hair_build.py -- \
--out /tmp/gigline/character-hair.fbx --report /tmp/gigline/character-hair.json
What it builds:
SM_Char_Hair scalp cap + 18 cards in three rings, UV-mapped to the strand sheet
from Tools/gen/generate_character_textures.py, every vertex weighted
to `head` at 1.0 so it follows the head without a second rig.
Cards are built with a taper and a slight outward curl so the silhouette is not a
shell; the strand texture runs vertically down each card, which is the direction
hair actually reads in. Topology is quads only (the skill's animation-ready rule),
and there are no poles on the deformation surface because the whole mesh is rigidly
weighted to one bone.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import bpy
from mathutils import Vector
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
HAIR_NAME = "SM_Char_Hair"
CARD_RINGS = 3 # rings of cards around the skull
CARDS_INNER = 6
CARDS_MIDDLE = 7
CARDS_OUTER = 5
CARD_HEIGHT = 0.072 # m: short, close cut - taller cards spiked up
CAP_THICKNESS = 0.004
PARTS: list[dict] = []
def log(message: str) -> None:
PARTS.append(message)
print(f"[hair] {message}")
def hair_material() -> bpy.types.Material:
mat = bpy.data.materials.get("M_Character_Hair") or bpy.data.materials.new("M_Character_Hair")
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf:
# Dark brown, low roughness: strands catch a highlight. The strand texture is
# sampled in Unreal, so Blender only needs a sane base for the FBX material.
bsdf.inputs["Base Color"].default_value = (0.052, 0.036, 0.028, 1.0)
bsdf.inputs["Roughness"].default_value = 0.38
if "Alpha" in bsdf.inputs:
bsdf.inputs["Alpha"].default_value = 1.0
mat.use_backface_culling = False
return mat
def card(name, anchor, along, across, normal, width, height, lift, segments=4):
"""A card that lies ALONG the scalp rather than sticking out of it.
`along` is the direction the hair falls over the skull (the downward tangent at
the anchor), `across` the card's width direction and `normal` the scalp normal.
The first version passed a fixed global up and an outward vector, which made every
card stand away from the head - the spiked crown in the review shot.
"""
mesh = bpy.data.meshes.new(name)
obj = bpy.data.objects.new(name, mesh)
bpy.context.scene.collection.objects.link(obj)
verts = []
faces = []
for i in range(segments + 1):
t = i / segments
w = width * (1.0 - 0.65 * t)
# Follow the skull, then lift a little at the tip so the cut has volume.
base = anchor + along * (height * t) + normal * (lift * math.sin(t * math.pi * 0.7))
left = base - across * w
right = base + across * w
verts.append(left)
verts.append(right)
for i in range(segments):
a = i * 2
faces.append((a, a + 1, a + 3, a + 2))
mesh.from_pydata(verts, [], faces)
mesh.update()
return obj
def scalp_cap(centre, radius, material):
bpy.ops.mesh.primitive_uv_sphere_add(segments=20, ring_count=12, radius=radius,
location=centre)
cap = bpy.context.active_object
cap.name = "HairScalp"
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.bisect(plane_co=(0.0, 0.0, centre.z - radius * 0.15),
plane_no=(0.0, 0.0, 1.0), clear_inner=True, use_fill=False)
bpy.ops.object.mode_set(mode="OBJECT")
cap.scale = (1.03, 1.06, 0.86)
cap.data.materials.append(material)
return cap
def main(argv=None) -> int:
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/character-hair.fbx")
ap.add_argument("--report", default=None)
args = ap.parse_args(argv)
arm = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None)
if arm is None:
raise SystemExit("no armature in the .blend")
head = arm.data.bones.get("head")
if head is None:
raise SystemExit("no 'head' bone")
centre = (head.head_local + head.tail_local) * 0.5
radius = max(0.088, (head.tail_local - head.head_local).length * 0.60)
log(f"head centre {[round(c, 3) for c in centre]}, radius {radius:.3f} m")
material = hair_material()
pieces = [scalp_cap(Vector((centre.x, centre.y, centre.z + 0.012)), radius, material)]
down = Vector((0.0, 0.0, -1.0))
# Two rows: an inner row that covers the crown and an outer row that overlaps it
# down to the hairline. Overlap is what stops the cards reading as separate spikes.
rings = (
# count, radius scale, anchor height, length, width, lift
(7, 0.55, 0.80, 0.085, 0.30, 0.25),
(9, 0.72, 0.62, 0.070, 0.26, 0.18),
(10, 0.86, 0.42, 0.055, 0.24, 0.12),
)
index = 0
for ring, (count, radius_scale, height_scale, length, width_scale, lift_scale) in enumerate(rings):
for i in range(count):
angle = math.tau * (i + 0.5 * ring) / count
horizontal = Vector((math.cos(angle), math.sin(angle), 0.0))
normal = (horizontal * radius_scale + Vector((0.0, 0.0, height_scale))).normalized()
anchor = Vector((centre.x, centre.y, centre.z)) + normal * radius
# Downward tangent: the part of -Z that lies in the scalp's tangent plane.
along = (down - normal * down.dot(normal)).normalized()
across = along.cross(normal).normalized()
pieces.append(card(f"HairCard_{index}", anchor, along, across, normal,
width=radius * width_scale,
height=radius * length * 10.0,
lift=radius * lift_scale))
index += 1
log(f"built {len(pieces)} pieces: 1 scalp cap + {index} cards")
# Join into one object and weight every vertex to the head bone.
bpy.ops.object.select_all(action="DESELECT")
for piece in pieces:
piece.select_set(True)
bpy.context.view_layer.objects.active = pieces[0]
bpy.ops.object.join()
hair = bpy.context.active_object
hair.name = HAIR_NAME
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
hair.data.materials.clear()
hair.data.materials.append(material)
group = hair.vertex_groups.new(name="head")
group.add(list(range(len(hair.data.vertices))), 1.0, "REPLACE")
# UVs: a simple planar unwrap is enough because the strand texture is directional.
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.smart_project(angle_limit=1.15, island_margin=0.01)
bpy.ops.object.mode_set(mode="OBJECT")
quads = sum(1 for p in hair.data.polygons if len(p.vertices) == 4)
tris = sum(len(p.vertices) - 2 for p in hair.data.polygons)
log(f"{HAIR_NAME}: {len(hair.data.vertices)} verts, {quads} quads, {tris} tris, "
f"{len(hair.data.materials)} material slot(s)")
# Parent to the armature so the export carries the skin.
modifier = hair.modifiers.new(name="Armature", type="ARMATURE")
modifier.object = arm
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
hair.select_set(True)
bpy.context.view_layer.objects.active = arm
os.makedirs(os.path.dirname(args.out), exist_ok=True)
fbx_preset.export_character(args.out, selection=True)
log(f"exported {args.out}")
if args.report:
with open(args.report, "w") as handle:
json.dump({
"asset": HAIR_NAME,
"head_centre_m": [round(c, 4) for c in centre],
"head_radius_m": round(radius, 4),
"cards": index,
"rings": [r[0] for r in rings],
"vertices": len(hair.data.vertices),
"quads": quads,
"triangles": tris,
"material": material.name,
"texture": "Art/textures/generated/T_Character_Hair.png",
"weighting": "all vertices 1.0 on 'head'",
"log": PARTS,
}, handle, indent=2)
print(f"[hair] report -> {args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+368
View File
@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""Evaluate a rendered character against a generated blueprint.
The blueprint comes from `Tools/blender/comfyui_character_blueprint.py`; the render comes from
the review pipeline. This tool isolates the figure in both, then reports how far the
model has drifted from the intended character:
python3 Tools/blender/character_match.py \
--render Screenshots/demo/review-demo-third-person-front.png \
--blueprint Art/characters/blueprints/front.png \
--out /tmp/gigline/character-match.json \
--montage /tmp/gigline/character-match.png
Reported per pair:
aspect_delta |height/width difference| between the two figures, in ratio units
fill_delta |figure area / bbox area| - bulk, catches a thin or bloated build
coverage_delta |figure area / frame area| - how much of the frame the figure takes
mean_delta_e mean CIE76 colour distance over the figure
palette_delta_e mean distance between the five dominant colours of each figure
Why these and not a similarity score: an absolute "match percentage" between a
photoreal generated sheet and a low-detail game body would be a number with no
meaning. The deltas are separable, so a report says *what* drifted - build, framing
or colour - instead of blending all three into one figure.
The figure mask is a heuristic and the tool says so in its report: for the blueprint
it is "differs from the corner colour", for the render it is "more saturated or darker
than the frame median", largest connected component. Pair the tool with a human look
at the montage; it narrows where to look, it does not replace the review.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
from PIL import Image
# ---------------------------------------------------------------- colour maths
def srgb_to_linear(value: float) -> float:
value /= 255.0
return value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4
def rgb_to_lab(rgb) -> tuple[float, float, float]:
"""sRGB (0-255) to CIELAB under D65."""
r, g, b = (srgb_to_linear(v) for v in rgb[:3])
x = r * 0.4124 + g * 0.3576 + b * 0.1805
y = r * 0.2126 + g * 0.7152 + b * 0.0722
z = r * 0.0193 + g * 0.1192 + b * 0.9505
# D65 white point
xn, yn, zn = 0.95047, 1.0, 1.08883
def f(t: float) -> float:
return t ** (1.0 / 3.0) if t > 0.008856 else (7.787 * t) + (16.0 / 116.0)
fx, fy, fz = f(x / xn), f(y / yn), f(z / zn)
return (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz))
def delta_e76(lab_a, lab_b) -> float:
return math.sqrt(sum((a - b) ** 2 for a, b in zip(lab_a, lab_b)))
# ------------------------------------------------------------------- masking
def luminance(rgb) -> float:
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
def _subject_mask(image: Image.Image, saturation_threshold: int = 20,
dark_ratio: float = 0.62) -> Image.Image:
"""Mask the figure as "more saturated than the backdrop, or notably darker".
Both inputs have a desaturated backdrop - grey studio sweep in the blueprint,
beige pavement and pale walls in the render - and a figure that carries colour
(skin, denim, hair). Saturation separates them without needing a clean plate.
The previous version masked the blueprint by "differs from the corner colour",
which swallowed a gradient backdrop whole.
"""
rgb = image.convert("RGB")
pixels = rgb.load()
lums = [luminance(pixels[x, y])
for y in range(0, rgb.height, 4) for x in range(0, rgb.width, 4)]
lums.sort()
median = lums[len(lums) // 2] if lums else 128.0
mask = Image.new("L", rgb.size, 0)
out = mask.load()
for y in range(rgb.height):
for x in range(rgb.width):
r, g, b = pixels[x, y]
saturation = max(r, g, b) - min(r, g, b)
if saturation > saturation_threshold or luminance((r, g, b)) < median * dark_ratio:
out[x, y] = 255
return mask
def figure_mask_from_background(image: Image.Image, tolerance: int = 28):
"""Figure mask for a blueprint sheet: a coloured subject on a grey sweep."""
return _largest_component(_subject_mask(image, saturation_threshold=18,
dark_ratio=0.55))
def figure_mask_from_frame(image: Image.Image):
"""Figure mask for a rendered frame: the body against pavement and stucco."""
return _largest_component(_subject_mask(image, saturation_threshold=26,
dark_ratio=0.72))
def _largest_component(mask: Image.Image) -> Image.Image:
"""Keep the largest plausible figure blob.
"Largest" alone is wrong twice over: in both inputs the backdrop is one huge
connected region, so a plain largest-blob pick returned the background and the
first report measured the whole frame. The filter is size-based instead - a figure
occupies between 0.2 % and 55 % of the frame - which rejects the backdrop on one
side and road markings on the other.
"""
width, height = mask.size
frame_area = width * height
source = mask.load()
seen = bytearray(width * height)
best = [] # largest blob fully inside the frame
usable = [] # largest plausible blob, border or not
fallback = [] # largest blob at all, used when nothing is plausible
best_score = 0
for start_y in range(0, height, 3):
for start_x in range(0, width, 3):
index = start_y * width + start_x
if seen[index] or source[start_x, start_y] == 0:
continue
stack = [(start_x, start_y)]
blob = []
seen[index] = 1
while stack:
x, y = stack.pop()
blob.append((x, y))
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < width and 0 <= ny < height:
nindex = ny * width + nx
if not seen[nindex] and source[nx, ny] != 0:
seen[nindex] = 1
stack.append((nx, ny))
if len(blob) > len(fallback):
fallback = blob
share = len(blob) / frame_area
if not (0.002 < share < 0.55):
continue
# Blobs glued to the frame edge are backdrop: the sky band in a render and
# the studio sweep in a sheet both touch the border and both beat the
# figure on raw area. Prefer fully enclosed blobs, fall back by area.
xs = [p[0] for p in blob]
ys = [p[1] for p in blob]
enclosed = (min(xs) > 1 and min(ys) > 1
and max(xs) < width - 2 and max(ys) < height - 2)
if enclosed and len(blob) > best_score:
best_score = len(blob)
best = blob
elif len(blob) > len(usable):
usable = blob
keep = Image.new("L", mask.size, 0)
out = keep.load()
for x, y in (best or usable or fallback):
out[x, y] = 255
return keep
def bbox(mask: Image.Image):
return mask.getbbox()
def mask_stats(mask: Image.Image) -> dict:
box = mask.getbbox()
if not box:
return {"area": 0, "bbox": None, "aspect": None, "fill": None, "coverage": None}
x0, y0, x1, y1 = box
histogram = mask.histogram()
area = sum(histogram[1:])
box_area = max(1, (x1 - x0) * (y1 - y0))
return {
"area": area,
"bbox": [x0, y0, x1, y1],
"aspect": (y1 - y0) / max(1, (x1 - x0)),
"fill": area / box_area,
"coverage": area / (mask.width * mask.height),
}
def masked_colours(image: Image.Image, mask: Image.Image, limit=20000):
rgb = image.convert("RGB")
pixels = rgb.load()
values = mask.load()
collected = []
step = max(1, (rgb.width * rgb.height) // limit)
index = 0
for y in range(rgb.height):
for x in range(rgb.width):
index += 1
if index % step:
continue
if values[x, y]:
collected.append(pixels[x, y])
return collected
def dominant(counts: dict, count: int = 5):
"""Coarse palette: quantise to 32 levels per channel, then take the top bins."""
bins: dict[tuple, int] = {}
for colour in counts:
key = (colour[0] // 32, colour[1] // 32, colour[2] // 32)
bins[key] = bins.get(key, 0) + 1
ranked = sorted(bins.items(), key=lambda item: -item[1])[:count]
return [((k[0] * 32) + 16, (k[1] * 32) + 16, (k[2] * 32) + 16) for k, _ in ranked]
def palette_delta_e(palette_a, palette_b) -> float:
if not palette_a or not palette_b:
return float("nan")
labs_b = [rgb_to_lab(c) for c in palette_b]
total = 0.0
for colour in palette_a:
lab = rgb_to_lab(colour)
total += min(delta_e76(lab, other) for other in labs_b)
return total / len(palette_a)
# -------------------------------------------------------------------- report
def evaluate(render_path: str, blueprint_path: str, montage_path: str | None = None,
render_box: tuple[float, float, float, float] | None = None) -> dict:
render_full = Image.open(render_path).convert("RGB")
blueprint = Image.open(blueprint_path).convert("RGB")
# The figure box is explicit by default. Auto-segmentation on these shots failed
# in a way worth recording rather than papering over: the backdrop (sky band, pale
# wall, dark pavement) is more saturated and far larger than the subject, so every
# size-based rule picked a backdrop blob. A box read off the shot is honest and
# cheap; a background plate rendered without the character would be the proper fix.
width, height = render_full.size
box = render_box or (0.15, 0.02, 0.85, 0.99)
region = (int(width * box[0]), int(height * box[1]),
int(width * box[2]), int(height * box[3]))
render = render_full.crop(region)
render_mask = figure_mask_from_frame(render)
blueprint_mask = figure_mask_from_background(blueprint)
render_stats = mask_stats(render_mask)
blueprint_stats = mask_stats(blueprint_mask)
render_colours = masked_colours(render, render_mask)
blueprint_colours = masked_colours(blueprint, blueprint_mask)
def mean_lab(colours):
labs = [rgb_to_lab(c) for c in colours]
if not labs:
return None
return tuple(sum(l[i] for l in labs) / len(labs) for i in range(3))
lab_render = mean_lab(render_colours)
lab_blueprint = mean_lab(blueprint_colours)
mean_de = (delta_e76(lab_render, lab_blueprint)
if lab_render and lab_blueprint else float("nan"))
palette_render = dominant(render_colours)
palette_blueprint = dominant(blueprint_colours)
def delta(a, b):
if a is None or b is None:
return None
return abs(a - b)
report = {
"render": os.path.abspath(render_path),
"blueprint": os.path.abspath(blueprint_path),
"render_mask": "saturated-or-dark vs frame median, largest component",
"blueprint_mask": "differs from corner colour",
"render_region": list(region),
"render_figure": render_stats,
"blueprint_figure": blueprint_stats,
"aspect_delta": delta(render_stats["aspect"], blueprint_stats["aspect"]),
"fill_delta": delta(render_stats["fill"], blueprint_stats["fill"]),
"coverage_delta": delta(render_stats["coverage"], blueprint_stats["coverage"]),
"mean_lab_render": lab_render,
"mean_lab_blueprint": lab_blueprint,
"mean_delta_e": mean_de,
"palette_render": palette_render,
"palette_blueprint": palette_blueprint,
"palette_delta_e": palette_delta_e(palette_render, palette_blueprint),
}
if montage_path:
report["montage"] = write_montage(render, render_mask, blueprint, blueprint_mask,
montage_path)
return report
def write_montage(render, render_mask, blueprint, blueprint_mask, path, height=640):
"""Render crop and blueprint crop side by side, same height, for a human look."""
def crop(image, mask):
box = mask.getbbox()
return image.crop(box) if box else image
left = crop(render, render_mask)
right = crop(blueprint, blueprint_mask)
left = left.resize((max(1, int(left.width * height / max(1, left.height))), height))
right = right.resize((max(1, int(right.width * height / max(1, right.height))), height))
sheet = Image.new("RGB", (left.width + right.width + 12, height), (20, 20, 20))
sheet.paste(left, (0, 0))
sheet.paste(right, (left.width + 12, 0))
os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
sheet.save(path)
return os.path.abspath(path)
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--render", required=True)
ap.add_argument("--blueprint", required=True)
ap.add_argument("--out", default="/tmp/gigline/character-match.json")
ap.add_argument("--montage", default=None)
ap.add_argument("--render-box", default=None,
help="figure box as x0,y0,x1,y1 fractions of the frame (default: central 70 percent)")
args = ap.parse_args(argv)
for path in (args.render, args.blueprint):
if not os.path.exists(path):
print(f"error: no such image: {path}", file=sys.stderr)
return 2
box = None
if args.render_box:
box = tuple(float(v) for v in args.render_box.split(","))
if len(box) != 4:
print("error: --render-box needs four comma-separated fractions", file=sys.stderr)
return 2
report = evaluate(args.render, args.blueprint, args.montage, box)
os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True)
with open(args.out, "w") as handle:
json.dump(report, handle, indent=2)
def show(value, digits=3):
return "n/a" if value is None or (isinstance(value, float) and math.isnan(value)) \
else f"{value:.{digits}f}"
print(f"render {report['render_figure']}")
print(f"blueprint {report['blueprint_figure']}")
print(f"aspect delta {show(report['aspect_delta'])}")
print(f"fill delta {show(report['fill_delta'])}")
print(f"coverage delta {show(report['coverage_delta'])}")
print(f"mean deltaE {show(report['mean_delta_e'], 1)}")
print(f"palette deltaE {show(report['palette_delta_e'], 1)}")
print(f"report -> {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Generate character blueprints with the local ComfyUI instance.
A "blueprint" here is an art-direction sheet for one character: a front, side and back
view, plus a head detail, generated from a fixed prompt and seed set so the sheet can
be regenerated and compared against later. It is the reference the modelled character
is evaluated against — see `Tools/blender/character_match.py`.
python3 Tools/blender/comfyui_character_blueprint.py \
--character "GigLine walker" \
--desc "lean adult pedestrian, short dark hair, olive skin, plain navy t-shirt, dark jeans, worn sneakers" \
--out Art/characters/blueprints
Writes one PNG per view plus `contact-sheet.png` and `blueprint.json`, which records
the prompt, model, seeds and the ComfyUI prompt ids. Everything is generated locally
by `Tools/gen/comfyui_generate.py`; no network service and no credential is involved.
The sheet is generated art, not photography, and it stays out of the reference photo
folders on purpose: AGENTS.md forbids committing reference photography, and a
generated concept should not blur that line.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import comfyui_generate as comfy # noqa: E402
from PIL import Image # noqa: E402
VIEWS = (
# name, prompt suffix, seed
("front", "full body front view, neutral standing pose, arms at sides, "
"even studio light, plain light grey background, character sheet", 101),
("side", "full body side profile view, neutral standing pose, arms at sides, "
"even studio light, plain light grey background, character sheet", 202),
("back", "full body back view, neutral standing pose, arms at sides, "
"even studio light, plain light grey background, character sheet", 303),
("head", "head and shoulders portrait, front view, neutral expression, "
"even studio light, plain light grey background", 404),
)
NEGATIVE = ("multiple people, crowd, text, watermark, signature, blurry, low quality, "
"cropped limbs, extra limbs, deformed hands, cartoon, anime, logo")
def contact_sheet(paths, out_path, columns=4):
images = [Image.open(p).convert("RGB") for p in paths if os.path.exists(p)]
if not images:
return None
width = max(i.width for i in images)
height = max(i.height for i in images)
sheet = Image.new("RGB", (width * columns, height), (24, 24, 24))
for index, image in enumerate(images[:columns]):
sheet.paste(image.resize((width, height)), (index * width, 0))
sheet.save(out_path)
return out_path
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--character", default="GigLine walker")
ap.add_argument("--desc", required=True,
help="what the character wears and looks like; drives every view")
ap.add_argument("--out", default="Art/characters/blueprints")
ap.add_argument("--model", default=comfy.DEFAULT_MODEL)
ap.add_argument("--size", default="832x1216")
ap.add_argument("--steps", type=int, default=30)
ap.add_argument("--cfg", type=float, default=5.0)
args = ap.parse_args(argv)
if not os.path.isdir(comfy.HOST) and "://" not in comfy.HOST:
print(f"error: bad COMFYUI_HOST {comfy.HOST}", file=sys.stderr)
return 2
os.makedirs(args.out, exist_ok=True)
width, height = (int(v) for v in args.size.lower().split("x"))
record = {"character": args.character, "description": args.desc,
"model": args.model, "views": {}}
written = []
for name, suffix, seed in VIEWS:
prompt = f"{args.character}, {args.desc}, {suffix}"
out = os.path.join(args.out, f"{name}.png")
graph = comfy.workflow(prompt, NEGATIVE, args.model, width, height, seed,
args.steps, args.cfg, "dpmpp_2m", "karras")
queued = comfy.post("/prompt", {"prompt": graph})
prompt_id = queued.get("prompt_id")
if not prompt_id:
print(f"error: ComfyUI rejected the {name} prompt: {queued}", file=sys.stderr)
return 1
print(f"{name}: queued {prompt_id} (seed {seed})")
image = comfy.wait_for_image(prompt_id, timeout=600.0)
if image is None:
print(f"error: no image for {name}", file=sys.stderr)
return 1
with open(out, "wb") as handle:
handle.write(image)
record["views"][name] = {"file": out, "seed": seed, "prompt": prompt,
"negative": NEGATIVE, "prompt_id": prompt_id}
written.append(out)
sheet = contact_sheet(written, os.path.join(args.out, "contact-sheet.png"))
record["contact_sheet"] = sheet
record["note"] = ("generated locally by Tools/blender/comfyui_character_blueprint.py; "
"record the model licence in THIRD_PARTY_NOTICES.md before shipping")
with open(os.path.join(args.out, "blueprint.json"), "w") as handle:
json.dump(record, handle, indent=2)
print(f"wrote {len(written)} views + contact sheet -> {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+88
View File
@@ -0,0 +1,88 @@
# Export the south-side block frontage from the OSM blockout.
#
# blender -b --factory-startup Art/blender/blockout/blockout-2026-11-06.blend \
# --python Tools/blender/export_block_frontage.py -- --out /tmp/gigline/frontage
#
# The building set is the one verified in
# `Geo/verification/2026-11-06-el-capitan-block.md` ("South frontage, west to
# east"), minus 6840 which is modelled by hand as SM_HeroFacade. Everything is
# already in the shared project frame (metres, X east / Y north / Z up, origin
# Hollywood x Highland), so each object exports at its own world position with no
# transform: these are south-side buildings and need no Y mirror.
#
# The block runs from Highland (X 0) to Orange Drive (X -279.6 m). Objects that
# are OSM `element` placeholders are named by their verified address instead, so
# the Unreal asset names say what the building is.
import argparse
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
# blockout object name -> (asset name, note). Addresses from the verification doc.
BUILDINGS = {
# Way 424024151, 6922, commercial 12 levels - the west end of the frontage.
"element.020": ("SM_Hollywood_6922", "6922, west end of the frontage"),
# Way 206486872, 6902, retail, height 13.8.
"element": ("SM_Hollywood_6902", "6902, retail, H 13.8"),
# Way 424024148, 6838, El Capitan Theatre - distinct from 6840.
"El Capitan Theatre": ("SM_ElCapitanTheatre", "6838, cinema, 6 levels"),
# Way 424024144, 6822, Budget car rental.
"Budget": ("SM_Budget_6822", "6822, car rental, H 5.5"),
# Way 424024150, 6806-6820, retail, height 12.1.
"element.019": ("SM_Hollywood_6806", "6806-6820, retail, H 12.1"),
# Way 424024149, 6800, Lee Drug Building.
"Lee Drug Building": ("SM_LeeDrug_6800", "6800, Lee Drug Building, H 12.1"),
}
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="/tmp/gigline/frontage")
args = ap.parse_args(argv)
os.makedirs(args.out, exist_ok=True)
print(f"[frontage] scene objects: {len(bpy.data.objects)}")
exported = 0
for obj_name, (asset_name, note) in BUILDINGS.items():
obj = bpy.data.objects.get(obj_name)
if obj is None or obj.type != "MESH":
print(f"[frontage] MISSING blockout object {obj_name!r}")
continue
mesh = obj.data
if not mesh.materials:
mat = bpy.data.materials.new(f"M_{asset_name}")
mat.diffuse_color = (0.72, 0.70, 0.66, 1.0)
mat.use_nodes = False
mesh.materials.append(mat)
# No Y mirror: the FBX preset lands Blender -Y (south) on Unreal +Y,
# which is the south side of the street (south sidewalk Y +718..+1447).
new_mesh = obj.data
new_obj = obj
bpy.ops.object.select_all(action="DESELECT")
new_obj.select_set(True)
bpy.context.view_layer.objects.active = new_obj
path = os.path.join(args.out, asset_name + ".fbx")
fbx_preset.export_static(path, selection=True)
world = new_obj.matrix_world
zs = [(world @ v.co).z for v in new_mesh.vertices]
ys = [(world @ v.co).y for v in new_mesh.vertices]
xs = [(world @ v.co).x for v in new_mesh.vertices]
print(f"[frontage] {asset_name:22} {note:34} "
f"X {min(xs):7.1f}..{max(xs):6.1f} Y {min(ys):6.1f}..{max(ys):6.1f} "
f"H {max(zs):5.1f} verts {len(new_mesh.vertices):5} -> {path}")
exported += 1
print(f"[frontage] exported {exported} of {len(BUILDINGS)} frontage buildings")
main()
+66
View File
@@ -0,0 +1,66 @@
# Export the two in-scope north-side background buildings from the OSM blockout.
#
# blender -b --factory-startup Art/blender/blockout/blockout-2026-11-06.blend \
# --python Tools/blender/export_north_buildings.py -- --out /tmp/gigline/north
#
# The blockout is already in the shared project frame (metres, X east / Y north /
# Z up, origin Hollywood x Highland), so exporting each object at its own world
# position preserves the offset from the hero facade. The site export preset
# (`ue_metric_v1`) is reused verbatim so the metre->centimetre mapping matches.
import argparse
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
BUILDINGS = ("TCL Chinese Theatre", "Madame Tussaud's")
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="/tmp/gigline/north")
args = ap.parse_args(argv)
os.makedirs(args.out, exist_ok=True)
print(f"[north] scene objects: {len(bpy.data.objects)}")
for name in BUILDINGS:
obj = bpy.data.objects.get(name)
if obj is None or obj.type != "MESH":
print(f"[north] MISSING {name}")
continue
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
mesh = obj.data
if not mesh.materials:
mat = bpy.data.materials.new(f"M_{name.replace(' ', '_')}")
mat.diffuse_color = (0.75, 0.72, 0.68, 1.0)
mat.use_nodes = False
mesh.materials.append(mat)
# No copy and no Y mirror: the FBX preset already lands Blender +Y
# (north) on Unreal -Y, and UE north is -Y here (north sidewalk
# Y -1912..-1053). The old mirror put both buildings on the south side.
new_obj = obj
new_mesh = mesh
bpy.ops.object.select_all(action="DESELECT")
new_obj.select_set(True)
bpy.context.view_layer.objects.active = new_obj
path = os.path.join(args.out, name.replace(" ", "_").replace("'", "") + ".fbx")
fbx_preset.export_static(path, selection=True)
world = new_obj.matrix_world
zs = [(world @ v.co).z for v in new_mesh.vertices]
print(f"[north] {name:22} verts {len(new_mesh.vertices):5} z {min(zs):+.2f}..{max(zs):+.2f} m "
f"-> {path}")
bpy.data.objects.remove(new_obj, do_unlink=True)
print(f"[north] done")
main()
+315
View File
@@ -0,0 +1,315 @@
#!/usr/bin/env python3
"""Build the reusable façade module kit (0.1.4).
One module, measured: the **4.96 m bay** (the hero's column rhythm, which the
El Capitan Theatre next door shares per the reference notes). Every block building
should assemble from the same pieces instead of being a bare extrusion, so the
street wall gets a consistent window rhythm, shopfront line, cornice and parapet.
Pieces (`ue_metric_v1`, metres, origin at the module's bottom-front centre so a
building can place them by bay index and storey index):
Kit_Pilaster vertical pier on a bay boundary
Kit_Sill window sill band
Kit_Lintel window head / lintel band
Kit_WindowBay recessed window opening in one bay (glass + reveal)
Kit_ShopfrontBay ground-floor shopfront: piers, glass, fascia
Kit_DoorBay entrance bay: recessed door, jambs, threshold
Kit_Cornice projecting cornice band, one module long
Kit_Parapet parapet wall + coping, one module long
Dimensions come from the measured storey height and the reference photography
where it resolves them (window proportions, shopfront line); everything the
photographs do not resolve is an assumption and is recorded as one in the part
report, so model and evidence stay separable.
python3 Tools/blender/facade_kit_build.py --out /tmp/gigline/kit \
--report /tmp/gigline/kit-report.json
blender -b --factory-startup --python Tools/blender/facade_kit_build.py -- --out /tmp/gigline/kit
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
# --- module ------------------------------------------------------------------
BAY = 4.96 # m, measured: hero column rhythm, shared by the theatre
STOREY = 3.10 # m, the calibrated level height (see block_height_report)
PARAPET_H = 0.90 # m, coping above the top storey
CORNICE_H = 0.55 # m, projecting band
CORNICE_OUT = 0.35 # m, how far the cornice projects
PILASTER_W = 0.60
PILASTER_D = 0.28
WINDOW_W = 1.60
WINDOW_H = 1.85
WINDOW_REVEAL = 0.18 # how far the glass sits behind the wall face
SILL_H = 0.14
LINTEL_H = 0.34
SHOP_GLASS_H = 2.30 # shopfront glazing height from the pavement
SHOP_FASCIA_H = 0.55 # sign band above the glazing
DOOR_W = 1.90
DOOR_H = 2.45
STONE = None # materials are assigned in Unreal; Blender carries one grey
PARTS: list[dict] = []
def make_material(name, value):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (value, value * 0.98, value * 0.94, 1.0)
bsdf.inputs["Roughness"].default_value = 0.75
return mat
def validate(obj, tri_budget=4000):
"""Topology check before handoff (blender-modeler cleanup checklist).
Reports non-manifold edges, loose geometry and the triangle count. Kit pieces
are primitive solids, so any non-manifold edge means a modelling slip rather
than an intentional hole.
"""
import bmesh
me = obj.data
bm = bmesh.new()
bm.from_mesh(me)
bm.edges.ensure_lookup_table()
non_manifold = [e for e in bm.edges if len(e.link_faces) != 2]
loose_verts = [v for v in bm.verts if not v.link_edges]
tri_count = sum(len(f.verts) - 2 for f in bm.faces)
bm.free()
if non_manifold or loose_verts:
print(f"[kit] WARNING {obj.name}: {len(non_manifold)} non-manifold edge(s), "
f"{len(loose_verts)} loose vertex/vertices")
if tri_count > tri_budget:
print(f"[kit] WARNING {obj.name}: {tri_count} tris exceeds the {tri_budget} budget")
return {"manifold": not non_manifold, "loose_verts": len(loose_verts),
"tris": tri_count}
def note(obj, note_text):
PARTS.append({
"object": obj.name,
"asset": "SM_" + obj.name,
"dims_m": [round(d, 3) for d in obj.dimensions],
"location_m": [round(c, 3) for c in obj.location],
"topology": validate(obj),
"evidence": note_text,
})
print(f"[kit] {obj.name:22} {obj.dimensions.x:.2f} x {obj.dimensions.y:.2f} "
f"x {obj.dimensions.z:.2f} m ({note_text})")
def cube(name, x, y, z, dx, dy, dz, material):
bpy.ops.mesh.primitive_cube_add(size=1.0, location=(x, y, z))
obj = bpy.context.active_object
obj.name = name
obj.scale = (dx, dy, dz)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
obj.data.materials.append(material)
return obj
def cylinder(name, x, y, z0, z1, radius, material, vertices=16):
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius,
depth=(z1 - z0), location=(x, y, (z0 + z1) / 2.0))
obj = bpy.context.active_object
obj.name = name
obj.data.materials.append(material)
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(35.0))
return obj
def clear_scene():
"""Remove objects and orphan meshes. Materials are left alone: deleting them
invalidates handles the previous piece still holds ("StructRNA of type
Material has been removed")."""
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
for mesh in list(bpy.data.meshes):
if mesh.users == 0:
bpy.data.meshes.remove(mesh)
# --- pieces ------------------------------------------------------------------
# Each builder returns [(object, note)] for one module. Origin: bottom-front
# centre of the module, +Y into the wall depth, +Z up, X across the bay.
def build_pilaster(mat):
o = cube("Kit_Pilaster", 0.0, PILASTER_D / 2.0, STOREY / 2.0,
PILASTER_W, PILASTER_D, STOREY, mat)
return [(o, "assumption: 0.60 x 0.28 m pier on the bay boundary")]
def build_sill(mat):
o = cube("Kit_Sill", 0.0, 0.10, SILL_H / 2.0, WINDOW_W + 0.30, 0.24, SILL_H, mat)
return [(o, "assumption: projecting stone sill under each opening")]
def build_lintel(mat):
o = cube("Kit_Lintel", 0.0, 0.10, LINTEL_H / 2.0, WINDOW_W + 0.30, 0.24, LINTEL_H, mat)
return [(o, "assumption: lintel band over each opening")]
def build_window_bay(mat):
"""One storey of window: a surround standing proud of the wall, glazing inset.
The blockout wall is a solid box, so a real recess cannot be cut. The piece is
therefore a shallow surround that projects 3 cm and a glazing plane set 6 cm
behind its front, which reads as a window from any street angle. The first
version modelled everything at +Y (into the wall), so the whole bay sat inside
the solid wall and was invisible in every render.
"""
parts = []
surround_d = 0.22
surround = cube("Kit_WindowBay", 0.0, -0.03 + surround_d / 2.0, WINDOW_H / 2.0,
WINDOW_W, surround_d, WINDOW_H, mat)
parts.append((surround, "photographs: window proportions 1.60 x 1.85 m; surround "
"projects 3 cm so it reads against a solid blockout wall"))
glass = cube("Kit_WindowGlass", 0.0, 0.09, WINDOW_H / 2.0,
WINDOW_W - 0.16, 0.04, WINDOW_H - 0.16, mat)
parts.append((glass, "glazing plane set 6 cm behind the surround face; "
"material assigned in Unreal"))
return parts
def build_shopfront_bay(mat):
"""Ground floor: two piers, glazing between, fascia above."""
parts = []
for side in (-1, 1):
x = side * (BAY / 2.0 - PILASTER_W / 2.0)
o = cube(f"Kit_ShopPier_{'L' if side < 0 else 'R'}", x, PILASTER_D / 2.0,
SHOP_GLASS_H / 2.0, PILASTER_W, PILASTER_D, SHOP_GLASS_H, mat)
parts.append((o, "shopfront pier, same 0.60 m module as the pilaster"))
glass = cube("Kit_ShopGlass", 0.0, 0.06, SHOP_GLASS_H / 2.0,
BAY - 2 * PILASTER_W, 0.05, SHOP_GLASS_H - 0.25, mat)
parts.append((glass, "photographs: continuous shopfront glazing to a "
"2.30 m head"))
fascia = cube("Kit_ShopFascia", 0.0, 0.14, SHOP_GLASS_H + SHOP_FASCIA_H / 2.0,
BAY, 0.26, SHOP_FASCIA_H, mat)
parts.append((fascia, "sign band over the shopfront; 0.55 m assumption"))
return parts
def build_door_bay(mat):
parts = []
for side in (-1, 1):
x = side * (DOOR_W / 2.0 + 0.35)
o = cube(f"Kit_DoorJamb_{'L' if side < 0 else 'R'}", x, 0.14, STOREY / 2.0,
0.35, 0.30, STOREY, mat)
parts.append((o, "entrance jamb standing 0.30 m proud of the wall"))
door = cube("Kit_Door", 0.0, 0.06, DOOR_H / 2.0, DOOR_W, 0.09, DOOR_H, mat)
parts.append((door, "assumption: 1.90 x 2.45 m recessed door"))
threshold = cube("Kit_Threshold", 0.0, 0.22, 0.05, DOOR_W + 0.70, 0.44, 0.10, mat)
parts.append((threshold, "threshold slab, flush with the pavement"))
return parts
def build_cornice(mat):
o = cube("Kit_Cornice", 0.0, -CORNICE_OUT / 2.0, CORNICE_H / 2.0,
BAY, CORNICE_OUT * 2.0, CORNICE_H, mat)
return [(o, "projecting cornice band, one bay long; 0.35 m projection assumption")]
def build_parapet(mat):
parts = []
wall = cube("Kit_Parapet", 0.0, -0.15, PARAPET_H / 2.0, BAY, 0.55, PARAPET_H, mat)
parts.append((wall, "assumption: 0.90 m parapet above the top storey"))
coping = cube("Kit_Coping", 0.0, -0.15, PARAPET_H - 0.06, BAY, 0.65, 0.12, mat)
parts.append((coping, "coping slab over the parapet"))
return parts
BUILDERS = {
"pilaster": build_pilaster,
"sill": build_sill,
"lintel": build_lintel,
"window": build_window_bay,
"shopfront": build_shopfront_bay,
"door": build_door_bay,
"cornice": build_cornice,
"parapet": build_parapet,
}
def build_piece(name):
"""Build one piece group in a clean scene. The material is created *after*
the clear so its handle stays valid."""
clear_scene()
mat = make_material(f"M_Kit_{name}", 0.6)
return BUILDERS[name](mat)
def export_group(objects, out_dir):
"""Export each object to its own FBX with the shared preset."""
exported = []
for obj in objects:
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
# Unreal naming convention for static meshes is SM_<Name>; the Blender
# object keeps its readable name, the asset the importable one.
path = os.path.join(out_dir, "SM_" + obj.name + ".fbx")
fbx_preset.export_static(path, selection=True)
exported.append(path)
return exported
def main(argv=None) -> int:
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/kit")
ap.add_argument("--report", default=None)
ap.add_argument("--only", default=None, help="comma-separated piece names")
args = ap.parse_args(argv)
os.makedirs(args.out, exist_ok=True)
names = [n.strip() for n in args.only.split(",")] if args.only else list(BUILDERS)
unknown = [n for n in names if n not in BUILDERS]
if unknown:
raise SystemExit(f"unknown piece(s): {unknown}; known: {sorted(BUILDERS)}")
total = 0
for name in names:
made = build_piece(name)
if name == "window":
made = made # window bay exports two objects (reveal + glass)
objects = [o for o, _ in made]
for obj, note_text in made:
note(obj, note_text)
exported = export_group(objects, args.out)
total += len(exported)
print(f"[kit] {name}: {len(exported)} object(s) exported")
if args.report:
with open(args.report, "w", encoding="utf-8") as handle:
json.dump({
"module_m": BAY,
"storey_m": STOREY,
"pieces": sorted(BUILDERS),
"parts": PARTS,
}, handle, indent=2)
print(f"[kit] report -> {args.report}")
print(f"[kit] exported {total} object(s) to {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""The one FBX export preset for this project ("ue_metric_v1"/"ue_character_v1").
Before this module the same `bpy.ops.export_scene.fbx(...)` argument block was
copied into six scripts (hero, frontage, north buildings, site, scale test, MPFB
character). They had already drifted only by accident, and every one of them
carried `mesh_smooth_type="OFF"` — which is why Unreal logged "No smoothing group
information was found for this mesh" on every import and round parts rendered
faceted. One preset, one place to fix that.
Use from inside Blender:
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "Tools"))
import fbx_export_preset as preset
preset.export_static(path) # whole scene, MESH only
preset.export_static(path, selection=True) # selected objects only
preset.export_character(path) # armature + mesh, character flags
`preset_kwargs()` is pure (no `bpy`), so the argument set can be unit-tested
without Blender — see Tools/tests/test_fbx_export_preset.py.
Why these values:
* `FBX_SCALE_NONE` + `apply_unit_scale` keeps Blender metres → Unreal centimetres
at exactly ×100, verified by the three-cube scale test (1 m → 100.00 uu).
* `axis_forward="-Y"`, `axis_up="Z"` is the project's recorded mapping: east → +X,
north → −Y, up → +Z.
* `mesh_smooth_type="FACE"` exports smoothing groups. `"OFF"` (the old value)
drops them, so Unreal rebuilds flat normals and every cylinder is faceted.
* `use_mesh_modifiers=True` bakes the bevel/modifier stack at export; the source
`.blend` stays non-destructive.
"""
from __future__ import annotations
# Shared by every mesh export. Values are the recorded ue_metric_v1 preset.
_METRIC = {
"object_types": {"MESH"},
"apply_unit_scale": True,
"apply_scale_options": "FBX_SCALE_NONE",
"axis_forward": "-Y",
"axis_up": "Z",
"use_space_transform": True,
"use_mesh_modifiers": True,
"mesh_smooth_type": "FACE",
"path_mode": "AUTO",
"bake_space_transform": False,
}
# Character exports differ only in the object set and the armature/path flags.
#
# Animation export (added for the 0.2.0 animation set): without `bake_anim` the
# exporter silently writes a static bind pose even when the armature carries
# actions, and Unreal then imports a character with no AnimSequence and no error.
# `bake_anim_use_all_actions` exports every action as its own clip; NLA strips are
# off because the project does not author NLA. `bake_anim_simplify_factor=0.0`
# keeps the sampled keys exactly as authored - the clips are short, so the size
# cost is irrelevant and a simplified curve would drift from the source poses.
_CHARACTER = dict(
_METRIC,
object_types={"ARMATURE", "MESH"},
add_leaf_bones=False,
path_mode="COPY",
embed_textures=False,
bake_anim=True,
bake_anim_use_all_actions=True,
bake_anim_use_nla_strips=False,
bake_anim_force_startend_keying=True,
bake_anim_simplify_factor=0.0,
)
PRESETS = {"ue_metric_v1": _METRIC, "ue_character_v1": _CHARACTER}
def preset_kwargs(preset: str = "ue_metric_v1", selection: bool = False, **overrides) -> dict:
"""Return the `bpy.ops.export_scene.fbx` argument dict (no `bpy` needed).
`preset` selects the base set, `selection` decides whether only selected
objects are exported, and `overrides` wins last for the rare special case.
"""
if preset not in PRESETS:
raise KeyError(f"unknown preset {preset!r}; known: {sorted(PRESETS)}")
kwargs = dict(PRESETS[preset])
kwargs["use_selection"] = bool(selection)
kwargs.update(overrides)
if "filepath" in kwargs:
raise ValueError("pass the path to export_static/export_character, not as an override")
return kwargs
def _export(path: str, preset: str, selection: bool, overrides: dict) -> str:
import bpy # imported here so `preset_kwargs` stays usable without Blender
kwargs = preset_kwargs(preset, selection=selection, **overrides)
bpy.ops.export_scene.fbx(filepath=path, **kwargs)
return path
def export_static(path: str, selection: bool = False, **overrides) -> str:
"""Export meshes with the ue_metric_v1 preset. Returns the path."""
return _export(path, "ue_metric_v1", selection, overrides)
def export_character(path: str, selection: bool = False, **overrides) -> str:
"""Export armature + mesh with the ue_character_v1 preset. Returns the path."""
return _export(path, "ue_character_v1", selection, overrides)
+527
View File
@@ -0,0 +1,527 @@
"""Parametric hero façade: 6840 Hollywood Blvd / El Capitan Entertainment Centre.
Builds the north elevation in the project frame (X east, Y north, Z up, metres,
origin Hollywood x Highland) and exports it with the named FBX preset
`ue_metric_v1`, so it imports into Unreal at 1 m = 100 uu.
blender -b --factory-startup --python Tools/blender/hero_facade_build.py -- \
--out /tmp/gigline/hero-facade.fbx \
--save-blend Art/blender/hero/hero-facade-2026-11-07.blend \
--report Art/blender/hero/hero-facade-2026-11-07.json
Every dimension traces to a recorded measurement or a named assumption; the
report writes both, so a later pass can see what was evidence and what was
invented:
width 25.75 m measured, OSM way 424024147 north edge
height 14.50 m tag `height` on the same way
6 columns, 4.96 m sibling venue notes, cross-checked against the 25.75 m
frontage (5 bays + one column = 25.70 m)
three portals photographs: three separate openings, each with its own
short flight, not one 12 m grand stair
everything else assumption, listed in the report
Quieter choices are deliberate: a blockout for the demo, not a scanned facade.
Detail that the photographs do not resolve (moulding profiles, window rhythm)
is left abstract rather than invented in fine detail.
"""
import argparse
import json
import math
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
# --- measured / derived ------------------------------------------------------
FRONTAGE_W = 25.75
BODY_D = 12.0 # street-side mass only; the building runs 54.9 m deep
HEIGHT = 14.5
COLUMN_COUNT = 6
COLUMN_BAY = 4.96
FRONTAGE_Y = 0.0 # front plane sits at the building line of the block frame
# --- assumptions -------------------------------------------------------------
# Calibrated against the 2009 façade photograph: the entablature starts a little
# over half the total height, and the parapet carries the motto at about three
# quarters. The first pass had the enframed wall carrying 5.7 m of blank stone
# above the capitals, which read as a warehouse rather than a temple.
PEDESTAL_H = 1.45 # column pedestal top; entrance platform sits on it
COLUMN_D = 0.95 # Ionic shaft diameter at the base
CAPITAL_H = 0.95
ENTABLATURE_H = 3.00 # band between the capitals and the motto panel
# 3.40 -> 3.00 on 2026-11-09: the photographs put the
# colonnade at 50-60 % of the 14.5 m elevation, and our
# 3.40/2.20 entablature+parapet left it at 47.6 %. Thinning
# both lifts the colonnade to 7.70 m = 53.1 %, inside the band.
PARAPET_H = 1.80 # motto panel and cornice above the entablature (see above)
PORTAL_W = 3.10 # each of the three door openings
PORTAL_H = 3.40
STEP_TREAD = 0.34
STEP_RISE = 0.15
STEP_WIDTH = 3.60
def cube(name, x, y, z, dx, dy, dz, material=None):
bpy.ops.mesh.primitive_cube_add(size=1.0, location=(x, y, z))
obj = bpy.context.active_object
obj.name = name
obj.scale = (dx, dy, dz)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
if material:
obj.data.materials.append(material)
return obj
def cylinder(name, x, y, z0, z1, radius, material, vertices=24, smooth=True):
bpy.ops.mesh.primitive_cylinder_add(
vertices=vertices,
radius=radius,
depth=(z1 - z0),
location=(x, y, (z0 + z1) / 2.0),
)
obj = bpy.context.active_object
obj.name = name
if material:
obj.data.materials.append(material)
if smooth:
# Round parts must shade smoothly *around* the axis while keeping the
# caps creased. Without this the columns read as faceted prisms, and the
# FBX carries no smoothing groups at all (Unreal warns about it).
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(35.0))
return obj
def disc_on_wall(name, x, y, z, radius, thickness, material=None, vertices=24):
"""A round disc mounted vertically on a wall face.
`cylinder()` builds along Z, so a "medallion" made with it is a pancake lying
flat on top of the parapet rather than a disc on its face — which is what the
first build did (1.1 m across in Y, 0.02 m thick in Z, sitting at the coping).
This rotates the cylinder 90 degrees about X so the axis points along Y and
the disc faces the street.
"""
obj = cylinder(name, x, y, z - thickness / 2.0, z + thickness / 2.0,
radius, material, vertices=vertices)
obj.rotation_euler = (math.radians(90.0), 0.0, 0.0)
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
return obj
def make_material(name, rgba, roughness=0.75):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = rgba
bsdf.inputs["Roughness"].default_value = roughness
return mat
def build():
scene = bpy.context.scene
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
# Warm limestone, sampled from 02-masonictemple01.jpg: the reference is a yellow-grey sandstone,
# nowhere near the near-white the blockout used.
stone = make_material("M_Facade_Stone", (0.34, 0.29, 0.21, 1.0), 0.82)
stone_dark = make_material("M_Facade_StoneShadow", (0.22, 0.185, 0.135, 1.0), 0.85)
glass = make_material("M_Facade_GlassDark", (0.05, 0.06, 0.07, 1.0), 0.25)
banner = make_material("M_Facade_Banner", (0.66, 0.06, 0.05, 1.0), 0.9)
half_w = FRONTAGE_W / 2.0
lines = []
parts = []
def note(name, dims, evidence):
parts.append({"object": name, "dims_m": [round(d, 3) for d in dims], "evidence": evidence})
lines.append(f"[hero] {name:22} {dims[0]:6.2f} x {dims[1]:6.2f} x {dims[2]:6.2f} m ({evidence})")
# --- main mass ---------------------------------------------------------
# The mass sits behind the colonnade; its front face is the colonnade line.
# Inset so the entablature (1.5 m deep) and parapet (1.2 m deep) sit inside the
# measured 25.75 m frontage instead of overhanging it.
inset = 0.75
# The wall's front face, and the only Y that matters for what is visible.
# +Y is the street side: anything with y < FRONT_Y is inside the solid mass and never renders.
# Hand-typed negative Y values throughout this file put the banners 55 cm, the portals 2 m and
# the windows 6 cm behind this plane, which is why the captures showed a plain block with six
# columns. `seat_on_front()` below re-seats every applied detail relative to FRONT_Y, so the
# depth is stated once instead of guessed at each call site.
global FRONT_Y
FRONT_Y = -BODY_D / 2.0 - 0.5 + BODY_D / 2.0 # = -0.5 with the current BODY_D
cube("Hero_Mass", 0.0, -BODY_D / 2.0 - 0.5, HEIGHT / 2.0, FRONTAGE_W - 2 * inset, BODY_D, HEIGHT, stone_dark)
note("Hero_Mass", (FRONTAGE_W - 2 * inset, BODY_D, HEIGHT),
"measured frontage x assumption depth x measured height, inset behind the cornice")
# --- stylobate and steps ----------------------------------------------
# A plinth from the pavement to the pedestal shelf, with three door flights.
# The stylobate is built later as piers around the three door openings.
# --- Ionic colonnade ---------------------------------------------------
col_xs = []
span = (COLUMN_COUNT - 1) * COLUMN_BAY
for i in range(COLUMN_COUNT):
x = -span / 2.0 + i * COLUMN_BAY
col_xs.append(x)
cube(f"Hero_Pedestal_{i + 1}", x, -0.62, PEDESTAL_H + 0.27,
1.24, 1.24, 0.54, stone)
# Base moulding: a taller, wider cylinder where the shaft meets the
# pedestal. Without it the shaft starts straight off a square plinth,
# which is not how an Ionic base reads in silhouette.
base_z0 = PEDESTAL_H + 0.55
base_h = 0.22
cylinder(f"Hero_ColumnBase_{i + 1}", x, -0.55, base_z0, base_z0 + base_h,
0.68, stone, vertices=20)
shaft_z0 = base_z0 + base_h
cap_z0 = HEIGHT - ENTABLATURE_H - PARAPET_H - CAPITAL_H
cylinder(f"Hero_Column_{i + 1}", x, -0.55, shaft_z0, cap_z0,
COLUMN_D / 2.0, stone)
# Fluting: 20 shallow grooves around the shaft, as the photographs show on
# the colonnade. Each flute is a thin cylinder set into the shaft surface;
# modelling them as grooves would need booleans on 6 columns for detail the
# eye reads only in silhouette and grazing light, so they are added mass in
# the *shadow* direction instead — cheap, and it breaks the flat highlight.
flutes = 20
flute_r = 0.032
for f in range(flutes):
ang = (2.0 * math.pi * f) / flutes
fx = x + (COLUMN_D / 2.0 - flute_r * 0.4) * math.cos(ang)
fy = -0.55 + (COLUMN_D / 2.0 - flute_r * 0.4) * math.sin(ang)
cylinder(f"Hero_Flute_{i + 1}_{f + 1}", fx, fy, shaft_z0 + 0.05,
cap_z0 - 0.05, flute_r, stone_dark, vertices=6)
# Capital block, then the square abacus that carries the architrave.
# The abacus takes the top 0.14 m of the capital zone so the colonnade
# still ends at HEIGHT - ENTABLATURE_H - PARAPET_H (proportion unchanged).
abacus_h = 0.14
cylinder(f"Hero_Capital_{i + 1}", x, -0.55, cap_z0,
HEIGHT - ENTABLATURE_H - PARAPET_H - abacus_h, 0.62, stone, vertices=16)
cube(f"Hero_Abacus_{i + 1}", x, -0.55,
HEIGHT - ENTABLATURE_H - PARAPET_H - abacus_h / 2.0,
1.30, 1.30, abacus_h, stone)
note("Hero_Column_x6", (COLUMN_D, COLUMN_D, HEIGHT - ENTABLATURE_H - PARAPET_H - PEDESTAL_H),
f"6 columns at {COLUMN_BAY} m centres (sibling notes x measured frontage)")
note("Hero_Flute_x120", (0.064, 0.064, cap_z0 - shaft_z0),
"photographs: fluted shafts; 20 grooves per column modelled as shallow "
"cylinders in the shadow tone rather than boolean cut grooves")
note("Hero_ColumnBase_x6", (1.36, 1.36, 0.22),
"assumption: Ionic base moulding; photographs show a wider base at the shaft foot")
note("Hero_Abacus_x6", (1.30, 1.30, 0.14),
"assumption: square abacus under the architrave, as Ionic capitals carry")
# --- entablature and parapet ------------------------------------------
ent_z0 = HEIGHT - ENTABLATURE_H - PARAPET_H
# Lower entablature band (architrave) standing proud of the wall, so the
# elevation reads as layered stone rather than one flat plane.
cube("Hero_Architrave", 0.0, -1.55, ent_z0 + 0.42, FRONTAGE_W, 1.65, 0.84, stone)
note("Hero_Architrave", (FRONTAGE_W, 1.65, 0.84), "assumption: stacked entablature bands")
# Cornice under the parapet: a projecting corona carried on a dentil course,
# so the top of the order reads as layered stone with a shadow line rather
# than one flat box. Dentils hang under the corona and stand 0.25 m clear of
# it, which is what gives the elevation its cornice shadow at street level.
cornice_z = HEIGHT - PARAPET_H - 0.18
cube("Hero_Cornice", 0.0, -1.6, cornice_z, FRONTAGE_W, 1.75, 0.36, stone)
note("Hero_Cornice", (FRONTAGE_W, 1.75, 0.36), "assumption: projecting cornice line")
DENTIL_W, DENTIL_D, DENTIL_H, DENTIL_GAP = 0.18, 0.30, 0.22, 0.27
dentil_y = -2.30
dentil_z = cornice_z - 0.18 - DENTIL_H / 2.0
dentil_span = FRONTAGE_W - 2.0
dentil_count = int(dentil_span / (DENTIL_W + DENTIL_GAP))
for i in range(dentil_count):
dx = -dentil_span / 2.0 + (DENTIL_W + DENTIL_GAP) * i + DENTIL_W / 2.0
cube(f"Hero_Dentil_{i + 1}", dx, dentil_y, dentil_z,
DENTIL_W, DENTIL_D, DENTIL_H, stone)
note("Hero_Dentil_xN", (DENTIL_W, DENTIL_D, DENTIL_H),
f"{dentil_count} dentils at {DENTIL_W + DENTIL_GAP:.2f} m centres under the corona; "
"assumption: photographed cornice reads as a dentilled course")
cube("Hero_Entablature", 0.0, -0.75, ent_z0 + ENTABLATURE_H / 2.0,
FRONTAGE_W, 1.5, ENTABLATURE_H, stone)
note("Hero_Entablature", (FRONTAGE_W, 1.5, ENTABLATURE_H), "assumption: entablature depth")
cube("Hero_Parapet", 0.0, -0.75, HEIGHT - PARAPET_H / 2.0,
FRONTAGE_W, 1.2, PARAPET_H, stone)
note("Hero_Parapet", (FRONTAGE_W, 1.2, PARAPET_H), "assumption: parapet carries the motto band")
# Motto band: a recessed panel across the parapet with a moulded surround, as
# the photographs show. The panel was a 8 cm stripe of dark stone; it is now a
# real recess — surround frame standing 6 cm proud, panel set 12 cm behind it —
# so it reads as an inscription panel from the street instead of a paint line.
motto_z = HEIGHT - PARAPET_H / 2.0
motto_w = FRONTAGE_W - 3.0
for x_off in (-(motto_w / 2.0 + 0.16), (motto_w / 2.0 + 0.16)):
cube(f"Hero_MottoSurround_v{int(x_off > 0)}", x_off, -1.13, motto_z,
0.32, 0.26, 1.15, stone)
for z_off in (-0.575, 0.575):
cube(f"Hero_MottoSurround_h{int(z_off > 0)}", 0.0, -1.13, motto_z + z_off,
motto_w + 0.64, 0.26, 0.32, stone)
cube("Hero_MottoBand", 0.0, -1.06, motto_z, motto_w, 0.10, 0.85, stone_dark)
note("Hero_MottoBand", (motto_w, 0.10, 0.85),
"photographs: motto panel above the colonnade, now an inset panel with a "
"moulded surround rather than a flat stripe")
# Medallions either side of the inscription: discs on the parapet face, not
# pancakes on the coping (see disc_on_wall).
for i, x in enumerate((-6.2, 6.2), start=1):
disc_on_wall(f"Hero_Medallion_{i}", x, -1.30, HEIGHT - PARAPET_H / 2.0,
0.55, 0.04, stone_dark, vertices=20)
note("Hero_Medallion_x2", (1.1, 0.04, 1.1),
"photographs: two stone discs beside the motto, mounted on the parapet face")
# --- entrances ---------------------------------------------------------
# Three portals between column pairs, each with its own short flight. The
# doorway is cut INTO the stylobate face rather than laid on it: the plinth is
# modelled as three segments so the opening is a real recess, and the portals
# stand proud of the wall behind the colonnade so they read from the street.
portal_xs = (-COLUMN_BAY, 0.0, COLUMN_BAY)
plinth_front = -2.4 # front face of the plinth, in the facade frame
plinth_back = 0.0
gap = PORTAL_W
# The plinth is four piers between and beside the openings, plus a band over
# them, so each door is a real recess instead of a panel on a solid face.
piers = [
(-(FRONTAGE_W / 2.0), portal_xs[0] - gap / 2.0),
(portal_xs[0] + gap / 2.0, portal_xs[1] - gap / 2.0),
(portal_xs[1] + gap / 2.0, portal_xs[2] - gap / 2.0),
(portal_xs[2] + gap / 2.0, (FRONTAGE_W / 2.0)),
]
for pi, (x0, x1) in enumerate(piers, start=1):
cube(f"Hero_Stylobate_{pi}", (x0 + x1) / 2.0, -1.2, PEDESTAL_H / 2.0,
x1 - x0, 2.4, PEDESTAL_H, stone)
# Band above the openings, closing the plinth to full height between the doors.
# The reference has no blank band across the portals - the openings run up to the entablature
# with the banners hanging in front of them - so this filler is gone. What stays is the thin
# lintel over each doorway, modelled with the portals.
note("Hero_Stylobate", (FRONTAGE_W, 2.4, PEDESTAL_H),
"assumption: pedestal height; four piers and a lintel band leave three recesses")
for i, x in enumerate(portal_xs, start=1):
# Recessed doorway: a dark reveal set back inside the plinth, 1.2 m deep so
# it reads as an opening from any angle.
depth = 1.2
# Door leaf set back inside the opening. A solid stone reveal box at the same volume z-fought
# with the leaf and read as stripes, so the recess is carried by the jambs, the lintel and the
# shadow instead.
cube(f"Hero_PortalLeaf_{i}", x, plinth_front + depth - 0.10, PORTAL_H / 2.0,
PORTAL_W - 0.10, 0.08, PORTAL_H - 0.10, glass)
# Stone surround, standing 0.1 m proud of the plinth face.
for side, sx in (("L", -1.0), ("R", 1.0)):
# Jambs stop at the pavement: a 0.15 m overhang below Z 0 would sink the
# whole facade into the ground plane.
cube(f"Hero_PortalJamb_{i}{side}", x + sx * (PORTAL_W / 2.0 + 0.15),
plinth_front - 0.10, (PORTAL_H + 0.3) / 2.0, 0.3, 0.2, PORTAL_H + 0.3, stone)
cube(f"Hero_PortalLintel_{i}", x, plinth_front - 0.10, PORTAL_H + 0.15,
PORTAL_W + 0.6, 0.2, 0.3, stone)
# Door grille: mullions across the opening, dark metal read at distance.
for m in range(1, 4):
cube(f"Hero_PortalMullion_{i}_{m}", x - PORTAL_W / 2.0 + m * PORTAL_W / 4.0,
plinth_front + 0.12, PORTAL_H / 2.0, 0.09, 0.06, PORTAL_H, stone_dark)
# Sign panel over the doorway, as the photographs show.
cube(f"Hero_PortalSign_{i}", x, plinth_front - 0.16, PORTAL_H + 0.75,
0.7, 0.06, 0.9, banner)
# Photographs show four to five risers per portal; the count follows the
# pedestal height rather than inheriting the old "eight steps" claim.
steps = max(3, min(5, int(round((PEDESTAL_H - 0.2) / STEP_RISE))))
# Steps descend from the plinth to the pavement; the lowest tread must land
# exactly on Z 0, never below it, so the flight meets the ground plane.
steps = max(3, min(steps, int(round(PEDESTAL_H / STEP_RISE))))
for st in range(steps):
top = PEDESTAL_H - st * STEP_RISE
height = top if st == steps - 1 else STEP_RISE
cube(f"Hero_Step_{i}_{st + 1}", x,
plinth_front - 0.14 - st * STEP_TREAD - STEP_TREAD / 2.0 + 0.28,
top - height / 2.0, STEP_WIDTH, STEP_TREAD, height, stone)
note(f"Hero_PortalFlight_{i}", (PORTAL_W, STEP_TREAD * steps, PEDESTAL_H - 0.2),
f"{steps} steps at {STEP_RISE:.2f} m rise / {STEP_TREAD:.2f} m tread "
f"(assumption; photos show 4-5, the old notes said 8)")
# --- banners -----------------------------------------------------------
# Three vertical event banners hung from the column line, as the recordings show.
for i, x in enumerate(portal_xs, start=1):
# The reference banners hang from just below the capitals down to the portal heads:
# 6.2 m tall and 1.35 m wide, not the 5.4 x 1.55 blockout that sat too low and too fat.
cube(f"Hero_Banner_{i}", x, -1.05, 7.2, 1.35, 0.06, 6.2, banner)
note("Hero_Banner_x3", (1.35, 0.06, 6.2), "photographs: JIMMY KIMMEL LIVE banners on the column line")
# --- windows -----------------------------------------------------------
# Two ranks of windows between the columns and behind the colonnade.
# Y must sit on the wall plane: the mass front face is at Y -0.50 and the
# columns stand proud to -1.02. These were at -1.98, i.e. 1.44 m in FRONT of
# the wall and ahead of the column line, so the glazing floated in the
# colonnade instead of reading as openings behind it (visible in the
# review-hero-facade-* shots). WINDOW_Y puts the pane 6 cm proud of the wall,
# safely behind the columns.
WINDOW_Y = -0.56
for rank, z in enumerate((4.4, 8.2), start=1):
for i, x in enumerate(col_xs[:-1], start=1):
wx = x + COLUMN_BAY / 2.0
cube(f"Hero_Window_{rank}_{i}", wx, WINDOW_Y, z, 2.0, 0.08, 1.9, glass)
note("Hero_Window_x10", (2.0, 0.08, 1.9), "photographs: window bands behind the colonnade")
# --- window grilles ------------------------------------------------------
# The reference bays are closed with a dark metal trellis, not plain glass. Seven vertical and five
# horizontal bars per opening, 4 cm square, read as a grille at street distance and cost 60 cubes.
grille = make_material("M_Facade_Grille", (0.10, 0.10, 0.11, 1.0), 0.5)
barred = 0
for obj in list(bpy.data.objects):
if not obj.name.startswith("Hero_Window_"):
continue
wx, wy, wz, wdx, wdy, wdz = (obj.location.x, obj.location.y, obj.location.z,
obj.dimensions.x, obj.dimensions.y, obj.dimensions.z)
for v in range(7):
x = wx - wdx / 2.0 + (v + 0.5) * wdx / 7.0
cube(f"Hero_GrilleV_{barred:02d}", x, wy - 0.02, wz, 0.04, 0.03, wdz * 0.96, grille)
barred += 1
for hcount in range(5):
z = wz - wdz / 2.0 + (hcount + 0.5) * wdz / 5.0
cube(f"Hero_GrilleH_{barred:02d}", wx, wy - 0.02, z, wdx * 0.96, 0.03, 0.04, grille)
barred += 1
print(f"[hero] window grilles: {barred} bars")
# --- rustication and roof cresting --------------------------------------
# The photographs show the ground storey in banded ashlar and a cresting with balusters along
# the roof edge; without them the elevation reads as a flat card.
# Courses run from the pavement to the portal heads, as the street-level photograph shows.
rustication(FRONTAGE_W, PORTAL_H, stone_dark)
balustrade(FRONTAGE_W, HEIGHT - PARAPET_H * 0.15, stone)
# --- seat the applied detail on the wall ---------------------------------
# Depth is a property of the façade, not of each call site: state it once here. Parts whose
# front face is behind the wall plane are moved until it sits at FRONT_Y + offset.
seat_on_front((
("Hero_Architrave", 0.05), ("Hero_Cornice", 0.05), ("Hero_Dentil", 0.05),
("Hero_Entablature", 0.03), ("Hero_MottoBand", 0.06), ("Hero_MottoSurround", 0.06),
("Hero_Medallion", 0.08), ("Hero_Banner", 0.08), ("Hero_Window", 0.03),
("Hero_Portal", 0.05), ("Hero_PortalJamb", 0.08), ("Hero_PortalLintel", 0.08),
("Hero_PortalMullion", 0.04), ("Hero_PortalSign", 0.10),
))
return parts, lines
def rustication(width_m, height_m, material, course_m=0.55, joints=0):
"""Horizontal ashlar joints cut across the ground storey.
A real façade reads as stone because of the shadow in its joints, not because of its colour.
Grooves are 3 cm deep and 4 cm tall, spaced `course_m`, and sit just proud of the wall so they
catch the light the way the photographs show.
"""
z = course_m
count = 0
while z < height_m - 0.2:
cube(f"Hero_Course_{count:02d}", 0.0, FRONT_Y + 0.015, z,
width_m - 1.5, 0.04, 0.04, material)
count += 1
z += course_m
print(f"[hero] rustication: {count} courses at {course_m:.2f} m")
def balustrade(width_m, z, material, spacing_m=0.62):
"""A row of balusters with a bottom and top rail along the roof edge."""
half = width_m / 2.0 - 0.9
count = int((2 * half) / spacing_m)
cube("Hero_BalustradeRailBottom", 0.0, FRONT_Y - 0.05, z - 0.06, 2 * half, 0.14, 0.10, material)
cube("Hero_BalustradeRailTop", 0.0, FRONT_Y - 0.05, z + 0.42, 2 * half, 0.16, 0.10, material)
for i in range(count):
x = -half + i * spacing_m + spacing_m / 2.0
cube(f"Hero_Baluster_{i:02d}", x, FRONT_Y - 0.05, z + 0.18, 0.12, 0.12, 0.40, material)
print(f"[hero] balustrade: {count} balusters")
def seat_on_front(table):
"""Move each named part so its street-facing face sits `offset` metres proud of FRONT_Y.
`table` is (name_prefix, offset_m) pairs. Parts that name no prefix - the mass, the colonnade,
the steps and the stylobate - are left where the geometry put them, because they define the
plane rather than hang on it.
"""
moved = 0
for obj in bpy.data.objects:
if obj.type != "MESH":
continue
for prefix, offset in table:
if not obj.name.startswith(prefix):
continue
world_ys = [(obj.matrix_world @ v.co).y for v in obj.data.vertices]
front = max(world_ys)
delta = (FRONT_Y + offset) - front
if abs(delta) < 1e-4:
moved += 1
else:
obj.location.y += delta
print(f"[hero] seat {obj.name:22} y {front:+.2f} -> {front + delta:+.2f} (offset {offset:+.2f})")
moved += 1
break
print(f"[hero] seated {moved} parts on the wall plane at Y {FRONT_Y:+.2f}")
def export_fbx(path):
# Preset lives in Tools/blender/fbx_export_preset.py (ue_metric_v1).
os.makedirs(os.path.dirname(path), exist_ok=True)
# Named export preset "ue_metric_v1" - same settings as Tools/blender/scale_test_export.py.
fbx_preset.export_static(path)
print(f"[hero] exported FBX preset ue_metric_v1 -> {path}")
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
parser = argparse.ArgumentParser()
parser.add_argument("--out", default="/tmp/gigline/hero-facade.fbx")
parser.add_argument("--save-blend", default=None)
parser.add_argument("--report", default=None)
args = parser.parse_args(argv)
parts, lines = build()
for line in lines:
print(line)
# Bounds check: the facade must be exactly the measured frontage and height.
import mathutils
mn = mathutils.Vector((1e9, 1e9, 1e9))
mx = mathutils.Vector((-1e9, -1e9, -1e9))
for obj in bpy.context.scene.objects:
if obj.type != "MESH":
continue
for corner in obj.bound_box:
world = obj.matrix_world @ mathutils.Vector(corner)
mn = mathutils.Vector((min(mn[i], world[i]) for i in range(3)))
mx = mathutils.Vector((max(mx[i], world[i]) for i in range(3)))
size = mx - mn
print(f"[hero] bounds: {size.x:.2f} x {size.y:.2f} x {size.z:.2f} m "
f"(x {mn.x:.2f}..{mx.x:.2f}, y {mn.y:.2f}..{mx.y:.2f}, z {mn.z:.2f}..{mx.z:.2f})")
if abs(size.z - HEIGHT) > 0.05:
print(f"[hero] WARNING: height {size.z:.2f} m does not match the {HEIGHT} m tag")
if args.save_blend:
os.makedirs(os.path.dirname(args.save_blend), exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=args.save_blend)
print(f"[hero] saved {args.save_blend}")
if args.report:
os.makedirs(os.path.dirname(args.report), exist_ok=True)
with open(args.report, "w") as handle:
json.dump(
{
"asset": "hero-facade",
"frame": "project frame, metres, X east Y north Z up",
"bounds_m": {"min": [round(v, 3) for v in mn], "max": [round(v, 3) for v in mx],
"size": [round(v, 3) for v in size]},
"parts": parts,
},
handle,
indent=2,
)
print(f"[hero] wrote {args.report}")
export_fbx(args.out)
main()
+180
View File
@@ -0,0 +1,180 @@
# MPFB character export for the Blender -> Unreal pipeline (0.1.0 acceptance).
#
# Generates a default human, adds the standard deform rig with weights,
# materializes the v2 skin, bakes its diffuse to a texture, and exports
# FBX with the named export preset "ue_character_v1".
#
# blender -b --factory-startup --python Tools/blender/mpfb_character_export.py -- \
# --out /tmp/gigline/character.fbx --pose idle \
# --save-blend Art/blender/character/character-<date>.blend
# --pose applies one of the named standing poses to the standard rig and bakes
# it into the rest pose before export, so the caller-controlled vertex
# weights still deform the exported bind correctly (pose | none | idle).
import argparse
import os
import struct
import sys
import zlib
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
import numpy
from mathutils import Euler
def write_png(path, img):
# Write image pixels to PNG. `img.save()` writes black for an in-memory
# image with no backing file even though the bake updates img.pixels, and a
# per-pixel stdlib loop is far too slow (256^2 took 25 s). numpy makes the
# whole encode ~0.3 s for 1024^2; Blender ships numpy.
w, h = img.size
arr = numpy.asarray(img.pixels[:], dtype=numpy.float32).reshape(h, w, 4)
rgba = (numpy.clip(arr, 0.0, 1.0) * 255.0 + 0.5).astype(numpy.uint8)[::-1]
byt = rgba.tobytes()
raw = bytearray()
for y in range(h):
raw.append(0) # filter type 0
raw += byt[y * w * 4:(y + 1) * w * 4]
def chunk(tag, data):
c = struct.pack(">I", len(data)) + tag + data
c += struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff)
return c
ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
png += chunk(b"IDAT", zlib.compress(bytes(raw))) + chunk(b"IEND", b"")
with open(path, "wb") as f:
f.write(png)
IDLE_POSE = {
# Brings the A/T bind arms down into a relaxed standing position.
# Values are local-pose euler deltas; verified 2026-11-07.
"shoulder01.L": (0.0, -0.15, -0.03),
"shoulder01.R": (0.0, 0.15, 0.03),
"upperarm01.L": (0.0, 0.0, -1.55),
"upperarm01.R": (0.0, 0.0, 1.55),
"lowerarm01.L": (0.0, 0.0, -0.10),
"lowerarm01.R": (0.0, 0.0, 0.10),
}
REPORT = "/tmp/mpfb-export-report.txt"
lines = []
def log(msg):
lines.append(str(msg))
def enable_mpfb():
bpy.ops.preferences.addon_enable(module="bl_ext.blender_org.mpfb")
assert "bl_ext.blender_org.mpfb" in bpy.context.preferences.addons, "MPFB did not enable"
def apply_pose(arm, name="idle"):
if name == "none":
return
table = IDLE_POSE
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="POSE")
for bone_name, eul in table.items():
bone = arm.pose.bones[bone_name]
bone.rotation_mode = "XYZ"
bone.rotation_euler = Euler(eul)
bpy.ops.pose.armature_apply()
bpy.ops.object.mode_set(mode="OBJECT")
log(f"pose: applied '{name}' to rest pose")
def build_character(pose="idle"):
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
bpy.ops.mpfb.create_human("EXEC_DEFAULT")
human = bpy.context.active_object
log(f"create_human: {human.name}, {len(human.data.vertices)} verts, dims={[round(d, 3) for d in human.dimensions]}")
bpy.ops.mpfb.add_standard_rig("EXEC_DEFAULT")
armatures = [o for o in bpy.data.objects if o.type == "ARMATURE"]
log(f"add_standard_rig: {len(armatures)} armature(s), bones={[len(a.data.bones) for a in armatures]}")
apply_pose(armatures[0], pose)
bpy.ops.object.select_all(action="DESELECT")
human.select_set(True)
bpy.context.view_layer.objects.active = human
bpy.ops.mpfb.create_v2_skin("EXEC_DEFAULT")
mats = [m.name for m in human.data.materials]
log(f"create_v2_skin: materials={mats}")
return human
def make_diffuse(human, size=1024):
# A Cycles bake of MPFB's v2-skin node group returns black and is slow at
# 1024^2 headless. We ship a flat export material anyway (the node tree
# does not map to FBX), so synthesize a warm skin-tone diffuse directly.
img = bpy.data.images.new("T_Character_Diffuse", size, size)
body = (0.72, 0.52, 0.38, 1.0) # warm skin
cloth = (0.30, 0.34, 0.40, 1.0) # grey-blue, bottom band to suggest clothing
band = int(size * 0.25)
pixels = []
for y in range(size):
tone = cloth if y < band else body
pixels += list(tone) * size
img.pixels[:] = pixels
log(f"make_diffuse: synthesized {size}x{size} body/shorts-suggestive diffuse")
write_png("/tmp/gigline/T_Character_Diffuse.png", img)
log("make_diffuse: wrote diffuse via stdlib PNG writer -> /tmp/gigline/T_Character_Diffuse.png")
# MPFB's v2 skin is a node-group tree the FBX exporter cannot map to a
# texture. Build a flat export material carrying the diffuse so the FBX
# (and therefore Unreal) gets a real texture.
export_mat = bpy.data.materials.new("M_Character_Export")
export_mat.use_nodes = True
nt = export_mat.node_tree
nt.nodes.clear()
out = nt.nodes.new("ShaderNodeOutputMaterial")
bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
tex = nt.nodes.new("ShaderNodeTexImage")
tex.image = img
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
human.data.materials.clear()
human.data.materials.append(export_mat)
log("export material: M_Character_Export with diffuse assigned")
def export_fbx(human, path):
os.makedirs(os.path.dirname(path), exist_ok=True)
# Named export preset "ue_character_v1" — change only via a new preset name.
fbx_preset.export_character(path)
log(f"export: FBX preset ue_character_v1 -> {path}")
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
parser = argparse.ArgumentParser()
parser.add_argument("--out", default="/tmp/gigline/character.fbx")
parser.add_argument("--pose", choices=("none", "idle"), default="idle")
parser.add_argument("--save-blend", default=None)
args = parser.parse_args(argv)
enable_mpfb()
human = build_character(pose=args.pose)
make_diffuse(human)
if args.save_blend:
os.makedirs(os.path.dirname(args.save_blend), exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=args.save_blend)
log(f"save: {args.save_blend}")
export_fbx(human, args.out)
with open(REPORT, "w") as f:
f.write("\n".join(lines) + "\n")
main()
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env python3
"""Retail frontage trim for 6800 Lee Drug and 6806–6820 (0.1.6).
Both buildings are bare OSM extrusions with a flat stucco material; what makes them
read as *the* retail run beside the hero is the façade layer: the bronze cornice and
stepped Deco parapet on Lee Drug, and the pilaster-and-fascia rhythm on 6806–6820.
The trim is built **in world coordinates** in the shared frame (metres, X east,
Y north, Z from the project datum), so the export carries its position and the
Unreal staging step is a single spawn at the origin — the same convention as
`Tools/blender/theatre_trim_build.py` and `Tools/blender/export_block_frontage.py`.
blender -b --factory-startup --python Tools/blender/retail_frontage_build.py -- \
--out /tmp/gigline/retail --report /tmp/gigline/retail-report.json
Bounds are **measured**, not assumed: they are the blockout objects' world bounds
(`Lee Drug Building`, `element.019`), which sit at Z 0 with the volume top at 12.10 m.
Vertical proportions come from the single reference photograph and are recorded with
the assumption in `Art/blender/retail/ref-analysis.md`.
6822 Budget is deliberately **not** built: there is no photograph of it, and the TODO
item says so. Guessing its façade would be the misattribution the item warns against.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import _common # noqa: E402
import fbx_export_preset as fbx_preset # noqa: E402
# --- measured bounds, from the blockout (world metres) ------------------------
# Lee Drug 6800, OSM way 424024149. Front face is the north edge (max Y).
LD_X0, LD_X1 = -33.20, -14.37
LD_FACE_Y = -14.63
LD_H = 12.10
# 6806-6820, OSM way 424024150.
B6_X0, B6_X1 = -66.08, -33.07
B6_FACE_Y = -12.52
B6_H = 12.10
# --- proportions, read off the reference photograph ---------------------------
# Fractions of the 12.10 m height; see Art/blender/retail/ref-analysis.md for how
# they were read and why they are assumptions at ±0.03 H.
LD_SHOP_TOP = 0.36 * LD_H # 4.36 m: shopfront glazing / cornice soffit
LD_CORNICE_TOP = 0.43 * LD_H # 5.20 m: top of the bronze band
LD_MURAL_Z = (0.49 * LD_H, 0.61 * LD_H)
LD_LETTER_Z = (0.65 * LD_H, 0.76 * LD_H)
LD_PARAPET_Z = 0.82 * LD_H # 9.92 m: bottom of the stepped parapet
B6_SHOP_TOP = 0.32 * B6_H # 3.87 m
B6_FASCIA_TOP = 0.38 * B6_H # 4.60 m
B6_WINDOW_Z = (0.47 * B6_H, 0.64 * B6_H)
B6_PARAPET_Z = 0.74 * B6_H # 8.95 m (first pass used 0.62 and left a 4.6 m blank band)
BAY = 2.20 # mural / bay rhythm on Lee Drug, assumed
PROJECT = 0.55 # bronze cornice projection, assumed
TOWER_R = 2.10 # Lee Drug corner tower radius, assumed
PARTS: list[dict] = []
def make_material(name, rgb, rough=0.6, metallic=0.0):
"""Principled material with an explicit colour; the export preset carries it as a slot."""
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (rgb[0], rgb[1], rgb[2], 1.0)
bsdf.inputs["Roughness"].default_value = rough
bsdf.inputs["Metallic"].default_value = metallic
return mat
def clear_scene():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
for mesh in list(bpy.data.meshes):
if mesh.users == 0:
bpy.data.meshes.remove(mesh)
def cube(name, x, y, z, dx, dy, dz, material):
bpy.ops.mesh.primitive_cube_add(size=1.0, location=(x, y, z))
obj = bpy.context.active_object
obj.name = name
obj.scale = (dx, dy, dz)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
obj.data.materials.append(material)
return obj
def cylinder(name, x, y, z, radius, height, material, vertices=28):
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius, depth=height,
location=(x, y, z))
obj = bpy.context.active_object
obj.name = name
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
obj.data.materials.append(material)
return obj
def bay_centres(x0, x1, margin=0.9, bay=BAY):
"""Bay centres; the arithmetic lives in `_common` so it is testable without Blender."""
return _common.bay_centres(x0, x1, margin=margin, bay=bay)
def note(obj, evidence):
PARTS.append({
"object": obj.name,
"asset": "SM_" + obj.name,
"dims_m": [round(d, 3) for d in obj.dimensions],
"location_m": [round(c, 3) for c in obj.location],
"evidence": evidence,
})
print(f"[retail] {obj.name:26} {obj.dimensions.x:6.2f} x {obj.dimensions.y:5.2f} "
f"x {obj.dimensions.z:5.2f} m ({evidence})")
def build_lee_drug(mats):
"""6800 Lee Drug: bronze cornice, mural row, lettering band, stepped Deco parapet,
rounded corner tower."""
stucco, bronze, mural, glass, white = (mats["stucco"], mats["bronze"], mats["mural"],
mats["glass"], mats["white"])
lettering, reveal = mats["lettering"], mats["reveal"]
parts = []
cx = (LD_X0 + LD_X1) / 2.0
width = LD_X1 - LD_X0
# Bronze cornice: the strongest horizontal line on the building.
cornice = cube("LD_Cornice", cx, LD_FACE_Y + PROJECT / 2.0,
(LD_SHOP_TOP + LD_CORNICE_TOP) / 2.0,
width + 0.4, PROJECT, LD_CORNICE_TOP - LD_SHOP_TOP, bronze)
parts.append((cornice, "photographs: projecting bronze cornice over the shopfront; "
f"{PROJECT:.2f} m projection assumed"))
# Shopfront glazing, recessed behind the face, one panel per bay.
for i, bx in enumerate(bay_centres(LD_X0, LD_X1, margin=1.2), start=1):
panel = cube(f"LD_ShopGlass_{i}", bx, LD_FACE_Y + 0.12, LD_SHOP_TOP / 2.0,
BAY - 0.30, 0.10, LD_SHOP_TOP - 0.55, glass)
parts.append((panel, "photographs: full-height shopfront glazing; bay width and "
"the 0.55 m kickplate are assumed (a bus hides this zone)"))
mullion = cube(f"LD_Mullion_{i}", bx - (BAY - 0.30) / 2.0, LD_FACE_Y - 0.02,
LD_SHOP_TOP / 2.0, 0.10, 0.16, LD_SHOP_TOP - 0.55, bronze)
parts.append((mullion, "assumption: slim mullion at each bay boundary"))
# Celebrity-portrait panels, one per bay, in the band above the cornice.
#
# First pass modelled these as a shallow plate on the wall plane and they vanished
# at review distance: no shadow, no edge. Now each panel is a near-black plate set
# 0.14 m back inside a stucco frame that stands 0.10 m proud, so the sun casts a
# shadow line down one side and the row reads as a row of pictures.
z0, z1 = LD_MURAL_Z
for i, bx in enumerate(bay_centres(LD_X0, LD_X1, margin=1.2), start=1):
frame = cube(f"LD_MuralFrame_{i}", bx, LD_FACE_Y + 0.20, (z0 + z1) / 2.0,
BAY - 0.55, 0.12, (z1 - z0) + 0.30, white)
parts.append((frame, "photographs: stucco frame around each portrait panel; "
"0.30 m border assumed"))
panel = cube(f"LD_Mural_{i}", bx, LD_FACE_Y + 0.12, (z0 + z1) / 2.0,
BAY - 0.85, 0.06, (z1 - z0), mural)
parts.append((panel, "photographs: row of black-framed portrait panels; count and "
"size assumed from the visible row"))
# Lettering band: the photographs show dark letters painted straight on the stucco,
# so this is a slightly proud plate in a mid-dark tone rather than a black recess.
# The words themselves remain a decal job; the plate only reserves the band.
lz0, lz1 = LD_LETTER_Z
letter = cube("LD_LetterBand", cx, LD_FACE_Y + 0.10, (lz0 + lz1) / 2.0,
width - 3.6, 0.10, lz1 - lz0, lettering)
parts.append((letter, "photographs: large dark lettering across the upper wall; "
"modelled as a painted band, the text itself is a decal job"))
# Stepped Deco parapet. A single 2.18 m wall with 0.10 m reveals read as one blank
# white band, so it is now two tiers with a 0.10 m step between them, and the
# reveals are 0.22 m tall and cut 0.16 m deep - deep enough to hold a shadow.
tier_h = (LD_H - LD_PARAPET_Z) / 2.0
lower = cube("LD_ParapetLower", cx, LD_FACE_Y + 0.14, LD_PARAPET_Z + tier_h / 2.0,
width + 0.2, 0.34, tier_h, stucco)
parts.append((lower, "photographs: parapet lower tier above the frontage wall; "
"thickness assumed"))
upper = cube("LD_ParapetUpper", cx, LD_FACE_Y + 0.24, LD_PARAPET_Z + tier_h * 1.5,
width - 3.2, 0.30, tier_h, stucco)
parts.append((upper, "photographs: parapet upper tier, stepped back from the lower "
"one; setback and length assumed"))
for i, (z, span) in enumerate(((LD_PARAPET_Z + tier_h * 0.45, width + 0.2),
(LD_PARAPET_Z + tier_h * 1.30, width + 0.2),
(LD_H - 0.14, width - 3.2)), start=1):
groove = cube(f"LD_ParapetReveal_{i}", cx, LD_FACE_Y - 0.02, z,
span, 0.16, 0.22, reveal)
parts.append((groove, "photographs: horizontal reveal lines in the parapet; "
"count read off the frame"))
# Rounded corner tower at the east end, stepped twice at the top. The centre
# sits INSIDE the footprint so the tower projects 0.35 m from the face instead of
# bulging 4 m into the pavement, which the first run did.
tower_y = LD_FACE_Y - TOWER_R + 0.35
tower = cylinder("LD_CornerTower", LD_X1 - TOWER_R, tower_y,
(LD_H - 0.9) / 2.0, TOWER_R, LD_H - 0.9, stucco)
parts.append((tower, "photographs: rounded Deco corner tower rising above the "
f"frontage; {TOWER_R:.2f} m radius assumed"))
# Vertical fins around the curve: the photograph's tower is fluted, not a smooth
# drum. Twelve ribs at this radius are legible from the street.
for i in range(12):
ang = math.pi * (i + 0.5) / 12.0
fx = (LD_X1 - TOWER_R) + math.cos(ang) * (TOWER_R + 0.06)
fy = tower_y + math.sin(ang) * (TOWER_R + 0.06)
fin = cube(f"LD_TowerFin_{i + 1}", fx, fy, (LD_H - 0.9) / 2.0,
0.16, 0.16, LD_H - 0.9, white)
parts.append((fin, "photographs: fluted fins on the corner tower; count assumed"))
# Cap rings stack exactly to the measured 12.10 m top: 11.20 -> 11.75 -> 12.10.
cap_z = LD_H - 0.9
for i, (dh, radius) in enumerate(((0.55, TOWER_R - 0.35), (0.35, TOWER_R - 0.70)), start=1):
cap = cylinder(f"LD_TowerCap_{i}", LD_X1 - TOWER_R, tower_y,
cap_z + dh / 2.0, radius, dh, white)
parts.append((cap, "photographs: two stepped cap rings on the tower"))
cap_z += dh
return parts
def build_6806(mats):
"""6806–6820: burgundy fascia, brown pilasters, white-framed windows, banded parapet."""
stucco, bronze, glass, white, burgundy = (mats["stucco"], mats["bronze"], mats["glass"],
mats["white"], mats["burgundy"])
reveal = mats["reveal"]
parts = []
cx = (B6_X0 + B6_X1) / 2.0
width = B6_X1 - B6_X0
fascia = cube("B6_Fascia", cx, B6_FACE_Y - 0.02,
(B6_SHOP_TOP + B6_FASCIA_TOP) / 2.0, width, 0.22,
B6_FASCIA_TOP - B6_SHOP_TOP, burgundy)
parts.append((fascia, "photographs: dark fascia band over the shopfront; colour "
"sampled, height assumed"))
centres = bay_centres(B6_X0, B6_X1, margin=1.0)
for i, bx in enumerate(centres, start=1):
panel = cube(f"B6_ShopGlass_{i}", bx, B6_FACE_Y + 0.12, B6_SHOP_TOP / 2.0,
BAY - 0.25, 0.10, B6_SHOP_TOP - 0.45, glass)
parts.append((panel, "photographs: framed shopfront glazing behind the fascia"))
# Brown pilaster on every second bay boundary, as the photo shows.
if i % 2 == 1:
pil = cube(f"B6_Pilaster_{i}", bx, B6_FACE_Y - 0.10,
(B6_FASCIA_TOP + B6_PARAPET_Z) / 2.0, 0.40, 0.22,
B6_PARAPET_Z - B6_FASCIA_TOP, bronze)
parts.append((pil, "photographs: brown pilaster strips dividing the frontage"))
wz0, wz1 = B6_WINDOW_Z
for i, bx in enumerate(centres, start=1):
frame = cube(f"B6_WindowFrame_{i}", bx, B6_FACE_Y - 0.08, (wz0 + wz1) / 2.0,
1.70, 0.18, wz1 - wz0, white)
parts.append((frame, "photographs: white-framed upper windows with security bars; "
"count and size assumed"))
pane = cube(f"B6_WindowGlass_{i}", bx, B6_FACE_Y + 0.04, (wz0 + wz1) / 2.0,
1.46, 0.08, wz1 - wz0 - 0.24, glass)
parts.append((pane, "photographs: dark glazing inside each frame"))
# Security bars: three thin verticals per window, the detail that stops the
# upper storey reading as a row of black rectangles.
for b in (-1, 0, 1):
bar = cube(f"B6_WindowBar_{i}_{b + 2}", bx + b * 0.40, B6_FACE_Y - 0.15,
(wz0 + wz1) / 2.0, 0.06, 0.06, wz1 - wz0 - 0.30, white)
parts.append((bar, "photographs: vertical security bars on the upper windows"))
# Two-tier parapet with deep reveals, as on Lee Drug: one flat band with hairlines
# disappeared at review distance.
tier_h = (B6_H - B6_PARAPET_Z) / 2.0
para = cube("B6_Parapet", cx, B6_FACE_Y + 0.12, B6_PARAPET_Z + tier_h / 2.0,
width + 0.2, 0.30, tier_h, stucco)
parts.append((para, "photographs: parapet lower tier above the window storey; "
"thickness assumed"))
upper = cube("B6_ParapetUpper", cx, B6_FACE_Y + 0.20, B6_PARAPET_Z + tier_h * 1.5,
width - 2.4, 0.26, tier_h, stucco)
parts.append((upper, "photographs: parapet upper tier stepped back; length assumed"))
for i, (z, span) in enumerate(((B6_PARAPET_Z + tier_h * 0.45, width + 0.2),
(B6_PARAPET_Z + tier_h * 1.30, width + 0.2),
(B6_H - 0.12, width - 2.4)), start=1):
groove = cube(f"B6_ParapetReveal_{i}", cx, B6_FACE_Y - 0.02, z,
span, 0.14, 0.18, reveal)
parts.append((groove, "photographs: reveal lines in the parapet; count assumed"))
return parts
def join_parts(parts, name):
"""Join one building's parts into a single mesh so Unreal imports one asset.
The per-part dimensions and evidence are recorded before this runs, so joining
loses nothing from the report. Materials survive as separate slots.
"""
bpy.ops.object.select_all(action="DESELECT")
for obj, _text in parts:
obj.select_set(True)
bpy.context.view_layer.objects.active = parts[0][0]
bpy.ops.object.join()
joined = bpy.context.active_object
joined.name = name
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
return joined
def export_objects(objects, out_dir):
count = 0
for obj in objects:
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
fbx_preset.export_static(os.path.join(out_dir, "SM_" + obj.name + ".fbx"),
selection=True)
count += 1
return count
def main(argv=None) -> int:
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/retail")
ap.add_argument("--report", default=None)
args = ap.parse_args(argv)
os.makedirs(args.out, exist_ok=True)
clear_scene()
mats = {
"stucco": make_material("M_Retail_Stucco", (0.86, 0.83, 0.76), rough=0.72),
"bronze": make_material("M_Retail_Bronze", (0.20, 0.11, 0.06), rough=0.45, metallic=0.35),
"mural": make_material("M_Retail_Mural", (0.035, 0.032, 0.030), rough=0.55),
"glass": make_material("M_Retail_Glass", (0.035, 0.045, 0.055), rough=0.15),
"white": make_material("M_Retail_White", (0.90, 0.88, 0.84), rough=0.60),
"burgundy": make_material("M_Retail_Burgundy", (0.20, 0.09, 0.09), rough=0.55),
# Painted lettering and shadow reveals: same value family as the wall so they
# read as paint and shadow, not as black holes.
"lettering": make_material("M_Retail_Lettering", (0.30, 0.27, 0.24), rough=0.65),
"reveal": make_material("M_Retail_Reveal", (0.42, 0.38, 0.34), rough=0.70),
}
ld_parts = build_lee_drug(mats)
b6_parts = build_6806(mats)
for obj, text in ld_parts + b6_parts:
note(obj, text)
# One mesh per building: 85 separate actors over the frontage would cost more
# draw calls than the trim is worth, and the staging step stays two spawns.
joined = [join_parts(ld_parts, "Retail_LeeDrug"), join_parts(b6_parts, "Retail_6806")]
for obj in joined:
print(f"[retail] joined {obj.name}: {len(obj.data.vertices)} verts, "
f"{len(obj.data.materials)} material slot(s), "
f"X {min(v.co.x for v in obj.data.vertices):.2f}.."
f"{max(v.co.x for v in obj.data.vertices):.2f}")
exported = export_objects(joined, args.out)
print(f"[retail] exported {exported} object(s) to {args.out}")
if args.report:
with open(args.report, "w", encoding="utf-8") as handle:
json.dump({
"scope": ["6800 Lee Drug (way 424024149)", "6806-6820 (way 424024150)"],
"deferred": ["6822 Budget (way 424024144): no photograph, not modelled"],
"bounds_m": {
"lee_drug": [LD_X0, LD_X1, LD_FACE_Y, LD_H],
"b6806": [B6_X0, B6_X1, B6_FACE_Y, B6_H],
},
"reference": "Art/reference/retail-neighbours/01-lee-drug-and-neighbor.jpg "
"(Gb321, CC BY-SA 4.0)",
"analysis": "Art/blender/retail/ref-analysis.md",
"parts": PARTS,
}, handle, indent=2)
print(f"[retail] report -> {args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+71
View File
@@ -0,0 +1,71 @@
# Scale-test scene builder and FBX exporter for the Blender -> Unreal pipeline.
#
# Builds three 1 m cubes in the project geographic frame (EPSG:26911, origin
# Hollywood x Highland, X east / Y north / Z up):
# ScaleTest_A centred at (0, 0, 0.5) - origin + 1 m size reference
# ScaleTest_B centred at (10, 0, 0.5) - +10 m east probe
# ScaleTest_C centred at (0, 10, 0.5) - +10 m north probe
# and exports them as one FBX with the named export preset "ue_metric_v1".
#
# Run headless (see Art/blender/README.md for the Flatpak invocation):
# blender -b --factory-startup --python Tools/blender/scale_test_export.py -- \
# --out /tmp/gigline/scaletest.fbx --save-blend Art/blender/scaletest/scale-test-<date>.blend
import argparse
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
def build_scene():
scene = bpy.context.scene
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
mat = bpy.data.materials.new("M_ScaleTest_Red")
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (0.8, 0.05, 0.05, 1.0)
specs = {
"ScaleTest_A": (0.0, 0.0, 0.5),
"ScaleTest_B": (10.0, 0.0, 0.5),
"ScaleTest_C": (0.0, 10.0, 0.5),
}
for name, loc in specs.items():
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc)
obj = bpy.context.active_object
obj.name = name
obj.data.materials.append(mat)
print(f"[scale_test] created {name} at {loc}")
def export_fbx(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
# Named export preset "ue_metric_v1" - change only via a new preset name.
fbx_preset.export_static(path)
print(f"[scale_test] exported FBX preset ue_metric_v1 -> {path}")
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
parser = argparse.ArgumentParser()
parser.add_argument("--out", default="/tmp/gigline/scaletest.fbx")
parser.add_argument("--save-blend", default=None)
args = parser.parse_args(argv)
build_scene()
if args.save_blend:
os.makedirs(os.path.dirname(args.save_blend), exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=args.save_blend)
print(f"[scale_test] saved {args.save_blend}")
export_fbx(args.out)
main()
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""El Capitan Theatre (6838) trim: marquee canopy and blade sign.
The theatre blockout is a plain volume; what makes it read as *the* El Capitan
Theatre is the projecting marquee over the pavement and the vertical blade sign on
the corner. Both are built **in world coordinates** here, so the export carries its
position and the Unreal staging step is a single spawn at the origin — the same
convention as the block frontage and the north buildings.
The building's verified span (from `Geo/verification/2026-11-06-el-capitan-block.md`
and the staged mesh) is X −12082..−8210, street face at Y ≈ +1001, height 20.55 m.
blender -b --factory-startup --python Tools/blender/theatre_trim_build.py -- \
--out /tmp/gigline/theatre --report /tmp/gigline/theatre-report.json
Everything the photographs do not resolve at this scale (exact marquee depth,
soffit height, sign lettering) is an assumption recorded in the part report.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
# Verified building span, metres (Unreal X, Y, Z but Blender-frame: X east,
# Y north, Z up, metres).
THEATRE_X0 = -120.82
THEATRE_X1 = -82.10
# Street face of the blockout volume, in BLENDER metres. Blender's +Y is north and
# Unreal's is -Y, so the theatre's Unreal face at Y +1001 cm is Blender Y -10.01 m.
# Getting this sign wrong put the whole marquee on the far side of the boulevard.
FACE_Y = -10.01
HEIGHT = 20.55
# Ground level at the theatre frontage, in the shared frame (Z up, metres). The
# pavement here is 4.56 m above the project datum, so every trim height below is
# measured from the pavement and then offset: the first build used absolute Z and
# put the marquee canopy 5 cm above the pavement, where the wall probe found it at
# knee height.
GROUND_Z = 4.56
# Marquee: centred on the frontage, projecting over the pavement.
MARQUEE_W = 12.0
MARQUEE_DEPTH = 2.60
MARQUEE_Z = GROUND_Z + 4.60 # underside of the canopy above the pavement
CANOPY_T = 0.30
FASCIA_H = 1.10
BLADE_W = 1.30
BLADE_H = 5.20
BLADE_Z0 = GROUND_Z + 5.20
BLADE_PROJECT = 2.00
PARTS: list[dict] = []
def make_material(name, value, rough=0.6):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (value, value * 0.97, value * 0.92, 1.0)
bsdf.inputs["Roughness"].default_value = rough
return mat
def validate(obj, tri_budget=6000):
import bmesh
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.edges.ensure_lookup_table()
bad = [e for e in bm.edges if len(e.link_faces) != 2]
loose = [v for v in bm.verts if not v.link_edges]
tris = sum(len(f.verts) - 2 for f in bm.faces)
bm.free()
if bad or loose:
print(f"[trim] WARNING {obj.name}: {len(bad)} non-manifold, {len(loose)} loose")
if tris > tri_budget:
print(f"[trim] WARNING {obj.name}: {tris} tris over budget")
return {"manifold": not bad, "loose_verts": len(loose), "tris": tris}
def note(obj, text):
PARTS.append({
"object": obj.name,
"asset": "SM_" + obj.name,
"dims_m": [round(d, 3) for d in obj.dimensions],
"location_m": [round(c, 3) for c in obj.location],
"topology": validate(obj),
"evidence": text,
})
print(f"[trim] {obj.name:22} {obj.dimensions.x:6.2f} x {obj.dimensions.y:5.2f} "
f"x {obj.dimensions.z:5.2f} m ({text})")
def cube(name, x, y, z, dx, dy, dz, material):
bpy.ops.mesh.primitive_cube_add(size=1.0, location=(x, y, z))
obj = bpy.context.active_object
obj.name = name
obj.scale = (dx, dy, dz)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
obj.data.materials.append(material)
return obj
def clear_scene():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
for mesh in list(bpy.data.meshes):
if mesh.users == 0:
bpy.data.meshes.remove(mesh)
def build():
"""Marquee canopy, fascia, soffit and the blade sign, in world metres."""
clear_scene()
stone = make_material("M_Trim_Stone", 0.62)
dark = make_material("M_Trim_Shadow", 0.30, rough=0.75)
lightbox = make_material("M_Trim_Lightbox", 0.86, rough=0.35)
cx = (THEATRE_X0 + THEATRE_X1) / 2.0
# The street is at smaller Unreal Y than the face, which is *larger* Blender Y,
# so the marquee projects in +Y here.
y_outer = FACE_Y + MARQUEE_DEPTH
parts = []
canopy = cube("Trim_MarqueeCanopy", cx, FACE_Y + MARQUEE_DEPTH / 2.0,
MARQUEE_Z + CANOPY_T / 2.0, MARQUEE_W, MARQUEE_DEPTH, CANOPY_T, stone)
parts.append((canopy, "photographs: projecting marquee over the pavement; "
"12.0 x 2.60 m at 4.60 m soffit — depth and height assumed"))
fascia = cube("Trim_MarqueeFascia", cx, y_outer - 0.12,
MARQUEE_Z + CANOPY_T + FASCIA_H / 2.0,
MARQUEE_W, 0.24, FASCIA_H, lightbox)
parts.append((fascia, "photographs: illuminated fascia band around the marquee "
"edge; 1.10 m depth assumed"))
soffit = cube("Trim_MarqueeSoffit", cx, FACE_Y + MARQUEE_DEPTH / 2.0,
MARQUEE_Z - 0.05, MARQUEE_W - 0.4, MARQUEE_DEPTH - 0.4, 0.10, dark)
parts.append((soffit, "assumption: recessed soffit panel under the canopy; "
"reads as shadow from the pavement"))
# Blade sign on the west corner, projecting perpendicular to the facade.
bx = THEATRE_X0 + 3.6
blade = cube("Trim_BladeSign", bx, FACE_Y + BLADE_PROJECT / 2.0,
BLADE_Z0 + BLADE_H / 2.0, BLADE_W, 0.42, BLADE_H, lightbox)
parts.append((blade, "photographs: vertical blade sign on the corner; "
"1.30 x 5.20 m, 2.00 m projection assumed"))
frame = cube("Trim_BladeSignFrame", bx, FACE_Y + BLADE_PROJECT / 2.0,
BLADE_Z0 + BLADE_H / 2.0, BLADE_W + 0.28, 0.60, 0.35, stone)
parts.append((frame, "assumption: stone frame band at the base of the blade"))
# Bracket back to the wall so the sign does not float.
bracket = cube("Trim_BladeSignBracket", bx, FACE_Y + BLADE_PROJECT - 0.6,
BLADE_Z0 + BLADE_H - 0.5, 0.60, 1.40, 0.30, dark)
parts.append((bracket, "assumption: bracket tying the blade back to the facade"))
return parts
def export_objects(parts, out_dir):
count = 0
for obj, _text in parts:
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
path = os.path.join(out_dir, "SM_" + obj.name + ".fbx")
fbx_preset.export_static(path, selection=True)
count += 1
return count
def main(argv=None) -> int:
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/theatre")
ap.add_argument("--report", default=None)
args = ap.parse_args(argv)
os.makedirs(args.out, exist_ok=True)
parts = build()
for obj, text in parts:
note(obj, text)
exported = export_objects(parts, args.out)
print(f"[trim] exported {exported} object(s) to {args.out}")
if args.report:
with open(args.report, "w", encoding="utf-8") as handle:
json.dump({
"building": "El Capitan Theatre (6838)",
"span_m": [THEATRE_X0, THEATRE_X1, FACE_Y, HEIGHT],
"parts": PARTS,
}, handle, indent=2)
print(f"[trim] report -> {args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+415
View File
@@ -0,0 +1,415 @@
#!/usr/bin/env python3
"""Model a drivable sedan in Blender: body, wheels, collision and LOD.
blender -b --factory-startup --python Tools/blender/vehicle_build.py -- \
--out Art/blender/vehicle/gigline-sedan.blend \
--fbx /tmp/gigline/vehicle/sedan.fbx \
--report /tmp/gigline/vehicle/report.json
Scale is metres, sourced from real sedan dimensions: 4.72 m long, 1.83 m wide,
1.44 m tall, 2.78 m wheelbase. Wheels are separate objects so Chaos can attach
PhysicsAssets-lite wheel references and so the wheel radius the vehicle config needs is
read from the mesh rather than typed in twice.
Collision is `UCX_`-prefixed convex boxes per panel group, which is what the
`collision-proxy` skill asks for and what Unreal's `ue_metric_v1` preset expects: one
hull for the body, one per wheel-less corner trimmed, and a floor slab. Simplified
hulls are deliberate — a car does not need per-panel collision for driving.
Every measurement is logged and written to the report, so the vehicle config values
(wheelbase, track, radius) can be read from the build instead of guessed.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import bpy
from mathutils import Vector
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset # noqa: E402
# Real sedan reference: length, width, height, wheelbase, track, wheel radius.
DIMS = {
"length": 4.72,
"width": 1.80,
"height": 1.44,
"wheelbase": 2.78,
"track": 1.62,
"wheel_radius": 0.33,
"wheel_width": 0.22,
"ride_height": 0.16,
}
PARTS: list[str] = []
def log(message: str) -> None:
PARTS.append(message)
print(f"[vehicle] {message}")
def material(name, colour, roughness=0.45, metallic=0.0):
mat = bpy.data.materials.get(name) or bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf:
bsdf.inputs["Base Color"].default_value = (*colour, 1.0)
bsdf.inputs["Roughness"].default_value = roughness
bsdf.inputs["Metallic"].default_value = metallic
return mat
def box(name, centre, size, mat):
bpy.ops.mesh.primitive_cube_add(size=1.0, location=centre)
obj = bpy.context.active_object
obj.name = name
obj.scale = size
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
obj.data.materials.append(mat)
return obj
def build_body(mats):
"""Three-tier silhouette: sill slab, bonnet and boot shoulders, cabin greenhouse.
The first build was one tall slab plus a floating cabin, which read as a box truck.
A sedan reads from the step between the bonnet and the roof, so the volumes are
stacked instead: a slab to beltline height, a lower bonnet in front and boot behind,
and the cabin only over the middle.
"""
L, W, H = DIMS["length"], DIMS["width"], DIMS["height"]
ride = DIMS["ride_height"]
belt = 0.98 # beltline height above ground
roof = H # 1.44
parts = []
# Sill slab: full length, narrow enough that the wheels stand proud of it.
parts.append(box("Body_Sill", (0.0, 0.0, ride + (belt - ride) * 0.5),
(L, 1.56, belt - ride), mats["paint"]))
# Bonnet: front 1.55 m, from beltline to a lower bonnet top.
parts.append(box("Body_Bonnet", (L * 0.5 - 0.78, 0.0, (belt + 1.06) * 0.5),
(1.56, W * 0.94, 1.06 - belt), mats["paint"]))
# Boot: rear 1.05 m, slightly lower than the bonnet.
parts.append(box("Body_Boot", (-L * 0.5 + 0.52, 0.0, (belt + 1.02) * 0.5),
(1.04, W * 0.94, 1.02 - belt), mats["paint"]))
# Cabin: the greenhouse, between bonnet and boot, narrower than the sills.
parts.append(box("Body_Cabin", (-0.02, 0.0, (1.06 + roof) * 0.5),
(2.02, W * 0.86, roof - 1.06), mats["paint"]))
# Glazing, inset a couple of centimetres so it reads as glass, not paint.
parts.append(box("Glass_Windscreen", (0.96, 0.0, 1.24), (0.05, W * 0.78, 0.30), mats["glass"]))
parts.append(box("Glass_Rear", (-1.00, 0.0, 1.24), (0.05, W * 0.76, 0.28), mats["glass"]))
for side in (-1.0, 1.0):
parts.append(box(f"Glass_Side_{'L' if side > 0 else 'R'}",
(-0.02, side * W * 0.43, 1.24), (1.76, 0.02, 0.26), mats["glass"]))
# Lamps, grille, bumpers, mirrors.
for side in (-1.0, 1.0):
parts.append(box(f"Lamp_Front_{'L' if side > 0 else 'R'}",
(L * 0.5 - 0.03, side * W * 0.32, 0.94), (0.05, 0.28, 0.12), mats["lamp"]))
parts.append(box(f"Lamp_Rear_{'L' if side > 0 else 'R'}",
(-L * 0.5 + 0.03, side * W * 0.32, 0.94), (0.04, 0.26, 0.10), mats["tail"]))
parts.append(box(f"Mirror_{'L' if side > 0 else 'R'}",
(0.80, side * (W * 0.5 + 0.05), 1.18), (0.08, 0.12, 0.07), mats["trim"]))
parts.append(box("Grille", (L * 0.5 - 0.02, 0.0, 0.80), (0.04, W * 0.48, 0.12), mats["trim"]))
for front in (1.0, -1.0):
parts.append(box(f"Bumper_{'F' if front > 0 else 'R'}",
(front * (L * 0.5 - 0.06), 0.0, ride + 0.14),
(0.16, W * 0.90, 0.20), mats["trim"]))
log(f"body: {len(parts)} parts, {L} x {W} x {H} m, beltline {belt} m")
return parts
def build_wheels(mats):
r, w = DIMS["wheel_radius"], DIMS["wheel_width"]
half_base = DIMS["wheelbase"] * 0.5
half_track = DIMS["track"] * 0.5
wheels = []
for name, x, y in (("Wheel_FL", half_base, half_track),
("Wheel_FR", half_base, -half_track),
("Wheel_RL", -half_base, half_track),
("Wheel_RR", -half_base, -half_track)):
bpy.ops.mesh.primitive_cylinder_add(vertices=20, radius=r, depth=w,
location=(x, y, r), rotation=(0.0, math.pi / 2, 0.0))
wheel = bpy.context.active_object
wheel.name = name
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
wheel.data.materials.append(mats["tyre"])
wheels.append(wheel)
log(f"wheels: 4 at r={r} m, width {w} m, wheelbase {DIMS['wheelbase']} m, track {DIMS['track']} m")
return wheels
def build_collision(mats):
"""One convex hull per major volume: body, cabin, and the four wheel arches.
Unreal reads `UCX_<mesh>_NN` names; the exporter ships them as separate meshes and
the import preset turns them into simple collision.
"""
L, W, H = DIMS["length"], DIMS["width"], DIMS["height"]
ride = DIMS["ride_height"]
hulls = [
box("UCX_Sedan_Body_01", (0.0, 0.0, ride + 0.40), (L * 0.98, W * 0.98, 0.70), mats["collision"]),
box("UCX_Sedan_Cabin_01", (-0.12, 0.0, ride + 0.94), (L * 0.44, W * 0.86, 0.42), mats["collision"]),
box("UCX_Sedan_BumperF_01", (L * 0.47, 0.0, ride + 0.24), (0.24, W * 0.94, 0.30), mats["collision"]),
box("UCX_Sedan_BumperR_01", (-L * 0.47, 0.0, ride + 0.24), (0.24, W * 0.94, 0.30), mats["collision"]),
]
for hull in hulls:
hull.hide_render = True
hull.display_type = "WIRE"
log(f"collision: {len(hulls)} convex hulls (body, cabin, two bumpers), hidden from render")
return hulls
def build_skeletal(joined, wheels, out_fbx, exp_dir):
"""Bind the whole vehicle to a five-bone rig for Chaos.
Chaos Vehicles drive a *skeletal* mesh: the movement component names wheel bones and
spins them, and the physics asset supplies the collision. The static split above
stays for parts that do not deform, but the drivable asset has to be skeletal, so
this builds a rig with a root plus one bone per wheel and skins every wheel to its
own bone at full weight.
"""
bpy.ops.object.armature_add(location=(0.0, 0.0, 0.0))
rig = bpy.context.active_object
rig.name = "VehicleRig"
bpy.ops.object.mode_set(mode="EDIT")
edit_bones = rig.data.edit_bones
root = edit_bones[0]
root.name = "root"
root.head = (0.0, 0.0, 0.0)
root.tail = (0.0, 0.0, 0.30)
half_base = DIMS["wheelbase"] * 0.5
half_track = DIMS["track"] * 0.5
r = DIMS["wheel_radius"]
wheel_positions = {
"Wheel_FL": (half_base, half_track),
"Wheel_FR": (half_base, -half_track),
"Wheel_RL": (-half_base, half_track),
"Wheel_RR": (-half_base, -half_track),
}
for name, (x, y) in wheel_positions.items():
bone = edit_bones.new(name)
bone.head = (x, y, r)
# Point the bone along the axle: the wheel spins about its own Y.
bone.tail = (x, y - math.copysign(0.25, y), r)
bone.parent = root
bpy.ops.object.mode_set(mode="OBJECT")
# Join every visible system plus the wheels into one mesh, then weight by part.
parts = [obj for obj in joined.values()] + list(wheels)
bpy.ops.object.select_all(action="DESELECT")
for part in parts:
part.select_set(True)
bpy.context.view_layer.objects.active = joined["body"]
bpy.ops.object.join()
mesh = bpy.context.active_object
mesh.name = "SM_Vehicle_Skeletal"
groups = {name: mesh.vertex_groups.new(name=name) for name in ["root", *wheel_positions]}
# Join order is preserved per part: re-derive which vertices belong to which wheel by
# their world position, since the join loses the object boundaries.
half_base = DIMS["wheelbase"] * 0.5
half_track = DIMS["track"] * 0.5
for vertex in mesh.data.vertices:
co = vertex.co
nearest = None
for name, (x, y) in wheel_positions.items():
if abs(co.x - x) < 0.45 and abs(co.y - y) < 0.16 and co.z < DIMS["wheel_radius"] * 2.2:
nearest = name
break
groups[nearest or "root"].add([vertex.index], 1.0, "REPLACE")
armature = mesh.modifiers.new(name="Armature", type="ARMATURE")
armature.object = rig
mesh.parent = rig
bpy.ops.object.select_all(action="DESELECT")
rig.select_set(True)
mesh.select_set(True)
bpy.context.view_layer.objects.active = rig
path = os.path.join(exp_dir, "vehicle_skeletal.fbx")
fbx_export_preset.export_character(path, selection=True)
log(f"skeletal vehicle exported: {path}, bones root + 4 wheels, "
f"{sum(len(p.vertices) - 2 for p in mesh.data.polygons)} triangles")
return path
def main(argv=None) -> int:
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="Art/blender/vehicle/gigline-sedan.blend")
ap.add_argument("--fbx", default="/tmp/gigline/vehicle/sedan.fbx")
ap.add_argument("--report", default="/tmp/gigline/vehicle/report.json")
args = ap.parse_args(argv)
bpy.ops.wm.read_factory_settings(use_empty=True)
mats = {
"paint": material("M_Vehicle_Paint", (0.42, 0.05, 0.05), 0.28, 0.35),
"glass": material("M_Vehicle_Glass", (0.06, 0.08, 0.09), 0.12),
"tyre": material("M_Vehicle_Tyre", (0.02, 0.02, 0.02), 0.85),
"trim": material("M_Vehicle_Trim", (0.04, 0.04, 0.04), 0.55),
"lamp": material("M_Vehicle_Lamp", (0.85, 0.82, 0.70), 0.18),
"tail": material("M_Vehicle_Tail", (0.45, 0.03, 0.03), 0.25),
"collision": material("M_Vehicle_Collision", (0.2, 0.2, 0.2), 0.9),
}
body = build_body(mats)
wheels = build_wheels(mats)
hulls = build_collision(mats)
# Object split per the vehicle-artist skill: body, glass, lights and wheels stay
# separate so each can be swapped, made transparent or made emissive on its own. A
# single merged mesh is exactly what the skill forbids for a game vehicle.
systems = {}
for part in body:
name = part.name
if name.startswith("Glass_"):
key = "glass"
elif name.startswith("Lamp_"):
key = "light_front"
elif name.startswith("Lamp_Rear") or name.startswith("Lamp_Rear_"):
key = "light_rear"
elif name.startswith("Mirror_"):
key = "mirrors"
else:
key = "body"
systems.setdefault(key, []).append(part)
# Lamps were named lamp_front/lamp_rear by prefix; separate them properly.
rear = [p for p in systems.pop("light_front", []) if "Rear" in p.name]
front = [p for p in systems.get("body", []) if False]
# Collect every lamp again from the body list by name.
lamps = [p for p in body if p.name.startswith("Lamp_")]
systems["light_front"] = [p for p in lamps if "Rear" not in p.name]
systems["light_rear"] = [p for p in lamps if "Rear" in p.name]
systems["body"] = [p for p in body if not p.name.startswith(
("Glass_", "Lamp_", "Mirror_"))]
joined = {}
for key, parts in systems.items():
if not parts:
continue
bpy.ops.object.select_all(action="DESELECT")
for part in parts:
part.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
if len(parts) > 1:
bpy.ops.object.join()
obj = bpy.context.active_object
obj.name = {
"body": "SM_Vehicle_Body",
"glass": "SM_Vehicle_Glass",
"light_front": "SM_Vehicle_Light_Front",
"light_rear": "SM_Vehicle_Light_Rear",
"mirrors": "SM_Vehicle_Mirrors",
}[key]
joined[key] = obj
shell = joined["body"]
hull_join = hulls
log("systems: " + ", ".join(f"{k}={len(v)} parts" for k, v in systems.items()))
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=os.path.abspath(args.out))
log(f"saved {args.out}")
# Export: shell + collision hulls in one file, wheels in their own file so Chaos
# can bind them to wheel bones or use them as visual wheels.
os.makedirs(os.path.dirname(os.path.abspath(args.fbx)), exist_ok=True)
exp_dir = os.path.dirname(os.path.abspath(args.fbx))
exported = {}
for key, obj in joined.items():
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
path = os.path.join(exp_dir, f"{key}.fbx")
fbx_export_preset.export_static(path, selection=True)
exported[key] = path
# Body carries the collision hulls so the hulls and the shell land in one asset,
# which is how Unreal wires UCX_ meshes to their mesh.
bpy.ops.object.select_all(action="DESELECT")
shell.select_set(True)
for hull in hull_join:
hull.select_set(True)
bpy.context.view_layer.objects.active = shell
fbx_export_preset.export_static(os.path.join(exp_dir, "body.fbx"), selection=True)
# One wheel asset, reused four times: Chaos binds the same wheel mesh per bone.
bpy.ops.object.select_all(action="DESELECT")
wheels[0].select_set(True)
bpy.context.view_layer.objects.active = wheels[0]
fbx_export_preset.export_static(os.path.join(exp_dir, "wheel.fbx"), selection=True)
exported["wheel"] = os.path.join(exp_dir, "wheel.fbx")
log(f"exported {len(exported)} system FBX files to {exp_dir}")
per_system = {key: sum(len(p.vertices) - 2 for p in obj.data.polygons)
for key, obj in joined.items()}
system_names = {key: obj.name for key, obj in joined.items()}
wheel_names = [w.name for w in wheels]
wheel_triangles = sum(len(p.vertices) - 2 for p in wheels[0].data.polygons)
triangles = sum(len(p.vertices) - 2 for p in shell.data.polygons)
shell_triangles = triangles
for hull in hull_join:
triangles += sum(len(p.vertices) - 2 for p in hull.data.polygons)
# LODs per the vehicle-artist targets: LOD1 drops mirrors and lamp detail, LOD2 is a
# body-plus-glass shell. They are separate objects here and get wired into the
# body's LOD group at import time.
lod_paths = {}
for index, keep_glass, ratio in ((1, True, 0.55), (2, False, 0.25)):
lod_key = f"body_lod{index}"
bpy.ops.object.select_all(action="DESELECT")
shell.select_set(True)
if keep_glass and "glass" in joined:
joined["glass"].select_set(True)
bpy.context.view_layer.objects.active = shell
bpy.ops.object.duplicate()
lod = bpy.context.active_object
lod.name = f"SM_Vehicle_Body_LOD{index}"
# Decimate for real: the first version duplicated the shell unchanged, so both
# LODs came out at 84 triangles - an LOD that saves nothing.
decimate = lod.modifiers.new(name="LOD", type="DECIMATE")
decimate.ratio = ratio
bpy.ops.object.modifier_apply(modifier=decimate.name)
lod_path = os.path.join(exp_dir, f"{lod_key}.fbx")
fbx_export_preset.export_static(lod_path, selection=True)
lod_paths[lod_key] = lod_path
lod.hide_set(False)
log(f"{lod_key}: exported, {sum(len(p.vertices) - 2 for p in lod.data.polygons)} triangles")
bpy.data.objects.remove(lod, do_unlink=True)
# Skeletal asset last: joining the parts for it destroys the objects the LOD and
# system steps need. The first attempt called it earlier and died on
# "StructRNA of type Object has been removed".
skeletal_path = build_skeletal(joined, wheels, args.fbx, exp_dir)
exported["skeletal"] = skeletal_path
report = {
"asset": "SM_Vehicle_Sedan",
"dimensions_m": DIMS,
"body_parts": len(body),
"triangles": shell_triangles,
"collision_hulls": [h.name for h in hull_join],
"wheels": wheel_names,
"systems": {key: {"object": system_names.get(key), "triangles": per_system.get(key, 0),
"fbx": exported.get(key)}
for key in system_names},
"wheel_triangles": wheel_triangles,
"lods": lod_paths,
"log": PARTS,
}
if args.report:
os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True)
with open(args.report, "w") as handle:
json.dump(report, handle, indent=2)
print(f"[vehicle] report -> {args.report}")
print(f"[vehicle] {triangles} triangles, {len(hull_join)} hulls")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+368
View File
@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""Turn the provided hot-hatch .blend into UE-ready assets.
blender -b "Art/blender/vehicle/hot hatch.blend" \
--python Tools/blender/vehicle_from_template.py -- \
--out /tmp/gigline/hatch --report /tmp/gigline/hatch/report.json
Follows two skills:
* **vehicle-artist** — one object per system (body, wheels, glass, lights, trim,
mirrors), collision planned with the body, LODs planned before export.
* **unreal-export** — scale confirmed with a known dimension, `SM_`/`UCX_` naming,
LOD chain, and every asset measured after import.
The template is not to scale: its longest axis is 7.21 m, which no hot hatch is. The
tool therefore normalises to a documented real length (default 4.05 m) and records the
factor, rather than exporting whatever units the file happened to use. That is the
"confirm scale with known-dimension test" step, done before the export instead of after
the import.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset # noqa: E402
# A three-door hot hatch: 4.05 m long, 1.75 m wide, 1.42 m tall.
TARGET_LENGTH_M = 4.05
TARGET_WIDTH_M = 1.75
TARGET_HEIGHT_M = 1.42
PARTS: list[str] = []
def log(message: str) -> None:
PARTS.append(message)
print(f"[hatch] {message}")
SYSTEM_RULES = (
# (system, material substrings that identify it, object-name substrings)
("glass", ("windows",), ("Cube.003",)),
("lights", ("light", "reflector"), ("lights and grill", "Cylinder.003", "Cylinder.004")),
("mirrors", ("mirror",), ("Cube.005",)),
("wheels", ("rubber", "brake caliper", "black shiny metal"), ("Cylinder", "Cylinder.001")),
("trim", ("plastic black", "pure black", "chrome", "black metal", "rough chrome"),
("Cube.001", "Cube.002", "Cube.004", "Cube.006", "Cube.007", "Cylinder.002")),
)
def _colour_rank(colour):
"""Sort key for picking a paint colour: saturation first, then brightness."""
return (max(colour) - min(colour), sum(colour))
def read_material(material):
"""Base colour, roughness and metallic for a material, Principled or legacy.
The template's car paint is a **legacy node graph** - mix shaders over glossy and
diffuse BSDFs - not a Principled shader. Reading only Principled skipped it, which is
exactly why the car arrived in Unreal near-black: the paint colour never left Blender.
For those graphs the colour is taken from the mix nodes, which is where the red lives.
"""
if not material.use_nodes:
return None
nodes = material.node_tree.nodes
bsdf = nodes.get("Principled BSDF")
if bsdf:
colour = bsdf.inputs["Base Color"].default_value
return {
"base_color": [round(colour[0], 4), round(colour[1], 4), round(colour[2], 4)],
"roughness": round(bsdf.inputs["Roughness"].default_value, 4),
"metallic": round(bsdf.inputs["Metallic"].default_value, 4),
"source": "principled",
}
# Legacy graph: the largest colour found on a mix or diffuse node is the paint.
best = None
roughness = 0.5
metallic = 0.0
for node in nodes:
if node.type in ("MIX", "MIX_RGB"):
for socket in node.inputs:
try:
value = socket.default_value
except Exception: # noqa: BLE001 - not every socket has a default
continue
if not hasattr(value, "__len__") or len(value) < 3:
continue
candidate = (value[0], value[1], value[2])
if max(candidate) < 0.03:
continue
# Prefer the most saturated colour, not the brightest: the paint's red
# (0.49, 0, 0) is darker than the 0.8 grey diffuse beside it, and a
# "brightest wins" rule picked the grey - which is how a red car reaches
# Unreal as a grey one.
if best is None or _colour_rank(candidate) > _colour_rank(best):
best = candidate
elif node.type == "BSDF_DIFFUSE":
colour = node.inputs["Color"].default_value
candidate = (colour[0], colour[1], colour[2])
if best is None or _colour_rank(candidate) > _colour_rank(best):
best = candidate
elif node.type == "BSDF_GLOSSY":
try:
roughness = round(node.inputs["Roughness"].default_value, 4)
except Exception: # noqa: BLE001
pass
if best is None:
return None
return {
"base_color": [round(best[0], 4), round(best[1], 4), round(best[2], 4)],
"roughness": roughness,
"metallic": metallic,
"source": "legacy graph",
}
def ensure_principled(material):
"""Rewire a legacy graph to a Principled BSDF so the FBX export carries a colour.
The template's car paint is a mix-shader graph, and the FBX exporter cannot map that -
which is why SM_Vehicle_Body arrived in Unreal holding `WorldGridMaterial` (the default
fallback) and rendered black while Blender showed red. Converting in place keeps the
colour and makes the export uniform.
"""
if not material.use_nodes:
return False
nodes = material.node_tree.nodes
if nodes.get("Principled BSDF"):
return False
spec = read_material(material)
if not spec:
return False
nodes.clear()
output = nodes.new("ShaderNodeOutputMaterial")
bsdf = nodes.new("ShaderNodeBsdfPrincipled")
colour = spec["base_color"]
bsdf.inputs["Base Color"].default_value = (colour[0], colour[1], colour[2], 1.0)
bsdf.inputs["Roughness"].default_value = max(0.05, spec.get("roughness", 0.5))
bsdf.inputs["Metallic"].default_value = spec.get("metallic", 0.0)
material.node_tree.links.new(bsdf.outputs["BSDF"], output.inputs["Surface"])
log(f"converted {material.name} to Principled, base "
f"{tuple(round(c, 3) for c in colour)}")
return True
def classify(obj):
"""Assign an object to a system by its materials first, then by its name."""
materials = [m.name.lower() for m in obj.data.materials if m]
name = obj.name.lower()
for system, materials_hint, names_hint in SYSTEM_RULES:
if any(hint in name for hint in (n.lower() for n in names_hint)):
return system
if any(any(h in material for h in materials_hint) for material in materials):
return system
return "body"
def min_z(objects):
from mathutils import Vector
lowest = None
for obj in objects:
for corner in obj.bound_box:
z = (obj.matrix_world @ Vector(corner)).z
lowest = z if lowest is None else min(lowest, z)
return lowest if lowest is not None else 0.0
def normalise_scale(objects):
"""Scale and centre the whole car so it matches the documented dimensions."""
xs, ys, zs = [], [], []
for obj in objects:
for corner in obj.bound_box:
world = obj.matrix_world @ __import__("mathutils").Vector(corner)
xs.append(world.x)
ys.append(world.y)
zs.append(world.z)
length = max(ys) - min(ys)
width = max(xs) - min(xs)
height = max(zs) - min(zs)
log(f"template bounds: {length:.2f} x {width:.2f} x {height:.2f} m")
factor = TARGET_LENGTH_M / length if length > 0.01 else 1.0
log(f"scale factor {factor:.4f} (target length {TARGET_LENGTH_M} m, "
f"was {length:.2f} m)")
for obj in objects:
obj.scale = tuple(s * factor for s in obj.scale)
obj.location = tuple(l * factor for l in obj.location)
bpy.context.view_layer.update()
# Drop the car onto Z 0 and centre it on the origin in X/Y. Ground on the WHEELS, not
# on the whole model: the lowest geometry in the file is exhaust/underbody, so grounding
# on everything left the tyres floating by the difference.
xs, ys, zs = [], [], []
for obj in objects:
for corner in obj.bound_box:
world = obj.matrix_world @ __import__("mathutils").Vector(corner)
xs.append(world.x)
ys.append(world.y)
zs.append(world.z)
wheels = [o for o in objects if "rubber" in [m.name.lower() for m in o.data.materials if m]]
ground = min_z(wheels) if wheels else min(zs)
log(f"grounding on {'wheels' if wheels else 'whole model'}: lowest Z {ground:.3f} m")
offset = __import__("mathutils").Vector(
(-(min(xs) + max(xs)) / 2.0, -(min(ys) + max(ys)) / 2.0, -ground))
for obj in objects:
obj.location = tuple(l + o for l, o in zip(obj.location, offset))
bpy.context.view_layer.update()
log(f"centred and grounded (offset {offset.x:.2f}, {offset.y:.2f}, {offset.z:.2f} m)")
def join_system(objects, name):
bpy.ops.object.select_all(action="DESELECT")
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
if len(objects) > 1:
bpy.ops.object.join()
joined = bpy.context.active_object
joined.name = name
return joined
def add_collision(body):
"""One convex box per major volume, `UCX_`-prefixed for Unreal."""
hulls = []
xs, ys, zs = [], [], []
for corner in body.bound_box:
world = body.matrix_world @ __import__("mathutils").Vector(corner)
xs.append(world.x)
ys.append(world.y)
zs.append(world.z)
length, width, height = max(ys) - min(ys), max(xs) - min(xs), max(zs) - min(zs)
centre = ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0,
min(zs) + height * 0.5)
bpy.ops.mesh.primitive_cube_add(size=1.0, location=centre)
hull = bpy.context.active_object
hull.name = "UCX_SM_Vehicle_Body_01"
hull.scale = (width * 0.96, length * 0.98, height * 0.62)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
hull.hide_render = True
hulls.append(hull)
log(f"collision: 1 hull {width*0.96:.2f} x {length*0.98:.2f} x {height*0.62:.2f} m")
return hulls
def decimate_copy(source, name, ratio):
bpy.ops.object.select_all(action="DESELECT")
source.select_set(True)
bpy.context.view_layer.objects.active = source
bpy.ops.object.duplicate()
copy = bpy.context.active_object
copy.name = name
modifier = copy.modifiers.new(name="LOD", type="DECIMATE")
modifier.ratio = ratio
bpy.ops.object.modifier_apply(modifier=modifier.name)
return copy
def main(argv=None) -> int:
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/hatch")
ap.add_argument("--report", default="/tmp/gigline/hatch/report.json")
args = ap.parse_args(argv)
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
if not meshes:
raise SystemExit("no meshes in the template")
log(f"template: {len(meshes)} mesh objects, "
f"{sum(len(o.data.vertices) for o in meshes)} vertices")
normalise_scale(meshes)
systems = {}
for obj in meshes:
systems.setdefault(classify(obj), []).append(obj)
log("systems: " + ", ".join(f"{k}={len(v)} objects" for k, v in systems.items()))
os.makedirs(args.out, exist_ok=True)
exported = {}
joined = {}
for system, objects in systems.items():
name = {
"body": "SM_Vehicle_Body",
"glass": "SM_Vehicle_Glass",
"lights": "SM_Vehicle_Lights",
"mirrors": "SM_Vehicle_Mirrors",
"wheels": "SM_Vehicle_Wheels",
"trim": "SM_Vehicle_Trim",
}[system]
obj = join_system(objects, name)
joined[system] = obj
path = os.path.join(args.out, f"{system}.fbx")
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
fbx_export_preset.export_static(path, selection=True)
exported[system] = path
tris = sum(len(p.vertices) - 2 for p in obj.data.polygons)
log(f"{name}: {tris} triangles -> {path}")
hulls = add_collision(joined["body"])
bpy.ops.object.select_all(action="DESELECT")
joined["body"].select_set(True)
for hull in hulls:
hull.select_set(True)
bpy.context.view_layer.objects.active = joined["body"]
fbx_export_preset.export_static(os.path.join(args.out, "body.fbx"), selection=True)
log("body re-exported with its UCX hull")
lods = {}
for index, ratio in ((1, 0.35), (2, 0.12)):
lod = decimate_copy(joined["body"], f"SM_Vehicle_Body_LOD{index}", ratio)
path = os.path.join(args.out, f"body_lod{index}.fbx")
bpy.ops.object.select_all(action="DESELECT")
lod.select_set(True)
bpy.context.view_layer.objects.active = lod
fbx_export_preset.export_static(path, selection=True)
tris = sum(len(p.vertices) - 2 for p in lod.data.polygons)
lods[f"body_lod{index}"] = {"path": path, "triangles": tris}
log(f"SM_Vehicle_Body_LOD{index}: {tris} triangles")
bpy.data.objects.remove(lod, do_unlink=True)
converted = sum(1 for m in bpy.data.materials if ensure_principled(m))
log(f"legacy materials converted for export: {converted}")
materials = {}
for material in bpy.data.materials:
found = read_material(material)
if found:
materials[material.name] = found
principled = sum(1 for m in bpy.data.materials
if m.use_nodes and m.node_tree.nodes.get("Principled BSDF"))
log(f"materials: {len(materials)} read, {principled} of them Principled, "
f"{len(materials) - principled} from legacy graphs")
report = {
"template": bpy.data.filepath,
"materials": materials,
"target_dimensions_m": {"length": TARGET_LENGTH_M, "width": TARGET_WIDTH_M,
"height": TARGET_HEIGHT_M},
"systems": {system: {"object": joined[system].name,
"triangles": sum(len(p.vertices) - 2
for p in joined[system].data.polygons),
"fbx": exported.get(system)}
for system in joined},
"lods": lods,
"collision": [h.name for h in hulls],
"log": PARTS,
}
os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True)
with open(args.report, "w") as handle:
json.dump(report, handle, indent=2)
print(f"[hatch] report -> {args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())