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:
1 parent
f681d83736
commit
4d27109668
168 files changed
+799
-407
No files matched your search
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user