Skip to content

Commit 6c36858

Browse files
fix: polish damped-track-aim gallery still and studio lighting
Replace the washed cyan blob with an ember core and brass spike cage so the aim constraint reads as a deliberate product shot, not a default-cone dump. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent dbed2b2 commit 6c36858

8 files changed

Lines changed: 299 additions & 97 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ the compositor on and is exactly zero with it off — and that EEVEE has no `use
237237
</tr>
238238
<tr>
239239
<td width="46%" valign="middle">
240-
<a href="examples/damped-track-aim/"><img src="examples/damped-track-aim/preview.webp" alt="Damped Track aim: twelve metallic needles in a ring around a cyan emissive core, each tip constrained to point at the core on a dark studio floor" /></a>
240+
<a href="examples/damped-track-aim/"><img src="examples/damped-track-aim/preview.webp" alt="Damped Track aim: brass spikes in a tilted cage around an ember core on a dark studio plinth, each tip constrained toward the center" /></a>
241241
</td>
242242
<td valign="middle">
243243

13.5 KB
Loading

‎docs/gallery/damped-track-aim/index.html‎

Lines changed: 150 additions & 49 deletions
Large diffs are not rendered by default.

‎docs/gallery/index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ <h2><a href="compositor-glare/">compositor-glare</a></h2>
345345
</a>
346346
<div class="card-body">
347347
<h2><a href="damped-track-aim/">damped-track-aim</a></h2>
348-
<p class="teaches">Aim constraints via the data API — Object.constraints.new(&#x27;DAMPED_TRACK&#x27;) with target and TRACK_Z, not bpy.ops.object.constraint_add in a headless loop.</p>
348+
<p class="teaches">Aim constraints via the data API — Object.constraints.new(&#x27;DAMPED_TRACK&#x27;) with target and TRACK_Z, not bpy.ops.object.constraint_add in a headless loop. Gallery still: brass spike cage around an ember core.</p>
349349
<p class="witnesses"><span class="tag">witnesses</span> Every needle has one unmuted DAMPED_TRACK on the core; evaluated local +Z aligns toward the core (dot ≥ 0.998). TRACK_TO stand-ins and flipped axes fail.</p>
350350
<a class="card-link" href="damped-track-aim/">View example <span aria-hidden="true">&rarr;</span></a>
351351
</div>

‎examples/damped-track-aim/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
# Damped Track Aim
22

3-
A runnable example that aims twelve metallic needles at an emissive core with
3+
A runnable example that aims twelve brass spikes at an ember core with
44
`Object.constraints.new('DAMPED_TRACK')` — the data-API path, not
55
`bpy.ops.object.constraint_add` (which needs an active object and fails in
66
headless loops). Damped Track is the twist-stable aim constraint: it points one
77
local axis at a target without the roll fights Track To is known for.
88

9-
**What it witnesses:** every needle carries exactly one unmuted `DAMPED_TRACK`
9+
**What it witnesses:** every spike carries exactly one unmuted `DAMPED_TRACK`
1010
bound to the core on `TRACK_Z`. After a depsgraph update, each evaluated local
1111
`+Z` aligns with the world vector toward the core (dot ≥ 0.998 ≈ 3°). A missing
1212
constraint, a muted one, a `TRACK_TO` stand-in, or a flipped axis fails the

‎examples/damped-track-aim/damped_track_aim.py‎

Lines changed: 144 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -22,102 +22,163 @@
2222
from mathutils import Vector
2323

2424
N_NEEDLES = 12
25-
ORBIT_RADIUS = 2.05
26-
CORE_RADIUS = 0.38
27-
NEEDLE_DEPTH = 1.15
28-
NEEDLE_RADIUS = 0.09
25+
ORBIT_RADIUS = 1.85
26+
CORE_RADIUS = 0.32
27+
NEEDLE_DEPTH = 1.05
28+
NEEDLE_RADIUS = 0.055
2929
# Local +Z must face the core; cos(3°) ≈ 0.9986 — leave a little room for
3030
# cone tessellation / float noise while still catching a flipped axis.
3131
MIN_AIM_DOT = 0.998
32+
LIFT = 1.55
3233

3334

3435
def orbit_positions(n, radius):
35-
"""Ring in XY plus two polar needles — every tip reads clearly in a 3/4 view."""
36+
"""Tilted equatorial ring + poles — readable as a cage, not a flat wreath."""
3637
pts = []
3738
ring = n - 2
39+
tilt = math.radians(18.0)
3840
for i in range(ring):
39-
a = 2.0 * math.pi * i / ring
40-
pts.append(Vector((radius * math.cos(a), radius * math.sin(a), 0.0)))
41-
pts.append(Vector((0.0, 0.0, radius)))
42-
pts.append(Vector((0.0, 0.0, -radius)))
41+
a = 2.0 * math.pi * i / ring + math.radians(9.0)
42+
x = radius * math.cos(a)
43+
y = radius * math.sin(a) * math.cos(tilt)
44+
z = radius * math.sin(a) * math.sin(tilt)
45+
pts.append(Vector((x, y, z)))
46+
pts.append(Vector((0.0, 0.0, radius * 0.92)))
47+
pts.append(Vector((0.0, 0.0, -radius * 0.92)))
4348
return pts
4449

4550

4651
def make_needle_mesh():
47-
"""Unit cone along +Z (tip at +Z) — TRACK_Z then aims the tip at the core."""
52+
"""Long tapered spike with tip on +Z — TRACK_Z aims the tip at the core."""
4853
me = bpy.data.meshes.new("Needle")
4954
bm = bmesh.new()
5055
try:
5156
bmesh.ops.create_cone(
5257
bm,
5358
cap_ends=True,
54-
segments=10,
59+
segments=48,
5560
radius1=NEEDLE_RADIUS,
5661
radius2=0.0,
5762
depth=NEEDLE_DEPTH,
5863
)
64+
# Slight belly toward the base so it reads as turned metal, not a pencil.
65+
for v in bm.verts:
66+
# Map z from [-d/2, +d/2] → t in [0,1] (base→tip).
67+
t = (v.co.z + NEEDLE_DEPTH * 0.5) / NEEDLE_DEPTH
68+
flare = 1.0 + 0.55 * ((1.0 - t) ** 2)
69+
v.co.x *= flare
70+
v.co.y *= flare
71+
bm.normal_update()
5972
bm.to_mesh(me)
6073
finally:
6174
bm.free()
75+
for poly in me.polygons:
76+
poly.use_smooth = True
6277
return me
6378

6479

6580
def make_core_mesh():
6681
me = bpy.data.meshes.new("Core")
6782
bm = bmesh.new()
6883
try:
69-
bmesh.ops.create_uvsphere(bm, u_segments=24, v_segments=16, radius=CORE_RADIUS)
84+
bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=CORE_RADIUS)
7085
bm.to_mesh(me)
7186
finally:
7287
bm.free()
88+
for poly in me.polygons:
89+
poly.use_smooth = True
7390
return me
7491

7592

76-
def make_metal(name, color, roughness=0.28, metallic=1.0):
93+
def make_plinth_mesh():
94+
me = bpy.data.meshes.new("Plinth")
95+
bm = bmesh.new()
96+
try:
97+
bmesh.ops.create_cone(
98+
bm,
99+
cap_ends=True,
100+
segments=64,
101+
radius1=1.55,
102+
radius2=1.35,
103+
depth=0.12,
104+
)
105+
bm.to_mesh(me)
106+
finally:
107+
bm.free()
108+
for poly in me.polygons:
109+
poly.use_smooth = True
110+
return me
111+
112+
113+
def set_specular(bsdf, value):
114+
"""4.x Specular → 5.x Specular IOR Level."""
115+
socks = bsdf.inputs
116+
if "Specular IOR Level" in socks:
117+
socks["Specular IOR Level"].default_value = value
118+
elif "Specular" in socks:
119+
socks["Specular"].default_value = value
120+
121+
122+
def make_metal(name, color, roughness=0.22, metallic=1.0):
77123
mat = bpy.data.materials.new(name)
78124
mat.use_nodes = True
79125
bsdf = mat.node_tree.nodes["Principled BSDF"]
80126
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
81127
bsdf.inputs["Roughness"].default_value = roughness
82128
bsdf.inputs["Metallic"].default_value = metallic
129+
set_specular(bsdf, 0.5)
83130
return mat
84131

85132

86-
def make_emissive(name, color, strength):
133+
def make_core_material():
134+
"""Dark shell + ember emission so the core has form, not a clipped white blob."""
135+
mat = bpy.data.materials.new("CoreEmber")
136+
mat.use_nodes = True
137+
bsdf = mat.node_tree.nodes["Principled BSDF"]
138+
bsdf.inputs["Base Color"].default_value = (0.02, 0.01, 0.015, 1.0)
139+
bsdf.inputs["Roughness"].default_value = 0.18
140+
bsdf.inputs["Metallic"].default_value = 0.85
141+
set_specular(bsdf, 0.6)
142+
# Principled emission — present on both 4.5 and 5.x.
143+
if "Emission Color" in bsdf.inputs:
144+
bsdf.inputs["Emission Color"].default_value = (1.0, 0.22, 0.04, 1.0)
145+
bsdf.inputs["Emission Strength"].default_value = 6.5
146+
elif "Emission" in bsdf.inputs:
147+
bsdf.inputs["Emission"].default_value = (1.0, 0.22, 0.04, 1.0)
148+
if "Emission Strength" in bsdf.inputs:
149+
bsdf.inputs["Emission Strength"].default_value = 6.5
150+
return mat
151+
152+
153+
def make_dielectric(name, color, roughness=0.35):
87154
mat = bpy.data.materials.new(name)
88155
mat.use_nodes = True
89-
nodes = mat.node_tree.nodes
90-
links = mat.node_tree.links
91-
nodes.clear()
92-
out = nodes.new("ShaderNodeOutputMaterial")
93-
emit = nodes.new("ShaderNodeEmission")
94-
emit.inputs["Color"].default_value = (*color, 1.0)
95-
emit.inputs["Strength"].default_value = strength
96-
links.new(emit.outputs["Emission"], out.inputs["Surface"])
156+
bsdf = mat.node_tree.nodes["Principled BSDF"]
157+
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
158+
bsdf.inputs["Roughness"].default_value = roughness
159+
bsdf.inputs["Metallic"].default_value = 0.0
160+
set_specular(bsdf, 0.45)
97161
return mat
98162

99163

100164
def build():
101165
bpy.ops.wm.read_factory_settings(use_empty=True)
102166
col = bpy.context.collection
103167

104-
# Lift the whole constellation so the south polar needle clears the floor.
105-
lift = ORBIT_RADIUS + 0.25
106-
107168
core = bpy.data.objects.new("Core", make_core_mesh())
108-
# Modest emission so the cyan reads as colour, not a white clip.
109-
core.data.materials.append(make_emissive("CoreEmit", (0.25, 0.75, 1.0), 4.5))
110-
core.location = (0.0, 0.0, lift)
169+
core.data.materials.append(make_core_material())
170+
core.location = (0.0, 0.0, LIFT)
111171
col.objects.link(core)
112172

113173
needle_me = make_needle_mesh()
114-
metal = make_metal("NeedleMetal", (0.82, 0.78, 0.70), roughness=0.18)
174+
# Warm brass — rim light + ember core will ping reflections hard.
175+
metal = make_metal("NeedleMetal", (0.78, 0.55, 0.28), roughness=0.16)
115176
needle_me.materials.append(metal)
116177

117178
needles = []
118179
for i, loc in enumerate(orbit_positions(N_NEEDLES, ORBIT_RADIUS)):
119180
ob = bpy.data.objects.new(f"Needle_{i:02d}", needle_me)
120-
ob.location = (loc.x, loc.y, loc.z + lift)
181+
ob.location = (loc.x, loc.y, loc.z + LIFT)
121182
# Deliberate identity rotation — the constraint alone must do the aiming.
122183
ob.rotation_euler = (0.0, 0.0, 0.0)
123184
col.objects.link(ob)
@@ -204,29 +265,45 @@ def eevee_engine_id():
204265
def render_still(core, needles, path, engine):
205266
scene = bpy.context.scene
206267

268+
# Soft cyclorama — one curved-feeling backdrop via oversized floor + back plane.
207269
floor_me = bpy.data.meshes.new("Floor")
208270
bm = bmesh.new()
209271
try:
210-
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0)
272+
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=40.0)
211273
bm.to_mesh(floor_me)
212274
finally:
213275
bm.free()
214-
fmat = make_metal("StudioFloor", (0.045, 0.05, 0.055), roughness=0.42, metallic=0.35)
276+
fmat = make_dielectric("StudioFloor", (0.035, 0.037, 0.042), roughness=0.22)
215277
floor_me.materials.append(fmat)
216278
floor = bpy.data.objects.new("Floor", floor_me)
217279
scene.collection.objects.link(floor)
280+
218281
wall = bpy.data.objects.new("Wall", floor_me.copy())
219-
wall.location = (0.0, 9.0, 0.0)
282+
wall.data = floor_me.copy()
283+
wall.data.materials.clear()
284+
wall.data.materials.append(
285+
make_dielectric("StudioWall", (0.012, 0.013, 0.016), roughness=0.55)
286+
)
287+
wall.location = (0.0, 11.0, 0.0)
220288
wall.rotation_euler = (math.radians(90), 0.0, 0.0)
221289
scene.collection.objects.link(wall)
222290

291+
plinth = bpy.data.objects.new("Plinth", make_plinth_mesh())
292+
plinth.data.materials.append(
293+
make_metal("PlinthMetal", (0.06, 0.065, 0.07), roughness=0.38)
294+
)
295+
plinth.location = (0.0, 0.0, 0.06)
296+
scene.collection.objects.link(plinth)
297+
223298
world = bpy.data.worlds.new("World")
224299
world.use_nodes = True
225-
world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.006, 0.007, 0.01, 1.0)
300+
bg = world.node_tree.nodes["Background"]
301+
bg.inputs["Color"].default_value = (0.004, 0.0045, 0.006, 1.0)
302+
bg.inputs["Strength"].default_value = 0.35
226303
scene.world = world
227304

228305
aim = bpy.data.objects.new("Aim", None)
229-
aim.location = (0.0, 0.0, ORBIT_RADIUS + 0.25)
306+
aim.location = (0.0, 0.0, LIFT)
230307
scene.collection.objects.link(aim)
231308

232309
def light(name, loc, energy, size, col):
@@ -236,40 +313,64 @@ def light(name, loc, energy, size, col):
236313
ld.color = col
237314
ob = bpy.data.objects.new(name, ld)
238315
ob.location = loc
316+
# Illuminate without drawing the area-light quad into the beauty pass.
317+
ob.visible_camera = False
239318
scene.collection.objects.link(ob)
240319
lc = ob.constraints.new("TRACK_TO")
241320
lc.target = aim
242321
lc.track_axis = "TRACK_NEGATIVE_Z"
243322
lc.up_axis = "UP_Y"
323+
return ob
244324

245-
light("Key", (-4.0, -4.5, 6.5), 900.0, 6.0, (1.0, 0.97, 0.92))
246-
light("Fill", (5.0, -3.0, 3.8), 220.0, 8.0, (0.75, 0.85, 1.0))
247-
light("Rim", (1.2, 5.0, 3.2), 380.0, 4.0, (1.0, 0.7, 0.4))
325+
# Product lighting: soft warm key, cool fill, hot rim that skims the spikes.
326+
light("Key", (-3.8, -5.2, LIFT + 3.2), 1100.0, 5.5, (1.0, 0.95, 0.9))
327+
light("Fill", (5.2, -2.8, LIFT + 1.4), 320.0, 9.0, (0.6, 0.75, 1.0))
328+
light("Rim", (1.0, 4.5, LIFT + 1.8), 1200.0, 2.8, (1.0, 0.42, 0.15))
248329

249330
cam_data = bpy.data.cameras.new("Cam")
250-
cam_data.lens = 50.0
331+
cam_data.lens = 65.0
332+
cam_data.dof.use_dof = False
251333
cam = bpy.data.objects.new("Cam", cam_data)
252-
cam.location = (5.2, -5.6, 4.4)
334+
# Offset three-quarter — full cage in frame, plinth reflection, not dead-center.
335+
cam.location = (5.1, -5.6, LIFT + 1.15)
253336
scene.collection.objects.link(cam)
254337
scene.camera = cam
255338
track = cam.constraints.new("TRACK_TO")
339+
# Bias look-at slightly down so the floor reflection earns space.
340+
aim.location = (0.15, 0.0, LIFT - 0.12)
256341
track.target = aim
257342
track.track_axis = "TRACK_NEGATIVE_Z"
258343
track.up_axis = "UP_Y"
259344

260-
# Keep references live for the renderer (needles share one mesh).
261345
_ = (core, needles)
262346

263347
scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
264348
if engine == "cycles":
265-
scene.cycles.samples = 48
349+
scene.cycles.samples = 64
350+
scene.cycles.use_denoising = True
266351
else:
267352
try:
268-
scene.eevee.taa_render_samples = 64
353+
scene.eevee.taa_render_samples = 128
269354
except AttributeError:
270355
pass
356+
# Bloom helps the ember core read without a compositor tree.
357+
for attr, val in (
358+
("use_bloom", True),
359+
("bloom_intensity", 0.12),
360+
("bloom_threshold", 0.85),
361+
("bloom_radius", 4.5),
362+
):
363+
if hasattr(scene.eevee, attr):
364+
try:
365+
setattr(scene.eevee, attr, val)
366+
except Exception:
367+
pass
368+
271369
scene.render.resolution_x = 1280
272370
scene.render.resolution_y = 720
371+
scene.render.film_transparent = False
372+
scene.view_settings.view_transform = "Filmic"
373+
scene.view_settings.look = "Medium High Contrast"
273374
scene.render.image_settings.file_format = "PNG"
274375
scene.render.filepath = path
275376
bpy.ops.render.render(write_still=True)
12.4 KB
Loading

‎examples/gallery.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@
163163
{
164164
"name": "damped-track-aim",
165165
"dir": "examples/damped-track-aim",
166-
"teaches": "Aim constraints via the data API \u2014 Object.constraints.new('DAMPED_TRACK') with target and TRACK_Z, not bpy.ops.object.constraint_add in a headless loop.",
166+
"teaches": "Aim constraints via the data API \u2014 Object.constraints.new('DAMPED_TRACK') with target and TRACK_Z, not bpy.ops.object.constraint_add in a headless loop. Gallery still: brass spike cage around an ember core.",
167167
"witnessesFix": "Every needle has one unmuted DAMPED_TRACK on the core; evaluated local +Z aligns toward the core (dot \u2265 0.998). TRACK_TO stand-ins and flipped axes fail.",
168168
"hero": "docs/gallery/assets/damped-track-aim-hero.webp",
169169
"preview": "examples/damped-track-aim/preview.webp",

0 commit comments

Comments
 (0)