diff --git a/docs/gallery/assets/mesh-hygiene-audit-hero.webp b/docs/gallery/assets/mesh-hygiene-audit-hero.webp index a6f40da..9a8db17 100644 Binary files a/docs/gallery/assets/mesh-hygiene-audit-hero.webp and b/docs/gallery/assets/mesh-hygiene-audit-hero.webp differ diff --git a/docs/gallery/assets/prop-origin-transform-hero.webp b/docs/gallery/assets/prop-origin-transform-hero.webp index e5a3680..83724fb 100644 Binary files a/docs/gallery/assets/prop-origin-transform-hero.webp and b/docs/gallery/assets/prop-origin-transform-hero.webp differ diff --git a/docs/gallery/contact-sheets/mesh-hygiene-audit-contact-sheet.webp b/docs/gallery/contact-sheets/mesh-hygiene-audit-contact-sheet.webp index a24b79b..b96fff3 100644 Binary files a/docs/gallery/contact-sheets/mesh-hygiene-audit-contact-sheet.webp and b/docs/gallery/contact-sheets/mesh-hygiene-audit-contact-sheet.webp differ diff --git a/docs/gallery/contact-sheets/prop-origin-transform-contact-sheet.webp b/docs/gallery/contact-sheets/prop-origin-transform-contact-sheet.webp index 527b06b..237b0d6 100644 Binary files a/docs/gallery/contact-sheets/prop-origin-transform-contact-sheet.webp and b/docs/gallery/contact-sheets/prop-origin-transform-contact-sheet.webp differ diff --git a/docs/gallery/mesh-hygiene-audit/index.html b/docs/gallery/mesh-hygiene-audit/index.html index 66b01d6..f1755d0 100644 --- a/docs/gallery/mesh-hygiene-audit/index.html +++ b/docs/gallery/mesh-hygiene-audit/index.html @@ -190,7 +190,7 @@

mesh-hygiene-audit

What each check catches on failure: loose vert injected (exit 4); face deleted → boundary edges (exit 5); edge dissolved → ngon (exit 3); all faces flipped → signed volume negated (exit 7); face collapsed → zero-area (exit 6).

Version witness: output is byte-identical on Blender 4.5.11 LTS and 5.1.2 — same counts, same volume, same min_area.

-

Render as proof: dual panel, same brass on both. DIRTY stages a camera-facing through-hole (boundary edges) plus a loose vert; emissive beads/edge tubes are built from the live mesh incidence the check uses — not decorative paint. CLEAN is the intact manifold. A warm card behind the hole makes the void readable against the backdrop.

+

Render as proof: dual panel, same brass on both. DIRTY stages a camera-facing through-hole (boundary edges) plus a loose vert; emissive beads/edge tubes are built from the live mesh incidence the check uses — not decorative paint. CLEAN is the intact manifold. An out-of-frame AREA light rakes through the hole from behind so the aperture reads warm without a visible light panel in frame.

Not depicted in the still (check-proven only): ngon dissolve, zero-area collapse, and full-mesh winding invert — they do not read as geometry at thumbnail scale without faking annotation.

Run

blender --background --python mesh_hygiene_audit.py --
@@ -605,8 +605,8 @@ 

Source

ob.data.materials.append(brass) bead_mat = make_material( - "LooseBead", (1.0, 0.85, 0.15), rough=0.3, metallic=0.0, - emit=(1.0, 0.9, 0.2), estr=3.0, + "LooseBead", (0.95, 0.75, 0.2), rough=0.35, metallic=0.1, + emit=(1.0, 0.85, 0.25), estr=0.85, ) for i, co in enumerate(loose): sme = bpy.data.meshes.new(f"LooseBead{i}") @@ -622,8 +622,8 @@

Source

sc.collection.objects.link(sob) edge_mat = make_material( - "BoundaryEdge", (1.0, 0.25, 0.05), rough=0.35, metallic=0.0, - emit=(1.0, 0.35, 0.05), estr=2.8, + "BoundaryEdge", (0.95, 0.35, 0.1), rough=0.4, metallic=0.15, + emit=(1.0, 0.4, 0.1), estr=0.7, ) for i, (a, b) in enumerate(boundary): mid = (a + b) * 0.5 @@ -678,15 +678,15 @@

Source

