diff --git a/AGENTS.md b/AGENTS.md index c842cdb..ca0f8d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,8 @@ The content base (counts are CI-enforced against README.md and the manifest): API contract with deterministic checks, exit non-zero on failure, and optionally render a still via `--output`. Each is executed headless on Blender 4.5 LTS and 5.1 by `blender-smoke.yml`; its render ships in the - site gallery. Anatomy and authoring rules: copy `examples/bmesh-gear/`. + site gallery. Anatomy and authoring rules: copy `examples/bmesh-gear/`; + the render look is specified in `docs/VISUAL-STYLE.md`. ## Repository structure diff --git a/CLAUDE.md b/CLAUDE.md index 4db9355..b63837e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,7 @@ gallery's source of truth. When authoring a new one, copy the anatomy of wire all of: gallery.json entry, `.cursor-plugin/plugin.json` examples array (CI-gated), a `blender-smoke.yml` step, a README gallery row, hero webp (1280×720) in `docs/gallery/assets/` + preview webp (1200×675), then run `python scripts/build_gallery.py`. +Renders must conform to the gallery look spec at `docs/VISUAL-STYLE.md`. ## Development Workflow diff --git a/README.md b/README.md index 9c7604f..7d6c3b2 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ that actually runs. Browse them in the
-Swatch grid: a row of six shaded spheres - gold and copper metals, red and blue plastics, an emissive orange, and a matte white - on a reflective studio floor, rendered with EEVEE +Swatch grid: a three-by-two grid of shaded spheres - polished gold and brushed copper metals, red and blue plastics, an emissive orange, and a matte white - against a dark studio backdrop, rendered with EEVEE @@ -193,7 +193,7 @@ verts = 72, faces = 54, and `Set Material` carries the lime accent.
-Shape-key blend: a violet truncated pyramid on a dark studio floor, lifted and flared by a relative Tall shape key at value 0.5 +Shape-key blend: three violet blocks on a dark studio floor showing a relative Tall shape key at values 0, 0.25, and 0.5 - plain cube to lifted, flared truncated pyramid, left to right diff --git a/docs/VISUAL-STYLE.md b/docs/VISUAL-STYLE.md new file mode 100644 index 0000000..7006271 --- /dev/null +++ b/docs/VISUAL-STYLE.md @@ -0,0 +1,77 @@ +# Gallery Visual Style + +The spec for every example render that ships in the gallery. New examples must +conform; deviations are defects. Calibration references: `armature-bend`, +`color-attribute-wheel`, `grease-pencil-rosette`, `compositor-glare`, +`damped-track-aim`, `parent-inverse-orrery`. + +## The look in one sentence + +A dark staged studio — near-black floor and back wall, one shaped warm key, a +warm light pool raking the backdrop — with a saturated hero subject filling +the frame. + +## Color management + +- `scene.view_settings.view_transform = 'Standard'`, always, with a comment. + AgX (the 4.x/5.x default) desaturates hero materials toward pastel and + lifts the stage toward grey — the washed look of the pre-audit gallery + traces to renders that never set a transform. +- Standard does not compress highlights: tune light energies so nothing + clips. If a region reads pure white at full size, the key is too hot. + +## Stage + +- Floor: 30-unit `create_grid` plane. Wall: a copy at `y ≈ 7–9`, rotated 90° + about X. Both share one Principled material: + `Base Color (0.03, 0.032, 0.037)`, `Roughness 0.7`. +- World background: `(0.02, 0.021, 0.025)`. +- The floor/wall seam is acceptable only where it falls into shadow; never + let it cross the subject. + +## Lighting (all AREA lights) + +- **Key** — warm white `(1.0, 0.96, 0.9)`, upper left, ~`(-4, -5, 6)`, + size 4–6, 300–700 W depending on subject albedo. It must shape: visible + falloff across the subject, a readable cast shadow. +- **Fill** — cool `(0.75, 0.85, 1.0)`, low right, size ~9, ~100 W. Just + enough that shadow sides stay legible; if the image looks flat, the fill + is too strong. +- **Rim** — cool `(0.6, 0.78, 1.0)`, behind, 200–400 W, to lift silhouettes + off the backdrop. +- **Wedge** — the signature: warm `(1.0, 0.76, 0.5)`, 200–500 W, size 5–7, + raking the back wall so a soft pool of light sits behind the subject. + Place it **between the subject and the wall**. A grazing area light draws + a hard terminator line across any flat surface it skims — if a stray + bright band crosses the subject, a light is grazing it. + +## Materials + +- Hero materials are designed, never Principled defaults: saturated base + colors, roughness chosen (0.3–0.6 glossy, 0.7+ matte), optional faint + emission (~0.1) when the subject carries color data that must read exactly. +- Surfaces that display flat color data (attribute fills, pixel buffers) go + fully matte: `Specular IOR Level = 0`, otherwise the wall/floor horizon + reflects as a line across the face. + +## Framing + +- The subject fills roughly 70–90 % of the frame in at least one axis. + Nothing that matters may touch or cross the frame edge; no featureless + quadrant of empty wall or floor. +- Camera: 45–55 mm lens, slightly above subject height, aimed with a + `TRACK_TO` constraint at an empty on the subject — a chosen angle, not the + default. Flat subjects present toward the camera (lean or tilt them); + progressions read left to right. +- The image must still read in under a second at thumbnail scale, and it + must witness the API: if the contract failed, the render should visibly + break. + +## Output + +- Render 1280×720 PNG (`taa_render_samples`/`cycles.samples` 32–64). +- Gallery assets: hero webp 1280×720 and preview webp 1200×675, quality 85 + (load the PNG in Blender, `save(filepath=..., quality=85)`, `scale()` for + the preview — this preserves pixels without re-applying color management). +- After touching any example: `python scripts/build_gallery.py`, and update + the README gallery-row alt text if the composition changed. diff --git a/docs/gallery/assets/curve-bevel-arc-hero.webp b/docs/gallery/assets/curve-bevel-arc-hero.webp index 5620709..c41e2a7 100644 Binary files a/docs/gallery/assets/curve-bevel-arc-hero.webp and b/docs/gallery/assets/curve-bevel-arc-hero.webp differ diff --git a/docs/gallery/assets/depsgraph-export-hero.webp b/docs/gallery/assets/depsgraph-export-hero.webp index d85823f..9276917 100644 Binary files a/docs/gallery/assets/depsgraph-export-hero.webp and b/docs/gallery/assets/depsgraph-export-hero.webp differ diff --git a/docs/gallery/assets/driver-wave-hero.webp b/docs/gallery/assets/driver-wave-hero.webp index 74e638d..70bf5f6 100644 Binary files a/docs/gallery/assets/driver-wave-hero.webp and b/docs/gallery/assets/driver-wave-hero.webp differ diff --git a/docs/gallery/assets/gn-instance-grid-hero.webp b/docs/gallery/assets/gn-instance-grid-hero.webp index 8a6ccac..9433790 100644 Binary files a/docs/gallery/assets/gn-instance-grid-hero.webp and b/docs/gallery/assets/gn-instance-grid-hero.webp differ diff --git a/docs/gallery/assets/shape-key-blend-hero.webp b/docs/gallery/assets/shape-key-blend-hero.webp index 6ecfd93..bcaa60c 100644 Binary files a/docs/gallery/assets/shape-key-blend-hero.webp and b/docs/gallery/assets/shape-key-blend-hero.webp differ diff --git a/docs/gallery/assets/swatch-grid-hero.webp b/docs/gallery/assets/swatch-grid-hero.webp index 6e915f1..785e11e 100644 Binary files a/docs/gallery/assets/swatch-grid-hero.webp and b/docs/gallery/assets/swatch-grid-hero.webp differ diff --git a/docs/gallery/assets/turntable-hero.webp b/docs/gallery/assets/turntable-hero.webp index 23e5745..8666547 100644 Binary files a/docs/gallery/assets/turntable-hero.webp and b/docs/gallery/assets/turntable-hero.webp differ diff --git a/docs/gallery/color-attribute-wheel/index.html b/docs/gallery/color-attribute-wheel/index.html index 7ecbea0..2e13a3a 100644 --- a/docs/gallery/color-attribute-wheel/index.html +++ b/docs/gallery/color-attribute-wheel/index.html @@ -177,7 +177,7 @@

