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
D(_Q`^
literal 0
HcmV?d00001
diff --git a/docs/gallery/index.html b/docs/gallery/index.html
index 13309b8..4a1b6c3 100644
--- a/docs/gallery/index.html
+++ b/docs/gallery/index.html
@@ -399,6 +399,17 @@
View example →
+
+
+
+
+
+
+
The TextCurve data API — curves.new(type='FONT'), live body text from bpy.app.version_string, extrude and bevel_depth solids, and evaluated-mesh conversion — so every render self-documents which Blender produced it.
+
witnesses TextCurve solids are exactly predictable: evaluated z-extent = 2 × (extrude + bevel_depth), bevel widens the outline by 2 × bevel_depth, editing body regenerates geometry — and version_string is not bare semver on LTS ("4.5.11 LTS"), so parse bpy.app.version instead.
+
View example →
+
+
diff --git a/docs/gallery/text-version-stamp/index.html b/docs/gallery/text-version-stamp/index.html
new file mode 100644
index 0000000..d3b8a18
--- /dev/null
+++ b/docs/gallery/text-version-stamp/index.html
@@ -0,0 +1,524 @@
+
+
+
+
+
+ text-version-stamp — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ text-version-stamp
+ The TextCurve data API — curves.new(type='FONT'), live body text from bpy.app.version_string, extrude and bevel_depth solids, and evaluated-mesh conversion — so every render self-documents which Blender produced it.
+
+
+
+
+
+ Rendered headless by the example itself — click to zoom.
+ witnesses TextCurve solids are exactly predictable: evaluated z-extent = 2 × (extrude + bevel_depth), bevel widens the outline by 2 × bevel_depth, editing body regenerates geometry — and version_string is not bare semver on LTS ("4.5.11 LTS"), so parse bpy.app.version instead.
+
+
blender --background --python examples/text-version-stamp/text_version_stamp.py --
+
Copy
+
+
+A runnable example that builds a beveled 3D stamp of the running Blender version — a TextCurve (bpy.data.curves.new(type='FONT')) whose body is the live bpy.app.version_string, so every render self-documents which Blender produced it. CI artifacts made this way are self-labeling.
+What it witnesses: the TextCurve data-API contract. curves.new(type='FONT') returns a Curve subclass, the built-in font ("Bfont Regular") is always loaded even headless, body is plain assignable text that regenerates geometry on edit (more characters → strictly wider evaluated mesh), and extrude / bevel_depth produce exactly predictable solids: the evaluated mesh z-extent equals 2 × (extrude + bevel_depth) and the round bevel widens the outline in-plane by 2 × bevel_depth. Flat text (no extrude, no bevel) is already *filled* — faces exist — but strictly planar. The check also witnesses the depsgraph lifetime hazard: after to_mesh_clear() the returned Mesh reference is dead and any access raises ReferenceError.
+Version divergence: the string format itself differs — "5.1.2" on 5.x but "4.5.11 LTS" on 4.5 (the suffix is part of version_string). The check therefore asserts the contract that holds on both: version_string starts with the dotted bpy.app.version tuple. Code that parses version_string as a bare semver breaks on every LTS build; branch on the bpy.app.version tuple instead.
+Run
+# Cheap correctness check (no render) — the CI check:
+blender --background --python text_version_stamp.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python text_version_stamp.py -- --output stamp.png
+blender --background --python text_version_stamp.py -- --output stamp.png --engine cycles
+It exits non-zero on failure (wrong subclass, missing font, body/version mismatch, non-planar flat text, extrude/bevel closed form off, geometry not regenerating, or a to_mesh_clear() reference surviving). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1. The render scales the stamp to a constant width, so the frame holds for any version-string length.
+
+
+ Source
+
+ """A beveled 3D stamp of the running Blender version — a runnable example.
+
+Witnesses the TextCurve data-API contract: `bpy.data.curves.new(type='FONT')`
+returns a Curve subclass whose `body` is plain assignable text, whose default
+font ("Bfont Regular") is always loaded even headless, and whose `extrude` /
+`bevel_depth` produce exactly predictable solid geometry — the evaluated mesh
+z-extent equals 2 x (extrude + bevel_depth) and the round bevel widens the
+glyph outline in-plane by 2 x bevel_depth. Because the body is the live
+`bpy.app.version_string`, every render self-documents which Blender made it.
+
+Version note: the string format itself diverges — plain "5.1.2" on 5.x but
+"4.5.11 LTS" on 4.5 — so the check asserts the cross-version contract
+(`version_string` starts with the dotted `bpy.app.version` tuple), not an
+exact format. It also witnesses the depsgraph lifetime hazard: after
+`to_mesh_clear()` the returned Mesh reference is dead and any access raises
+ReferenceError.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+ blender --background --python text_version_stamp.py -- # check only
+ blender --background --python text_version_stamp.py -- --output v.png # + render
+"""
+import bpy, sys, os, math, argparse
+
+EXTRUDE = 0.06
+BEVEL = 0.02
+TOL = 1e-4
+
+
+def build_stamp():
+ bpy.ops.wm.read_factory_settings(use_empty=True )
+ txt = bpy.data.curves.new("VersionStamp" , type='FONT' )
+ txt.body = bpy.app.version_string # the self-documenting payload
+ txt.align_x = 'CENTER'
+ txt.align_y = 'CENTER'
+ obj = bpy.data.objects.new("VersionStamp" , txt)
+ bpy.context.collection.objects.link(obj)
+ return obj
+
+
+def eval_extents(obj, deps):
+ """(vert count, face count, x extent, z extent) of the evaluated mesh."""
+ deps.update()
+ ev = obj.evaluated_get(deps)
+ me = ev.to_mesh()
+ xs = [v.co.x for v in me.vertices]
+ zs = [v.co.z for v in me.vertices]
+ out = (len(me.vertices), len(me.polygons),
+ max(xs) - min(xs) if xs else 0.0 ,
+ max(zs) - min(zs) if zs else 0.0 )
+ ev.to_mesh_clear()
+ return out, me # me is now DEAD — returned only to witness that
+
+
+def check(obj):
+ txt = obj.data
+
+ # TextCurve is a Curve; a FONT object; the built-in font is always loaded
+ if not isinstance(txt, bpy.types.TextCurve) or not isinstance(txt, bpy.types.Curve):
+ print("ERROR: curves.new(type='FONT') did not return a TextCurve/Curve" ,
+ file=sys.stderr)
+ return 3
+ if obj.type != 'FONT' or txt.font is None or "Bfont" not in txt.font.name:
+ print(f" ERROR: expected a FONT object with the built-in Bfont, got "
+ f" type= {obj.type} font= {txt.font}" , file=sys.stderr)
+ return 3
+
+ # body is the live version string, and version_string starts with the
+ # dotted version tuple on every supported release ("5.1.2", "4.5.11 LTS")
+ dotted = "%d.%d.%d" % bpy.app.version
+ if txt.body != bpy.app.version_string or not bpy.app.version_string.startswith(dotted):
+ print(f" ERROR: body= {txt.body!r} version_string= {bpy.app.version_string!r} "
+ f" tuple= {bpy.app.version}" , file=sys.stderr)
+ return 4
+
+ deps = bpy.context.evaluated_depsgraph_get()
+
+ # flat text: glyph outlines are filled (faces exist) but strictly planar
+ (nv, nf, x_flat, z_flat), _ = eval_extents(obj, deps)
+ if nf == 0 or z_flat > TOL:
+ print(f" ERROR: flat text expected filled planar mesh, got faces= {nf} "
+ f" z-extent= {z_flat}" , file=sys.stderr)
+ return 5
+
+ # solidify: z-extent is exactly 2*(extrude+bevel), bevel widens x by 2*bevel
+ txt.extrude = EXTRUDE
+ txt.bevel_depth = BEVEL
+ txt.bevel_resolution = 3
+ (nv, nf, x_solid, z_solid), dead = eval_extents(obj, deps)
+ want_z = 2.0 * (EXTRUDE + BEVEL)
+ want_x = x_flat + 2.0 * BEVEL
+ if abs(z_solid - want_z) > TOL or abs(x_solid - want_x) > TOL:
+ print(f" ERROR: extrude/bevel closed form failed: z {z_solid:.5f } != "
+ f" {want_z:.5f } or x {x_solid:.5f } != {want_x:.5f }" , file=sys.stderr)
+ return 6
+
+ # editing body regenerates geometry: more characters -> strictly wider
+ txt.body = txt.body + " WWW"
+ (_, _, x_long, _), _ = eval_extents(obj, deps)
+ txt.body = bpy.app.version_string
+ if x_long <= x_solid:
+ print(f" ERROR: appending characters did not widen the text "
+ f" ( {x_long:.5f } <= {x_solid:.5f }) " , file=sys.stderr)
+ return 7
+
+ # lifetime hazard: after to_mesh_clear() the Mesh reference is dead
+ try :
+ len(dead.vertices)
+ print("ERROR: Mesh survived to_mesh_clear() — lifetime contract broken" ,
+ file=sys.stderr)
+ return 8
+ except ReferenceError:
+ pass
+
+ print(f" body= {txt.body!r} flat x-extent= {x_flat:.4f } solid z-extent= {z_solid:.4f } "
+ f" (= 2*(extrude+bevel)) faces= {nf}" )
+ return 0
+
+
+def eevee_engine_id():
+ return 'BLENDER_EEVEE' if bpy.app.version >= (5 , 0 , 0 ) else 'BLENDER_EEVEE_NEXT'
+
+
+def render_still(obj, path, engine):
+ scene = bpy.context.scene
+ txt = obj.data
+ txt.size = 1.0
+ txt.space_character = 1.05
+
+ # brass stamp
+ mat = bpy.data.materials.new("Brass" )
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF" ]
+ bsdf.inputs["Base Color" ].default_value = (0.85 , 0.62 , 0.28 , 1.0 )
+ bsdf.inputs["Metallic" ].default_value = 1.0
+ bsdf.inputs["Roughness" ].default_value = 0.32
+ txt.materials.append(mat)
+
+ # stand the text upright, feet on the floor, scaled to a constant width
+ # so the frame works for any version-string length ("5.1.2", "4.5.11 LTS")
+ deps = bpy.context.evaluated_depsgraph_get()
+ deps.update()
+ ev = obj.evaluated_get(deps)
+ me = ev.to_mesh()
+ width = max(v.co.x for v in me.vertices) - min(v.co.x for v in me.vertices)
+ height = max(v.co.y for v in me.vertices) - min(v.co.y for v in me.vertices)
+ ev.to_mesh_clear()
+ s = 3.1 / width
+ obj.scale = (s, s, s)
+ obj.rotation_euler = (math.radians(90 ), 0.0 , 0.0 )
+ obj.location = (0.0 , 0.0 , s * height / 2 + 0.02 )
+
+ # small steel caption above, also a TextCurve
+ cap = bpy.data.curves.new("Caption" , type='FONT' )
+ cap.body = "B L E N D E R"
+ cap.align_x = 'CENTER' ; cap.align_y = 'CENTER'
+ cap.size = 0.34 ; cap.extrude = 0.012 ; cap.bevel_depth = 0.004
+ cmat = bpy.data.materials.new("Steel" )
+ cmat.use_nodes = True
+ cb = cmat.node_tree.nodes["Principled BSDF" ]
+ cb.inputs["Base Color" ].default_value = (0.62 , 0.66 , 0.72 , 1.0 )
+ cb.inputs["Metallic" ].default_value = 1.0
+ cb.inputs["Roughness" ].default_value = 0.4
+ cap.materials.append(cmat)
+ cap_obj = bpy.data.objects.new("Caption" , cap)
+ cap_obj.rotation_euler = (math.radians(90 ), 0.0 , 0.0 )
+ cap_obj.location = (0.0 , 0.0 , s * height + 0.62 )
+ scene.collection.objects.link(cap_obj)
+
+ # teal emissive underline bar grounding the stamp
+ import bmesh
+ bar_me = bpy.data.meshes.new("Bar" )
+ bm = bmesh.new()
+ try :
+ bmesh.ops.create_cube(bm, size=1.0 )
+ bm.to_mesh(bar_me)
+ finally :
+ bm.free()
+ bmat = bpy.data.materials.new("Glow" )
+ bmat.use_nodes = True
+ bb = bmat.node_tree.nodes["Principled BSDF" ]
+ bb.inputs["Emission Color" ].default_value = (0.1 , 0.9 , 0.8 , 1.0 )
+ bb.inputs["Emission Strength" ].default_value = 14.0
+ bar_me.materials.append(bmat)
+ bar = bpy.data.objects.new("Bar" , bar_me)
+ bar.scale = (3.9 , 0.06 , 0.02 )
+ bar.location = (0.0 , -0.35 , 0.02 )
+ scene.collection.objects.link(bar)
+
+ # dark studio: floor + back wall
+ floor_me = bpy.data.meshes.new("Floor" )
+ bm = bmesh.new()
+ try :
+ bmesh.ops.create_grid(bm, x_segments=1 , y_segments=1 , size=30.0 )
+ bm.to_mesh(floor_me)
+ finally :
+ bm.free()
+ fmat = bpy.data.materials.new("Studio" )
+ fmat.use_nodes = True
+ fb = fmat.node_tree.nodes["Principled BSDF" ]
+ fb.inputs["Base Color" ].default_value = (0.05 , 0.055 , 0.065 , 1.0 )
+ fb.inputs["Roughness" ].default_value = 0.35
+ floor_me.materials.append(fmat)
+ floor = bpy.data.objects.new("Floor" , floor_me)
+ scene.collection.objects.link(floor)
+ wall = bpy.data.objects.new("Wall" , floor_me.copy())
+ wall.location = (0.0 , 11.0 , 0.0 )
+ wall.rotation_euler = (math.radians(90 ), 0.0 , 0.0 )
+ scene.collection.objects.link(wall)
+
+ world = bpy.data.worlds.new("World" )
+ world.use_nodes = True
+ world.node_tree.nodes["Background" ].inputs["Color" ].default_value = (0.03 , 0.035 , 0.045 , 1.0 )
+ scene.world = world
+
+ def light(name, loc, energy, size, col, rot):
+ ld = bpy.data.lights.new(name, 'AREA' )
+ ld.energy = energy; ld.size = size; ld.color = col
+ ob = bpy.data.objects.new(name, ld)
+ ob.location = loc
+ ob.rotation_euler = tuple(math.radians(a) for a in rot)
+ scene.collection.objects.link(ob)
+
+ # brass reads on reflections: warm key, broad cool fill, hard warm rim
+ light("Key" , (-3.2 , -4.8 , 4.6 ), 850.0 , 6.0 , (1.0 , 0.95 , 0.86 ), (52 , 0 , -32 ))
+ light("Fill" , (4.6 , -3.6 , 2.4 ), 320.0 , 8.0 , (0.75 , 0.84 , 1.0 ), (66 , 0 , 48 ))
+ light("Rim" , (0.8 , 4.2 , 3.4 ), 900.0 , 3.0 , (1.0 , 0.75 , 0.45 ), (-65 , 0 , 170 ))
+
+ cam_data = bpy.data.cameras.new("Cam" )
+ cam_data.lens = 50.0
+ cam = bpy.data.objects.new("Cam" , cam_data)
+ cam.location = (0.0 , -8.5 , 1.15 )
+ cam.rotation_euler = (math.radians(86.5 ), 0.0 , 0.0 )
+ scene.collection.objects.link(cam)
+ scene.camera = cam
+
+ scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id()
+ if engine == 'cycles' :
+ scene.cycles.samples = 32
+ else :
+ try :
+ scene.eevee.taa_render_samples = 64
+ except AttributeError:
+ pass
+ scene.render.resolution_x = 1280
+ scene.render.resolution_y = 720
+ scene.render.image_settings.file_format = 'PNG'
+ scene.render.filepath = path
+ bpy.ops.render.render(write_still=True )
+ return os.path.exists(path) and os.path.getsize(path) > 0
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--" ) + 1 :] if "--" in sys.argv else []
+ p = argparse.ArgumentParser()
+ p.add_argument("--output" , default=None , help="optional: render a still PNG here" )
+ p.add_argument("--engine" , default="eevee" , choices=("eevee" , "cycles" ),
+ help="render engine for --output (cycles for GPU-less hosts)" )
+ args = p.parse_args(argv)
+
+ obj = build_stamp()
+ code = check(obj)
+ if code:
+ return code
+
+ if args.output:
+ if not render_still(obj, os.path.abspath(args.output), args.engine):
+ print("ERROR: render produced no file" , file=sys.stderr)
+ return 9
+ print(f" rendered still {args.output}" )
+
+ print("text-version-stamp OK" )
+ return 0
+
+
+if __name__ == "__main__" :
+ try :
+ sys.exit(main())
+ except Exception as e:
+ import traceback; traceback.print_exc(); print(f" FATAL: {e}" , file=sys.stderr); sys.exit(1 )
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/gallery.json b/examples/gallery.json
index 705e2ef..fe62af8 100644
--- a/examples/gallery.json
+++ b/examples/gallery.json
@@ -220,6 +220,19 @@
"armature",
"depsgraph"
]
+ },
+ {
+ "name": "text-version-stamp",
+ "dir": "examples/text-version-stamp",
+ "teaches": "The TextCurve data API — curves.new(type='FONT'), live body text from bpy.app.version_string, extrude and bevel_depth solids, and evaluated-mesh conversion — so every render self-documents which Blender produced it.",
+ "witnessesFix": "TextCurve solids are exactly predictable: evaluated z-extent = 2 × (extrude + bevel_depth), bevel widens the outline by 2 × bevel_depth, editing body regenerates geometry — and version_string is not bare semver on LTS (\"4.5.11 LTS\"), so parse bpy.app.version instead.",
+ "hero": "docs/gallery/assets/text-version-stamp-hero.webp",
+ "preview": "examples/text-version-stamp/preview.webp",
+ "tags": [
+ "curves",
+ "depsgraph",
+ "rendering"
+ ]
}
]
}
diff --git a/examples/text-version-stamp/README.md b/examples/text-version-stamp/README.md
new file mode 100644
index 0000000..3d7e684
--- /dev/null
+++ b/examples/text-version-stamp/README.md
@@ -0,0 +1,40 @@
+# Text Version Stamp
+
+A runnable example that builds a beveled 3D stamp of the running Blender version — a
+`TextCurve` (`bpy.data.curves.new(type='FONT')`) whose `body` is the live
+`bpy.app.version_string`, so every render self-documents which Blender produced it.
+CI artifacts made this way are self-labeling.
+
+**What it witnesses:** the TextCurve data-API contract. `curves.new(type='FONT')`
+returns a Curve subclass, the built-in font ("Bfont Regular") is always loaded even
+headless, `body` is plain assignable text that regenerates geometry on edit (more
+characters → strictly wider evaluated mesh), and `extrude` / `bevel_depth` produce
+exactly predictable solids: the evaluated mesh z-extent equals 2 × (extrude +
+bevel_depth) and the round bevel widens the outline in-plane by 2 × bevel_depth.
+Flat text (no extrude, no bevel) is already *filled* — faces exist — but strictly
+planar. The check also witnesses the depsgraph lifetime hazard: after
+`to_mesh_clear()` the returned Mesh reference is dead and any access raises
+`ReferenceError`.
+
+**Version divergence:** the string format itself differs — `"5.1.2"` on 5.x but
+`"4.5.11 LTS"` on 4.5 (the suffix is part of `version_string`). The check therefore
+asserts the contract that holds on both: `version_string` starts with the dotted
+`bpy.app.version` tuple. Code that parses `version_string` as a bare semver breaks
+on every LTS build; branch on the `bpy.app.version` tuple instead.
+
+## Run
+
+```bash
+# Cheap correctness check (no render) — the CI check:
+blender --background --python text_version_stamp.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python text_version_stamp.py -- --output stamp.png
+blender --background --python text_version_stamp.py -- --output stamp.png --engine cycles
+```
+
+It exits non-zero on failure (wrong subclass, missing font, body/version mismatch,
+non-planar flat text, extrude/bevel closed form off, geometry not regenerating, or a
+`to_mesh_clear()` reference surviving). The `blender-smoke` workflow runs the check on
+Blender 4.5 LTS and 5.1. The render scales the stamp to a constant width, so the frame
+holds for any version-string length.
diff --git a/examples/text-version-stamp/preview.webp b/examples/text-version-stamp/preview.webp
new file mode 100644
index 0000000000000000000000000000000000000000..b3ceba7a5c2c37de910f0c72fe94ec7478d157a0
GIT binary patch
literal 12442
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 TOL:
+ print(f"ERROR: flat text expected filled planar mesh, got faces={nf} "
+ f"z-extent={z_flat}", file=sys.stderr)
+ return 5
+
+ # solidify: z-extent is exactly 2*(extrude+bevel), bevel widens x by 2*bevel
+ txt.extrude = EXTRUDE
+ txt.bevel_depth = BEVEL
+ txt.bevel_resolution = 3
+ (nv, nf, x_solid, z_solid), dead = eval_extents(obj, deps)
+ want_z = 2.0 * (EXTRUDE + BEVEL)
+ want_x = x_flat + 2.0 * BEVEL
+ if abs(z_solid - want_z) > TOL or abs(x_solid - want_x) > TOL:
+ print(f"ERROR: extrude/bevel closed form failed: z {z_solid:.5f} != "
+ f"{want_z:.5f} or x {x_solid:.5f} != {want_x:.5f}", file=sys.stderr)
+ return 6
+
+ # editing body regenerates geometry: more characters -> strictly wider
+ txt.body = txt.body + " WWW"
+ (_, _, x_long, _), _ = eval_extents(obj, deps)
+ txt.body = bpy.app.version_string
+ if x_long <= x_solid:
+ print(f"ERROR: appending characters did not widen the text "
+ f"({x_long:.5f} <= {x_solid:.5f})", file=sys.stderr)
+ return 7
+
+ # lifetime hazard: after to_mesh_clear() the Mesh reference is dead
+ try:
+ len(dead.vertices)
+ print("ERROR: Mesh survived to_mesh_clear() — lifetime contract broken",
+ file=sys.stderr)
+ return 8
+ except ReferenceError:
+ pass
+
+ print(f"body={txt.body!r} flat x-extent={x_flat:.4f} solid z-extent={z_solid:.4f} "
+ f"(= 2*(extrude+bevel)) faces={nf}")
+ return 0
+
+
+def eevee_engine_id():
+ return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
+
+
+def render_still(obj, path, engine):
+ scene = bpy.context.scene
+ txt = obj.data
+ txt.size = 1.0
+ txt.space_character = 1.05
+
+ # brass stamp
+ mat = bpy.data.materials.new("Brass")
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF"]
+ bsdf.inputs["Base Color"].default_value = (0.85, 0.62, 0.28, 1.0)
+ bsdf.inputs["Metallic"].default_value = 1.0
+ bsdf.inputs["Roughness"].default_value = 0.32
+ txt.materials.append(mat)
+
+ # stand the text upright, feet on the floor, scaled to a constant width
+ # so the frame works for any version-string length ("5.1.2", "4.5.11 LTS")
+ deps = bpy.context.evaluated_depsgraph_get()
+ deps.update()
+ ev = obj.evaluated_get(deps)
+ me = ev.to_mesh()
+ width = max(v.co.x for v in me.vertices) - min(v.co.x for v in me.vertices)
+ height = max(v.co.y for v in me.vertices) - min(v.co.y for v in me.vertices)
+ ev.to_mesh_clear()
+ s = 3.1 / width
+ obj.scale = (s, s, s)
+ obj.rotation_euler = (math.radians(90), 0.0, 0.0)
+ obj.location = (0.0, 0.0, s * height / 2 + 0.02)
+
+ # small steel caption above, also a TextCurve
+ cap = bpy.data.curves.new("Caption", type='FONT')
+ cap.body = "B L E N D E R"
+ cap.align_x = 'CENTER'; cap.align_y = 'CENTER'
+ cap.size = 0.34; cap.extrude = 0.012; cap.bevel_depth = 0.004
+ cmat = bpy.data.materials.new("Steel")
+ cmat.use_nodes = True
+ cb = cmat.node_tree.nodes["Principled BSDF"]
+ cb.inputs["Base Color"].default_value = (0.62, 0.66, 0.72, 1.0)
+ cb.inputs["Metallic"].default_value = 1.0
+ cb.inputs["Roughness"].default_value = 0.4
+ cap.materials.append(cmat)
+ cap_obj = bpy.data.objects.new("Caption", cap)
+ cap_obj.rotation_euler = (math.radians(90), 0.0, 0.0)
+ cap_obj.location = (0.0, 0.0, s * height + 0.62)
+ scene.collection.objects.link(cap_obj)
+
+ # teal emissive underline bar grounding the stamp
+ import bmesh
+ bar_me = bpy.data.meshes.new("Bar")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cube(bm, size=1.0)
+ bm.to_mesh(bar_me)
+ finally:
+ bm.free()
+ bmat = bpy.data.materials.new("Glow")
+ bmat.use_nodes = True
+ bb = bmat.node_tree.nodes["Principled BSDF"]
+ bb.inputs["Emission Color"].default_value = (0.1, 0.9, 0.8, 1.0)
+ bb.inputs["Emission Strength"].default_value = 14.0
+ bar_me.materials.append(bmat)
+ bar = bpy.data.objects.new("Bar", bar_me)
+ bar.scale = (3.9, 0.06, 0.02)
+ bar.location = (0.0, -0.35, 0.02)
+ scene.collection.objects.link(bar)
+
+ # dark studio: floor + back wall
+ floor_me = bpy.data.meshes.new("Floor")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0)
+ bm.to_mesh(floor_me)
+ finally:
+ bm.free()
+ fmat = bpy.data.materials.new("Studio")
+ fmat.use_nodes = True
+ fb = fmat.node_tree.nodes["Principled BSDF"]
+ fb.inputs["Base Color"].default_value = (0.05, 0.055, 0.065, 1.0)
+ fb.inputs["Roughness"].default_value = 0.35
+ floor_me.materials.append(fmat)
+ floor = bpy.data.objects.new("Floor", floor_me)
+ scene.collection.objects.link(floor)
+ wall = bpy.data.objects.new("Wall", floor_me.copy())
+ wall.location = (0.0, 11.0, 0.0)
+ wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+ scene.collection.objects.link(wall)
+
+ world = bpy.data.worlds.new("World")
+ world.use_nodes = True
+ world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.03, 0.035, 0.045, 1.0)
+ scene.world = world
+
+ def light(name, loc, energy, size, col, rot):
+ ld = bpy.data.lights.new(name, 'AREA')
+ ld.energy = energy; ld.size = size; ld.color = col
+ ob = bpy.data.objects.new(name, ld)
+ ob.location = loc
+ ob.rotation_euler = tuple(math.radians(a) for a in rot)
+ scene.collection.objects.link(ob)
+
+ # brass reads on reflections: warm key, broad cool fill, hard warm rim
+ light("Key", (-3.2, -4.8, 4.6), 850.0, 6.0, (1.0, 0.95, 0.86), (52, 0, -32))
+ light("Fill", (4.6, -3.6, 2.4), 320.0, 8.0, (0.75, 0.84, 1.0), (66, 0, 48))
+ light("Rim", (0.8, 4.2, 3.4), 900.0, 3.0, (1.0, 0.75, 0.45), (-65, 0, 170))
+
+ cam_data = bpy.data.cameras.new("Cam")
+ cam_data.lens = 50.0
+ cam = bpy.data.objects.new("Cam", cam_data)
+ cam.location = (0.0, -8.5, 1.15)
+ cam.rotation_euler = (math.radians(86.5), 0.0, 0.0)
+ scene.collection.objects.link(cam)
+ scene.camera = cam
+
+ scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id()
+ if engine == 'cycles':
+ scene.cycles.samples = 32
+ else:
+ try:
+ scene.eevee.taa_render_samples = 64
+ except AttributeError:
+ pass
+ scene.render.resolution_x = 1280
+ scene.render.resolution_y = 720
+ scene.render.image_settings.file_format = 'PNG'
+ scene.render.filepath = path
+ bpy.ops.render.render(write_still=True)
+ return os.path.exists(path) and os.path.getsize(path) > 0
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ p = argparse.ArgumentParser()
+ p.add_argument("--output", default=None, help="optional: render a still PNG here")
+ p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
+ help="render engine for --output (cycles for GPU-less hosts)")
+ args = p.parse_args(argv)
+
+ obj = build_stamp()
+ code = check(obj)
+ if code:
+ return code
+
+ if args.output:
+ if not render_still(obj, os.path.abspath(args.output), args.engine):
+ print("ERROR: render produced no file", file=sys.stderr)
+ return 9
+ print(f"rendered still {args.output}")
+
+ print("text-version-stamp OK")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(main())
+ except Exception as e:
+ import traceback; traceback.print_exc(); print(f"FATAL: {e}", file=sys.stderr); sys.exit(1)