Skip to content

Commit 27af6a3

Browse files
TMHSDigitalclaude
andcommitted
fix(examples): redesign grease-pencil-rosette as a neon sign on a lacquer board
The rosette floated as flat neon loops on a blue-grey backdrop. It is now a wall-mounted neon sign. The unlit GPv3 strokes are the tubes, a few centimetres proud of a satin black-lacquer board in a brass frame, with brass standoff caps, a maker's plate and a mains cable. Grease Pencil does not light its surroundings, so the spill is staged: one small shadowless disk light per sample along each ring, placed on that ring's rose_point and tinted with its ring_color, washes the board behind each tube. Dark-studio key, fill and wedge replace the blue halo. The build, check and falsifier are unchanged. The render path now runs the gallery_framing gate (exit 10), which it previously skipped. Evidence (live runs, local): - .scratch/blender-5.2.1-windows-x64/blender.exe (Blender 5.2.1 LTS): check-only exit 0 (5.x contract; rings=5 points/stroke=192 round-trip worst=5.90e-08); --open-strokes exits 5 ("not every stroke is cyclic"). - .scratch/blender-4.5.11-windows-x64/blender.exe (Blender 4.5.11 LTS): check + --output exit 0 (4.5 legacy/_v3 contract), framing_ok. - Framing: hero is the sign board and frame. Fill x=0.472 y=0.856; margins l=0.250 r=0.278 b=0.067 t=0.061. - Contact sheet: candidate luma 0.251 / stage 0.218 / warmth -0.165 / sat 0.254, against armature-bend 0.243/0.192/-0.144/0.237, damped-track-aim 0.237/0.218/+0.105/0.178, and bmesh-gear 0.236/0.177/+0.303/0.250. Warmth is negative because the cyan and magenta neon dominate the lower half. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 0cd13c5 commit 27af6a3

7 files changed

Lines changed: 139 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ that actually runs. Browse them all with filters and full-size renders in the
226226
or expand a category below.
227227

228228
<p align="center">
229-
<a href="examples/grease-pencil-rosette/"><img src="examples/grease-pencil-rosette/preview.webp" width="24%" alt="Grease pencil rosette: five nested neon rose curves glowing against a dark studio wall" /></a>
229+
<a href="examples/grease-pencil-rosette/"><img src="examples/grease-pencil-rosette/preview.webp" width="24%" alt="Grease pencil rosette: five nested neon rose curves mounted as a sign on a brass-framed black lacquer board" /></a>
230230
<a href="examples/parent-inverse-orrery/"><img src="examples/parent-inverse-orrery/preview.webp" width="24%" alt="Parent inverse orrery: a brass tabletop orrery with a glowing yellow sun and three planets" /></a>
231231
<a href="examples/compositor-glare/"><img src="examples/compositor-glare/preview.webp" width="24%" alt="Compositor glare: three neon rings with colored bloom halos" /></a>
232232
<a href="examples/image-pixels-testcard/"><img src="examples/image-pixels-testcard/preview.webp" width="24%" alt="Image pixels testcard: a studio monitor showing a procedural broadcast test card" /></a>
@@ -819,7 +819,7 @@ on 5.2.
819819
</tr>
820820
<tr>
821821
<td width="46%" valign="middle">
822-
<a href="examples/grease-pencil-rosette/"><img src="examples/grease-pencil-rosette/preview.webp" alt="Grease pencil rosette: five nested neon rose curves drawn as tapered Grease Pencil v3 strokes, cyan through magenta to red, glowing against a dark studio wall with a soft blue halo" /></a>
822+
<a href="examples/grease-pencil-rosette/"><img src="examples/grease-pencil-rosette/preview.webp" alt="Grease pencil rosette: five nested neon rose curves drawn as tapered Grease Pencil v3 strokes, cyan through magenta to red, mounted as a neon sign on a brass-framed black lacquer board and washing it with coloured spill" /></a>
823823
</td>
824824
<td valign="middle">
825825

11.1 KB
Loading
58.7 KB
Loading