color-attribute-wheel

color-attribute-wheel render

Rendered headless by the example itself — click to zoom.

-
witnesses CORNER-domain attributes are sized to loop count, not vertex count; the check asserts that, that active_color points at it, and that the Attribute node is actually linked to Base Color, not just present.
+
witnesses 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.
blender --background --python examples/color-attribute-wheel/color_attribute_wheel.py --
diff --git a/docs/gallery/curve-bevel-arc/index.html b/docs/gallery/curve-bevel-arc/index.html index c1c80d5..a43758c 100644 --- a/docs/gallery/curve-bevel-arc/index.html +++ b/docs/gallery/curve-bevel-arc/index.html @@ -335,8 +335,8 @@

Source

fmat = bpy.data.materials.new("Studio") fmat.use_nodes = True fb = fmat.node_tree.nodes["Principled BSDF"] - fb.inputs["Base Color"].default_value = (0.055, 0.06, 0.07, 1.0) - fb.inputs["Roughness"].default_value = 0.5 + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 floor_me.materials.append(fmat) floor = bpy.data.objects.new("Floor", floor_me) scene.collection.objects.link(floor) @@ -347,7 +347,7 @@

Source

world = bpy.data.worlds.new("World") world.use_nodes = True - world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.008, 0.009, 0.012, 1.0) + world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0) scene.world = world aim = bpy.data.objects.new("Aim", None) @@ -367,14 +367,23 @@

Source

lc.track_axis = 'TRACK_NEGATIVE_Z' lc.up_axis = 'UP_Y' - light("Key", (-3.5, -4.5, 5.5), 1500.0, 6.0, (1.0, 0.98, 0.94)) - light("Fill", (5.0, -3.5, 2.5), 340.0, 8.0, (0.8, 0.87, 1.0)) - light("Rim", (1.5, 4.5, 2.0), 480.0, 4.0, (1.0, 0.75, 0.45)) + # warm shaped key, faint cool fill, warm wedge on the back wall + # (docs/VISUAL-STYLE.md) + light("Key", (-3.5, -4.5, 5.5), 550.0, 4.5, (1.0, 0.96, 0.9)) + light("Fill", (5.0, -3.5, 2.5), 110.0, 8.0, (0.75, 0.85, 1.0)) + wedge = bpy.data.lights.new("Wedge", 'AREA') + wedge.energy = 380.0 + wedge.size = 6.0 + wedge.color = (1.0, 0.76, 0.5) + wob = bpy.data.objects.new("Wedge", wedge) + wob.location = (2.5, 5.5, 4.0) + wob.rotation_euler = (math.radians(-68), 0.0, math.radians(190)) + scene.collection.objects.link(wob) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (3.0, -4.2, 2.6) + cam.location = (2.4, -3.4, 1.7) scene.collection.objects.link(cam) scene.camera = cam track = cam.constraints.new('TRACK_TO') @@ -394,6 +403,8 @@

Source

scene.render.resolution_y = 720 scene.render.image_settings.file_format = 'PNG' scene.render.filepath = path + # AgX would wash the rose tube toward salmon (docs/VISUAL-STYLE.md) + scene.view_settings.view_transform = 'Standard' bpy.ops.render.render(write_still=True) return os.path.exists(path) and os.path.getsize(path) > 0 diff --git a/docs/gallery/depsgraph-export/index.html b/docs/gallery/depsgraph-export/index.html index 6de2046..42f0325 100644 --- a/docs/gallery/depsgraph-export/index.html +++ b/docs/gallery/depsgraph-export/index.html @@ -314,12 +314,12 @@

Source

bm.to_mesh(floor_me) finally: bm.free() - floor_me.materials.append(principled("Studio", (0.24, 0.24, 0.25, 1.0), 0.0, 0.6)) + floor_me.materials.append(principled("Studio", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7)) floor = bpy.data.objects.new("Floor", floor_me) scene.collection.objects.link(floor) wall = bpy.data.objects.new("Wall", floor_me.copy()) wall.data.materials.clear() - wall.data.materials.append(principled("Wall", (0.045, 0.05, 0.055, 1.0), 0.0, 0.6)) + wall.data.materials.append(principled("Wall", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7)) wall.location = (0.0, 9.0, 0.0) wall.rotation_euler = (math.pi / 2, 0.0, 0.0) scene.collection.objects.link(wall) @@ -327,7 +327,7 @@

Source

world = bpy.data.worlds.new("World") world.use_nodes = True world.node_tree.nodes["Background"].inputs["Color"].default_value = \ - (0.035, 0.04, 0.05, 1.0) + (0.02, 0.021, 0.025, 1.0) scene.world = world def light(name, loc, energy, size, col, rot): @@ -338,9 +338,11 @@

Source

ob.rotation_euler = tuple(math.radians(a) for a in rot) scene.collection.objects.link(ob) - light("Key", (-4.0, -5.0, 6.0), 1000.0, 8.0, (1.0, 0.99, 0.96), (46, 0, -35)) - light("Fill", (5.0, -3.5, 3.0), 350.0, 9.0, (0.85, 0.9, 1.0), (62, 0, 50)) - light("Rim", (1.5, 5.0, 4.0), 120.0, 6.0, (1.0, 0.85, 0.65), (-70, 0, 165)) + # warm shaped key, faint cool fill, warm wedge on the back wall + # (docs/VISUAL-STYLE.md) + light("Key", (-4.0, -5.0, 6.0), 650.0, 5.0, (1.0, 0.96, 0.9), (46, 0, -35)) + light("Fill", (5.0, -3.5, 3.0), 120.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50)) + light("Wedge", (2.5, 5.5, 4.0), 380.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190)) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 42.0 @@ -362,6 +364,9 @@

Source

scene.render.resolution_y = 720 scene.render.image_settings.file_format = 'PNG' scene.render.filepath = path + # AgX would flatten the graphite-vs-green contrast this still hinges on + # (docs/VISUAL-STYLE.md) + scene.view_settings.view_transform = 'Standard' bpy.ops.render.render(write_still=True) return os.path.exists(path) and os.path.getsize(path) > 0 diff --git a/docs/gallery/driver-wave/index.html b/docs/gallery/driver-wave/index.html index 4670034..5f16cf9 100644 --- a/docs/gallery/driver-wave/index.html +++ b/docs/gallery/driver-wave/index.html @@ -308,8 +308,8 @@

Source

fmat = bpy.data.materials.new("FloorMat") fmat.use_nodes = True fb = fmat.node_tree.nodes["Principled BSDF"] - fb.inputs["Base Color"].default_value = (0.055, 0.06, 0.07, 1.0) # dark graphite studio - fb.inputs["Roughness"].default_value = 0.5 + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) # dark staged studio + fb.inputs["Roughness"].default_value = 0.7 floor_me.materials.append(fmat) scene.collection.objects.link(floor) wall = bpy.data.objects.new("Wall", floor_me.copy()) @@ -319,32 +319,36 @@

Source

