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 @@
len(poly.vertices) <= 4 for every face.min_area.V − E + F == 2 for the clean closed manifold (measured: verts=66 edges=136 faces=72, volume≈0.652001, min_area≈1.146e-02).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.
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
- Stale
matrix_world. After a location edit, matrix_world is unchanged until view_layer.update() (same half of the orrery contract). - Scale apply via data API. Non-uniform scale baked into verts;
obj.scale == (1,1,1) exactly afterward. - Origin at base center. Local bbox
min.z == 0, XY centered; world AABB delta across the bake is 0 (gate 1e-5). - Bare-parent trap. Parenting the accessory without MPI jumps it (~0.43 m measured); setting
matrix_parent_inverse restores world location (err ~3e-8).
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