diff --git a/docs/gallery/assets/mesh-hygiene-audit-hero.webp b/docs/gallery/assets/mesh-hygiene-audit-hero.webp index a6f40da7..9a8db17f 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 e5a3680a..83724fb9 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 a24b79bd..b96fff34 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 527b06be..237b0d66 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 66b01d6b..f1755d0c 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 626a19e1..c2e0e753 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 9f763d2e..ddfb794d 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 b8a64ff5..c4d34948 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 f9f15389..721b0e9b 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 e5875b96..656b1b0f 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 bc1066ef..2ab38cae 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 56099942..cdd850b7 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