world = bpy.data.worlds.new("World") world.use_nodes = True - world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.008, 0.009, 0.012, 1.0) + world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0) scene.world = world - key = bpy.data.lights.new("Key", 'AREA'); key.energy = 1600.0; key.size = 5.0 - key.color = (1.0, 0.97, 0.92) + # warm shaped key, faint cool fill, warm wedge pooling on the back wall + # (docs/VISUAL-STYLE.md) + key = bpy.data.lights.new("Key", 'AREA'); key.energy = 650.0; key.size = 4.5 + key.color = (1.0, 0.96, 0.9) key_ob = bpy.data.objects.new("Key", key) key_ob.location = (-4.5, -5.5, 6.5) key_ob.rotation_euler = (math.radians(46), 0.0, math.radians(-33)) scene.collection.objects.link(key_ob) - fill = bpy.data.lights.new("Fill", 'AREA'); fill.energy = 260.0; fill.size = 8.0 - fill.color = (0.8, 0.87, 1.0) + fill = bpy.data.lights.new("Fill", 'AREA'); fill.energy = 110.0; fill.size = 8.0 + fill.color = (0.75, 0.85, 1.0) fill_ob = bpy.data.objects.new("Fill", fill) fill_ob.location = (5.5, -4.0, 3.5) fill_ob.rotation_euler = (math.radians(62), 0.0, math.radians(48)) scene.collection.objects.link(fill_ob) - rim = bpy.data.lights.new("Rim", 'AREA'); rim.energy = 480.0; rim.size = 5.0 - rim.color = (0.75, 0.85, 1.0) - rim_ob = bpy.data.objects.new("Rim", rim) - rim_ob.location = (0.0, 6.5, 3.0) - rim_ob.rotation_euler = (math.radians(-78), 0.0, math.radians(180)) - scene.collection.objects.link(rim_ob) - - cam_data = bpy.data.cameras.new("Cam"); cam_data.lens = 45.0 + wedge = bpy.data.lights.new("Wedge", 'AREA'); wedge.energy = 380.0; wedge.size = 6.0 + wedge.color = (1.0, 0.76, 0.5) + wedge_ob = bpy.data.objects.new("Wedge", wedge) + wedge_ob.location = (2.5, 6.0, 4.0) + wedge_ob.rotation_euler = (math.radians(-68), 0.0, math.radians(190)) + scene.collection.objects.link(wedge_ob) + + # lens/distance chosen so all sixteen columns (span 11.1 units) sit inside + # the frame with a small margin -- the skyline must not clip + cam_data = bpy.data.cameras.new("Cam"); cam_data.lens = 42.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (0.0, -13.0, 2.4) - cam.rotation_euler = (math.radians(83), 0.0, 0.0) + cam.location = (0.0, -13.5, 2.0) + cam.rotation_euler = (math.radians(86), 0.0, 0.0) scene.collection.objects.link(cam) scene.camera = cam @@ -360,6 +364,8 @@

Source

scene.render.resolution_y = 720 scene.render.image_settings.file_format = 'PNG' scene.render.filepath = path + # AgX would wash the orange columns toward tan (docs/VISUAL-STYLE.md) + scene.view_settings.view_transform = 'Standard' bpy.ops.render.render(write_still=True) return os.path.exists(path) and os.path.getsize(path) > 0 diff --git a/docs/gallery/gn-instance-grid/index.html b/docs/gallery/gn-instance-grid/index.html index ca9da74..a60b19e 100644 --- a/docs/gallery/gn-instance-grid/index.html +++ b/docs/gallery/gn-instance-grid/index.html @@ -365,8 +365,8 @@

Source

fmat = bpy.data.materials.new("Studio") fmat.use_nodes = True fb = fmat.node_tree.nodes["Principled BSDF"] - fb.inputs["Base Color"].default_value = (0.055, 0.06, 0.07, 1.0) - fb.inputs["Roughness"].default_value = 0.5 + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 floor_me.materials.append(fmat) floor = bpy.data.objects.new("Floor", floor_me) scene.collection.objects.link(floor) @@ -377,7 +377,7 @@

Source

world = bpy.data.worlds.new("World") world.use_nodes = True - world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.008, 0.009, 0.012, 1.0) + world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0) scene.world = world # tip the grid so depth reads; cubes already rest on the floor via the tree @@ -400,14 +400,23 @@

Source

lc.track_axis = 'TRACK_NEGATIVE_Z' lc.up_axis = 'UP_Y' - light("Key", (-3.5, -4.5, 5.5), 1500.0, 6.0, (1.0, 0.98, 0.94)) - light("Fill", (5.0, -3.5, 2.5), 340.0, 8.0, (0.8, 0.87, 1.0)) - light("Rim", (1.5, 4.5, 2.0), 480.0, 4.0, (1.0, 0.75, 0.45)) + # warm shaped key, faint cool fill, warm wedge on the back wall + # (docs/VISUAL-STYLE.md) + light("Key", (-3.5, -4.5, 5.5), 520.0, 4.5, (1.0, 0.96, 0.9)) + light("Fill", (5.0, -3.5, 2.5), 110.0, 8.0, (0.75, 0.85, 1.0)) + wedge = bpy.data.lights.new("Wedge", 'AREA') + wedge.energy = 400.0 + wedge.size = 6.0 + wedge.color = (1.0, 0.76, 0.5) + wob = bpy.data.objects.new("Wedge", wedge) + wob.location = (2.5, 5.5, 4.0) + wob.rotation_euler = (math.radians(-68), 0.0, math.radians(190)) + scene.collection.objects.link(wob) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (4.6, -5.4, 3.4) + cam.location = (3.7, -4.3, 2.7) scene.collection.objects.link(cam) scene.camera = cam track = cam.constraints.new('TRACK_TO') @@ -427,6 +436,8 @@

Source

scene.render.resolution_y = 720 scene.render.image_settings.file_format = 'PNG' scene.render.filepath = path + # AgX would wash the Lime instances toward sage (docs/VISUAL-STYLE.md) + scene.view_settings.view_transform = 'Standard' bpy.ops.render.render(write_still=True) return os.path.exists(path) and os.path.getsize(path) > 0 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 3a1a85f..91c6f88 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -363,7 +363,7 @@

damped-track-aim

color-attribute-wheel

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.

-

witnesses CORNER-domain attributes are sized to loop count, not vertex count; the check asserts that, that active_color points at it, and that the Attribute node is actually linked to Base Color, not just present.

+

witnesses 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.

View example
diff --git a/docs/gallery/shape-key-blend/index.html b/docs/gallery/shape-key-blend/index.html index 0fca64d..982165b 100644 --- a/docs/gallery/shape-key-blend/index.html +++ b/docs/gallery/shape-key-blend/index.html @@ -333,12 +333,27 @@

Source

mat.use_nodes = True bsdf = mat.node_tree.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = (0.52, 0.06, 0.98, 1.0) # violet - bsdf.inputs["Roughness"].default_value = 0.22 + bsdf.inputs["Roughness"].default_value = 0.35 obj.data.materials.clear() obj.data.materials.append(mat) - # bottom verts stay at -HALF; lift so the block rests on the floor - obj.location = (0.0, 0.0, HALF) - obj.rotation_euler = (0.0, 0.0, math.radians(28)) + + # the still shows the blend as a progression: Basis (0), half blend, and + # the checked BLEND value, left to right — one silhouette per key value, + # so a failed blend is visible as three identical boxes. The two extra + # blocks are render-only copies; the check already ran on `obj`. + blocks = [] + for value, x in ((0.0, -1.9), (BLEND / 2, 0.0), (None, 1.9)): + if value is None: + ob = obj + else: + me = obj.data.copy() + me.shape_keys.key_blocks["Tall"].value = value + ob = bpy.data.objects.new(f"ShapeBlock_{value}", me) + bpy.context.collection.objects.link(ob) + # bottom verts stay at -HALF; lift so each block rests on the floor + ob.location = (x, 0.0, HALF) + ob.rotation_euler = (0.0, 0.0, math.radians(28)) + blocks.append(ob) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() @@ -350,8 +365,8 @@

Source

fmat = bpy.data.materials.new("Studio") fmat.use_nodes = True fb = fmat.node_tree.nodes["Principled BSDF"] - fb.inputs["Base Color"].default_value = (0.055, 0.06, 0.07, 1.0) - fb.inputs["Roughness"].default_value = 0.5 + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 floor_me.materials.append(fmat) floor = bpy.data.objects.new("Floor", floor_me) scene.collection.objects.link(floor) @@ -362,7 +377,7 @@

Source

world = bpy.data.worlds.new("World") world.use_nodes = True - world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.008, 0.009, 0.012, 1.0) + world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0) scene.world = world world_top = HALF + EXPECT_TOP_Z @@ -383,14 +398,24 @@

Source