‎examples/gallery.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@
232232
"name": "grease-pencil-rosette",
233233
"dir": "examples/grease-pencil-rosette",
234234
"teaches": "Grease Pencil v3's attribute-based API — layer → frames.new(1).drawing → add_strokes → per-point position/radius/opacity/vertex_color — drawing five nested neon rose curves.",
235-
"alt": "A neon Grease Pencil rosette of nested cyan, violet, magenta and red petal loops on a dark blue backdrop.",
235+
"alt": "A neon rosette sign: five nested Grease Pencil rose loops, cyan through magenta to red, glowing a few centimetres proud of a black lacquer board in a brass frame on a dark wall.",
236236
"witnessesFix": "The GPv3 address break: 4.5 keeps GPv3 at grease_pencils_v3 while grease_pencils is still legacy GPencil (frame.strokes, no .drawing); 5.x deletes legacy and GPv3 takes over the grease_pencils name. Point writes lazily materialize attribute layers, and every position round-trips through the raw POINT buffer.",
237237
"hero": "docs/gallery/assets/grease-pencil-rosette-hero.webp",
238238
"preview": "examples/grease-pencil-rosette/preview.webp",

‎examples/grease-pencil-rosette/README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ layers*: writing `pt.radius` / `pt.opacity` / `pt.vertex_color` lazily materiali
2121
`drawing.attributes`, and every closed-form rose position round-trips exactly through the
2222
raw `position` attribute buffer via `foreach_get`.
2323

24+
## Render
25+
26+
The strokes stay unlit (`layer.use_lights = False`) and act as the neon tubes of a sign:
27+
they hang a few centimetres proud of a satin black-lacquer backboard in a brass frame with
28+
brass standoff caps and a maker's plate, mounted on the studio wall. Grease Pencil does not
29+
light its surroundings, so the spill is staged: one small shadowless disk light per sample
30+
along each ring, placed on that ring's `rose_point` and tinted with its `ring_color`, washes
31+
the board behind each tube. The spill reads the same closed-form curve the check asserts.
32+
The render path gates framing through `examples/gallery_framing.py` (exit 10).
33+
2434
## Run
2535

