Skip to content

Commit 9530d78

Browse files
TMHSDigitalclaude
andcommitted
fix(examples): redesign color-attribute-wheel as a domed ceramic plate on a walnut easel
The flat board on a plinth read as a pale, washed-out oval. The render path now domes the checked disc into a shallow plate. Only vertex positions move, through one foreach_get/foreach_set after check(); the CORNER colours ride the same loops, so the colours on the plate are the verified ones. It also gives the plate a Solidify body, a turned brass bezel and a dark backing disc, and stands it at a 14-degree lean on a walnut studio easel. The glaze is a satin base under a thin clear coat, which the dome curves into a soft sheen. Key energy drops from 320 to 230 and emission from 0.12 to 0.10, so the rim hues stay saturated. The render path now runs the gallery_framing gate (exit 10), which it previously skipped. Checks are unchanged. Evidence (live runs, local): - E:\Blender-Developer-Tools\.scratch\blender-5.2.1-windows-x64\blender.exe (Blender 5.2.1 LTS): check-only exit 0, verts=1009 faces=1008 loops=3960 domain=CORNER active=True probes_ok=4. --point-domain exits 5 ("attribute domain/type = POINT/FLOAT_COLOR"). - E:\Blender-Developer-Tools\.scratch\blender-4.5.11-windows-x64\blender.exe (Blender 4.5.11 LTS): check exit 0 with the same numbers; the --output render path also exits 0. - Framing: fill x=0.412 y=0.739, margins l=0.287 r=0.300 b=0.050 t=0.050. - Contact sheet: candidate luma 0.262 / stage 0.204 / warmth +0.141 / sat 0.354, 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. Stage and luma are in range. Saturation is the highest in the set because the hue wheel is the subject. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 3408e46 commit 9530d78

7 files changed