lc.track_axis = 'TRACK_NEGATIVE_Z' lc.up_axis = 'UP_Y' - light("Key", (-3.5, -4.5, 5.5), 1500.0, 6.0, (1.0, 0.98, 0.94)) - light("Fill", (5.0, -3.5, 2.5), 340.0, 8.0, (0.8, 0.87, 1.0)) - light("Rim", (1.5, 4.5, 2.0), 480.0, 4.0, (1.0, 0.75, 0.45)) + # shaped warm key, faint cool fill, cool rim, warm wedge pooling on the + # back wall (docs/VISUAL-STYLE.md) + light("Key", (-3.5, -4.5, 5.5), 520.0, 4.5, (1.0, 0.96, 0.9)) + light("Fill", (5.0, -3.5, 2.5), 110.0, 8.0, (0.75, 0.85, 1.0)) + light("Rim", (0.5, 4.5, 4.5), 300.0, 4.0, (0.6, 0.78, 1.0)) + wedge = bpy.data.lights.new("Wedge", 'AREA') + wedge.energy = 380.0 + wedge.size = 6.0 + wedge.color = (1.0, 0.76, 0.5) + wob = bpy.data.objects.new("Wedge", wedge) + wob.location = (2.5, 5.5, 4.0) + wob.rotation_euler = (math.radians(-68), 0.0, math.radians(190)) + scene.collection.objects.link(wob) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (3.6, -4.8, 2.4) + cam.location = (0.0, -8.8, 2.05) scene.collection.objects.link(cam) scene.camera = cam track = cam.constraints.new('TRACK_TO') @@ -409,6 +434,8 @@

Source

scene.render.resolution_x = 1280 scene.render.resolution_y = 720 scene.render.image_settings.file_format = 'PNG' + # AgX would wash the violet toward pastel grey (docs/VISUAL-STYLE.md) + scene.view_settings.view_transform = 'Standard' scene.render.filepath = path bpy.ops.render.render(write_still=True) return os.path.exists(path) and os.path.getsize(path) > 0 diff --git a/docs/gallery/swatch-grid/index.html b/docs/gallery/swatch-grid/index.html index 7815bda..9b3eae9 100644 --- a/docs/gallery/swatch-grid/index.html +++ b/docs/gallery/swatch-grid/index.html @@ -287,24 +287,29 @@

Source

"""Return a list of (material, label) covering metal, dielectric, emissive, and the set_specular shim. The list order maps left-to-right, top-to-bottom across the grid.""" mats, specular_socket = [], None - m, specular_socket = make_principled("Gold", (1.00, 0.77, 0.34, 1), 1.0, 0.15) + # polished vs brushed: the roughness gap is what separates the two metals + # on the dark stage -- their sampled patch means must stay > 0.10 apart + # for verify_png, and a mirror finish vs a broad soft highlight does it + m, specular_socket = make_principled("Gold", (1.00, 0.77, 0.34, 1), 1.0, 0.08) mats.append(m) - m, _ = make_principled("Copper", (0.95, 0.64, 0.54, 1), 1.0, 0.28) + m, _ = make_principled("Copper", (0.92, 0.47, 0.36, 1), 1.0, 0.55) mats.append(m) m, sr = make_principled("RedPlastic", (0.80, 0.05, 0.05, 1), 0.0, 0.40, specular=0.5) mats.append(m) specular_socket = specular_socket or sr m, _ = make_principled("BluePlastic", (0.05, 0.20, 0.80, 1), 0.0, 0.30, specular=0.5) mats.append(m) - mats.append(make_emissive("EmissiveOrange", (1.0, 0.35, 0.05, 1), 6.0)) + # strength stays low enough that the Standard view transform keeps the + # hue: at 6.0 every channel clips and the swatch reads white-yellow + mats.append(make_emissive("EmissiveOrange", (1.0, 0.35, 0.05, 1), 1.4)) m, _ = make_principled("WhiteRough", (0.90, 0.90, 0.92, 1), 0.0, 0.70, specular=0.3) mats.append(m) return mats, specular_socket def build_scene(mats): - xs = [-2.2, 0.0, 2.2] - zs = [1.1, -1.1] + xs = [-2.3, 0.0, 2.3] + zs = [1.15, -1.15] i = 0 for r in range(GRID_ROWS): for c in range(GRID_COLS): @@ -322,21 +327,47 @@

Source

bpy.context.collection.objects.link(ob) ob.data.materials.append(mats[i]) i += 1 - # ortho camera framed exactly on the grid cells + # ortho camera framed on the grid cells: verify_png() samples the image at + # each cell center, so every sphere must stay inside its third/half of the + # frame. ortho height = ortho_scale * 9/16 = 4.39 units for a grid that + # spans 4.14 -- nothing clips, nothing drifts out of its cell. cam_d = bpy.data.cameras.new("cam") cam_d.type = 'ORTHO' - cam_d.ortho_scale = 6.6 + cam_d.ortho_scale = 7.8 cam = bpy.data.objects.new("cam", cam_d) cam.location = (0.0, -10.0, 0.0) cam.rotation_euler = (math.radians(90), 0, 0) bpy.context.collection.objects.link(cam) bpy.context.scene.camera = cam + + # dark staged backdrop behind the grid (docs/VISUAL-STYLE.md) + wall_me = bpy.data.meshes.new("Wall") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0) + bm.to_mesh(wall_me) + finally: + bm.free() + wmat, _ = make_principled("Studio", (0.03, 0.032, 0.037, 1), 0.0, 0.7) + wall_me.materials.append(wmat) + wall = bpy.data.objects.new("Wall", wall_me) + wall.location = (0.0, 2.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + bpy.context.collection.objects.link(wall) + aim = bpy.data.objects.new("Aim", None) bpy.context.collection.objects.link(aim) - for lname, loc, energy in [("KeyL", (-5, -6, 4), 1500), ("FillL", (5, -6, -2), 700)]: + # warm shaped key, faint cool fill, and a warm wedge pooling on the + # backdrop -- the gallery's staged look + for lname, loc, energy, size, col in [ + ("KeyL", (-5, -6, 4), 900, 5.0, (1.0, 0.96, 0.9)), + ("FillL", (5, -6, -2), 220, 6.0, (0.75, 0.85, 1.0)), + ("WedgeL", (2.6, 1.3, 4.4), 260, 5.0, (1.0, 0.76, 0.5)), + ]: ld = bpy.data.lights.new(lname, 'AREA') ld.energy = energy - ld.size = 5.0 + ld.size = size + ld.color = col lo = bpy.data.objects.new(lname, ld) lo.location = loc bpy.context.collection.objects.link(lo) @@ -344,9 +375,12 @@

Source

con.target = aim con.track_axis = 'TRACK_NEGATIVE_Z' con.up_axis = 'UP_Y' + # the wedge aims past the spheres at the wall, not at the grid + bpy.data.objects["WedgeL"].constraints.clear() + bpy.data.objects["WedgeL"].rotation_euler = (math.radians(-56), 0.0, math.radians(190)) world = bpy.data.worlds.new("W") world.use_nodes = True - world.node_tree.nodes["Background"].inputs[0].default_value = (0.03, 0.03, 0.035, 1) + world.node_tree.nodes["Background"].inputs[0].default_value = (0.02, 0.021, 0.025, 1) bpy.context.scene.world = world @@ -416,6 +450,9 @@

Source

sc.render.resolution_y = int(args.width * 9 / 16) sc.render.image_settings.file_format = 'PNG' sc.render.filepath = args.output + # AgX would desaturate the swatches toward pastel -- exactly the material + # colors this example exists to show (docs/VISUAL-STYLE.md) + sc.view_settings.view_transform = 'Standard' os.makedirs(os.path.dirname(os.path.abspath(args.output)) or ".", exist_ok=True) bpy.ops.render.render(write_still=True) if not (os.path.exists(args.output) and os.path.getsize(args.output) > 0): diff --git a/docs/gallery/turntable/index.html b/docs/gallery/turntable/index.html index c59753c..9a0b11d 100644 --- a/docs/gallery/turntable/index.html +++ b/docs/gallery/turntable/index.html @@ -277,17 +277,31 @@

Source

bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme) finally: bm.free() + # dark staged studio: shared floor/wall material, warm shaped key, faint + # cool fill, cool rim, warm wedge on the back wall (docs/VISUAL-STYLE.md) + fmat = bpy.data.materials.new("Studio"); fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + fme.materials.append(fmat) floor = bpy.data.objects.new("Floor", fme); bpy.context.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", fme.copy()); wall.location = (0, 9.0, 0) + wall.rotation_euler = (math.radians(90), 0, 0); bpy.context.collection.objects.link(wall) w = bpy.data.worlds.new("W"); w.use_nodes = True - w.node_tree.nodes["Background"].inputs[0].default_value = (0.04, 0.05, 0.07, 1); sc.world = w + w.node_tree.nodes["Background"].inputs[0].default_value = (0.02, 0.021, 0.025, 1); sc.world = w aim = bpy.data.objects.new("Aim", None); aim.location = (0, 0, 1.0); bpy.context.collection.objects.link(aim) - cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")); cam.location = (0, -7, 3.0) + cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")); cam.location = (0, -5.9, 2.4) bpy.context.collection.objects.link(cam); sc.camera = cam c = cam.constraints.new('TRACK_TO'); c.target = aim; c.track_axis = 'TRACK_NEGATIVE_Z'; c.up_axis = 'UP_Y' - for nm, loc, en in [("K", (-4, -5, 7), 900), ("F2", (5, -4, 2), 350)]: - ld = bpy.data.lights.new(nm, 'AREA'); ld.energy = en; ld.size = 5.0 + for nm, loc, en, size, col in [("K", (-4, -5, 7), 620, 5.0, (1.0, 0.96, 0.9)), + ("F2", (5, -4, 2), 130, 8.0, (0.75, 0.85, 1.0)), + ("R", (0.5, 4.5, 4.0), 300, 4.0, (0.6, 0.78, 1.0))]: + ld = bpy.data.lights.new(nm, 'AREA'); ld.energy = en; ld.size = size; ld.color = col lo = bpy.data.objects.new(nm, ld); lo.location = loc; bpy.context.collection.objects.link(lo) lc = lo.constraints.new('TRACK_TO'); lc.target = aim; lc.track_axis = 'TRACK_NEGATIVE_Z'; lc.up_axis = 'UP_Y' + wd = bpy.data.lights.new("Wedge", 'AREA'); wd.energy = 380; wd.size = 6.0; wd.color = (1.0, 0.76, 0.5) + wo = bpy.data.objects.new("Wedge", wd); wo.location = (2.5, 5.5, 4.0) + wo.rotation_euler = (math.radians(-68), 0, math.radians(190)); bpy.context.collection.objects.link(wo) sc.render.engine = 'CYCLES' if engine == 'cycles' else get_eevee_engine_id() if sc.render.engine == 'CYCLES': try: sc.cycles.samples = 16 @@ -298,6 +312,8 @@

Source