2636
```bash
@@ -52,6 +62,7 @@ against it.
5262
| 7 | Position buffer length or closed-form round-trip |
5363
| 8 | Grease Pencil material missing gpencil settings |
5464
| 9 | `--output` produced no file |
65+
| 10 | Framing gate violation on the `--output` path (`gallery_framing`) |
5566

5667
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
5768
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).

‎examples/grease-pencil-rosette/grease_pencil_rosette.py‎

Lines changed: 125 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
"""
3333
import bpy, sys, os, math, argparse, colorsys
3434

35+
# Shared Layer 1 framing measurement (render path only) -- see
36+
# gallery_framing.py for the __file__-relative import shim this relies on.
37+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
38+
import gallery_framing # noqa: E402
39+
3540
RINGS = 5 # nested rose curves, one stroke each
3641
POINTS = 192 # samples per stroke
3742
R_OUTER = 1.55 # radius of the outermost rose
@@ -198,11 +203,53 @@ def eevee_engine_id():
198203
return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
199204

200205

206+
def _box(name, dims, loc, mat, bevel=0.0, segments=3):
207+
import bmesh
208+
me = bpy.data.meshes.new(name)
209+
bm = bmesh.new()
210+
try:
211+
bmesh.ops.create_cube(bm, size=1.0)
212+
for v in bm.verts:
213+
v.co.x *= dims[0]; v.co.y *= dims[1]; v.co.z *= dims[2]
214+
if bevel > 0.0:
215+
bmesh.ops.bevel(bm, geom=list(bm.edges), offset=bevel, segments=segments,
216+
profile=0.5, affect='EDGES', clamp_overlap=True)
217+
bm.to_mesh(me)
218+
finally:
219+
bm.free()
220+
for poly in me.polygons:
221+
poly.use_smooth = True
222+
me.materials.append(mat)
223+
ob = bpy.data.objects.new(name, me)
224+
ob.location = loc
225+
bpy.context.scene.collection.objects.link(ob)
226+
return ob
227+
228+
229+
def _mat(name, rgb, rough, metal=0.0, coat=0.0):
230+
m = bpy.data.materials.new(name)
231+
m.use_nodes = True
232+
b = m.node_tree.nodes["Principled BSDF"]
233+
b.inputs["Base Color"].default_value = (*rgb, 1.0)
234+
b.inputs["Roughness"].default_value = rough
235+
b.inputs["Metallic"].default_value = metal
236+
if coat:
237+
try:
238+
b.inputs["Coat Weight"].default_value = coat
239+
except KeyError:
240+
pass
241+
return m
242+
243+
201244
def render_still(obj, path, engine):
245+
"""The rosette as a neon sign: the unlit GPv3 strokes are the tubes,
246+
mounted a few centimetres proud of a black-lacquer backboard in a brass
247+
frame, hung on the studio wall. Coloured area lights just in front of the
248+
board stand in for the spill real neon throws on its backing."""
202249
scene = bpy.context.scene
203250
obj.data.layers[0].use_lights = False # neon ink stays unlit and vivid
204-
obj.location = (0.0, 0.0, 1.9)
205-
obj.rotation_euler = (math.radians(4), 0.0, 0.0)
251+
SIGN_Z = 2.25
252+
obj.location = (0.0, -0.06, SIGN_Z)
206253
# unlit saturated ink wants the graphic transform, not AgX's filmic desaturation
207254
scene.view_settings.view_transform = 'Standard'
208255

@@ -214,41 +261,93 @@ def render_still(obj, path, engine):
214261
bm.to_mesh(floor_me)
215262
finally:
216263
bm.free()
217-
fmat = bpy.data.materials.new("Studio")
218-
fmat.use_nodes = True
219-
fb = fmat.node_tree.nodes["Principled BSDF"]
220-
fb.inputs["Base Color"].default_value = (0.045, 0.05, 0.065, 1.0)
221-
fb.inputs["Roughness"].default_value = 0.35
264+
fmat = _mat("Studio", (0.03, 0.032, 0.037), 0.7)
222265
floor_me.materials.append(fmat)
223266
floor = bpy.data.objects.new("Floor", floor_me)
224267
scene.collection.objects.link(floor)
225268
wall = bpy.data.objects.new("Wall", floor_me.copy())
226-
wall.location = (0.0, 6.0, 0.0)
269+
wall.location = (0.0, 0.32, 0.0)
227270
wall.rotation_euler = (math.radians(90), 0.0, 0.0)
228271
scene.collection.objects.link(wall)
229272

230273
world = bpy.data.worlds.new("World")
231274
world.use_nodes = True
232-
world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.01, 0.012, 0.02, 1.0)
275+
world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.012, 0.012, 0.016, 1.0)
233276
scene.world = world
234277

278+
# sign: black lacquer board, brass frame, four brass standoff caps
279+
lacquer = _mat("Lacquer", (0.02, 0.02, 0.024), 0.55, coat=0.15)
280+
brass = _mat("Brass", (0.78, 0.55, 0.24), 0.28, metal=1.0)
281+
BW, BH = 4.0, 3.7
282+
board = _box("SignBoard", (BW, 0.06, BH), (0.0, 0.26, SIGN_Z), lacquer, bevel=0.015)
283+
fw = 0.09
284+
frame = [
285+
_box("FrameTop", (BW + 2 * fw, 0.1, fw), (0.0, 0.24, SIGN_Z + BH / 2 + fw / 2), brass, 0.02),
286+
_box("FrameBot", (BW + 2 * fw, 0.1, fw), (0.0, 0.24, SIGN_Z - BH / 2 - fw / 2), brass, 0.02),
287+
_box("FrameL", (fw, 0.1, BH), (-(BW + fw) / 2, 0.24, SIGN_Z), brass, 0.02),
288+
_box("FrameR", (fw, 0.1, BH), ((BW + fw) / 2, 0.24, SIGN_Z), brass, 0.02),
289+
]
290+
caps = []
291+
for sx in (-1, 1):
292+
for sz in (-1, 1):
293+
bpy.ops.mesh.primitive_cylinder_add(vertices=24, radius=0.07, depth=0.1,
294+
location=(sx * (BW / 2 - 0.25), 0.18,
295+
SIGN_Z + sz * (BH / 2 - 0.25)),
296+
rotation=(math.radians(90), 0, 0))
297+
cap = bpy.context.active_object
298+
cap.data.materials.append(brass)
299+
caps.append(cap)
300+
# a brass plate under the tubes, a mains cable dropping to the floor
301+
plate = _box("MakerPlate", (0.9, 0.03, 0.16), (0.0, 0.215, SIGN_Z - BH / 2 + 0.22), brass, 0.01)
302+
cable_mat = _mat("Cable", (0.02, 0.02, 0.02), 0.45)
303+
bpy.ops.mesh.primitive_cylinder_add(vertices=12, radius=0.018, depth=SIGN_Z - BH / 2 - fw,
304+
location=(1.35, 0.28, (SIGN_Z - BH / 2 - fw) / 2))
305+
cable = bpy.context.active_object
306+
cable.data.materials.append(cable_mat)
307+
sign = [board, plate, cable] + frame + caps
308+
235309
def light(name, loc, energy, size, col, rot):
236310
ld = bpy.data.lights.new(name, 'AREA')
237311
ld.energy = energy; ld.size = size; ld.color = col
238312
ob = bpy.data.objects.new(name, ld)
239313
ob.location = loc
240314
ob.rotation_euler = tuple(math.radians(a) for a in rot)
241315
scene.collection.objects.link(ob)
316+
return ld
242317

243-
# the strokes are unlit; this only paints a soft halo on the wall behind them
244-
light("Halo", (0.0, 3.2, 1.9), 260.0, 7.0, (0.4, 0.45, 1.0), (90, 0, 0))
318+
# neon spill: one small shadowless disk per ring, set just in front of
319+
# the board on that ring's rose and tinted with its colour, so each tube
320+
# washes the satin lacquer behind it the way real neon does
321+
for ring in range(RINGS):
322+
for i in range(0, POINTS, POINTS // 16):
323+
x, _y, z = rose_point(ring, i)
324+
ld = bpy.data.lights.new(f"Spill{ring}_{i}", 'AREA')
325+
ld.shape = 'DISK'
326+
ld.size = 0.6
327+
ld.energy = 0.9
328+
ld.color = ring_color(ring, i)[:3]
329+
ld.use_shadow = False
330+
lo = bpy.data.objects.new(ld.name, ld)
331+
lo.location = (x, 0.05, SIGN_Z + z)
332+
lo.rotation_euler = (math.radians(90), 0.0, 0.0) # emit along +Y, onto the board
333+
scene.collection.objects.link(lo)
334+
# warm key grazing the brass frame, cool fill, warm wedge on the wall
335+
light("Key", (-4.5, -5.0, 5.5), 200.0, 3.0, (1.0, 0.93, 0.85), (48, 0, -40))
336+
light("Fill", (5.0, -4.0, 2.5), 50.0, 8.0, (0.75, 0.85, 1.0), (65, 0, 50))
337+
light("Wedge", (3.2, -1.6, 4.6), 380.0, 4.0, (1.0, 0.70, 0.42), (40, 0, 60))
245338

246339
cam_data = bpy.data.cameras.new("Cam")
247-
cam_data.lens = 50.0
340+
cam_data.lens = 41.0
248341
cam = bpy.data.objects.new("Cam", cam_data)
249-
cam.location = (0.0, -9.5, 1.9)
250-
cam.rotation_euler = (math.radians(90), 0.0, 0.0)
342+
cam.location = (-2.6, -9.2, 2.3)
251343
scene.collection.objects.link(cam)
344+
aim = bpy.data.objects.new("Aim", None)
345+
aim.location = (0.0, 0.2, SIGN_Z - 0.05)
346+
scene.collection.objects.link(aim)
347+
tr = cam.constraints.new('TRACK_TO')
348+
tr.target = aim
349+
tr.track_axis = 'TRACK_NEGATIVE_Z'
350+
tr.up_axis = 'UP_Y'
252351
scene.camera = cam
253352

254353
scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id()
@@ -263,8 +362,14 @@ def light(name, loc, energy, size, col, rot):
263362
scene.render.resolution_y = 720
264363
scene.render.image_settings.file_format = 'PNG'
265364
scene.render.filepath = path
365+
bpy.context.view_layer.update()
366+
# Layer 1 framing gate before the beauty render (exit 10 on violation)
367+
fcode = gallery_framing.check_framing(scene, cam, hero=[board] + frame,
368+
elements=[obj, board, plate] + frame + caps, stage=[floor, wall])
369+
if fcode:
370+
return fcode
266371
bpy.ops.render.render(write_still=True)
267-
return os.path.exists(path) and os.path.getsize(path) > 0
372+
return 0 if os.path.exists(path) and os.path.getsize(path) > 0 else 9
268373

269374

270375
def main():
@@ -284,9 +389,11 @@ def main():
284389
return code
285390

286391
if args.output:
287-
if not render_still(obj, os.path.abspath(args.output), args.engine):
288-
print("ERROR: render produced no file", file=sys.stderr)
289-
return 9
392+
rcode = render_still(obj, os.path.abspath(args.output), args.engine)
393+
if rcode:
394+
if rcode == 9:
395+
print("ERROR: render produced no file", file=sys.stderr)
396+
return rcode
290397
print(f"rendered still {args.output}")
291398

292399
print("grease-pencil-rosette OK")
10.3 KB
Loading

0 commit comments

Comments
 (0)