"""Prepare a photo-derived mesh for scale and shape review in Blender. Run manually. Nothing here runs during the site build. blender --background --python tools/art/prepare-statue.py -- INPUT.glb OUTPUT_DIR """ import argparse import json import math import sys from pathlib import Path import bpy from mathutils import Matrix, Vector def bounds(objects): points = [o.matrix_world @ v.co for o in objects for v in o.data.vertices] if not points: raise ValueError("The model contains no mesh vertices.") low = Vector([min(p[i] for p in points) for i in range(3)]) high = Vector([max(p[i] for p in points) for i in range(3)]) return low, high def aim(obj, target): obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler() def bake_geometry(meshes, rotate): # Capture first: changing a parent can change a child's world matrix. worlds = [(obj, rotate @ obj.matrix_world.copy()) for obj in meshes] for obj, world in worlds: obj.data = obj.data.copy() obj.parent = None obj.data.transform(world) obj.matrix_world = Matrix.Identity(4) def main(): parser = argparse.ArgumentParser() parser.add_argument("input", type=Path) parser.add_argument("output", type=Path) parser.add_argument("--front-degrees", type=float, default=0) args = parser.parse_args(sys.argv[sys.argv.index("--") + 1:]) args.output.mkdir(parents=True, exist_ok=True) bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.import_scene.gltf(filepath=str(args.input.resolve())) meshes = [o for o in bpy.context.scene.objects if o.type == "MESH"] # Bake the import transforms. Keep material slots and UV coordinates. rotate = Matrix.Rotation(math.radians(args.front_degrees), 4, "Z") bake_geometry(meshes, rotate) low, high = bounds(meshes) original = high - low target = Vector((0.120, 0.237, 0.208)) if min(original) <= 0: raise ValueError("The model has an empty axis.") # The retail diagram gives external bounds. Local anatomy remains inferred. scale = Vector([target[i] / original[i] for i in range(3)]) center = (low + high) / 2 offset = Vector((-center.x, -center.y, -low.z)) transform = Matrix.Diagonal((*scale, 1)) @ Matrix.Translation(offset) for obj in meshes: obj.data.transform(transform) for polygon in obj.data.polygons: polygon.use_smooth = True # The statue is painted resin. Generated metal maps can turn its tail silver. for material in bpy.data.materials: if not material.use_nodes: continue for node in material.node_tree.nodes: if node.type != "BSDF_PRINCIPLED": continue for name, value in [("Metallic", 0.0), ("Roughness", 0.72)]: socket = node.inputs[name] for link in list(socket.links): material.node_tree.links.remove(link) socket.default_value = value calibrated_low, calibrated_high = bounds(meshes) report = { "source": "Hi-Line Gift American Beaver Cub Standing Statue retail gallery", "units": "meters", "axisOrder": ["width", "depth including tail", "height"], "sourceBounds": list(original), "axisScale": list(scale), "calibratedBounds": list(calibrated_high - calibrated_low), "frontDegrees": args.front_degrees, "meshVertices": sum(len(o.data.vertices) for o in meshes), "meshFaces": sum(len(o.data.polygons) for o in meshes), "status": "draft for visual review", "limit": "External bounds follow the diagram. Hidden surfaces and local anatomy are inferred.", } (args.output / "scale-report.json").write_text(json.dumps(report, indent=2) + "\n") bpy.ops.object.select_all(action="DESELECT") for obj in meshes: obj.select_set(True) bpy.ops.export_scene.gltf(filepath=str(args.output / "beaver-scaled.glb"), export_format="GLB", use_selection=True) scene = bpy.context.scene scene.unit_settings.system = "METRIC" scene.unit_settings.scale_length = 1 scene.render.engine = "CYCLES" scene.cycles.samples = 24 scene.cycles.use_denoising = True scene.render.image_settings.file_format = "PNG" scene.render.film_transparent = False scene.world = bpy.data.worlds.new("White studio") scene.world.use_nodes = True scene.world.node_tree.nodes["Background"].inputs[0].default_value = (1, 1, 1, 1) scene.world.node_tree.nodes["Background"].inputs[1].default_value = 1.0 scene.view_settings.view_transform = "Standard" for name, position, energy, size in [ ("Key", (-0.3, -0.4, 0.55), 2, 0.45), ("Fill", (0.3, -0.15, 0.30), 0.6, 0.35), ("Rim", (0.05, 0.35, 0.40), 1, 0.30), ]: light = bpy.data.lights.new(name, "AREA") light.energy, light.shape, light.size = energy, "DISK", size obj = bpy.data.objects.new(name, light) scene.collection.objects.link(obj) obj.location = position aim(obj, (0, 0, 0.11)) camera_data = bpy.data.cameras.new("Review camera") camera_data.type = "ORTHO" camera = bpy.data.objects.new("Review camera", camera_data) scene.collection.objects.link(camera) scene.camera = camera scene.render.resolution_percentage = 100 scene.render.resolution_x, scene.render.resolution_y = 600, 800 camera_data.ortho_scale = 0.32 for index in range(8): angle = math.radians(index * 45) camera.location = (0.6 * math.sin(angle), -0.6 * math.cos(angle), 0.16) aim(camera, (0, 0, 0.104)) scene.render.filepath = str(args.output / f"view-{index * 45:03}.png") bpy.ops.render.render(write_still=True) # Extra headroom lets the hands rise without changing the camera in a clip. scene.render.resolution_x, scene.render.resolution_y = 1080, 1920 camera_data.ortho_scale = 0.36 camera.location = (0, -0.65, 0.14) aim(camera, (0, 0, 0.14)) scene.render.filepath = str(args.output / "neutral.png") bpy.ops.render.render(write_still=True) bpy.ops.wm.save_as_mainfile(filepath=str(args.output / "beaver-scaled.blend")) print(json.dumps(report, indent=2)) main()