sc.frame_set(FRAMES // 4) sc.render.resolution_x = 1280; sc.render.resolution_y = 720 sc.render.image_settings.file_format = 'PNG'; sc.render.filepath = path + # AgX would wash the copper toward beige (docs/VISUAL-STYLE.md) + sc.view_settings.view_transform = 'Standard' bpy.ops.render.render(write_still=True) return os.path.exists(path) and os.path.getsize(path) > 0 diff --git a/examples/curve-bevel-arc/curve_bevel_arc.py b/examples/curve-bevel-arc/curve_bevel_arc.py index 132ad61..7ffe57a 100644 --- a/examples/curve-bevel-arc/curve_bevel_arc.py +++ b/examples/curve-bevel-arc/curve_bevel_arc.py @@ -133,8 +133,8 @@ def render_still(obj, path, engine): fmat = bpy.data.materials.new("Studio") fmat.use_nodes = True fb = fmat.node_tree.nodes["Principled BSDF"] - fb.inputs["Base Color"].default_value = (0.055, 0.06, 0.07, 1.0) - fb.inputs["Roughness"].default_value = 0.5 + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 floor_me.materials.append(fmat) floor = bpy.data.objects.new("Floor", floor_me) scene.collection.objects.link(floor) @@ -145,7 +145,7 @@ def render_still(obj, path, engine): world = bpy.data.worlds.new("World") world.use_nodes = True - world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.008, 0.009, 0.012, 1.0) + world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0) scene.world = world aim = bpy.data.objects.new("Aim", None) @@ -165,14 +165,23 @@ def light(name, loc, energy, size, col): lc.track_axis = 'TRACK_NEGATIVE_Z' lc.up_axis = 'UP_Y' - light("Key", (-3.5, -4.5, 5.5), 1500.0, 6.0, (1.0, 0.98, 0.94)) - light("Fill", (5.0, -3.5, 2.5), 340.0, 8.0, (0.8, 0.87, 1.0)) - light("Rim", (1.5, 4.5, 2.0), 480.0, 4.0, (1.0, 0.75, 0.45)) + # warm shaped key, faint cool fill, warm wedge on the back wall + # (docs/VISUAL-STYLE.md) + light("Key", (-3.5, -4.5, 5.5), 550.0, 4.5, (1.0, 0.96, 0.9)) + light("Fill", (5.0, -3.5, 2.5), 110.0, 8.0, (0.75, 0.85, 1.0)) + wedge = bpy.data.lights.new("Wedge", 'AREA') + wedge.energy = 380.0 + wedge.size = 6.0 + wedge.color = (1.0, 0.76, 0.5) + wob = bpy.data.objects.new("Wedge", wedge) + wob.location = (2.5, 5.5, 4.0) + wob.rotation_euler = (math.radians(-68), 0.0, math.radians(190)) + scene.collection.objects.link(wob) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (3.0, -4.2, 2.6) + cam.location = (2.4, -3.4, 1.7) scene.collection.objects.link(cam) scene.camera = cam track = cam.constraints.new('TRACK_TO') @@ -192,6 +201,8 @@ def light(name, loc, energy, size, col): scene.render.resolution_y = 720 scene.render.image_settings.file_format = 'PNG' scene.render.filepath = path + # AgX would wash the rose tube toward salmon (docs/VISUAL-STYLE.md) + scene.view_settings.view_transform = 'Standard' bpy.ops.render.render(write_still=True) return os.path.exists(path) and os.path.getsize(path) > 0 diff --git a/examples/curve-bevel-arc/preview.webp b/examples/curve-bevel-arc/preview.webp index b8c1bcc..d4c7e37 100644 Binary files a/examples/curve-bevel-arc/preview.webp and b/examples/curve-bevel-arc/preview.webp differ diff --git a/examples/depsgraph-export/depsgraph_export.py b/examples/depsgraph-export/depsgraph_export.py index d0cbcf6..8206c36 100644 --- a/examples/depsgraph-export/depsgraph_export.py +++ b/examples/depsgraph-export/depsgraph_export.py @@ -109,12 +109,12 @@ def render_still(obj, path, engine): bm.to_mesh(floor_me) finally: bm.free() - floor_me.materials.append(principled("Studio", (0.24, 0.24, 0.25, 1.0), 0.0, 0.6)) + floor_me.materials.append(principled("Studio", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7)) floor = bpy.data.objects.new("Floor", floor_me) scene.collection.objects.link(floor) wall = bpy.data.objects.new("Wall", floor_me.copy()) wall.data.materials.clear() - wall.data.materials.append(principled("Wall", (0.045, 0.05, 0.055, 1.0), 0.0, 0.6)) + wall.data.materials.append(principled("Wall", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7)) wall.location = (0.0, 9.0, 0.0) wall.rotation_euler = (math.pi / 2, 0.0, 0.0) scene.collection.objects.link(wall) @@ -122,7 +122,7 @@ def render_still(obj, path, engine): world = bpy.data.worlds.new("World") world.use_nodes = True world.node_tree.nodes["Background"].inputs["Color"].default_value = \ - (0.035, 0.04, 0.05, 1.0) + (0.02, 0.021, 0.025, 1.0) scene.world = world def light(name, loc, energy, size, col, rot): @@ -133,9 +133,11 @@ def light(name, loc, energy, size, col, rot): ob.rotation_euler = tuple(math.radians(a) for a in rot) scene.collection.objects.link(ob) - light("Key", (-4.0, -5.0, 6.0), 1000.0, 8.0, (1.0, 0.99, 0.96), (46, 0, -35)) - light("Fill", (5.0, -3.5, 3.0), 350.0, 9.0, (0.85, 0.9, 1.0), (62, 0, 50)) - light("Rim", (1.5, 5.0, 4.0), 120.0, 6.0, (1.0, 0.85, 0.65), (-70, 0, 165)) + # warm shaped key, faint cool fill, warm wedge on the back wall + # (docs/VISUAL-STYLE.md) + light("Key", (-4.0, -5.0, 6.0), 650.0, 5.0, (1.0, 0.96, 0.9), (46, 0, -35)) + light("Fill", (5.0, -3.5, 3.0), 120.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50)) + light("Wedge", (2.5, 5.5, 4.0), 380.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190)) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 42.0 @@ -157,6 +159,9 @@ def light(name, loc, energy, size, col, rot): scene.render.resolution_y = 720 scene.render.image_settings.file_format = 'PNG' scene.render.filepath = path + # AgX would flatten the graphite-vs-green contrast this still hinges on + # (docs/VISUAL-STYLE.md) + scene.view_settings.view_transform = 'Standard' bpy.ops.render.render(write_still=True) return os.path.exists(path) and os.path.getsize(path) > 0 diff --git a/examples/depsgraph-export/preview.webp b/examples/depsgraph-export/preview.webp index 9504a0a..3e1f10f 100644 Binary files a/examples/depsgraph-export/preview.webp and b/examples/depsgraph-export/preview.webp differ diff --git a/examples/driver-wave/driver_wave.py b/examples/driver-wave/driver_wave.py index 1528d78..f7a4f0f 100644 --- a/examples/driver-wave/driver_wave.py +++ b/examples/driver-wave/driver_wave.py @@ -105,8 +105,8 @@ def render_still(objs, path, engine): fmat = bpy.data.materials.new("FloorMat") fmat.use_nodes = True fb = fmat.node_tree.nodes["Principled BSDF"] - fb.inputs["Base Color"].default_value = (0.055, 0.06, 0.07, 1.0) # dark graphite studio - fb.inputs["Roughness"].default_value = 0.5 + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) # dark staged studio + fb.inputs["Roughness"].default_value = 0.7 floor_me.materials.append(fmat) scene.collection.objects.link(floor) wall = bpy.data.objects.new("Wall", floor_me.copy()) @@ -116,32 +116,36 @@ def render_still(objs, path, engine): world = bpy.data.worlds.new("World") world.use_nodes = True - world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.008, 0.009, 0.012, 1.0) + world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0) scene.world = world - key = bpy.data.lights.new("Key", 'AREA'); key.energy = 1600.0; key.size = 5.0 - key.color = (1.0, 0.97, 0.92) + # warm shaped key, faint cool fill, warm wedge pooling on the back wall + # (docs/VISUAL-STYLE.md) + key = bpy.data.lights.new("Key", 'AREA'); key.energy = 650.0; key.size = 4.5 + key.color = (1.0, 0.96, 0.9) key_ob = bpy.data.objects.new("Key", key) key_ob.location = (-4.5, -5.5, 6.5) key_ob.rotation_euler = (math.radians(46), 0.0, math.radians(-33)) scene.collection.objects.link(key_ob) - fill = bpy.data.lights.new("Fill", 'AREA'); fill.energy = 260.0; fill.size = 8.0 - fill.color = (0.8, 0.87, 1.0) + fill = bpy.data.lights.new("Fill", 'AREA'); fill.energy = 110.0; fill.size = 8.0 + fill.color = (0.75, 0.85, 1.0) fill_ob = bpy.data.objects.new("Fill", fill) fill_ob.location = (5.5, -4.0, 3.5) fill_ob.rotation_euler = (math.radians(62), 0.0, math.radians(48)) scene.collection.objects.link(fill_ob) - rim = bpy.data.lights.new("Rim", 'AREA'); rim.energy = 480.0; rim.size = 5.0 - rim.color = (0.75, 0.85, 1.0) - rim_ob = bpy.data.objects.new("Rim", rim) - rim_ob.location = (0.0, 6.5, 3.0) - rim_ob.rotation_euler = (math.radians(-78), 0.0, math.radians(180)) - scene.collection.objects.link(rim_ob) - - cam_data = bpy.data.cameras.new("Cam"); cam_data.lens = 45.0 + wedge = bpy.data.lights.new("Wedge", 'AREA'); wedge.energy = 380.0; wedge.size = 6.0 + wedge.color = (1.0, 0.76, 0.5) + wedge_ob = bpy.data.objects.new("Wedge", wedge) + wedge_ob.location = (2.5, 6.0, 4.0) + wedge_ob.rotation_euler = (math.radians(-68), 0.0, math.radians(190)) + scene.collection.objects.link(wedge_ob) + + # lens/distance chosen so all sixteen columns (span 11.1 units) sit inside + # the frame with a small margin -- the skyline must not clip + cam_data = bpy.data.cameras.new("Cam"); cam_data.lens = 42.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (0.0, -13.0, 2.4) - cam.rotation_euler = (math.radians(83), 0.0, 0.0) + cam.location = (0.0, -13.5, 2.0) + cam.rotation_euler = (math.radians(86), 0.0, 0.0) scene.collection.objects.link(cam) scene.camera = cam @@ -157,6 +161,8 @@ def render_still(objs, path, engine): scene.render.resolution_y = 720 scene.render.image_settings.file_format = 'PNG' scene.render.filepath = path + # AgX would wash the orange columns toward tan (docs/VISUAL-STYLE.md) + scene.view_settings.view_transform = 'Standard' bpy.ops.render.render(write_still=True) return os.path.exists(path) and os.path.getsize(path) > 0 diff --git a/examples/driver-wave/preview.webp b/examples/driver-wave/preview.webp index be45953..6922999 100644 Binary files a/examples/driver-wave/preview.webp and b/examples/driver-wave/preview.webp differ diff --git a/examples/gallery.json b/examples/gallery.json index 0df1c92..83694a0 100644 --- a/examples/gallery.json +++ b/examples/gallery.json @@ -176,7 +176,7 @@ "name": "color-attribute-wheel", "dir": "examples/color-attribute-wheel", "teaches": "The modern color-attributes API \u2014 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.", - "witnessesFix": "CORNER-domain attributes are sized to loop count, not vertex count; the check asserts that, that active_color points at it, and that the Attribute node is actually linked to Base Color, not just present.", + "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.", "hero": "docs/gallery/assets/color-attribute-wheel-hero.webp", "preview": "examples/color-attribute-wheel/preview.webp", "tags": [ diff --git a/examples/gn-instance-grid/gn_instance_grid.py b/examples/gn-instance-grid/gn_instance_grid.py index 462a855..8897932 100644 --- a/examples/gn-instance-grid/gn_instance_grid.py +++ b/examples/gn-instance-grid/gn_instance_grid.py @@ -163,8 +163,8 @@ def render_still(obj, path, engine): fmat = bpy.data.materials.new("Studio") fmat.use_nodes = True fb = fmat.node_tree.nodes["Principled BSDF"] - fb.inputs["Base Color"].default_value = (0.055, 0.06, 0.07, 1.0) - fb.inputs["Roughness"].default_value = 0.5 + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 floor_me.materials.append(fmat) floor = bpy.data.objects.new("Floor", floor_me) scene.collection.objects.link(floor) @@ -175,7 +175,7 @@ def render_still(obj, path, engine): world = bpy.data.worlds.new("World") world.use_nodes = True - world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.008, 0.009, 0.012, 1.0) + world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0) scene.world = world # tip the grid so depth reads; cubes already rest on the floor via the tree @@ -198,14 +198,23 @@ def light(name, loc, energy, size, col): lc.track_axis = 'TRACK_NEGATIVE_Z' lc.up_axis = 'UP_Y' - light("Key", (-3.5, -4.5, 5.5), 1500.0, 6.0, (1.0, 0.98, 0.94)) - light("Fill", (5.0, -3.5, 2.5), 340.0, 8.0, (0.8, 0.87, 1.0)) - light("Rim", (1.5, 4.5, 2.0), 480.0, 4.0, (1.0, 0.75, 0.45)) + # warm shaped key, faint cool fill, warm wedge on the back wall + # (docs/VISUAL-STYLE.md) + light("Key", (-3.5, -4.5, 5.5), 520.0, 4.5, (1.0, 0.96, 0.9)) + light("Fill", (5.0, -3.5, 2.5), 110.0, 8.0, (0.75, 0.85, 1.0)) + wedge = bpy.data.lights.new("Wedge", 'AREA') + wedge.energy = 400.0 + wedge.size = 6.0 + wedge.color = (1.0, 0.76, 0.5) + wob = bpy.data.objects.new("Wedge", wedge) + wob.location = (2.5, 5.5, 4.0) + wob.rotation_euler = (math.radians(-68), 0.0, math.radians(190)) + scene.collection.objects.link(wob) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (4.6, -5.4, 3.4) + cam.location = (3.7, -4.3, 2.7) scene.collection.objects.link(cam) scene.camera = cam track = cam.constraints.new('TRACK_TO') @@ -225,6 +234,8 @@ def light(name, loc, energy, size, col): scene.render.resolution_y = 720 scene.render.image_settings.file_format = 'PNG' scene.render.filepath = path + # AgX would wash the Lime instances toward sage (docs/VISUAL-STYLE.md) + scene.view_settings.view_transform = 'Standard' bpy.ops.render.render(write_still=True) return os.path.exists(path) and os.path.getsize(path) > 0 diff --git a/examples/gn-instance-grid/preview.webp b/examples/gn-instance-grid/preview.webp index 94a9c35..6bf4e1b 100644 Binary files a/examples/gn-instance-grid/preview.webp and b/examples/gn-instance-grid/preview.webp differ diff --git a/examples/shape-key-blend/preview.webp b/examples/shape-key-blend/preview.webp index 1444329..e043ea0 100644 Binary files a/examples/shape-key-blend/preview.webp and b/examples/shape-key-blend/preview.webp differ diff --git a/examples/shape-key-blend/shape_key_blend.py b/examples/shape-key-blend/shape_key_blend.py index baa9d5a..5bc58dc 100644 --- a/examples/shape-key-blend/shape_key_blend.py +++ b/examples/shape-key-blend/shape_key_blend.py @@ -131,12 +131,27 @@ def render_still(obj, path, engine): mat.use_nodes = True bsdf = mat.node_tree.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = (0.52, 0.06, 0.98, 1.0) # violet - bsdf.inputs["Roughness"].default_value = 0.22 + bsdf.inputs["Roughness"].default_value = 0.35 obj.data.materials.clear() obj.data.materials.append(mat) - # bottom verts stay at -HALF; lift so the block rests on the floor - obj.location = (0.0, 0.0, HALF) - obj.rotation_euler = (0.0, 0.0, math.radians(28)) + + # the still shows the blend as a progression: Basis (0), half blend, and + # the checked BLEND value, left to right — one silhouette per key value, + # so a failed blend is visible as three identical boxes. The two extra + # blocks are render-only copies; the check already ran on `obj`. + blocks = [] + for value, x in ((0.0, -1.9), (BLEND / 2, 0.0), (None, 1.9)): + if value is None: + ob = obj + else: + me = obj.data.copy() + me.shape_keys.key_blocks["Tall"].value = value + ob = bpy.data.objects.new(f"ShapeBlock_{value}", me) + bpy.context.collection.objects.link(ob) + # bottom verts stay at -HALF; lift so each block rests on the floor + ob.location = (x, 0.0, HALF) + ob.rotation_euler = (0.0, 0.0, math.radians(28)) + blocks.append(ob) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() @@ -148,8 +163,8 @@ def render_still(obj, path, engine): fmat = bpy.data.materials.new("Studio") fmat.use_nodes = True fb = fmat.node_tree.nodes["Principled BSDF"] - fb.inputs["Base Color"].default_value = (0.055, 0.06, 0.07, 1.0) - fb.inputs["Roughness"].default_value = 0.5 + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 floor_me.materials.append(fmat) floor = bpy.data.objects.new("Floor", floor_me) scene.collection.objects.link(floor) @@ -160,7 +175,7 @@ def render_still(obj, path, engine): world = bpy.data.worlds.new("World") world.use_nodes = True - world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.008, 0.009, 0.012, 1.0) + world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0) scene.world = world world_top = HALF + EXPECT_TOP_Z @@ -181,14 +196,24 @@ def light(name, loc, energy, size, col): lc.track_axis = 'TRACK_NEGATIVE_Z' lc.up_axis = 'UP_Y' - light("Key", (-3.5, -4.5, 5.5), 1500.0, 6.0, (1.0, 0.98, 0.94)) - light("Fill", (5.0, -3.5, 2.5), 340.0, 8.0, (0.8, 0.87, 1.0)) - light("Rim", (1.5, 4.5, 2.0), 480.0, 4.0, (1.0, 0.75, 0.45)) + # shaped warm key, faint cool fill, cool rim, warm wedge pooling on the + # back wall (docs/VISUAL-STYLE.md) + light("Key", (-3.5, -4.5, 5.5), 520.0, 4.5, (1.0, 0.96, 0.9)) + light("Fill", (5.0, -3.5, 2.5), 110.0, 8.0, (0.75, 0.85, 1.0)) + light("Rim", (0.5, 4.5, 4.5), 300.0, 4.0, (0.6, 0.78, 1.0)) + wedge = bpy.data.lights.new("Wedge", 'AREA') + wedge.energy = 380.0 + wedge.size = 6.0 + wedge.color = (1.0, 0.76, 0.5) + wob = bpy.data.objects.new("Wedge", wedge) + wob.location = (2.5, 5.5, 4.0) + wob.rotation_euler = (math.radians(-68), 0.0, math.radians(190)) + scene.collection.objects.link(wob) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (3.6, -4.8, 2.4) + cam.location = (0.0, -8.8, 2.05) scene.collection.objects.link(cam) scene.camera = cam track = cam.constraints.new('TRACK_TO') @@ -207,6 +232,8 @@ def light(name, loc, energy, size, col): scene.render.resolution_x = 1280 scene.render.resolution_y = 720 scene.render.image_settings.file_format = 'PNG' + # AgX would wash the violet toward pastel grey (docs/VISUAL-STYLE.md) + scene.view_settings.view_transform = 'Standard' scene.render.filepath = path bpy.ops.render.render(write_still=True) return os.path.exists(path) and os.path.getsize(path) > 0 diff --git a/examples/swatch-grid/preview.webp b/examples/swatch-grid/preview.webp index c351409..45e49d6 100644 Binary files a/examples/swatch-grid/preview.webp and b/examples/swatch-grid/preview.webp differ diff --git a/examples/swatch-grid/swatch_grid.py b/examples/swatch-grid/swatch_grid.py index 85fc274..f9c115b 100644 --- a/examples/swatch-grid/swatch_grid.py +++ b/examples/swatch-grid/swatch_grid.py @@ -79,24 +79,29 @@ def build_materials(): """Return a list of (material, label) covering metal, dielectric, emissive, and the set_specular shim. The list order maps left-to-right, top-to-bottom across the grid.""" mats, specular_socket = [], None - m, specular_socket = make_principled("Gold", (1.00, 0.77, 0.34, 1), 1.0, 0.15) + # polished vs brushed: the roughness gap is what separates the two metals + # on the dark stage -- their sampled patch means must stay > 0.10 apart + # for verify_png, and a mirror finish vs a broad soft highlight does it + m, specular_socket = make_principled("Gold", (1.00, 0.77, 0.34, 1), 1.0, 0.08) mats.append(m) - m, _ = make_principled("Copper", (0.95, 0.64, 0.54, 1), 1.0, 0.28) + m, _ = make_principled("Copper", (0.92, 0.47, 0.36, 1), 1.0, 0.55) mats.append(m) m, sr = make_principled("RedPlastic", (0.80, 0.05, 0.05, 1), 0.0, 0.40, specular=0.5) mats.append(m) specular_socket = specular_socket or sr m, _ = make_principled("BluePlastic", (0.05, 0.20, 0.80, 1), 0.0, 0.30, specular=0.5) mats.append(m) - mats.append(make_emissive("EmissiveOrange", (1.0, 0.35, 0.05, 1), 6.0)) + # strength stays low enough that the Standard view transform keeps the + # hue: at 6.0 every channel clips and the swatch reads white-yellow + mats.append(make_emissive("EmissiveOrange", (1.0, 0.35, 0.05, 1), 1.4)) m, _ = make_principled("WhiteRough", (0.90, 0.90, 0.92, 1), 0.0, 0.70, specular=0.3) mats.append(m) return mats, specular_socket def build_scene(mats): - xs = [-2.2, 0.0, 2.2] - zs = [1.1, -1.1] + xs = [-2.3, 0.0, 2.3] + zs = [1.15, -1.15] i = 0 for r in range(GRID_ROWS): for c in range(GRID_COLS): @@ -114,21 +119,47 @@ def build_scene(mats): bpy.context.collection.objects.link(ob) ob.data.materials.append(mats[i]) i += 1 - # ortho camera framed exactly on the grid cells + # ortho camera framed on the grid cells: verify_png() samples the image at + # each cell center, so every sphere must stay inside its third/half of the + # frame. ortho height = ortho_scale * 9/16 = 4.39 units for a grid that + # spans 4.14 -- nothing clips, nothing drifts out of its cell. cam_d = bpy.data.cameras.new("cam") cam_d.type = 'ORTHO' - cam_d.ortho_scale = 6.6 + cam_d.ortho_scale = 7.8 cam = bpy.data.objects.new("cam", cam_d) cam.location = (0.0, -10.0, 0.0) cam.rotation_euler = (math.radians(90), 0, 0) bpy.context.collection.objects.link(cam) bpy.context.scene.camera = cam + + # dark staged backdrop behind the grid (docs/VISUAL-STYLE.md) + wall_me = bpy.data.meshes.new("Wall") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0) + bm.to_mesh(wall_me) + finally: + bm.free() + wmat, _ = make_principled("Studio", (0.03, 0.032, 0.037, 1), 0.0, 0.7) + wall_me.materials.append(wmat) + wall = bpy.data.objects.new("Wall", wall_me) + wall.location = (0.0, 2.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + bpy.context.collection.objects.link(wall) + aim = bpy.data.objects.new("Aim", None) bpy.context.collection.objects.link(aim) - for lname, loc, energy in [("KeyL", (-5, -6, 4), 1500), ("FillL", (5, -6, -2), 700)]: + # warm shaped key, faint cool fill, and a warm wedge pooling on the + # backdrop -- the gallery's staged look + for lname, loc, energy, size, col in [ + ("KeyL", (-5, -6, 4), 900, 5.0, (1.0, 0.96, 0.9)), + ("FillL", (5, -6, -2), 220, 6.0, (0.75, 0.85, 1.0)), + ("WedgeL", (2.6, 1.3, 4.4), 260, 5.0, (1.0, 0.76, 0.5)), + ]: ld = bpy.data.lights.new(lname, 'AREA') ld.energy = energy - ld.size = 5.0 + ld.size = size + ld.color = col lo = bpy.data.objects.new(lname, ld) lo.location = loc bpy.context.collection.objects.link(lo) @@ -136,9 +167,12 @@ def build_scene(mats): con.target = aim con.track_axis = 'TRACK_NEGATIVE_Z' con.up_axis = 'UP_Y' + # the wedge aims past the spheres at the wall, not at the grid + bpy.data.objects["WedgeL"].constraints.clear() + bpy.data.objects["WedgeL"].rotation_euler = (math.radians(-56), 0.0, math.radians(190)) world = bpy.data.worlds.new("W") world.use_nodes = True - world.node_tree.nodes["Background"].inputs[0].default_value = (0.03, 0.03, 0.035, 1) + world.node_tree.nodes["Background"].inputs[0].default_value = (0.02, 0.021, 0.025, 1) bpy.context.scene.world = world @@ -208,6 +242,9 @@ def main(): sc.render.resolution_y = int(args.width * 9 / 16) sc.render.image_settings.file_format = 'PNG' sc.render.filepath = args.output + # AgX would desaturate the swatches toward pastel -- exactly the material + # colors this example exists to show (docs/VISUAL-STYLE.md) + sc.view_settings.view_transform = 'Standard' os.makedirs(os.path.dirname(os.path.abspath(args.output)) or ".", exist_ok=True) bpy.ops.render.render(write_still=True) if not (os.path.exists(args.output) and os.path.getsize(args.output) > 0): diff --git a/examples/turntable/preview.webp b/examples/turntable/preview.webp index 77cb288..c893dbf 100644 Binary files a/examples/turntable/preview.webp and b/examples/turntable/preview.webp differ diff --git a/examples/turntable/turntable.py b/examples/turntable/turntable.py index 9c062dd..c5f4fb7 100644 --- a/examples/turntable/turntable.py +++ b/examples/turntable/turntable.py @@ -75,17 +75,31 @@ def render_still(obj, path, engine): bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme) finally: bm.free() + # dark staged studio: shared floor/wall material, warm shaped key, faint + # cool fill, cool rim, warm wedge on the back wall (docs/VISUAL-STYLE.md) + fmat = bpy.data.materials.new("Studio"); fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + fme.materials.append(fmat) floor = bpy.data.objects.new("Floor", fme); bpy.context.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", fme.copy()); wall.location = (0, 9.0, 0) + wall.rotation_euler = (math.radians(90), 0, 0); bpy.context.collection.objects.link(wall) w = bpy.data.worlds.new("W"); w.use_nodes = True - w.node_tree.nodes["Background"].inputs[0].default_value = (0.04, 0.05, 0.07, 1); sc.world = w + w.node_tree.nodes["Background"].inputs[0].default_value = (0.02, 0.021, 0.025, 1); sc.world = w aim = bpy.data.objects.new("Aim", None); aim.location = (0, 0, 1.0); bpy.context.collection.objects.link(aim) - cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")); cam.location = (0, -7, 3.0) + cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")); cam.location = (0, -5.9, 2.4) bpy.context.collection.objects.link(cam); sc.camera = cam c = cam.constraints.new('TRACK_TO'); c.target = aim; c.track_axis = 'TRACK_NEGATIVE_Z'; c.up_axis = 'UP_Y' - for nm, loc, en in [("K", (-4, -5, 7), 900), ("F2", (5, -4, 2), 350)]: - ld = bpy.data.lights.new(nm, 'AREA'); ld.energy = en; ld.size = 5.0 + for nm, loc, en, size, col in [("K", (-4, -5, 7), 620, 5.0, (1.0, 0.96, 0.9)), + ("F2", (5, -4, 2), 130, 8.0, (0.75, 0.85, 1.0)), + ("R", (0.5, 4.5, 4.0), 300, 4.0, (0.6, 0.78, 1.0))]: + ld = bpy.data.lights.new(nm, 'AREA'); ld.energy = en; ld.size = size; ld.color = col lo = bpy.data.objects.new(nm, ld); lo.location = loc; bpy.context.collection.objects.link(lo) lc = lo.constraints.new('TRACK_TO'); lc.target = aim; lc.track_axis = 'TRACK_NEGATIVE_Z'; lc.up_axis = 'UP_Y' + wd = bpy.data.lights.new("Wedge", 'AREA'); wd.energy = 380; wd.size = 6.0; wd.color = (1.0, 0.76, 0.5) + wo = bpy.data.objects.new("Wedge", wd); wo.location = (2.5, 5.5, 4.0) + wo.rotation_euler = (math.radians(-68), 0, math.radians(190)); bpy.context.collection.objects.link(wo) sc.render.engine = 'CYCLES' if engine == 'cycles' else get_eevee_engine_id() if sc.render.engine == 'CYCLES': try: sc.cycles.samples = 16 @@ -96,6 +110,8 @@ def render_still(obj, path, engine): sc.frame_set(FRAMES // 4) sc.render.resolution_x = 1280; sc.render.resolution_y = 720 sc.render.image_settings.file_format = 'PNG'; sc.render.filepath = path + # AgX would wash the copper toward beige (docs/VISUAL-STYLE.md) + sc.view_settings.view_transform = 'Standard' bpy.ops.render.render(write_still=True) return os.path.exists(path) and os.path.getsize(path) > 0