Lines changed: 195 additions & 90 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ the compositor on and is exactly zero with it off — and that EEVEE has no `use
282282
</tr>
283283
<tr>
284284
<td width="46%" valign="middle">
285-
<a href="examples/color-attribute-wheel/"><img src="examples/color-attribute-wheel/preview.webp" alt="Color attribute wheel: an HSV color wheel board with a white center fading into a vivid rainbow rim, leaning back on a dark plinth on a dark studio floor with a warm light pool behind it" /></a>
285+
<a href="examples/color-attribute-wheel/"><img src="examples/color-attribute-wheel/preview.webp" alt="Color attribute wheel: a domed ceramic HSV color wheel plate with a white center fading into a vivid rainbow rim, ringed in brass and standing on a walnut easel on a dark studio floor with a warm light pool behind it" /></a>
286286
</td>
287287
<td valign="middle">
288288

7.39 KB
Loading
260 Bytes
Loading

‎examples/color-attribute-wheel/README.md‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ Color, not just present in the node tree.
2121

2222
## Staging
2323

24-
The disc is zero-thickness, and the still used to show it as a paper-thin oval
25-
hovering 8 cm above the floor. The render path now gives it a render-only
26-
Solidify body (5 cm) and stands it at the same 52° lean on a dark plinth, with
27-
a strut behind it, easel-style. The height is computed so the lowest point of
28-
the rim rests on the plinth. The mesh `check()` asserts is unchanged: the
29-
modifier is added after the check and only in the render path. Measured
30-
framing on 5.2.1: fill 0.794 y, every margin ≥ 0.094.
24+
The checked disc is flat and zero-thickness. The render path — only after `check()`,
25+
so the verified mesh is untouched until then — domes it into a shallow glazed plate
26+
(one `foreach_get`/`foreach_set` over vertex positions; the CORNER colours ride the
27+
same loops, so the colours on the plate are the colours the check verified), gives it a
28+
Solidify body, rings it with a turned brass bezel over a dark backing disc, and stands it
29+
at a 14° lean on a walnut studio easel. The glaze is a satin base under a thin clear
30+
coat; the dome curves the coat's reflection into a soft sheen instead of the hard
31+
horizon line a flat glaze draws. The path gates framing through`
32+
examples/gallery_framing.py` (exit 10): fill 0.739 y, every margin ≥ 0.050.
3133

3234
## Run
3335

@@ -60,6 +62,7 @@ against it.
6062
| 7 | `active_color` not set |
6163
| 8 | Probe loop color off HSV closed form |
6264
| 9 | `--output` produced no file |
65+
| 10 | Framing gate violation on the `--output` path (`gallery_framing`) |
6366

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

‎examples/color-attribute-wheel/color_attribute_wheel.py‎

Lines changed: 183 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525
from array import array
2626
from mathutils import Vector
2727

28+
# Shared Layer 1 framing measurement (render path only) — see
29+
# gallery_framing.py for the __file__-relative import shim this relies on.
30+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
31+
import gallery_framing # noqa: E402
32+
2833
RINGS = 14
2934
SEGMENTS = 72
3035
R_OUTER = 1.6
@@ -163,14 +168,18 @@ def build_material():
163168
attr_node.attribute_type = 'GEOMETRY'
164169
attr_node.attribute_name = ATTR_NAME
165170
nt.links.new(attr_node.outputs["Color"], bsdf.inputs["Base Color"])
166-
if "Emission Color" in bsdf.inputs: # Principled gained built-in emission in 4.x+
167-
nt.links.new(attr_node.outputs["Color"], bsdf.inputs["Emission Color"])
168-
bsdf.inputs["Emission Strength"].default_value = 0.12
169-
# fully matte: any specular component reflects the wall/floor horizon as
170-
# a hard line across the disc face
171-
bsdf.inputs["Roughness"].default_value = 0.85
171+
# glazed ceramic: a satin base under a thin clear coat. The plate is domed
172+
# (render path), so the coat's reflection is a soft curved sheen, never
173+
# the hard horizon line a flat mirror-glaze would draw across the face.
174+
bsdf.inputs["Roughness"].default_value = 0.42
172175
if "Specular IOR Level" in bsdf.inputs:
173-
bsdf.inputs["Specular IOR Level"].default_value = 0.0
176+
bsdf.inputs["Specular IOR Level"].default_value = 0.25
177+
if "Coat Weight" in bsdf.inputs:
178+
bsdf.inputs["Coat Weight"].default_value = 0.35
179+
bsdf.inputs["Coat Roughness"].default_value = 0.18
180+
if "Emission Color" in bsdf.inputs: # a faint self-glow keeps the far hues from sinking
181+
nt.links.new(attr_node.outputs["Color"], bsdf.inputs["Emission Color"])
182+
bsdf.inputs["Emission Strength"].default_value = 0.10
174183

175184
# The step AI code most often skips: the attribute must actually be wired
176185
# into the shader, not just present on the mesh.
@@ -186,66 +195,156 @@ def build_material():
186195
return mat
187196

188197

198+
BEZEL_OUT = R_OUTER + 0.20 # brass bezel outer radius (render path)
199+
DOME = 0.11 # plate dome height at the centre (render path)
200+
TILT = math.radians(76) # plate leans back 14 degrees on the easel
201+
202+
203+
def _lathe(bm, profile, segs=96):
204+
"""Revolve an (r, z) profile about local Z (bottom -> out -> up -> in)."""
205+
rings = [[bm.verts.new((r * math.cos(2 * math.pi * i / segs),
206+
r * math.sin(2 * math.pi * i / segs), z)) for i in range(segs)]
207+
for r, z in profile]
208+
for a, b in zip(rings, rings[1:] + rings[:1]):
209+
for i in range(segs):
210+
j = (i + 1) % segs
211+
f = bm.faces.new((a[i], a[j], b[j], b[i]))
212+
f.smooth = True
213+
214+
215+
def _beam(bm, p0, p1, w, d):
216+
"""A w x d rectangular beam from p0 to p1 (world space)."""
217+
p0, p1 = Vector(p0), Vector(p1)
218+
axis = p1 - p0
219+
res = bmesh.ops.create_cube(bm, size=1.0)
220+
rot = Vector((0, 0, 1)).rotation_difference(axis.normalized()).to_matrix()
221+
for v in res["verts"]:
222+
v.co = rot @ Vector((v.co.x * w, v.co.y * d, v.co.z * axis.length)) + (p0 + p1) / 2
223+
224+
225+
def _mat(name, base, rough, metal=0.0, noise=None, coat=0.0):
226+
m = bpy.data.materials.new(name)
227+
m.use_nodes = True
228+
nt = m.node_tree
229+
b = nt.nodes["Principled BSDF"]
230+
b.inputs["Base Color"].default_value = (*base, 1.0)
231+
b.inputs["Roughness"].default_value = rough
232+
b.inputs["Metallic"].default_value = metal
233+
if coat and "Coat Weight" in b.inputs:
234+
b.inputs["Coat Weight"].default_value = coat
235+
if noise:
236+
# grain/handling wear: noise-mottled colour and roughness, never flat
237+
scale, amount, stretch = noise
238+
coord = nt.nodes.new("ShaderNodeTexCoord")
239+
mapping = nt.nodes.new("ShaderNodeMapping")
240+
mapping.inputs["Scale"].default_value = stretch
241+
nt.links.new(coord.outputs["Object"], mapping.inputs["Vector"])
242+
tex = nt.nodes.new("ShaderNodeTexNoise")
243+
tex.inputs["Scale"].default_value = scale
244+
tex.inputs["Detail"].default_value = 8.0
245+
nt.links.new(mapping.outputs["Vector"], tex.inputs["Vector"])
246+
ramp = nt.nodes.new("ShaderNodeValToRGB")
247+
ramp.color_ramp.elements[0].color = tuple(c * (1 - amount) for c in base) + (1.0,)
248+
ramp.color_ramp.elements[1].color = tuple(min(1.0, c * (1 + amount)) for c in base) + (1.0,)
249+
nt.links.new(tex.outputs["Fac"], ramp.inputs["Fac"])
250+
nt.links.new(ramp.outputs["Color"], b.inputs["Base Color"])
251+
rmap = nt.nodes.new("ShaderNodeMapRange")
252+
rmap.inputs["To Min"].default_value = rough - 0.08
253+
rmap.inputs["To Max"].default_value = rough + 0.12
254+
nt.links.new(tex.outputs["Fac"], rmap.inputs["Value"])
255+
nt.links.new(rmap.outputs["Result"], b.inputs["Roughness"])
256+
return m
257+
258+
189259
def render_still(obj, path, engine):
190260
scene = bpy.context.scene
191-
for poly in obj.data.polygons:
261+
me = obj.data
262+
for poly in me.polygons:
192263
poly.use_smooth = True
193264

194265
mat = build_material()
195266
if mat is None:
196-
return False
197-
obj.data.materials.append(mat)
198-
# stand the disc up toward the camera like an easel: the wheel is the
199-
# subject, so it should present nearly face-on and fill the frame instead
200-
# of lying foreshortened on the floor.
201-
# render-only: a Solidify body so the disc reads as a painted board, not
202-
# a zero-thickness sheet, resting on a dark plinth and propped from
203-
# behind by a strut, easel-style, instead of hovering above the floor.
204-
disc_t = 0.05
205-
plinth_h = 0.12
206-
tilt = math.radians(52)
207-
solid = obj.modifiers.new("Board", 'SOLIDIFY')
208-
solid.thickness = disc_t
267+
return 9
268+
me.materials.append(mat)
269+
270+
# render-only: dome the checked flat disc into a shallow glazed plate.
271+
# Only positions move; the CORNER attribute rides the same loops, so the
272+
# colours the check verified are the colours on the plate.
273+
co = array('f', [0.0]) * (len(me.vertices) * 3)
274+
me.vertices.foreach_get("co", co)
275+
for i in range(len(me.vertices)):
276+
x, y = co[3 * i], co[3 * i + 1]
277+
co[3 * i + 2] = DOME * (1.0 - (x * x + y * y) / (R_OUTER * R_OUTER))
278+
me.vertices.foreach_set("co", co)
279+
me.update()
280+
solid = obj.modifiers.new("Body", 'SOLIDIFY')
281+
solid.thickness = 0.06
209282
solid.offset = -1.0
210-
# lowest rim point sits R_OUTER * sin(tilt) below the centre, and the
211-
# board's back face another disc_t * cos(tilt) below that
212-
obj.location = (0.0, 0.0, plinth_h + R_OUTER * math.sin(tilt) + disc_t * math.cos(tilt))
213-
obj.rotation_euler = (tilt, 0.0, math.radians(10))
214-
215-
stand_mat = bpy.data.materials.new("Stand")
216-
stand_mat.use_nodes = True
217-
sb = stand_mat.node_tree.nodes["Principled BSDF"]
218-
sb.inputs["Base Color"].default_value = (0.012, 0.012, 0.014, 1.0)
219-
sb.inputs["Roughness"].default_value = 0.45
220-
221-
def stand_part(name, build):
222-
me = bpy.data.meshes.new(name)
223-
bm = bmesh.new()
224-
try:
225-
build(bm)
226-
bm.to_mesh(me)
227-
finally:
228-
bm.free()
229-
me.materials.append(stand_mat)
230-
ob = bpy.data.objects.new(name, me)
231-
scene.collection.objects.link(ob)
232-
return ob
233283

284+
brass = _mat("Brass", (0.92, 0.66, 0.30), 0.32, metal=1.0, noise=(40.0, 0.07, (1, 1, 1)))
285+
wood = _mat("Walnut", (0.20, 0.095, 0.045), 0.5, noise=(6.0, 0.45, (1.0, 1.0, 14.0)),
286+
coat=0.2)
287+
backing = _mat("Backing", (0.03, 0.03, 0.035), 0.55)
288+
289+
# brass bezel and a dark backing plate, built in plate space
290+
bezel_me = bpy.data.meshes.new("Bezel")
291+
bm = bmesh.new()
292+
try:
293+
# rolled lip over the plate edge, a flat band, a turned-down skirt
294+
_lathe(bm, [(R_OUTER - 0.02, -0.10), (BEZEL_OUT, -0.10), (BEZEL_OUT + 0.01, -0.06),
295+
(BEZEL_OUT, 0.02), (BEZEL_OUT - 0.04, 0.05), (R_OUTER + 0.03, 0.055),
296+
(R_OUTER - 0.03, 0.035), (R_OUTER - 0.05, 0.005)])
297+
bm.to_mesh(bezel_me)
298+
finally:
299+
bm.free()
300+
bezel_me.materials.append(brass)
301+
bezel = bpy.data.objects.new("Bezel", bezel_me)
302+
scene.collection.objects.link(bezel)
303+
back_me = bpy.data.meshes.new("Backing")
304+
bm = bmesh.new()
305+
try:
306+
bmesh.ops.create_circle(bm, cap_ends=True, segments=96, radius=BEZEL_OUT - 0.02)
307+
bmesh.ops.translate(bm, verts=bm.verts, vec=(0.0, 0.0, -0.105))
308+
bmesh.ops.reverse_faces(bm, faces=bm.faces)
309+
bm.to_mesh(back_me)
310+
finally:
311+
bm.free()
312+
back_me.materials.append(backing)
313+
back = bpy.data.objects.new("Backing", back_me)
314+
scene.collection.objects.link(back)
315+
316+
# the plate stands on a walnut easel, leaning back against its front legs
317+
ledge_z = 0.55
318+
zc = ledge_z + 0.06 + BEZEL_OUT * math.sin(TILT)
319+
for ob in (obj, bezel, back):
320+
ob.location = (0.0, 0.0, zc)
321+
ob.rotation_euler = (TILT, 0.0, 0.0)
234322
bpy.context.view_layer.update()
235-
mw = obj.matrix_world
236-
rim_low = mw @ Vector((0.0, -R_OUTER, 0.0))
237-
plinth = stand_part("Plinth", lambda bm: bmesh.ops.create_cube(bm, size=1.0))
238-
plinth.scale = (1.5, 0.9, plinth_h)
239-
plinth.location = (rim_low.x, rim_low.y + 0.25, plinth_h / 2.0)
240-
plinth.rotation_euler = (0.0, 0.0, math.radians(10))
241-
# the strut runs from the board's back, above centre, down to the plinth
242-
top = mw @ Vector((0.0, 0.45, -disc_t))
243-
foot = Vector((top.x, top.y + 0.95, plinth_h))
244-
span = top - foot
245-
strut = stand_part("Strut", lambda bm: bmesh.ops.create_cone(
246-
bm, cap_ends=True, segments=16, radius1=0.035, radius2=0.035, depth=span.length))
247-
strut.location = (top + foot) / 2.0
248-
strut.rotation_euler = Vector((0.0, 0.0, 1.0)).rotation_difference(span).to_euler()
323+
# the plate's mid-plane: y = cot(TILT) * (z - zc); the back sits behind it
324+
cot = math.cos(TILT) / math.sin(TILT)
325+
326+
def plane_y(z):
327+
return cot * (z - zc)
328+
329+
easel_me = bpy.data.meshes.new("Easel")
330+
bm = bmesh.new()
331+
try:
332+
top = zc + BEZEL_OUT * math.sin(TILT) + 0.12
333+
for sx in (-1.0, 1.0):
334+
_beam(bm, (sx * 1.05, plane_y(0.0) + 0.22, 0.0),
335+
(sx * 0.22, plane_y(top) + 0.22, top), 0.11, 0.07)
336+
_beam(bm, (0.0, 1.9, 0.0), (0.0, plane_y(top - 0.3) + 0.32, top - 0.3), 0.10, 0.07)
337+
# ledge the plate rests on, with a front lip, and a cross rail
338+
ly = plane_y(ledge_z) - 0.02
339+
_beam(bm, (-1.15, ly, ledge_z), (1.15, ly, ledge_z), 0.30, 0.07)
340+
_beam(bm, (-1.15, ly - 0.14, ledge_z + 0.035), (1.15, ly - 0.14, ledge_z + 0.035), 0.03, 0.10)
341+
_beam(bm, (-0.95, plane_y(0.28) + 0.23, 0.28), (0.95, plane_y(0.28) + 0.23, 0.28), 0.07, 0.06)
342+
bm.to_mesh(easel_me)
343+
finally:
344+
bm.free()
345+
easel_me.materials.append(wood)
346+
easel = bpy.data.objects.new("Easel", easel_me)
347+
scene.collection.objects.link(easel)
249348

250349
floor_me = bpy.data.meshes.new("Floor")
251350
bm = bmesh.new()
@@ -263,7 +362,7 @@ def stand_part(name, build):
263362
floor = bpy.data.objects.new("Floor", floor_me)
264363
scene.collection.objects.link(floor)
265364
wall = bpy.data.objects.new("Wall", floor_me.copy())
266-
wall.location = (0.0, 7.0, 0.0)
365+
wall.location = (0.0, 9.0, 0.0)
267366
wall.rotation_euler = (math.radians(90), 0.0, 0.0)
268367
scene.collection.objects.link(wall)
269368

@@ -272,35 +371,29 @@ def stand_part(name, build):
272371
world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0)
273372
scene.world = world
274373

275-
def light(name, loc, energy, size, col, rot):
374+
def light(name, loc, energy, size, col, at):
276375
ld = bpy.data.lights.new(name, 'AREA')
277376
ld.energy = energy; ld.size = size; ld.color = col
278377
ob = bpy.data.objects.new(name, ld)
279378
ob.location = loc
280-
ob.rotation_euler = tuple(math.radians(a) for a in rot)
379+
ob.rotation_euler = (Vector(at) - Vector(loc)).to_track_quat('-Z', 'Y').to_euler()
281380
scene.collection.objects.link(ob)
282381

283-
# a bright soft key from above reads the hue ring clearly; a low cool fill
284-
# keeps the shadow side legible; a faint warm rim separates the disc edge
285-
# from the dark backdrop without washing out the attribute colors.
286-
light("Key", (-2.0, -3.0, 5.5), 320.0, 8.0, (1.0, 0.98, 0.96), (58, 0, -28))
287-
light("Fill", (4.5, -2.5, 1.6), 90.0, 9.0, (0.78, 0.86, 1.0), (68, 0, 55))
288-
light("Rim", (0.5, 3.6, 2.2), 170.0, 4.0, (1.0, 0.78, 0.55), (-70, 0, 175))
289-
# a warm wedge raking the back wall — the falloff pool behind the subject
290-
# the rest of the gallery stages against.
291-
# placed between the disc and the back wall so it can only rake the wall:
292-
# from any position in front, its grazing terminator draws a hard line
293-
# across the flat disc face.
294-
light("Wedge", (2.0, 5.2, 3.6), 220.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190))
382+
centre = (0.0, 0.0, zc)
383+
# soft key high left, sized so the glaze sheen is a broad soft highlight;
384+
# a cool low fill; a warm rim on the brass; the wedge rakes the back wall
385+
light("Key", (-4.2, -3.6, 6.0), 230.0, 3.0, (1.0, 0.96, 0.9), centre)
386+
light("Fill", (4.5, -3.0, 1.8), 70.0, 9.0, (0.78, 0.86, 1.0), centre)
387+
light("Rim", (3.4, 2.4, 5.2), 420.0, 2.5, (1.0, 0.82, 0.6), (0.0, 0.0, zc + 1.2))
388+
light("Wedge", (0.5, 5.2, 5.5), 650.0, 8.0, (1.0, 0.70, 0.42), (1.5, 9.0, 1.5))
295389

296390
aim = bpy.data.objects.new("Aim", None)
297-
aim.location = obj.location - Vector((0.0, 0.0, 0.2))
391+
aim.location = (0.0, 0.0, zc - 0.30)
298392
scene.collection.objects.link(aim)
299-
300393
cam_data = bpy.data.cameras.new("Cam")
301-
cam_data.lens = 50.0
394+
cam_data.lens = 57.0
302395
cam = bpy.data.objects.new("Cam", cam_data)
303-
cam.location = (0.0, -9.4, 2.5)
396+
cam.location = (-3.0, -13.2, 3.3)
304397
con = cam.constraints.new('TRACK_TO')
305398
con.target = aim
306399
con.track_axis = 'TRACK_NEGATIVE_Z'
@@ -323,8 +416,15 @@ def light(name, loc, energy, size, col, rot):
323416
# AgX (the 4.x/5.x default) compresses bright regions toward white, which
324417
# would hide exactly the saturation gradient this example is showing off.
325418
scene.view_settings.view_transform = 'Standard'
419+
bpy.context.view_layer.update()
420+
# Layer 1 framing gate before the beauty render (exit 10 on violation)
421+
fcode = gallery_framing.check_framing(scene, cam, hero=[obj, bezel],
422+
elements=[obj, bezel, back, easel],
423+
stage=[floor, wall])
424+
if fcode:
425+
return fcode
326426
bpy.ops.render.render(write_still=True)
327-
return os.path.exists(path) and os.path.getsize(path) > 0
427+
return 0 if os.path.exists(path) and os.path.getsize(path) > 0 else 9
328428

329429

330430
def main():
@@ -343,9 +443,11 @@ def main():
343443
return code
344444

345445
if args.output:
346-
if not render_still(obj, os.path.abspath(args.output), args.engine):
347-
print("ERROR: render produced no file", file=sys.stderr)
348-
return 9
446+
rcode = render_still(obj, os.path.abspath(args.output), args.engine)
447+
if rcode:
448+
if rcode == 9:
449+
print("ERROR: render produced no file", file=sys.stderr)
450+
return rcode
349451
print(f"rendered still {args.output}")
350452

351453
print("color-attribute-wheel OK")
6.57 KB
Loading

‎examples/gallery.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@
205205
"name": "color-attribute-wheel",
206206
"dir": "examples/color-attribute-wheel",
207207
"teaches": "The modern color-attributes API — mesh.color_attributes.new() on the CORNER domain, filled by expanding per-vertex HSV across face corners with foreach_get/foreach_set, then wired into a shader Attribute node.",
208-
"alt": "A board painted with a full hue wheel that fades to white at the centre, leaning on a dark plinth.",
208+
"alt": "A domed ceramic plate glazed with a full hue wheel that fades to white at the centre, ringed in a brass bezel and standing on a walnut easel.",
209209
"witnessesFix": "CORNER-domain attributes are sized to loop count, not vertex count; the check asserts the sizing, that active_color points at it, and that the Attribute node is actually linked to Base Color, not just present.",
210210
"hero": "docs/gallery/assets/color-attribute-wheel-hero.webp",
211211
"preview": "examples/color-attribute-wheel/preview.webp",

0 commit comments

Comments
 (0)