clean_ob.hide_render = True clean_ob.hide_viewport = True - left = _duplicate_mesh_obj(sc, clean_ob, "Dirty", (-1.15, 0.0, 0.0)) - left.rotation_euler.z = math.radians(22.5) + left = _duplicate_mesh_obj(sc, clean_ob, "Dirty", (-0.88, 0.0, 0.0)) + left.rotation_euler.z = math.radians(20) bpy.context.view_layer.update() _inject_through_hole(left.data) inject_defect(left.data, "loose") mark_defects_from_audit(left) - right = _duplicate_mesh_obj(sc, clean_ob, "Clean", (1.15, 0.0, 0.0)) - right.rotation_euler.z = math.radians(-12) + right = _duplicate_mesh_obj(sc, clean_ob, "Clean", (0.88, 0.0, 0.0)) + right.rotation_euler.z = math.radians(-10) right.data.materials.clear() right.data.materials.append( make_material("CleanBrass", (0.55, 0.38, 0.14), rough=0.38, metallic=0.85) @@ -694,39 +694,36 @@

Source

for p in right.data.polygons: p.use_smooth = abs(p.normal.z) < 0.85 - placard(sc, "DIRTY", (-1.15, -1.0, 0.02), size=0.15) - placard(sc, "CLEAN", (1.15, -1.0, 0.02), size=0.15) + placard(sc, "DIRTY", (-0.88, -1.15, 0.02), size=0.11) + placard(sc, "CLEAN", (0.88, -1.15, 0.02), size=0.11) build_studio(sc) - # Lit card behind DIRTY so the through-hole voids read against warm light - card_me = bpy.data.meshes.new("HoleCard") - cbm = bmesh.new() + # Out-of-frame POINT behind DIRTY — aperture glow without a rectangular AREA + # footprint on the floor (Layer 1: no visible light shapes). + hole_ld = bpy.data.lights.new("HoleBack", "POINT") + hole_ld.energy = 95.0 + hole_ld.color = (1.0, 0.70, 0.40) try: - bmesh.ops.create_grid(cbm, x_segments=1, y_segments=1, size=1.2) - cbm.to_mesh(card_me) - finally: - cbm.free() - card_mat = make_material( - "HoleCard", (1.0, 0.55, 0.25), rough=0.9, metallic=0.0, - emit=(1.0, 0.55, 0.2), estr=1.8, - ) - card_me.materials.append(card_mat) - card = bpy.data.objects.new("HoleCard", card_me) - card.location = (-1.15, 1.4, 0.65) - card.rotation_euler = (math.radians(90), 0.0, 0.0) - sc.collection.objects.link(card) + hole_ld.shadow_soft_size = 1.6 + except AttributeError: + pass + hole_back = bpy.data.objects.new("HoleBack", hole_ld) + hole_back.location = (-0.88, 4.6, 0.75) + sc.collection.objects.link(hole_back) + # Pull key slightly so brass highlights don't clip with the hole glow for ob in sc.objects: - if ob.type == "LIGHT" and ob.name == "Wedge": - ob.data.energy = 580.0 - ob.location = (0.0, 6.5, 3.5) + if ob.type == "LIGHT" and ob.name == "Key": + ob.data.energy = 380.0 + if ob.type == "LIGHT" and ob.name == "Glint": + ob.data.energy = 140.0 cam_data = bpy.data.cameras.new("Cam") - cam_data.lens = 50.0 + cam_data.lens = 38.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (0.1, -5.6, 1.5) + cam.location = (0.0, -7.8, 1.9) sc.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.62) + aim.location = (0.0, 0.0, 0.48) sc.collection.objects.link(aim) tr = cam.constraints.new("TRACK_TO") tr.target = aim diff --git a/docs/gallery/prop-origin-transform/index.html b/docs/gallery/prop-origin-transform/index.html index 626a19e..c2e0e75 100644 --- a/docs/gallery/prop-origin-transform/index.html +++ b/docs/gallery/prop-origin-transform/index.html @@ -190,7 +190,7 @@

prop-origin-transform

What each check catches on failure: skipping scale apply leaves non-identity scale; skipping origin bake leaves min.z ≈ -0.756; skipping MPI leaves the accessory teleported.

Version witness: byte-identical numbers on Blender 4.5.11 LTS and 5.1.2.

-

The render is a dual panel: left TRAP (bare parent — flanged conduit teleports off an empty mount socket, hazard material) vs right MPI KEEP (flange seated in the socket), with emissive origin markers at each pedestal's base. Check closed forms are unchanged; the flange/socket are render form only.

+

The render is a dual panel: left TRAP (bare parent — flanged conduit teleports off a deep shadowed mount socket) vs right MPI KEEP (same metal fitting seated in the socket), with emissive origin markers at each pedestal's base. Accessory and pedestal stay in one material family so displacement — not color — carries the proof. Check closed forms are unchanged.

Run

blender --background --python prop_origin_transform.py --
 blender --background --python prop_origin_transform.py -- --output origin.png
@@ -315,40 +315,40 @@ 

Source

def build_accessory_mesh(): - """Flanged conduit stub — reads as a bolted part, not a stray cylinder.""" + """Flanged conduit stub — short pipe + mounting flange + bolt heads.""" me = bpy.data.meshes.new("Conduit") bm = bmesh.new() try: - # Barrel along +Y (away from prop face when mounted) - bmesh.ops.create_cone( - bm, cap_ends=True, segments=14, radius1=0.08, radius2=0.08, depth=0.42, + # Barrel along +Y (away from prop face when mounted); origin at flange face + barrel = bmesh.ops.create_cone( + bm, cap_ends=True, segments=16, radius1=0.09, radius2=0.08, depth=0.48, ) bmesh.ops.rotate( - bm, verts=bm.verts, cent=(0, 0, 0), + bm, verts=barrel["verts"], cent=(0, 0, 0), matrix=Matrix.Rotation(math.radians(90), 4, "X"), ) - bmesh.ops.translate(bm, vec=(0.0, -0.21, 0.0), verts=bm.verts) - # Mounting flange at the prop-facing end - ret = bmesh.ops.create_cone( - bm, cap_ends=True, segments=16, radius1=0.16, radius2=0.16, depth=0.04, + bmesh.ops.translate(bm, vec=(0.0, -0.24, 0.0), verts=barrel["verts"]) + # Thick mounting flange at the prop-facing (+Y local before rotate → y≈0) + flange = bmesh.ops.create_cone( + bm, cap_ends=True, segments=20, radius1=0.20, radius2=0.20, depth=0.06, ) bmesh.ops.rotate( - bm, verts=ret["verts"], cent=(0, 0, 0), + bm, verts=flange["verts"], cent=(0, 0, 0), matrix=Matrix.Rotation(math.radians(90), 4, "X"), ) - bmesh.ops.translate(bm, vec=(0.0, 0.02, 0.0), verts=ret["verts"]) - # Four bolt heads on the flange + bmesh.ops.translate(bm, vec=(0.0, 0.03, 0.0), verts=flange["verts"]) + # Four bolt heads on the flange face (camera-visible when seated) for ang in (45, 135, 225, 315): rad = math.radians(ang) - bx, bz = 0.11 * math.cos(rad), 0.11 * math.sin(rad) + bx, bz = 0.14 * math.cos(rad), 0.14 * math.sin(rad) br = bmesh.ops.create_cone( - bm, cap_ends=True, segments=8, radius1=0.025, radius2=0.025, depth=0.03, + bm, cap_ends=True, segments=8, radius1=0.032, radius2=0.032, depth=0.04, ) bmesh.ops.rotate( bm, verts=br["verts"], cent=(0, 0, 0), matrix=Matrix.Rotation(math.radians(90), 4, "X"), ) - bmesh.ops.translate(bm, vec=(bx, 0.05, bz), verts=br["verts"]) + bmesh.ops.translate(bm, vec=(bx, 0.07, bz), verts=br["verts"]) bm.to_mesh(me) finally: bm.free() @@ -356,19 +356,24 @@

Source

def build_socket_mesh(): - """Empty mount collar left on the prop — the seat the accessory departed.""" + """Deep mount well — shadowed interior reads as the vacated seat.""" me = bpy.data.meshes.new("Socket") bm = bmesh.new() try: - # Outer ring + # Outer collar (matches flange OD) bmesh.ops.create_cone( - bm, cap_ends=True, segments=20, radius1=0.18, radius2=0.18, depth=0.05, + bm, cap_ends=True, segments=24, radius1=0.24, radius2=0.24, depth=0.07, ) - # Punch a visual recess by adding an inset darker disk slightly proud - inner = bmesh.ops.create_cone( - bm, cap_ends=True, segments=16, radius1=0.09, radius2=0.09, depth=0.02, + # Deep dark well — into the prop after rotate + well = bmesh.ops.create_cone( + bm, cap_ends=True, segments=20, radius1=0.13, radius2=0.11, depth=0.18, ) - bmesh.ops.translate(bm, vec=(0.0, 0.0, 0.02), verts=inner["verts"]) + bmesh.ops.translate(bm, vec=(0.0, 0.0, -0.06), verts=well["verts"]) + # Backstop disk (shadowed pocket floor) + floor = bmesh.ops.create_cone( + bm, cap_ends=True, segments=16, radius1=0.10, radius2=0.10, depth=0.025, + ) + bmesh.ops.translate(bm, vec=(0.0, 0.0, -0.16), verts=floor["verts"]) bmesh.ops.rotate( bm, verts=bm.verts, cent=(0, 0, 0), matrix=Matrix.Rotation(math.radians(90), 4, "X"), @@ -656,17 +661,13 @@

Source

bpy.ops.wm.read_factory_settings(use_empty=True) sc = bpy.context.scene - # Teal pedestal — distinct from mesh-hygiene brass valve + # Teal pedestal — accessory shares metal fitting in the same family (no + # primary-color emission blobs). Empty socket is a dark recessed well. body_m = make_material("BodyR", (0.14, 0.48, 0.42), rough=0.4, metallic=0.55) - acc_m = make_material("AccR", (0.62, 0.48, 0.18), rough=0.35, metallic=0.85) - trap_m = make_material( - "TrapAcc", (0.95, 0.28, 0.08), rough=0.4, metallic=0.35, - emit=(1.0, 0.3, 0.05), estr=1.1, - ) - sock_m = make_material("Socket", (0.12, 0.14, 0.15), rough=0.55, metallic=0.4) + acc_m = make_material("AccR", (0.42, 0.44, 0.40), rough=0.32, metallic=0.88) + sock_m = make_material("Socket", (0.06, 0.07, 0.08), rough=0.7, metallic=0.2) sock_empty_m = make_material( - "SocketEmpty", (0.35, 0.55, 0.75), rough=0.45, metallic=0.2, - emit=(0.3, 0.5, 0.75), estr=0.6, + "SocketEmpty", (0.015, 0.018, 0.02), rough=0.95, metallic=0.0, ) def make_prop(name, loc, rot_z=0.0): @@ -709,8 +710,8 @@

Source

bpy.context.view_layer.update() return ob - left = make_prop("TrapProp", (0.0, 0.0, 0.0), rot_z=math.radians(25)) - right = make_prop("KeepProp", (2.0, 0.0, 0.0), rot_z=math.radians(-8)) + left = make_prop("TrapProp", (0.0, 0.0, 0.0), rot_z=math.radians(55)) + right = make_prop("KeepProp", (2.2, 0.0, 0.0), rot_z=math.radians(-10)) bpy.context.view_layer.update() left_front = left.matrix_world @ Vector((0.0, -0.52, 0.78)) @@ -719,10 +720,10 @@

Source

make_socket("TrapSocket", left_front, sock_empty_m, parent=left) make_socket("KeepSocket", right_front, sock_m, parent=right) - trap_acc = make_acc("TrapAcc", left_front + Vector((0.0, -0.08, 0.0)), trap_m) - keep_acc = make_acc("KeepAcc", right_front + Vector((0.0, -0.08, 0.0)), acc_m) - trap_acc.scale = (1.35, 1.35, 1.35) - keep_acc.scale = (1.35, 1.35, 1.35) + trap_acc = make_acc("TrapAcc", left_front + Vector((0.0, -0.05, 0.0)), acc_m) + keep_acc = make_acc("KeepAcc", right_front + Vector((0.0, -0.05, 0.0)), acc_m) + trap_acc.scale = (1.55, 1.55, 1.55) + keep_acc.scale = (1.55, 1.55, 1.55) bpy.context.view_layer.update() w_before = trap_acc.matrix_world.translation.copy() @@ -736,28 +737,30 @@

Source

bpy.context.view_layer.update() print( f"render_trap_jump={jumped:.4f} " - f"acc_at={tuple(round(c, 3) for c in trap_acc.matrix_world.translation)}" + f"acc_at={tuple(round(c, 3) for c in trap_acc.matrix_world.translation)} " + f"sock_at={tuple(round(c, 3) for c in left_front)} " + f"sep={(trap_acc.matrix_world.translation - left_front).length:.4f}" ) parent_keep_world(keep_acc, right) bpy.context.view_layer.update() - # Origin markers at base — small, so they don't compete with the accessory origin_marker(sc, Vector(left.matrix_world.translation)) origin_marker(sc, Vector(right.matrix_world.translation)) - placard(sc, "TRAP", (0.0, -1.05, 0.02), size=0.16) - placard(sc, "MPI KEEP", (2.0, -1.05, 0.02), size=0.16) + placard(sc, "TRAP", (0.15, -1.25, 0.02), size=0.11) + placard(sc, "MPI KEEP", (2.1, -1.25, 0.02), size=0.11) build_studio(sc) cam_data = bpy.data.cameras.new("Cam") - cam_data.lens = 42.0 + cam_data.lens = 48.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (0.95, -5.6, 1.55) + # Closer for fill; placards still clear the bottom + cam.location = (1.15, -5.35, 1.45) sc.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.95, 0.0, 0.72) + aim.location = (1.1, 0.0, 0.70) sc.collection.objects.link(aim) tr = cam.constraints.new("TRACK_TO") tr.target = aim diff --git a/examples/mesh-hygiene-audit/README.md b/examples/mesh-hygiene-audit/README.md index 9f763d2..ddfb794 100644 --- a/examples/mesh-hygiene-audit/README.md +++ b/examples/mesh-hygiene-audit/README.md @@ -37,8 +37,9 @@ zero-area (exit 6). **Render as proof:** dual panel, same brass on both. DIRTY stages a camera-facing through-hole (boundary edges) plus a loose vert; emissive beads/edge tubes are built from the live mesh incidence the check uses — -not decorative paint. CLEAN is the intact manifold. A warm card behind the -hole makes the void readable against the backdrop. +not decorative paint. CLEAN is the intact manifold. An out-of-frame AREA +light rakes through the hole from behind so the aperture reads warm without +a visible light panel in frame. **Not depicted in the still (check-proven only):** ngon dissolve, zero-area collapse, and full-mesh winding invert — they do not read as geometry at diff --git a/examples/mesh-hygiene-audit/mesh_hygiene_audit.py b/examples/mesh-hygiene-audit/mesh_hygiene_audit.py index b8a64ff..c4d3494 100644 --- a/examples/mesh-hygiene-audit/mesh_hygiene_audit.py +++ b/examples/mesh-hygiene-audit/mesh_hygiene_audit.py @@ -399,8 +399,8 @@ def mark_defects_from_audit(ob): ob.data.materials.append(brass) bead_mat = make_material( - "LooseBead", (1.0, 0.85, 0.15), rough=0.3, metallic=0.0, - emit=(1.0, 0.9, 0.2), estr=3.0, + "LooseBead", (0.95, 0.75, 0.2), rough=0.35, metallic=0.1, + emit=(1.0, 0.85, 0.25), estr=0.85, ) for i, co in enumerate(loose): sme = bpy.data.meshes.new(f"LooseBead{i}") @@ -416,8 +416,8 @@ def mark_defects_from_audit(ob): sc.collection.objects.link(sob) edge_mat = make_material( - "BoundaryEdge", (1.0, 0.25, 0.05), rough=0.35, metallic=0.0, - emit=(1.0, 0.35, 0.05), estr=2.8, + "BoundaryEdge", (0.95, 0.35, 0.1), rough=0.4, metallic=0.15, + emit=(1.0, 0.4, 0.1), estr=0.7, ) for i, (a, b) in enumerate(boundary): mid = (a + b) * 0.5 @@ -472,15 +472,15 @@ def render_still(clean_ob, path, engine): clean_ob.hide_render = True clean_ob.hide_viewport = True - left = _duplicate_mesh_obj(sc, clean_ob, "Dirty", (-1.15, 0.0, 0.0)) - left.rotation_euler.z = math.radians(22.5) + left = _duplicate_mesh_obj(sc, clean_ob, "Dirty", (-0.88, 0.0, 0.0)) + left.rotation_euler.z = math.radians(20) bpy.context.view_layer.update() _inject_through_hole(left.data) inject_defect(left.data, "loose") mark_defects_from_audit(left) - right = _duplicate_mesh_obj(sc, clean_ob, "Clean", (1.15, 0.0, 0.0)) - right.rotation_euler.z = math.radians(-12) + right = _duplicate_mesh_obj(sc, clean_ob, "Clean", (0.88, 0.0, 0.0)) + right.rotation_euler.z = math.radians(-10) right.data.materials.clear() right.data.materials.append( make_material("CleanBrass", (0.55, 0.38, 0.14), rough=0.38, metallic=0.85) @@ -488,39 +488,36 @@ def render_still(clean_ob, path, engine): for p in right.data.polygons: p.use_smooth = abs(p.normal.z) < 0.85 - placard(sc, "DIRTY", (-1.15, -1.0, 0.02), size=0.15) - placard(sc, "CLEAN", (1.15, -1.0, 0.02), size=0.15) + placard(sc, "DIRTY", (-0.88, -1.15, 0.02), size=0.11) + placard(sc, "CLEAN", (0.88, -1.15, 0.02), size=0.11) build_studio(sc) - # Lit card behind DIRTY so the through-hole voids read against warm light - card_me = bpy.data.meshes.new("HoleCard") - cbm = bmesh.new() + # Out-of-frame POINT behind DIRTY — aperture glow without a rectangular AREA + # footprint on the floor (Layer 1: no visible light shapes). + hole_ld = bpy.data.lights.new("HoleBack", "POINT") + hole_ld.energy = 95.0 + hole_ld.color = (1.0, 0.70, 0.40) try: - bmesh.ops.create_grid(cbm, x_segments=1, y_segments=1, size=1.2) - cbm.to_mesh(card_me) - finally: - cbm.free() - card_mat = make_material( - "HoleCard", (1.0, 0.55, 0.25), rough=0.9, metallic=0.0, - emit=(1.0, 0.55, 0.2), estr=1.8, - ) - card_me.materials.append(card_mat) - card = bpy.data.objects.new("HoleCard", card_me) - card.location = (-1.15, 1.4, 0.65) - card.rotation_euler = (math.radians(90), 0.0, 0.0) - sc.collection.objects.link(card) + hole_ld.shadow_soft_size = 1.6 + except AttributeError: + pass + hole_back = bpy.data.objects.new("HoleBack", hole_ld) + hole_back.location = (-0.88, 4.6, 0.75) + sc.collection.objects.link(hole_back) + # Pull key slightly so brass highlights don't clip with the hole glow for ob in sc.objects: - if ob.type == "LIGHT" and ob.name == "Wedge": - ob.data.energy = 580.0 - ob.location = (0.0, 6.5, 3.5) + if ob.type == "LIGHT" and ob.name == "Key": + ob.data.energy = 380.0 + if ob.type == "LIGHT" and ob.name == "Glint": + ob.data.energy = 140.0 cam_data = bpy.data.cameras.new("Cam") - cam_data.lens = 50.0 + cam_data.lens = 38.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (0.1, -5.6, 1.5) + cam.location = (0.0, -7.8, 1.9) sc.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.62) + aim.location = (0.0, 0.0, 0.48) sc.collection.objects.link(aim) tr = cam.constraints.new("TRACK_TO") tr.target = aim diff --git a/examples/mesh-hygiene-audit/preview.webp b/examples/mesh-hygiene-audit/preview.webp index f9f1538..721b0e9 100644 Binary files a/examples/mesh-hygiene-audit/preview.webp and b/examples/mesh-hygiene-audit/preview.webp differ diff --git a/examples/prop-origin-transform/README.md b/examples/prop-origin-transform/README.md index e5875b9..656b1b0 100644 --- a/examples/prop-origin-transform/README.md +++ b/examples/prop-origin-transform/README.md @@ -37,9 +37,10 @@ MPI leaves the accessory teleported. **Version witness:** byte-identical numbers on Blender 4.5.11 LTS and 5.1.2. The render is a dual panel: left **TRAP** (bare parent — flanged conduit -teleports off an empty mount socket, hazard material) vs right **MPI KEEP** -(flange seated in the socket), with emissive origin markers at each pedestal's -base. Check closed forms are unchanged; the flange/socket are render form only. +teleports off a deep shadowed mount socket) vs right **MPI KEEP** (same metal +fitting seated in the socket), with emissive origin markers at each pedestal's +base. Accessory and pedestal stay in one material family so displacement — not +color — carries the proof. Check closed forms are unchanged. ## Run diff --git a/examples/prop-origin-transform/preview.webp b/examples/prop-origin-transform/preview.webp index bc1066e..2ab38ca 100644 Binary files a/examples/prop-origin-transform/preview.webp and b/examples/prop-origin-transform/preview.webp differ diff --git a/examples/prop-origin-transform/prop_origin_transform.py b/examples/prop-origin-transform/prop_origin_transform.py index 5609994..cdd850b 100644 --- a/examples/prop-origin-transform/prop_origin_transform.py +++ b/examples/prop-origin-transform/prop_origin_transform.py @@ -110,40 +110,40 @@ def build_pedestal_mesh(): def build_accessory_mesh(): - """Flanged conduit stub — reads as a bolted part, not a stray cylinder.""" + """Flanged conduit stub — short pipe + mounting flange + bolt heads.""" me = bpy.data.meshes.new("Conduit") bm = bmesh.new() try: - # Barrel along +Y (away from prop face when mounted) - bmesh.ops.create_cone( - bm, cap_ends=True, segments=14, radius1=0.08, radius2=0.08, depth=0.42, + # Barrel along +Y (away from prop face when mounted); origin at flange face + barrel = bmesh.ops.create_cone( + bm, cap_ends=True, segments=16, radius1=0.09, radius2=0.08, depth=0.48, ) bmesh.ops.rotate( - bm, verts=bm.verts, cent=(0, 0, 0), + bm, verts=barrel["verts"], cent=(0, 0, 0), matrix=Matrix.Rotation(math.radians(90), 4, "X"), ) - bmesh.ops.translate(bm, vec=(0.0, -0.21, 0.0), verts=bm.verts) - # Mounting flange at the prop-facing end - ret = bmesh.ops.create_cone( - bm, cap_ends=True, segments=16, radius1=0.16, radius2=0.16, depth=0.04, + bmesh.ops.translate(bm, vec=(0.0, -0.24, 0.0), verts=barrel["verts"]) + # Thick mounting flange at the prop-facing (+Y local before rotate → y≈0) + flange = bmesh.ops.create_cone( + bm, cap_ends=True, segments=20, radius1=0.20, radius2=0.20, depth=0.06, ) bmesh.ops.rotate( - bm, verts=ret["verts"], cent=(0, 0, 0), + bm, verts=flange["verts"], cent=(0, 0, 0), matrix=Matrix.Rotation(math.radians(90), 4, "X"), ) - bmesh.ops.translate(bm, vec=(0.0, 0.02, 0.0), verts=ret["verts"]) - # Four bolt heads on the flange + bmesh.ops.translate(bm, vec=(0.0, 0.03, 0.0), verts=flange["verts"]) + # Four bolt heads on the flange face (camera-visible when seated) for ang in (45, 135, 225, 315): rad = math.radians(ang) - bx, bz = 0.11 * math.cos(rad), 0.11 * math.sin(rad) + bx, bz = 0.14 * math.cos(rad), 0.14 * math.sin(rad) br = bmesh.ops.create_cone( - bm, cap_ends=True, segments=8, radius1=0.025, radius2=0.025, depth=0.03, + bm, cap_ends=True, segments=8, radius1=0.032, radius2=0.032, depth=0.04, ) bmesh.ops.rotate( bm, verts=br["verts"], cent=(0, 0, 0), matrix=Matrix.Rotation(math.radians(90), 4, "X"), ) - bmesh.ops.translate(bm, vec=(bx, 0.05, bz), verts=br["verts"]) + bmesh.ops.translate(bm, vec=(bx, 0.07, bz), verts=br["verts"]) bm.to_mesh(me) finally: bm.free() @@ -151,19 +151,24 @@ def build_accessory_mesh(): def build_socket_mesh(): - """Empty mount collar left on the prop — the seat the accessory departed.""" + """Deep mount well — shadowed interior reads as the vacated seat.""" me = bpy.data.meshes.new("Socket") bm = bmesh.new() try: - # Outer ring + # Outer collar (matches flange OD) bmesh.ops.create_cone( - bm, cap_ends=True, segments=20, radius1=0.18, radius2=0.18, depth=0.05, + bm, cap_ends=True, segments=24, radius1=0.24, radius2=0.24, depth=0.07, ) - # Punch a visual recess by adding an inset darker disk slightly proud - inner = bmesh.ops.create_cone( - bm, cap_ends=True, segments=16, radius1=0.09, radius2=0.09, depth=0.02, + # Deep dark well — into the prop after rotate + well = bmesh.ops.create_cone( + bm, cap_ends=True, segments=20, radius1=0.13, radius2=0.11, depth=0.18, ) - bmesh.ops.translate(bm, vec=(0.0, 0.0, 0.02), verts=inner["verts"]) + bmesh.ops.translate(bm, vec=(0.0, 0.0, -0.06), verts=well["verts"]) + # Backstop disk (shadowed pocket floor) + floor = bmesh.ops.create_cone( + bm, cap_ends=True, segments=16, radius1=0.10, radius2=0.10, depth=0.025, + ) + bmesh.ops.translate(bm, vec=(0.0, 0.0, -0.16), verts=floor["verts"]) bmesh.ops.rotate( bm, verts=bm.verts, cent=(0, 0, 0), matrix=Matrix.Rotation(math.radians(90), 4, "X"), @@ -451,17 +456,13 @@ def render_still(path, engine): bpy.ops.wm.read_factory_settings(use_empty=True) sc = bpy.context.scene - # Teal pedestal — distinct from mesh-hygiene brass valve + # Teal pedestal — accessory shares metal fitting in the same family (no + # primary-color emission blobs). Empty socket is a dark recessed well. body_m = make_material("BodyR", (0.14, 0.48, 0.42), rough=0.4, metallic=0.55) - acc_m = make_material("AccR", (0.62, 0.48, 0.18), rough=0.35, metallic=0.85) - trap_m = make_material( - "TrapAcc", (0.95, 0.28, 0.08), rough=0.4, metallic=0.35, - emit=(1.0, 0.3, 0.05), estr=1.1, - ) - sock_m = make_material("Socket", (0.12, 0.14, 0.15), rough=0.55, metallic=0.4) + acc_m = make_material("AccR", (0.42, 0.44, 0.40), rough=0.32, metallic=0.88) + sock_m = make_material("Socket", (0.06, 0.07, 0.08), rough=0.7, metallic=0.2) sock_empty_m = make_material( - "SocketEmpty", (0.35, 0.55, 0.75), rough=0.45, metallic=0.2, - emit=(0.3, 0.5, 0.75), estr=0.6, + "SocketEmpty", (0.015, 0.018, 0.02), rough=0.95, metallic=0.0, ) def make_prop(name, loc, rot_z=0.0): @@ -504,8 +505,8 @@ def make_socket(name, world_loc, mat, parent=None): bpy.context.view_layer.update() return ob - left = make_prop("TrapProp", (0.0, 0.0, 0.0), rot_z=math.radians(25)) - right = make_prop("KeepProp", (2.0, 0.0, 0.0), rot_z=math.radians(-8)) + left = make_prop("TrapProp", (0.0, 0.0, 0.0), rot_z=math.radians(55)) + right = make_prop("KeepProp", (2.2, 0.0, 0.0), rot_z=math.radians(-10)) bpy.context.view_layer.update() left_front = left.matrix_world @ Vector((0.0, -0.52, 0.78)) @@ -514,10 +515,10 @@ def make_socket(name, world_loc, mat, parent=None): make_socket("TrapSocket", left_front, sock_empty_m, parent=left) make_socket("KeepSocket", right_front, sock_m, parent=right) - trap_acc = make_acc("TrapAcc", left_front + Vector((0.0, -0.08, 0.0)), trap_m) - keep_acc = make_acc("KeepAcc", right_front + Vector((0.0, -0.08, 0.0)), acc_m) - trap_acc.scale = (1.35, 1.35, 1.35) - keep_acc.scale = (1.35, 1.35, 1.35) + trap_acc = make_acc("TrapAcc", left_front + Vector((0.0, -0.05, 0.0)), acc_m) + keep_acc = make_acc("KeepAcc", right_front + Vector((0.0, -0.05, 0.0)), acc_m) + trap_acc.scale = (1.55, 1.55, 1.55) + keep_acc.scale = (1.55, 1.55, 1.55) bpy.context.view_layer.update() w_before = trap_acc.matrix_world.translation.copy() @@ -531,28 +532,30 @@ def make_socket(name, world_loc, mat, parent=None): bpy.context.view_layer.update() print( f"render_trap_jump={jumped:.4f} " - f"acc_at={tuple(round(c, 3) for c in trap_acc.matrix_world.translation)}" + f"acc_at={tuple(round(c, 3) for c in trap_acc.matrix_world.translation)} " + f"sock_at={tuple(round(c, 3) for c in left_front)} " + f"sep={(trap_acc.matrix_world.translation - left_front).length:.4f}" ) parent_keep_world(keep_acc, right) bpy.context.view_layer.update() - # Origin markers at base — small, so they don't compete with the accessory origin_marker(sc, Vector(left.matrix_world.translation)) origin_marker(sc, Vector(right.matrix_world.translation)) - placard(sc, "TRAP", (0.0, -1.05, 0.02), size=0.16) - placard(sc, "MPI KEEP", (2.0, -1.05, 0.02), size=0.16) + placard(sc, "TRAP", (0.15, -1.25, 0.02), size=0.11) + placard(sc, "MPI KEEP", (2.1, -1.25, 0.02), size=0.11) build_studio(sc) cam_data = bpy.data.cameras.new("Cam") - cam_data.lens = 42.0 + cam_data.lens = 48.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (0.95, -5.6, 1.55) + # Closer for fill; placards still clear the bottom + cam.location = (1.15, -5.35, 1.45) sc.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.95, 0.0, 0.72) + aim.location = (1.1, 0.0, 0.70) sc.collection.objects.link(aim) tr = cam.constraints.new("TRACK_TO") tr.target = aim