This commit is contained in:
2026-09-08 13:31:57 -07:00
commit 2637189690
257 changed files with 19205 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
# Sanctification TCG — Blender card material spike
Open `sanctification_card_material_prototype.blend` in Blender 5.2+.
The file uses one shared physical mesh (`GEO_Card_Master_63x88`) across every card instance. Each object overrides three material slots: front, universal back, and edge/core. The source art remains separate from geometry.
## Main organization
- `Cards/`: one collection per finish/substrate variant.
- `Scene_Setup/`: neutral studio, labels, four lights, and inspection cameras.
- `NG_CardSurfaceTexture`: preserves and lightly grades printed art.
- `NG_CardMicroNormal`: paper fiber, restrained linen weave, and scratch response.
- `NG_CardWear`: seeded surface/edge wear mask.
- `NG_CardFoil`: art-masked, view-responsive foil enhancement.
- `NG_CardHolographic`: broad, angle-dependent spectral shift with art-derived masking.
- `NG_CardBase`: shared Principled BSDF assembly.
The holographic animation is keyed on `CARD_Holo_Demo` from frames 1–120 at 24 fps.
### Premium finish revision (v3)
The three David finish materials now use a printed-ink BSDF mixed with a masked metallic reflection. Foil retains the artwork color in both layers: the old gold Screen blend, diffuse rainbow mix, thin-film color outlines, and high-frequency groove bump are absent from these materials. Holo colors the reflective layer using broad UV gradients and the signed view direction in card coordinates. It is an artistic diffraction approximation, not a spectral optical simulation.
`NG_David_PrintProtection` combines saturation/ink selection with soft UV exclusions for the face, hand, lamb, and central title panel. Those exclusions are specific to `legendary.png`; another artwork needs adjusted regions or an authored mask.
Edit `Finish Strength` on each premium material (foil 0.56, holo 0.60, metal experiment 0.72), the reflective BSDF Roughness (foil 0.19, holo 0.23), and `Broad diffraction spectrum` in the holo material. `CardRenderConfig.*` properties are descriptive snapshots; they do not drive shader values. `premium_finishes.py` owns these defaults and can update the existing scene without rebuilding geometry or the wear shader. The full builder calls it automatically.
Use `render_premium_review.py` in Blender to regenerate the review stills; set `REVIEW_ANIMATION=True` in its execution globals for the 120 PNG animation frames. The MP4 is encoded externally from `renders/holo_frames/holo_%04d.png` at 24 fps. Preview files with `material_revision` or `lighting_preview` in their names are historical experiments; the standard filenames are the current deliverables.
## Runtime translation
Reasonable bake/export candidates:
- Base color
- Roughness and metallic maps
- Paper/linen micro-normal
- Static wear and imperfection masks
Likely custom runtime shader work:
- View-dependent foil
- Holographic diffraction/spectral shift
- Angle response and moving reflection behavior
The Blender node setup is a look-development reference; it is not expected to survive glTF export unchanged.
## Delivered renders
- `comparison_lineup.png`
- `timothy_matte_closeup.png`
- `timothy_linen_closeup.png`
- `david_paper_closeup.png`
- `david_foil_closeup.png`
- `david_holographic_head_on.png`
- `david_holographic_spectrum_angle.png`
- `card_three_quarter_thickness.png`
- `wear_test_timothy.png`
- `universal_card_back.png`
- `holographic_rotation_demo.mp4` (5 seconds, 120 frames, 24 fps)
The animation is authored on the hidden `CARD_Holo_Demo` linked instance, not the lineup card. Its final range is −18° to +12°: wide enough to show the changing surface response while avoiding a strip-light angle that made the printed title unreadable. Blender in this environment did not expose FFmpeg output internally, so Blender rendered the PNG sequence and the included MP4 was encoded from those exact frames with H.264.
The studio uses 3 W key, 1 W fill, 0.65 W strip, and 1.4 W rim lights, a 0.012-strength world, and −1.35 stop AgX exposure. The narrower strip is positioned at (0.10, 0.012, 0.23) m to catch the demo's tilt range. These settings are shared by the saved scene, review stills, and animation.
## Spike observations
- Paper, linen, and laminate differ mainly through roughness, coat, and micro-normal; the art texture does not change.
- Linen remains intentionally close-inspection detail. Increasing `Linen Strength` beyond the current setting begins to compete with the watercolor/stained-glass texture.
- Foil is most convincing when the saturated/value-derived art mask controls the metallic lift instead of applying reflection uniformly.
- Holo needs a runtime view vector plus broad directional bands. Static glTF export can carry its base PBR state, but not the authored spectral shift.
- `David_MetalHolo` deliberately marks the excessive end of the range and is not a recommended product target.
## Rebuild
The scene was constructed through Blender MCP using `build_sanctification_card_prototype.py`. Running the script again intentionally rebuilds the current scene from scratch.

View File

@@ -0,0 +1,593 @@
import bpy
import math
import os
from mathutils import Vector
ROOT = "/home/dkzver/dev/sanctification-tcg"
OUT = os.path.join(ROOT, "blender_prototype", "renders")
BLEND = os.path.join(ROOT, "blender_prototype", "sanctification_card_material_prototype.blend")
COMMON = os.path.join(ROOT, "common.png")
LEGENDARY = os.path.join(ROOT, "legendary.png")
BACK = os.path.join(ROOT, "card-back.png")
os.makedirs(OUT, exist_ok=True)
def clear_scene():
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
for block in (bpy.data.collections, bpy.data.materials, bpy.data.meshes,
bpy.data.curves, bpy.data.cameras, bpy.data.lights,
bpy.data.node_groups):
for item in list(block):
if item.users == 0:
block.remove(item)
def collection(name, parent=None):
c = bpy.data.collections.new(name)
(parent or bpy.context.scene.collection).children.link(c)
return c
def link_only(obj, col):
for c in list(obj.users_collection):
c.objects.unlink(obj)
col.objects.link(obj)
def sock(node, name, fallback=None):
s = node.inputs.get(name)
if s is None and fallback is not None:
s = node.inputs[fallback]
return s
def new_socket(group, name, in_out, socket_type, default=None, min_value=None, max_value=None):
s = group.interface.new_socket(name=name, in_out=in_out, socket_type=socket_type)
if default is not None:
s.default_value = default
if min_value is not None:
s.min_value = min_value
if max_value is not None:
s.max_value = max_value
return s
def make_surface_texture_group():
g = bpy.data.node_groups.new("NG_CardSurfaceTexture", "ShaderNodeTree")
new_socket(g, "Art Color", "INPUT", "NodeSocketColor", (0.8, 0.8, 0.8, 1))
new_socket(g, "Saturation", "INPUT", "NodeSocketFloat", 1.0, 0.0, 2.0)
new_socket(g, "Value", "INPUT", "NodeSocketFloat", 1.0, 0.0, 2.0)
new_socket(g, "Color", "OUTPUT", "NodeSocketColor")
n_in = g.nodes.new("NodeGroupInput")
n_out = g.nodes.new("NodeGroupOutput")
hsv = g.nodes.new("ShaderNodeHueSaturation")
hsv.inputs[0].default_value = 0.5
hsv.inputs[3].default_value = 1.0
g.links.new(n_in.outputs["Art Color"], hsv.inputs[4])
g.links.new(n_in.outputs["Saturation"], hsv.inputs[1])
g.links.new(n_in.outputs["Value"], hsv.inputs[2])
g.links.new(hsv.outputs[0], n_out.inputs["Color"])
n_in.location = (-260, 0)
hsv.location = (0, 0)
n_out.location = (220, 0)
return g
def make_wear_group():
g = bpy.data.node_groups.new("NG_CardWear", "ShaderNodeTree")
new_socket(g, "Base Color", "INPUT", "NodeSocketColor", (0.8, 0.8, 0.8, 1))
new_socket(g, "Vector", "INPUT", "NodeSocketVector")
new_socket(g, "Wear Amount", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0)
new_socket(g, "Edge Wear Amount", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0)
new_socket(g, "Seed", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1000.0)
new_socket(g, "Color", "OUTPUT", "NodeSocketColor")
new_socket(g, "Wear Mask", "OUTPUT", "NodeSocketFloat")
ni = g.nodes.new("NodeGroupInput")
no = g.nodes.new("NodeGroupOutput")
sep = g.nodes.new("ShaderNodeSeparateXYZ")
subx = g.nodes.new("ShaderNodeMath"); subx.operation = "SUBTRACT"; subx.inputs[1].default_value = 0.5
suby = g.nodes.new("ShaderNodeMath"); suby.operation = "SUBTRACT"; suby.inputs[1].default_value = 0.5
absx = g.nodes.new("ShaderNodeMath"); absx.operation = "ABSOLUTE"
absy = g.nodes.new("ShaderNodeMath"); absy.operation = "ABSOLUTE"
maximum = g.nodes.new("ShaderNodeMath"); maximum.operation = "MAXIMUM"
edge = g.nodes.new("ShaderNodeMapRange")
edge.inputs[1].default_value = 0.435
edge.inputs[2].default_value = 0.5
edge.inputs[3].default_value = 0.0
edge.inputs[4].default_value = 1.0
edge.clamp = True
combine = g.nodes.new("ShaderNodeCombineXYZ")
seed_scale = g.nodes.new("ShaderNodeMath"); seed_scale.operation = "MULTIPLY"; seed_scale.inputs[1].default_value = 0.137
addvec = g.nodes.new("ShaderNodeVectorMath"); addvec.operation = "ADD"
noise = g.nodes.new("ShaderNodeTexNoise")
noise.inputs["Scale"].default_value = 22.0
noise.inputs["Detail"].default_value = 5.0
noise.inputs["Roughness"].default_value = 0.72
mul_edge_noise = g.nodes.new("ShaderNodeMath"); mul_edge_noise.operation = "MULTIPLY"
mul_edge_amt = g.nodes.new("ShaderNodeMath"); mul_edge_amt.operation = "MULTIPLY"
fine_noise = g.nodes.new("ShaderNodeTexNoise")
fine_noise.inputs["Scale"].default_value = 135.0
fine_noise.inputs["Detail"].default_value = 2.0
fine_ramp = g.nodes.new("ShaderNodeValToRGB")
fine_ramp.color_ramp.elements[0].position = 0.61
fine_ramp.color_ramp.elements[1].position = 0.73
mul_surface = g.nodes.new("ShaderNodeMath"); mul_surface.operation = "MULTIPLY"
add_masks = g.nodes.new("ShaderNodeMath"); add_masks.operation = "ADD"; add_masks.use_clamp = True
mix = g.nodes.new("ShaderNodeMixRGB")
mix.blend_type = "MIX"
mix.inputs[2].default_value = (0.72, 0.68, 0.57, 1)
g.links.new(ni.outputs["Vector"], sep.inputs[0])
g.links.new(sep.outputs[0], subx.inputs[0]); g.links.new(subx.outputs[0], absx.inputs[0])
g.links.new(sep.outputs[1], suby.inputs[0]); g.links.new(suby.outputs[0], absy.inputs[0])
g.links.new(absx.outputs[0], maximum.inputs[0]); g.links.new(absy.outputs[0], maximum.inputs[1])
g.links.new(maximum.outputs[0], edge.inputs[0])
g.links.new(ni.outputs["Seed"], seed_scale.inputs[0])
g.links.new(seed_scale.outputs[0], combine.inputs[0]); g.links.new(ni.outputs["Seed"], combine.inputs[1])
g.links.new(ni.outputs["Vector"], addvec.inputs[0]); g.links.new(combine.outputs[0], addvec.inputs[1])
g.links.new(addvec.outputs[0], noise.inputs["Vector"]); g.links.new(addvec.outputs[0], fine_noise.inputs["Vector"])
g.links.new(edge.outputs[0], mul_edge_noise.inputs[0]); g.links.new(noise.outputs["Fac"], mul_edge_noise.inputs[1])
g.links.new(mul_edge_noise.outputs[0], mul_edge_amt.inputs[0]); g.links.new(ni.outputs["Edge Wear Amount"], mul_edge_amt.inputs[1])
g.links.new(fine_noise.outputs["Fac"], fine_ramp.inputs[0])
g.links.new(fine_ramp.outputs[0], mul_surface.inputs[0]); g.links.new(ni.outputs["Wear Amount"], mul_surface.inputs[1])
g.links.new(mul_edge_amt.outputs[0], add_masks.inputs[0]); g.links.new(mul_surface.outputs[0], add_masks.inputs[1])
g.links.new(add_masks.outputs[0], mix.inputs[0]); g.links.new(ni.outputs["Base Color"], mix.inputs[1])
g.links.new(mix.outputs[0], no.inputs["Color"]); g.links.new(add_masks.outputs[0], no.inputs["Wear Mask"])
for i, n in enumerate(g.nodes):
n.location = ((i % 6) * 180 - 520, -(i // 6) * 180)
return g
def make_micro_group():
g = bpy.data.node_groups.new("NG_CardMicroNormal", "ShaderNodeTree")
new_socket(g, "Vector", "INPUT", "NodeSocketVector")
new_socket(g, "Micro Texture Strength", "INPUT", "NodeSocketFloat", 0.08, 0.0, 1.0)
new_socket(g, "Linen Strength", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0)
new_socket(g, "Holo Groove Strength", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0)
new_socket(g, "Scratch Amount", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0)
new_socket(g, "Seed", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1000.0)
new_socket(g, "Normal", "OUTPUT", "NodeSocketVector")
ni = g.nodes.new("NodeGroupInput"); no = g.nodes.new("NodeGroupOutput")
noise = g.nodes.new("ShaderNodeTexNoise")
noise.inputs["Scale"].default_value = 470.0
noise.inputs["Detail"].default_value = 2.0
noise.inputs["Roughness"].default_value = 0.62
wave_x = g.nodes.new("ShaderNodeTexWave"); wave_x.wave_type = "BANDS"; wave_x.bands_direction = "X"
wave_y = g.nodes.new("ShaderNodeTexWave"); wave_y.wave_type = "BANDS"; wave_y.bands_direction = "Y"
for w in (wave_x, wave_y):
w.inputs["Scale"].default_value = 190.0
w.inputs["Distortion"].default_value = 0.8
w.inputs["Detail"].default_value = 2.0
weave = g.nodes.new("ShaderNodeMath"); weave.operation = "ADD"
weave_amt = g.nodes.new("ShaderNodeMath"); weave_amt.operation = "MULTIPLY"
paper_amt = g.nodes.new("ShaderNodeMath"); paper_amt.operation = "MULTIPLY"
add = g.nodes.new("ShaderNodeMath"); add.operation = "ADD"
scratch_wave = g.nodes.new("ShaderNodeTexWave"); scratch_wave.wave_type = "BANDS"; scratch_wave.bands_direction = "X"
scratch_wave.inputs["Scale"].default_value = 1250.0
scratch_wave.inputs["Distortion"].default_value = 8.0
scratch_ramp = g.nodes.new("ShaderNodeValToRGB")
scratch_ramp.color_ramp.elements[0].position = 0.48
scratch_ramp.color_ramp.elements[1].position = 0.505
scratch_amt = g.nodes.new("ShaderNodeMath"); scratch_amt.operation = "MULTIPLY"
add2 = g.nodes.new("ShaderNodeMath"); add2.operation = "ADD"
groove_wave = g.nodes.new("ShaderNodeTexWave"); groove_wave.wave_type = "BANDS"; groove_wave.bands_direction = "X"
groove_wave.inputs["Scale"].default_value = 900.0
groove_wave.inputs["Distortion"].default_value = 1.8
groove_wave.inputs["Detail"].default_value = 2.0
groove_amt = g.nodes.new("ShaderNodeMath"); groove_amt.operation = "MULTIPLY"
add3 = g.nodes.new("ShaderNodeMath"); add3.operation = "ADD"
bump = g.nodes.new("ShaderNodeBump")
bump.inputs["Distance"].default_value = 0.00015
bump.inputs["Strength"].default_value = 0.42
g.links.new(ni.outputs["Vector"], noise.inputs["Vector"])
g.links.new(ni.outputs["Vector"], wave_x.inputs["Vector"]); g.links.new(ni.outputs["Vector"], wave_y.inputs["Vector"])
g.links.new(wave_x.outputs["Color"], weave.inputs[0]); g.links.new(wave_y.outputs["Color"], weave.inputs[1])
g.links.new(weave.outputs[0], weave_amt.inputs[0]); g.links.new(ni.outputs["Linen Strength"], weave_amt.inputs[1])
g.links.new(noise.outputs["Fac"], paper_amt.inputs[0]); g.links.new(ni.outputs["Micro Texture Strength"], paper_amt.inputs[1])
g.links.new(weave_amt.outputs[0], add.inputs[0]); g.links.new(paper_amt.outputs[0], add.inputs[1])
g.links.new(ni.outputs["Vector"], scratch_wave.inputs["Vector"]); g.links.new(scratch_wave.outputs["Color"], scratch_ramp.inputs[0])
g.links.new(scratch_ramp.outputs[0], scratch_amt.inputs[0]); g.links.new(ni.outputs["Scratch Amount"], scratch_amt.inputs[1])
g.links.new(add.outputs[0], add2.inputs[0]); g.links.new(scratch_amt.outputs[0], add2.inputs[1])
g.links.new(ni.outputs["Vector"], groove_wave.inputs["Vector"]); g.links.new(groove_wave.outputs["Color"], groove_amt.inputs[0]); g.links.new(ni.outputs["Holo Groove Strength"], groove_amt.inputs[1])
g.links.new(add2.outputs[0], add3.inputs[0]); g.links.new(groove_amt.outputs[0], add3.inputs[1])
g.links.new(add3.outputs[0], bump.inputs["Height"]); g.links.new(bump.outputs["Normal"], no.inputs["Normal"])
for i, n in enumerate(g.nodes): n.location = ((i % 5) * 190 - 450, -(i // 5) * 190)
return g
def make_foil_group():
g = bpy.data.node_groups.new("NG_CardFoil", "ShaderNodeTree")
new_socket(g, "Base Color", "INPUT", "NodeSocketColor")
new_socket(g, "Art Mask", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0)
new_socket(g, "Base Metallic", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0)
new_socket(g, "Base Roughness", "INPUT", "NodeSocketFloat", 0.4, 0.0, 1.0)
new_socket(g, "Foil Strength", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0)
new_socket(g, "Foil Roughness", "INPUT", "NodeSocketFloat", 0.22, 0.0, 1.0)
new_socket(g, "Color", "OUTPUT", "NodeSocketColor")
new_socket(g, "Metallic", "OUTPUT", "NodeSocketFloat")
new_socket(g, "Roughness", "OUTPUT", "NodeSocketFloat")
ni = g.nodes.new("NodeGroupInput"); no = g.nodes.new("NodeGroupOutput")
lw = g.nodes.new("ShaderNodeLayerWeight")
inv = g.nodes.new("ShaderNodeMath"); inv.operation = "SUBTRACT"; inv.inputs[0].default_value = 1.0
angle = g.nodes.new("ShaderNodeMath"); angle.operation = "MULTIPLY_ADD"; angle.inputs[1].default_value = 0.65; angle.inputs[2].default_value = 0.18
mask = g.nodes.new("ShaderNodeMath"); mask.operation = "MULTIPLY"
amt = g.nodes.new("ShaderNodeMath"); amt.operation = "MULTIPLY"
static_amt = g.nodes.new("ShaderNodeMath"); static_amt.operation = "MULTIPLY"
mix = g.nodes.new("ShaderNodeMixRGB"); mix.blend_type = "SCREEN"; mix.inputs[2].default_value = (0.74, 0.67, 0.48, 1)
metboost = g.nodes.new("ShaderNodeMath"); metboost.operation = "MULTIPLY"; metboost.inputs[1].default_value = 0.72
metadd = g.nodes.new("ShaderNodeMath"); metadd.operation = "ADD"; metadd.use_clamp = True
roughmix = g.nodes.new("ShaderNodeMix")
roughmix.data_type = "FLOAT"
g.links.new(lw.outputs["Facing"], inv.inputs[1]); g.links.new(lw.outputs["Facing"], angle.inputs[0])
g.links.new(ni.outputs["Art Mask"], mask.inputs[0]); g.links.new(angle.outputs[0], mask.inputs[1])
g.links.new(mask.outputs[0], amt.inputs[0]); g.links.new(ni.outputs["Foil Strength"], amt.inputs[1])
g.links.new(ni.outputs["Art Mask"], static_amt.inputs[0]); g.links.new(ni.outputs["Foil Strength"], static_amt.inputs[1])
g.links.new(amt.outputs[0], mix.inputs[0]); g.links.new(ni.outputs["Base Color"], mix.inputs[1])
g.links.new(static_amt.outputs[0], metboost.inputs[0]); g.links.new(metboost.outputs[0], metadd.inputs[0]); g.links.new(ni.outputs["Base Metallic"], metadd.inputs[1])
g.links.new(static_amt.outputs[0], roughmix.inputs[0]); g.links.new(ni.outputs["Base Roughness"], roughmix.inputs[2]); g.links.new(ni.outputs["Foil Roughness"], roughmix.inputs[3])
g.links.new(mix.outputs[0], no.inputs["Color"]); g.links.new(metadd.outputs[0], no.inputs["Metallic"]); g.links.new(roughmix.outputs[0], no.inputs["Roughness"])
for i, n in enumerate(g.nodes): n.location = ((i % 5) * 190 - 420, -(i // 5) * 190)
return g
def make_holo_group():
g = bpy.data.node_groups.new("NG_CardHolographic", "ShaderNodeTree")
new_socket(g, "Base Color", "INPUT", "NodeSocketColor")
new_socket(g, "Vector", "INPUT", "NodeSocketVector")
new_socket(g, "Art Mask", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0)
new_socket(g, "Base Metallic", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0)
new_socket(g, "Base Roughness", "INPUT", "NodeSocketFloat", 0.4, 0.0, 1.0)
new_socket(g, "Holo Strength", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.5)
new_socket(g, "Holo Scale", "INPUT", "NodeSocketFloat", 5.0, 0.5, 30.0)
new_socket(g, "Holo Saturation", "INPUT", "NodeSocketFloat", 0.72, 0.0, 1.5)
new_socket(g, "Holo Angle Response", "INPUT", "NodeSocketFloat", 0.85, 0.0, 2.0)
new_socket(g, "Color", "OUTPUT", "NodeSocketColor")
new_socket(g, "Metallic", "OUTPUT", "NodeSocketFloat")
new_socket(g, "Roughness", "OUTPUT", "NodeSocketFloat")
new_socket(g, "Thin Film Thickness", "OUTPUT", "NodeSocketFloat")
ni = g.nodes.new("NodeGroupInput"); no = g.nodes.new("NodeGroupOutput")
lw = g.nodes.new("ShaderNodeLayerWeight")
inv = g.nodes.new("ShaderNodeMath"); inv.operation = "SUBTRACT"; inv.inputs[0].default_value = 1.0
power = g.nodes.new("ShaderNodeMath"); power.operation = "POWER"; power.inputs[1].default_value = 0.38
angle = g.nodes.new("ShaderNodeMath"); angle.operation = "MULTIPLY"
anglebase = g.nodes.new("ShaderNodeMath"); anglebase.operation = "ADD"; anglebase.inputs[1].default_value = 0.035
wave = g.nodes.new("ShaderNodeTexWave"); wave.wave_type = "BANDS"; wave.bands_direction = "X"
wave.inputs["Distortion"].default_value = 2.2; wave.inputs["Detail"].default_value = 3.0
rotate = g.nodes.new("ShaderNodeVectorRotate"); rotate.rotation_type = "AXIS_ANGLE"; rotate.inputs["Axis"].default_value = (0,0,1); rotate.inputs["Angle"].default_value = math.radians(33)
wave2 = g.nodes.new("ShaderNodeTexWave"); wave2.wave_type = "BANDS"; wave2.bands_direction = "X"; wave2.inputs["Distortion"].default_value = 1.1; wave2.inputs["Detail"].default_value = 2.0
scale2 = g.nodes.new("ShaderNodeMath"); scale2.operation = "MULTIPLY"; scale2.inputs[1].default_value = 0.63
wave1_amt = g.nodes.new("ShaderNodeMath"); wave1_amt.operation = "MULTIPLY"; wave1_amt.inputs[1].default_value = 0.72
wave2_amt = g.nodes.new("ShaderNodeMath"); wave2_amt.operation = "MULTIPLY"; wave2_amt.inputs[1].default_value = 0.28
waves_add = g.nodes.new("ShaderNodeMath"); waves_add.operation = "ADD"
noise = g.nodes.new("ShaderNodeTexNoise"); noise.inputs["Scale"].default_value = 3.2; noise.inputs["Detail"].default_value = 2.0
nscale = g.nodes.new("ShaderNodeMath"); nscale.operation = "MULTIPLY"; nscale.inputs[1].default_value = 0.16
fscale = g.nodes.new("ShaderNodeMath"); fscale.operation = "MULTIPLY"; fscale.inputs[1].default_value = 0.72
add1 = g.nodes.new("ShaderNodeMath"); add1.operation = "ADD"
add2 = g.nodes.new("ShaderNodeMath"); add2.operation = "ADD"
fract = g.nodes.new("ShaderNodeMath"); fract.operation = "FRACT"
ramp = g.nodes.new("ShaderNodeValToRGB")
cr = ramp.color_ramp
cr.interpolation = "EASE"
while len(cr.elements) > 2: cr.elements.remove(cr.elements[-1])
cr.elements[0].position = 0.0; cr.elements[0].color = (1.0, 0.34, 0.28, 1)
cr.elements[1].position = 1.0; cr.elements[1].color = (1.0, 0.34, 0.28, 1)
for pos, color in [
(0.17, (1.0, 0.76, 0.24, 1)), (0.34, (0.28, 0.95, 0.58, 1)),
(0.51, (0.20, 0.78, 1.0, 1)), (0.68, (0.38, 0.48, 1.0, 1)),
(0.85, (0.95, 0.32, 0.82, 1))]:
e = cr.elements.new(pos); e.color = color
hsv = g.nodes.new("ShaderNodeHueSaturation"); hsv.inputs[0].default_value = 0.5; hsv.inputs[2].default_value = 1.0; hsv.inputs[3].default_value = 1.0
holoamt = g.nodes.new("ShaderNodeMath"); holoamt.operation = "MULTIPLY"
holoamt2 = g.nodes.new("ShaderNodeMath"); holoamt2.operation = "MULTIPLY"
holo_cap = g.nodes.new("ShaderNodeMath"); holo_cap.operation = "MINIMUM"; holo_cap.inputs[1].default_value = 0.62
mix = g.nodes.new("ShaderNodeMixRGB"); mix.blend_type = "MIX"
metboost = g.nodes.new("ShaderNodeMath"); metboost.operation = "MULTIPLY"; metboost.inputs[1].default_value = 0.42
metadd = g.nodes.new("ShaderNodeMath"); metadd.operation = "ADD"; metadd.use_clamp = True
rough_target = g.nodes.new("ShaderNodeMath"); rough_target.operation = "MULTIPLY"; rough_target.inputs[1].default_value = 0.62
roughmix = g.nodes.new("ShaderNodeMix"); roughmix.data_type = "FLOAT"
film_mask = g.nodes.new("ShaderNodeMath"); film_mask.operation = "MULTIPLY"
film_scale = g.nodes.new("ShaderNodeMath"); film_scale.operation = "MULTIPLY"; film_scale.inputs[1].default_value = 460.0
g.links.new(lw.outputs["Facing"], inv.inputs[1]); g.links.new(lw.outputs["Facing"], power.inputs[0]); g.links.new(power.outputs[0], angle.inputs[0]); g.links.new(ni.outputs["Holo Angle Response"], angle.inputs[1]); g.links.new(angle.outputs[0], anglebase.inputs[0])
g.links.new(ni.outputs["Vector"], wave.inputs["Vector"]); g.links.new(ni.outputs["Holo Scale"], wave.inputs["Scale"])
g.links.new(ni.outputs["Vector"], rotate.inputs["Vector"]); g.links.new(rotate.outputs["Vector"], wave2.inputs["Vector"]); g.links.new(ni.outputs["Holo Scale"], scale2.inputs[0]); g.links.new(scale2.outputs[0], wave2.inputs["Scale"])
g.links.new(wave.outputs["Fac"], wave1_amt.inputs[0]); g.links.new(wave2.outputs["Fac"], wave2_amt.inputs[0]); g.links.new(wave1_amt.outputs[0], waves_add.inputs[0]); g.links.new(wave2_amt.outputs[0], waves_add.inputs[1])
g.links.new(ni.outputs["Vector"], noise.inputs["Vector"]); g.links.new(noise.outputs["Fac"], nscale.inputs[0]); g.links.new(lw.outputs["Facing"], fscale.inputs[0])
g.links.new(waves_add.outputs[0], add1.inputs[0]); g.links.new(nscale.outputs[0], add1.inputs[1]); g.links.new(add1.outputs[0], add2.inputs[0]); g.links.new(fscale.outputs[0], add2.inputs[1]); g.links.new(add2.outputs[0], fract.inputs[0]); g.links.new(fract.outputs[0], ramp.inputs[0]); g.links.new(ramp.outputs[0], hsv.inputs[4]); g.links.new(ni.outputs["Holo Saturation"], hsv.inputs[1])
g.links.new(ni.outputs["Art Mask"], holoamt.inputs[0]); g.links.new(anglebase.outputs[0], holoamt.inputs[1]); g.links.new(holoamt.outputs[0], holoamt2.inputs[0]); g.links.new(ni.outputs["Holo Strength"], holoamt2.inputs[1])
g.links.new(holoamt2.outputs[0], holo_cap.inputs[0]); g.links.new(holo_cap.outputs[0], mix.inputs[0]); g.links.new(ni.outputs["Base Color"], mix.inputs[1]); g.links.new(hsv.outputs[0], mix.inputs[2])
g.links.new(holo_cap.outputs[0], metboost.inputs[0]); g.links.new(metboost.outputs[0], metadd.inputs[0]); g.links.new(ni.outputs["Base Metallic"], metadd.inputs[1])
g.links.new(ni.outputs["Base Roughness"], rough_target.inputs[0]); g.links.new(holo_cap.outputs[0], roughmix.inputs[0]); g.links.new(ni.outputs["Base Roughness"], roughmix.inputs[2]); g.links.new(rough_target.outputs[0], roughmix.inputs[3])
g.links.new(ni.outputs["Art Mask"], film_mask.inputs[0]); g.links.new(ni.outputs["Holo Strength"], film_mask.inputs[1]); g.links.new(film_mask.outputs[0], film_scale.inputs[0])
g.links.new(mix.outputs[0], no.inputs["Color"]); g.links.new(metadd.outputs[0], no.inputs["Metallic"]); g.links.new(roughmix.outputs[0], no.inputs["Roughness"]); g.links.new(film_scale.outputs[0], no.inputs["Thin Film Thickness"])
for i, n in enumerate(g.nodes): n.location = ((i % 6) * 190 - 520, -(i // 6) * 190)
return g
def make_base_group():
g = bpy.data.node_groups.new("NG_CardBase", "ShaderNodeTree")
new_socket(g, "Base Color", "INPUT", "NodeSocketColor")
new_socket(g, "Roughness", "INPUT", "NodeSocketFloat", 0.45, 0.0, 1.0)
new_socket(g, "Metallic", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0)
new_socket(g, "Coat Strength", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0)
new_socket(g, "Coat Roughness", "INPUT", "NodeSocketFloat", 0.18, 0.0, 1.0)
new_socket(g, "Anisotropic", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0)
new_socket(g, "Sheen Weight", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1.0)
new_socket(g, "Thin Film Thickness", "INPUT", "NodeSocketFloat", 0.0, 0.0, 1000.0)
new_socket(g, "Thin Film IOR", "INPUT", "NodeSocketFloat", 1.38, 1.0, 3.0)
new_socket(g, "Normal", "INPUT", "NodeSocketVector")
new_socket(g, "Shader", "OUTPUT", "NodeSocketShader")
ni = g.nodes.new("NodeGroupInput"); no = g.nodes.new("NodeGroupOutput")
bsdf = g.nodes.new("ShaderNodeBsdfPrincipled")
g.links.new(ni.outputs["Base Color"], sock(bsdf, "Base Color"))
g.links.new(ni.outputs["Roughness"], sock(bsdf, "Roughness"))
g.links.new(ni.outputs["Metallic"], sock(bsdf, "Metallic"))
g.links.new(ni.outputs["Coat Strength"], sock(bsdf, "Coat Weight", 18))
g.links.new(ni.outputs["Coat Roughness"], sock(bsdf, "Coat Roughness", 19))
g.links.new(ni.outputs["Anisotropic"], bsdf.inputs["Anisotropic"])
g.links.new(ni.outputs["Sheen Weight"], bsdf.inputs["Sheen Weight"])
g.links.new(ni.outputs["Thin Film Thickness"], bsdf.inputs["Thin Film Thickness"])
g.links.new(ni.outputs["Thin Film IOR"], bsdf.inputs["Thin Film IOR"])
g.links.new(ni.outputs["Normal"], sock(bsdf, "Normal"))
g.links.new(bsdf.outputs["BSDF"], no.inputs["Shader"])
ni.location = (-260, 0); bsdf.location = (0, 0); no.location = (260, 0)
return g
def load_image(path, name):
img = bpy.data.images.load(path, check_existing=True)
img.name = name
img.colorspace_settings.name = "sRGB"
return img
def create_front_material(name, image, cfg, groups):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
mat.diffuse_color = (0.35, 0.3, 0.24, 1)
nt = mat.node_tree; nt.nodes.clear()
out = nt.nodes.new("ShaderNodeOutputMaterial"); out.location = (1050, 0)
texcoord = nt.nodes.new("ShaderNodeTexCoord"); texcoord.location = (-1100, -180)
tex = nt.nodes.new("ShaderNodeTexImage"); tex.image = image; tex.interpolation = "Linear"; tex.location = (-1100, 180); tex.label = image.name
surf = nt.nodes.new("ShaderNodeGroup"); surf.node_tree = groups["surface"]; surf.location = (-820, 180)
wear = nt.nodes.new("ShaderNodeGroup"); wear.node_tree = groups["wear"]; wear.location = (-570, 180)
sep = nt.nodes.new("ShaderNodeSeparateColor"); sep.mode = "HSV"; sep.location = (-810, -80)
maskmul = nt.nodes.new("ShaderNodeMath"); maskmul.operation = "MULTIPLY"; maskmul.location = (-575, -80)
maskramp = nt.nodes.new("ShaderNodeValToRGB"); maskramp.location = (-390, -80)
maskramp.color_ramp.elements[0].position = 0.20
maskramp.color_ramp.elements[1].position = 0.56
foil = nt.nodes.new("ShaderNodeGroup"); foil.node_tree = groups["foil"]; foil.location = (-105, 170)
holo = nt.nodes.new("ShaderNodeGroup"); holo.node_tree = groups["holo"]; holo.location = (170, 170)
micro = nt.nodes.new("ShaderNodeGroup"); micro.node_tree = groups["micro"]; micro.location = (170, -240)
base = nt.nodes.new("ShaderNodeGroup"); base.node_tree = groups["base"]; base.location = (640, 80)
surf.inputs["Saturation"].default_value = cfg.get("saturation", 1.0)
surf.inputs["Value"].default_value = cfg.get("value", 1.0)
wear.inputs["Wear Amount"].default_value = cfg.get("wear", 0.0)
wear.inputs["Edge Wear Amount"].default_value = cfg.get("edge_wear", 0.0)
wear.inputs["Seed"].default_value = cfg.get("seed", 1.0)
foil.inputs["Base Metallic"].default_value = cfg.get("metallic", 0.0)
foil.inputs["Base Roughness"].default_value = cfg.get("roughness", 0.5)
foil.inputs["Foil Strength"].default_value = cfg.get("foil", 0.0)
foil.inputs["Foil Roughness"].default_value = cfg.get("foil_roughness", 0.22)
holo.inputs["Holo Strength"].default_value = cfg.get("holo", 0.0)
holo.inputs["Holo Scale"].default_value = cfg.get("holo_scale", 5.0)
holo.inputs["Holo Saturation"].default_value = cfg.get("holo_saturation", 0.72)
holo.inputs["Holo Angle Response"].default_value = cfg.get("holo_angle", 0.85)
micro.inputs["Micro Texture Strength"].default_value = cfg.get("micro", 0.06)
micro.inputs["Linen Strength"].default_value = cfg.get("linen", 0.0)
micro.inputs["Holo Groove Strength"].default_value = cfg.get("holo_groove", 0.0)
micro.inputs["Scratch Amount"].default_value = cfg.get("scratches", 0.0)
micro.inputs["Seed"].default_value = cfg.get("seed", 1.0)
base.inputs["Coat Strength"].default_value = cfg.get("coat", 0.0)
base.inputs["Coat Roughness"].default_value = cfg.get("coat_roughness", 0.2)
base.inputs["Anisotropic"].default_value = cfg.get("anisotropic", 0.0)
base.inputs["Sheen Weight"].default_value = cfg.get("sheen", 0.0)
base.inputs["Thin Film IOR"].default_value = cfg.get("thin_film_ior", 1.38)
nt.links.new(texcoord.outputs["UV"], tex.inputs["Vector"]); nt.links.new(tex.outputs["Color"], surf.inputs["Art Color"])
nt.links.new(surf.outputs["Color"], wear.inputs["Base Color"]); nt.links.new(texcoord.outputs["Generated"], wear.inputs["Vector"])
nt.links.new(surf.outputs["Color"], sep.inputs["Color"]); nt.links.new(sep.outputs[1], maskmul.inputs[0]); nt.links.new(sep.outputs[2], maskmul.inputs[1]); nt.links.new(maskmul.outputs[0], maskramp.inputs[0])
nt.links.new(wear.outputs["Color"], foil.inputs["Base Color"]); nt.links.new(maskramp.outputs["Color"], foil.inputs["Art Mask"])
nt.links.new(foil.outputs["Color"], holo.inputs["Base Color"]); nt.links.new(texcoord.outputs["Generated"], holo.inputs["Vector"]); nt.links.new(maskramp.outputs["Color"], holo.inputs["Art Mask"]); nt.links.new(foil.outputs["Metallic"], holo.inputs["Base Metallic"]); nt.links.new(foil.outputs["Roughness"], holo.inputs["Base Roughness"])
nt.links.new(texcoord.outputs["Generated"], micro.inputs["Vector"])
nt.links.new(holo.outputs["Color"], base.inputs["Base Color"]); nt.links.new(holo.outputs["Metallic"], base.inputs["Metallic"]); nt.links.new(holo.outputs["Roughness"], base.inputs["Roughness"]); nt.links.new(holo.outputs["Thin Film Thickness"], base.inputs["Thin Film Thickness"]); nt.links.new(micro.outputs["Normal"], base.inputs["Normal"]); nt.links.new(base.outputs["Shader"], out.inputs["Surface"])
for k, v in cfg.items():
if isinstance(v, (int, float, str, bool)):
mat["CardRenderConfig." + k] = v
mat["CardRenderConfig.frontTexture"] = image.filepath
mat["CardRenderConfig.backTexture"] = BACK
return mat
def create_simple_texture_material(name, image, roughness, coat, groups):
return create_front_material(name, image, dict(roughness=roughness, coat=coat, micro=0.04, seed=11), groups)
def create_edge_material(name, color, roughness, metallic=0.0):
m = bpy.data.materials.new(name); m.use_nodes = True
p = m.node_tree.nodes.get("Principled BSDF")
p.inputs["Base Color"].default_value = (*color, 1)
p.inputs["Roughness"].default_value = roughness
p.inputs["Metallic"].default_value = metallic
return m
def rounded_card_mesh():
w, h, t, r, seg = 0.063, 0.0882, 0.00040, 0.0028, 8
pts = []
for cx, cy, a0 in [
(w/2-r, h/2-r, 0), (-w/2+r, h/2-r, math.pi/2),
(-w/2+r, -h/2+r, math.pi), (w/2-r, -h/2+r, 3*math.pi/2)]:
for i in range(seg + 1):
a = a0 + (math.pi/2) * i / seg
pts.append((cx + r*math.cos(a), cy + r*math.sin(a)))
n = len(pts)
verts = [(x,y,-t/2) for x,y in pts] + [(x,y,t/2) for x,y in pts]
faces = [list(reversed(range(n))), list(range(n, 2*n))]
for i in range(n):
j = (i+1) % n
faces.append([i, j, n+j, n+i])
mesh = bpy.data.meshes.new("GEO_Card_Master_63x88")
mesh.from_pydata(verts, [], faces); mesh.update()
mesh.materials.append(None); mesh.materials.append(None); mesh.materials.append(None)
mesh.polygons[0].material_index = 1
mesh.polygons[1].material_index = 0
for p in mesh.polygons[2:]: p.material_index = 2
uv = mesh.uv_layers.new(name="UV_Card_Artwork")
for p in mesh.polygons:
for li in p.loop_indices:
vi = mesh.loops[li].vertex_index
x,y,z = mesh.vertices[vi].co
if p.index == 0:
uv.data[li].uv = (0.5-x/w, y/h+0.5)
elif p.index == 1:
uv.data[li].uv = (x/w+0.5, y/h+0.5)
else:
uv.data[li].uv = ((vi % n)/n, 0 if vi < n else 1)
return mesh
def add_card(name, mesh, col, front_mat, back_mat, edge_mat, loc, rot):
obj = bpy.data.objects.new(name, mesh)
col.objects.link(obj)
obj.location = loc; obj.rotation_euler = rot
for idx, mat in enumerate((front_mat, back_mat, edge_mat)):
slot = obj.material_slots[idx]
slot.link = "OBJECT"; slot.material = mat
bev = obj.modifiers.new("MOD_EdgeHighlight_Bevel", "BEVEL")
bev.width = 0.00028; bev.segments = 2; bev.limit_method = "ANGLE"
obj["CardRenderConfig.frontMaterial"] = front_mat.name
obj["CardRenderConfig.backMaterial"] = back_mat.name
obj["Physical.Width_mm"] = 63.0; obj["Physical.Height_mm"] = 88.2; obj["Physical.Thickness_mm"] = 0.4; obj["Physical.CornerRadius_mm"] = 2.8
return obj
def look_at(obj, target):
obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler()
def add_area(name, loc, energy, size, color, target, col, shape="DISK", size_y=None):
d = bpy.data.lights.new(name, "AREA"); d.energy = energy; d.shape = shape; d.size = size; d.color = color
if size_y is not None: d.size_y = size_y
o = bpy.data.objects.new(name, d); col.objects.link(o); o.location = loc; look_at(o, target); return o
def add_camera(name, loc, target, col, ortho=None, lens=55):
d = bpy.data.cameras.new(name); d.lens = lens
if ortho is not None: d.type = "ORTHO"; d.ortho_scale = ortho
o = bpy.data.objects.new(name, d); col.objects.link(o); o.location = loc; look_at(o, target); return o
def make_label(text, loc, col, mat):
curve = bpy.data.curves.new("TXT_" + text.replace(" ", "_"), "FONT")
curve.body = text; curve.align_x = "CENTER"; curve.align_y = "CENTER"; curve.size = 0.006; curve.extrude = 0.00002
obj = bpy.data.objects.new("LABEL_" + text.replace(" ", "_"), curve); col.objects.link(obj); obj.location = loc; obj.data.materials.append(mat); return obj
clear_scene()
scene = bpy.context.scene
scene.unit_settings.system = "METRIC"; scene.unit_settings.length_unit = "MILLIMETERS"; scene.unit_settings.scale_length = 1.0
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = 1280; scene.render.resolution_y = 900; scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.film_transparent = False
scene.render.image_settings.color_mode = "RGBA"
scene.render.image_settings.color_depth = "8"
scene.render.fps = 24; scene.frame_start = 1; scene.frame_end = 120
scene.view_settings.look = "AgX - Medium High Contrast"
scene.view_settings.exposure = -1.35
scene.world.color = (0.008, 0.009, 0.012)
world = scene.world; world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.008, 0.010, 0.014, 1)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.012
cards_root = collection("Cards")
setup = collection("Scene_Setup")
lights_col = collection("Lights", setup); cams_col = collection("Cameras", setup); labels_col = collection("Labels", setup); env_col = collection("Environment", setup)
groups = {"surface": make_surface_texture_group(), "wear": make_wear_group(), "micro": make_micro_group(), "foil": make_foil_group(), "holo": make_holo_group(), "base": make_base_group()}
img_common = load_image(COMMON, "TEX_Timothy_Front")
img_legendary = load_image(LEGENDARY, "TEX_David_Front")
img_back = load_image(BACK, "TEX_Card_Back")
cfgs = {
"Timothy_Matte": (img_common, dict(substrate="paper", finish="matte", roughness=0.61, metallic=0.0, coat=0.02, coat_roughness=0.36, micro=0.08, linen=0.0, sheen=0.0, foil=0.0, holo=0.0, wear=0.0, edge_wear=0.0, scratches=0.0, seed=13)),
"Timothy_Linen": (img_common, dict(substrate="linen", finish="matte", roughness=0.77, metallic=0.0, coat=0.0, micro=0.045, linen=0.38, sheen=0.16, foil=0.0, holo=0.0, wear=0.0, edge_wear=0.0, scratches=0.0, seed=23)),
"Timothy_Gloss": (img_common, dict(substrate="plastic_laminate", finish="gloss", roughness=0.29, metallic=0.0, coat=0.48, coat_roughness=0.13, micro=0.015, linen=0.0, foil=0.0, holo=0.0, wear=0.0, edge_wear=0.0, scratches=0.0, seed=31)),
"David_Paper": (img_legendary, dict(substrate="premium_paper", finish="satin", roughness=0.47, metallic=0.0, coat=0.14, coat_roughness=0.24, micro=0.055, linen=0.0, foil=0.0, holo=0.0, wear=0.0, edge_wear=0.0, scratches=0.0, seed=41)),
"David_Foil": (img_legendary, dict(substrate="foil_laminate", finish="selective_foil", roughness=0.24, metallic=0.04, coat=0.44, coat_roughness=0.09, anisotropic=0.55, micro=0.018, linen=0.0, foil=0.90, foil_roughness=0.075, holo=0.0, wear=0.0, edge_wear=0.0, scratches=0.0, seed=53)),
"David_Holo": (img_legendary, dict(substrate="premium_cardstock", finish="layered_holographic", roughness=0.17, metallic=0.02, coat=0.80, coat_roughness=0.055, anisotropic=0.68, micro=0.012, linen=0.0, holo_groove=0.035, foil=0.22, foil_roughness=0.085, holo=1.20, holo_scale=3.7, holo_saturation=0.82, holo_angle=1.48, thin_film_ior=1.42, wear=0.0, edge_wear=0.0, scratches=0.0, seed=67)),
"David_MetalHolo": (img_legendary, dict(substrate="metal", finish="layered_holographic", roughness=0.13, metallic=0.66, coat=0.78, coat_roughness=0.045, anisotropic=0.80, micro=0.008, linen=0.0, holo_groove=0.06, foil=0.78, foil_roughness=0.055, holo=1.45, holo_scale=4.8, holo_saturation=1.05, holo_angle=1.40, thin_film_ior=1.48, wear=0.0, edge_wear=0.0, scratches=0.0, seed=79)),
"Wear_Test": (img_common, dict(substrate="paper", finish="matte_worn", roughness=0.67, metallic=0.0, coat=0.01, coat_roughness=0.4, micro=0.10, linen=0.0, foil=0.0, holo=0.0, wear=0.11, edge_wear=0.60, scratches=0.14, seed=137)),
}
materials = {k: create_front_material("MAT_" + k, img, cfg, groups) for k, (img, cfg) in cfgs.items()}
back_mat = create_simple_texture_material("MAT_Card_Back_Universal", img_back, 0.44, 0.12, groups)
edge_paper = create_edge_material("MAT_Edge_PaperCore", (0.55, 0.48, 0.36), 0.72)
edge_plastic = create_edge_material("MAT_Edge_Plastic", (0.20, 0.22, 0.23), 0.30)
edge_metal = create_edge_material("MAT_Edge_Metal", (0.32, 0.24, 0.10), 0.18, 0.78)
mesh = rounded_card_mesh()
layout = {
"Timothy_Matte": ((-0.082, 0.061, 0), (math.radians(-2), math.radians(6), math.radians(-1.5))),
"Timothy_Linen": ((0.0, 0.061, 0), (math.radians(2), math.radians(-4), 0)),
"Timothy_Gloss": ((0.082, 0.061, 0), (math.radians(-3), math.radians(8), math.radians(1.5))),
"David_Paper": ((-0.123, -0.061, 0), (math.radians(2), math.radians(-6), math.radians(-1.5))),
"David_Foil": ((-0.041, -0.061, 0), (math.radians(-2), math.radians(8), math.radians(0.5))),
"David_Holo": ((0.041, -0.061, 0), (math.radians(3), math.radians(-11), math.radians(-0.5))),
"David_MetalHolo": ((0.123, -0.061, 0), (math.radians(-4), math.radians(13), math.radians(1.5))),
"Wear_Test": ((0.0, -0.185, 0), (0, 0, 0)),
}
objects = {}
for key, (loc, rot) in layout.items():
col = collection(key, cards_root)
edge = edge_metal if key == "David_MetalHolo" else edge_plastic if key == "Timothy_Gloss" else edge_paper
objects[key] = add_card("CARD_" + key, mesh, col, materials[key], back_mat, edge, loc, rot)
objects["Wear_Test"].hide_render = True
holo_demo_col = collection("Holo_Demo", cards_root)
holo_demo = add_card("CARD_Holo_Demo", mesh, holo_demo_col, materials["David_Holo"], back_mat, edge_paper, (0,0,0), (0,0,0))
holo_demo.hide_render = True
label_mat = bpy.data.materials.new("MAT_Label_White"); label_mat.use_nodes = True
lp = label_mat.node_tree.nodes.get("Principled BSDF"); lp.inputs["Base Color"].default_value = (0.72,0.75,0.80,1); lp.inputs["Roughness"].default_value = 0.65; lp.inputs["Emission Color"].default_value = (0.035,0.04,0.05,1); lp.inputs["Emission Strength"].default_value = 0.25
for key in ["Timothy_Matte", "Timothy_Linen", "Timothy_Gloss", "David_Paper", "David_Foil", "David_Holo", "David_MetalHolo"]:
x,y,_ = layout[key][0]
make_label(key.replace("_", " "), (x, y-0.051, 0.0015), labels_col, label_mat)
bg_mat = bpy.data.materials.new("MAT_Studio_Backdrop"); bg_mat.use_nodes = True
bp = bg_mat.node_tree.nodes.get("Principled BSDF"); bp.inputs["Base Color"].default_value = (0.012,0.015,0.022,1); bp.inputs["Roughness"].default_value = 0.82
bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0,0,-0.012))
backdrop = bpy.context.object; backdrop.name = "Studio_Backdrop"; link_only(backdrop, env_col); backdrop.data.materials.append(bg_mat)
add_area("LIGHT_Key_Softbox", (-0.20,0.19,0.27), 3.0, 0.16, (1.0,0.92,0.82), (0,0,0), lights_col, "DISK")
add_area("LIGHT_Fill_Soft", (0.18,-0.14,0.22), 1.0, 0.20, (0.80,0.88,1.0), (0,0,0), lights_col, "DISK")
add_area("LIGHT_Specular_Strip", (0.24,0.10,0.16), 3.0, 0.018, (1.0,0.98,0.94), (0.025,-0.02,0), lights_col, "RECTANGLE", 0.23)
add_area("LIGHT_Top_Rim", (0.0,0.31,0.12), 1.4, 0.035, (0.90,0.94,1.0), (0,0.03,0), lights_col, "RECTANGLE", 0.20)
cam_comp = add_camera("CAM_Comparison", (0,0,0.48), (0,0,0), cams_col, ortho=0.335)
cam_front = add_camera("CAM_Front_Inspection", (0,0,0.28), (0,0,0), cams_col, ortho=0.108)
cam_three = add_camera("CAM_ThreeQuarter", (0.14,-0.040,0.28), (0,0,0), cams_col, lens=66)
cam_holo_angle = add_camera("CAM_Holo_Angle", (0.105,-0.025,0.27), (0,0,0), cams_col, lens=72)
scene.camera = cam_comp
# Holographic demo keys live on a dedicated linked instance, leaving the lineup
# card deterministic for head-on material comparisons.
holo_obj = holo_demo
holo_obj.rotation_euler = (math.radians(-6), math.radians(-18), 0); holo_obj.keyframe_insert("rotation_euler", frame=1)
holo_obj.rotation_euler = (math.radians(5), math.radians(12), math.radians(1.5)); holo_obj.keyframe_insert("rotation_euler", frame=120)
# Blender 5 uses layered Actions; the default Bezier interpolation is intentionally
# retained so the inspection motion eases naturally at each end.
scene.frame_set(1)
scene["README_RuntimeTranslation"] = "Bake/export: base color, roughness, metallic, micro-normal, static wear/imperfection masks. Runtime shader: view-dependent foil, holographic diffraction, angle-based spectrum, dynamic reflection."
scene["README_PrototypeIntent"] = "Rarity remains artwork/framing; finish remains independent physical light response. Shared mesh GEO_Card_Master_63x88 and shared NG_* node groups support parameterized variants."
scene["CardRenderConfig_Schema"] = "frontTexture, backTexture, substrate, finish, roughness, metallic, foil, holo, wear, seed"
import runpy
runpy.run_path(os.path.join(ROOT, 'blender_prototype', 'premium_finishes.py'))['apply_premium_finishes']()
runpy.run_path(os.path.join(ROOT, 'blender_prototype', 'timothy_linen_foil.py'))['add_timothy_linen_foil']()
bpy.ops.wm.save_as_mainfile(filepath=BLEND)
result = {"status": "built", "blend": BLEND, "objects": list(objects.keys()), "materials": list(materials.keys()), "node_groups": list(groups.keys()), "output_dir": OUT}

View File

@@ -0,0 +1,156 @@
"""Artwork-independent foil/holo materials. No portrait or layout coordinates.
Call create_card_finish_material with a bpy Image and an optional grayscale mask.
Mask values: 0 = printed ink only, 1 = maximum requested finish coverage.
The default automatic mask is a color heuristic, not semantic segmentation.
"""
import bpy
def socket(g, name, kind, direction='INPUT', default=None):
s=g.interface.new_socket(name=name,in_out=direction,socket_type=kind)
if default is not None:s.default_value=default
return s
def node(t, kind, name):
n=t.nodes.new(kind);n.name=n.label=name
return n
def op(t, operation, a, b=0, name=None):
n=node(t,'ShaderNodeMath',name or operation);n.operation=operation
for i,v in enumerate((a,b)):
if isinstance(v,(int,float)):n.inputs[i].default_value=v
else:t.links.new(v,n.inputs[i])
return n.outputs[0]
def smooth(t,value,lo,hi,name):
n=node(t,'ShaderNodeMapRange',name);n.clamp=True;n.interpolation_type='SMOOTHSTEP'
n.inputs['From Min'].default_value=lo;n.inputs['From Max'].default_value=hi
t.links.new(value,n.inputs['Value']);return n.outputs[0]
def auto_mask_group():
name='NG_CardFinish_AutomaticMask'
if name in bpy.data.node_groups:return bpy.data.node_groups[name]
g=bpy.data.node_groups.new(name,'ShaderNodeTree')
socket(g,'Artwork','NodeSocketColor')
socket(g,'Mask','NodeSocketFloat','OUTPUT')
i=node(g,'NodeGroupInput','Artwork');o=node(g,'NodeGroupOutput','Color based coverage')
hsv=node(g,'ShaderNodeSeparateColor','Artwork saturation and value');hsv.mode='HSV'
g.links.new(i.outputs[0],hsv.inputs[0])
saturation=smooth(g,hsv.outputs[1],.08,.58,'Favor saturated color')
value=smooth(g,hsv.outputs[2],.015,.16,'Reduce finish over dark ink')
g.links.new(op(g,'MULTIPLY',saturation,value),o.inputs[0])
for index,n in enumerate(g.nodes):n.location=(index*200,0)
return g
def finish_group():
name='NG_CardFinish_ReflectiveCoating'
if name in bpy.data.node_groups:return bpy.data.node_groups[name]
g=bpy.data.node_groups.new(name,'ShaderNodeTree')
for nm,kind,default in [('Artwork','NodeSocketColor',None),('UV','NodeSocketVector',None),
('Normal','NodeSocketVector',None),('Coverage','NodeSocketFloat',1.0),
('Finish Strength','NodeSocketFloat',.56),('Finish Roughness','NodeSocketFloat',.19),
('Ink Roughness','NodeSocketFloat',.36),('Anisotropy','NodeSocketFloat',.32),
('Holographic','NodeSocketFloat',0.0),('Sheen','NodeSocketFloat',0.0)]:
socket(g,nm,kind,default=default)
socket(g,'Shader','NodeSocketShader','OUTPUT')
i=node(g,'NodeGroupInput','CardRenderConfig');o=node(g,'NodeGroupOutput','Card surface')
ink=node(g,'ShaderNodeBsdfPrincipled','Printed ink')
g.links.new(i.outputs['Artwork'],ink.inputs['Base Color']);g.links.new(i.outputs['Normal'],ink.inputs['Normal'])
g.links.new(i.outputs['Ink Roughness'],ink.inputs['Roughness']);g.links.new(i.outputs['Sheen'],ink.inputs['Sheen Weight'])
ink.inputs['Specular IOR Level'].default_value=.16
coat=node(g,'ShaderNodeBsdfPrincipled','Reflective coating')
coat.inputs['Metallic'].default_value=1
g.links.new(i.outputs['Finish Roughness'],coat.inputs['Roughness']);g.links.new(i.outputs['Normal'],coat.inputs['Normal'])
g.links.new(i.outputs['Anisotropy'],coat.inputs['Anisotropic'])
# Tangent from UV gradients is provided by Blender; the mesh uses its active UV.
tangent=node(g,'ShaderNodeTangent','Surface direction');tangent.direction_type='UV_MAP'
g.links.new(tangent.outputs[0],coat.inputs['Tangent'])
geo=node(g,'ShaderNodeNewGeometry','Incoming view')
transform=node(g,'ShaderNodeVectorTransform','View in card space')
transform.vector_type='VECTOR';transform.convert_from='WORLD';transform.convert_to='OBJECT'
g.links.new(geo.outputs['Incoming'],transform.inputs[0])
v=node(g,'ShaderNodeSeparateXYZ','Signed view');g.links.new(transform.outputs[0],v.inputs[0])
uv=node(g,'ShaderNodeSeparateXYZ','Spatial phase');g.links.new(i.outputs['UV'],uv.inputs[0])
phase=op(g,'ADD',op(g,'MULTIPLY',v.outputs[0],2.8),op(g,'MULTIPLY',v.outputs[1],1.6))
phase=op(g,'ADD',phase,op(g,'MULTIPLY',uv.outputs[0],.65))
phase=op(g,'ADD',phase,op(g,'MULTIPLY',uv.outputs[1],.25))
phase=op(g,'FRACT',op(g,'ADD',phase,.3))
spectrum=node(g,'ShaderNodeValToRGB','Broad reflection spectrum')
colors=[(0,(1,.32,.12,1)),(.2,(1,.84,.24,1)),(.4,(.12,1,.57,1)),(.6,(.12,.6,1,1)),(.8,(.68,.24,1,1)),(1,(1,.32,.12,1))]
spectrum.color_ramp.elements[0].color=colors[0][1];spectrum.color_ramp.elements[-1].color=colors[-1][1]
for p,c in colors[1:-1]:spectrum.color_ramp.elements.new(p).color=c
g.links.new(phase,spectrum.inputs[0])
color=node(g,'ShaderNodeMixRGB','Foil or spectral reflection')
g.links.new(i.outputs['Holographic'],color.inputs[0]);g.links.new(i.outputs['Artwork'],color.inputs[1]);g.links.new(spectrum.outputs[0],color.inputs[2])
g.links.new(color.outputs[0],coat.inputs['Base Color'])
facing=node(g,'ShaderNodeLayerWeight','Tilt activation')
angle=op(g,'ADD',.1,op(g,'MULTIPLY',smooth(g,facing.outputs['Facing'],.015,.38,'Holo tilt response'),.9))
# Lerp between constant foil coverage and view-gated holo coverage.
gate=op(g,'ADD',1,op(g,'MULTIPLY',i.outputs['Holographic'],op(g,'SUBTRACT',angle,1)))
coverage=op(g,'MULTIPLY',i.outputs['Coverage'],i.outputs['Finish Strength'])
coverage=op(g,'MINIMUM',1,op(g,'MAXIMUM',0,op(g,'MULTIPLY',coverage,gate)))
mix=node(g,'ShaderNodeMixShader','Ink plus finish')
g.links.new(coverage,mix.inputs[0]);g.links.new(ink.outputs[0],mix.inputs[1]);g.links.new(coat.outputs[0],mix.inputs[2]);g.links.new(mix.outputs[0],o.inputs[0])
for index,n in enumerate(g.nodes):n.location=((index%7)*220,-(index//7)*220)
return g
def create_card_finish_material(name, artwork, *, finish='foil', surface_texture='smooth',
finish_mask=None, mask_mode='automatic', strength=None, roughness=None, linen_strength=.38):
"""Create/update a material; no scene, object, camera, or light mutations.
artwork / finish_mask: bpy.types.Image. Optional masks must be Non-Color data.
finish: foil | holographic. surface_texture: smooth | paper | linen.
mask_mode: automatic | full. An explicit mask takes precedence.
Updates a named material in place: its existing users see the new settings.
"""
if finish not in {'foil','holographic'}:raise ValueError('Unknown finish')
if surface_texture not in {'smooth','paper','linen'}:raise ValueError('Unknown surface texture')
if mask_mode not in {'automatic','full'}:raise ValueError('Unknown mask mode')
if not isinstance(artwork,bpy.types.Image):raise TypeError('artwork must be a Blender image')
if finish_mask is not None and finish_mask.colorspace_settings.name!='Non-Color':
raise ValueError('Load finish_mask as Non-Color data')
if surface_texture!='smooth' and 'NG_CardMicroNormal' not in bpy.data.node_groups:
raise RuntimeError('Load/build NG_CardMicroNormal before using paper or linen')
strength=(.60 if finish=='holographic' else .56) if strength is None else strength
roughness=(.23 if finish=='holographic' else .19) if roughness is None else roughness
if not 0<=strength<=1 or not 0<=roughness<=1 or not 0<=linen_strength<=1:
raise ValueError('strength, roughness, linen_strength must be between zero and one')
mat=bpy.data.materials.get(name) or bpy.data.materials.new(name)
mat.use_nodes=True;t=mat.node_tree;t.nodes.clear()
uv=node(t,'ShaderNodeTexCoord','Artwork coordinates')
tex=node(t,'ShaderNodeTexImage','Front artwork');tex.image=artwork
t.links.new(uv.outputs['UV'],tex.inputs[0])
shader=node(t,'ShaderNodeGroup','Finish parameters');shader.node_tree=finish_group()
t.links.new(tex.outputs['Color'],shader.inputs['Artwork']);t.links.new(uv.outputs['UV'],shader.inputs['UV'])
shader.inputs['Finish Strength'].default_value=strength;shader.inputs['Finish Roughness'].default_value=roughness
shader.inputs['Holographic'].default_value=float(finish=='holographic')
shader.inputs['Anisotropy'].default_value=.45 if finish=='holographic' else .32
shader.inputs['Ink Roughness'].default_value=.77 if surface_texture=='linen' else (.61 if surface_texture=='paper' else .36)
shader.inputs['Sheen'].default_value=.16 if surface_texture=='linen' else 0
if finish_mask:
mask=node(t,'ShaderNodeTexImage','Optional finish mask');mask.image=finish_mask
t.links.new(uv.outputs['UV'],mask.inputs[0]);t.links.new(mask.outputs['Color'],shader.inputs['Coverage'])
elif mask_mode=='automatic':
mask=node(t,'ShaderNodeGroup','Automatic color mask');mask.node_tree=auto_mask_group()
t.links.new(tex.outputs['Color'],mask.inputs[0]);t.links.new(mask.outputs[0],shader.inputs['Coverage'])
if surface_texture!='smooth':
micro=node(t,'ShaderNodeGroup','Surface texture');micro.node_tree=bpy.data.node_groups['NG_CardMicroNormal']
micro.inputs['Linen Strength'].default_value=linen_strength if surface_texture=='linen' else 0
micro.inputs['Micro Texture Strength'].default_value=.045 if surface_texture=='linen' else .08
t.links.new(uv.outputs['Generated'],micro.inputs['Vector']);t.links.new(micro.outputs['Normal'],shader.inputs['Normal'])
else:
geo=node(t,'ShaderNodeNewGeometry','Smooth surface normal');t.links.new(geo.outputs['Normal'],shader.inputs['Normal'])
out=node(t,'ShaderNodeOutputMaterial','Card output');t.links.new(shader.outputs[0],out.inputs[0])
for index,n in enumerate(t.nodes):n.location=((index%4)*260,-(index//4)*300)
for k,v in dict(frontTexture=artwork.filepath,finish=finish,surfaceTexture=surface_texture,
maskMode='explicit' if finish_mask else mask_mode,finishStrength=strength,
finishRoughness=roughness,linenStrength=linen_strength if surface_texture=='linen' else 0).items():
mat['CardRenderConfig.'+k]=v
return mat

View File

@@ -0,0 +1,145 @@
"""Non-destructive material-only revision; call apply_premium_finishes() in Blender.
UV protection regions are specific to legendary.png. Replace these regions when
using a different portrait; they are an editable prototype mask, not segmentation.
"""
import bpy
def node(tree, kind, name):
n = tree.nodes.new(kind)
n.name = n.label = name
return n
def math_node(t, op, a, b=0, name=None):
n = node(t, 'ShaderNodeMath', name or op)
n.operation = op
for i, value in enumerate((a, b)):
if isinstance(value, (float, int)):
n.inputs[i].default_value = value
else:
t.links.new(value, n.inputs[i])
return n.outputs[0]
def ramp(t, value, lo, hi, name):
n = node(t, 'ShaderNodeMapRange', name)
n.clamp = True
n.interpolation_type = 'SMOOTHSTEP'
n.inputs['From Min'].default_value = lo
n.inputs['From Max'].default_value = hi
t.links.new(value, n.inputs['Value'])
return n.outputs[0]
def ellipse(t, uv, cx, cy, rx, ry, name):
x = math_node(t, 'DIVIDE', math_node(t, 'SUBTRACT', uv.outputs[0], cx), rx)
y = math_node(t, 'DIVIDE', math_node(t, 'SUBTRACT', uv.outputs[1], cy), ry)
d = math_node(t, 'ADD', math_node(t, 'MULTIPLY', x, x), math_node(t, 'MULTIPLY', y, y))
return ramp(t, d, .65, 1.2, name)
def make_mask():
name = 'NG_David_PrintProtection'
if name in bpy.data.node_groups:
return bpy.data.node_groups[name]
g = bpy.data.node_groups.new(name, 'ShaderNodeTree')
for nm, st in [('Artwork', 'NodeSocketColor'), ('UV', 'NodeSocketVector')]:
g.interface.new_socket(name=nm, in_out='INPUT', socket_type=st)
g.interface.new_socket(name='Finish Mask', in_out='OUTPUT', socket_type='NodeSocketFloat')
inp = node(g, 'NodeGroupInput', 'Source artwork and UV')
out = node(g, 'NodeGroupOutput', 'Protected decorative finish mask')
uv = node(g, 'ShaderNodeSeparateXYZ', 'Artwork coordinates')
g.links.new(inp.outputs['UV'], uv.inputs[0])
hsv = node(g, 'ShaderNodeSeparateColor', 'Saturation selection')
hsv.mode = 'HSV'
g.links.new(inp.outputs['Artwork'], hsv.inputs[0])
mask = ramp(g, hsv.outputs[1], .18, .62, 'Saturated glass and gold')
mask = math_node(g, 'MULTIPLY', mask, ramp(g, hsv.outputs[2], .015, .16, 'Protect dark ink'))
for cx, cy, rx, ry, label in [(.50,.72,.19,.23,'Face and neck'),
(.64,.44,.17,.13,'Hand'),
(.29,.44,.18,.18,'Lamb')]:
mask = math_node(g, 'MULTIPLY', mask, ellipse(g, uv,cx,cy,rx,ry,label))
# Soft central rectangle covering cream title/rules panel; ornament remains.
x = math_node(g,'ABSOLUTE',math_node(g,'SUBTRACT',uv.outputs[0],.5))
side = ramp(g,x,.34,.41,'Keep side ornament')
above = ramp(g,uv.outputs[1],.245,.29,'Protect title panel')
below = math_node(g,'SUBTRACT',1,ramp(g,uv.outputs[1],.07,.105,'Keep bottom ornament'))
panel = math_node(g,'MAXIMUM',side,math_node(g,'MAXIMUM',above,below))
mask = math_node(g,'MULTIPLY',mask,panel)
g.links.new(mask,out.inputs[0])
for i,n in enumerate(g.nodes): n.location=((i%8)*185,-(i//8)*170)
return g
def apply_premium_finishes():
mask_group = make_mask()
variants = [('MAT_David_Foil',False,False),('MAT_David_Holo',True,False),
('MAT_David_MetalHolo',True,True)]
for name,holo,metal in variants:
mat=bpy.data.materials[name]
t=mat.node_tree
image=next(n.image for n in t.nodes if n.type=='TEX_IMAGE')
t.nodes.clear()
uv=node(t,'ShaderNodeTexCoord','Card UV')
tex=node(t,'ShaderNodeTexImage','Printed David artwork');tex.image=image
t.links.new(uv.outputs['UV'],tex.inputs['Vector'])
mask=node(t,'ShaderNodeGroup','Protected finish regions');mask.node_tree=mask_group
t.links.new(tex.outputs['Color'],mask.inputs['Artwork']);t.links.new(uv.outputs['UV'],mask.inputs['UV'])
ink=node(t,'ShaderNodeBsdfPrincipled','Printed ink layer')
t.links.new(tex.outputs['Color'],ink.inputs['Base Color'])
ink.inputs['Roughness'].default_value=.36
ink.inputs['Coat Weight'].default_value=0
ink.inputs['Specular IOR Level'].default_value=.16
ink.inputs['Coat Roughness'].default_value=.24
finish=node(t,'ShaderNodeBsdfPrincipled','Diffractive reflection' if holo else 'Metal foil underprint')
finish.inputs['Metallic'].default_value=1
finish.inputs['Roughness'].default_value=.23 if holo else .19
finish.inputs['Anisotropic'].default_value=.45 if holo else .32
tangent=node(t,'ShaderNodeTangent','UV groove direction');tangent.direction_type='UV_MAP';tangent.uv_map='UV_Card_Artwork'
t.links.new(tangent.outputs[0],finish.inputs['Tangent'])
strength=node(t,'ShaderNodeValue','Finish Strength');strength.outputs[0].default_value=.72 if metal else (.60 if holo else .56)
weight=math_node(t,'MULTIPLY',mask.outputs[0],strength.outputs[0],'Masked coating coverage')
if holo:
geo=node(t,'ShaderNodeNewGeometry','View direction')
transform=node(t,'ShaderNodeVectorTransform','View in card coordinates')
transform.vector_type='VECTOR';transform.convert_from='WORLD';transform.convert_to='OBJECT'
t.links.new(geo.outputs['Incoming'],transform.inputs['Vector'])
v=node(t,'ShaderNodeSeparateXYZ','Signed view components');t.links.new(transform.outputs[0],v.inputs[0])
p=node(t,'ShaderNodeSeparateXYZ','Broad spatial phase');t.links.new(uv.outputs['UV'],p.inputs[0])
phase=math_node(t,'ADD',math_node(t,'MULTIPLY',v.outputs[0],2.8),math_node(t,'MULTIPLY',v.outputs[1],1.6))
phase=math_node(t,'ADD',phase,math_node(t,'MULTIPLY',p.outputs[0],.65))
phase=math_node(t,'ADD',phase,math_node(t,'MULTIPLY',p.outputs[1],.25))
phase=math_node(t,'FRACT',math_node(t,'ADD',phase,.3))
spectrum=node(t,'ShaderNodeValToRGB','Broad diffraction spectrum')
colors=[(0,(1,.32,.12,1)),(.2,(1,.84,.24,1)),(.4,(.12,1,.57,1)),(.6,(.12,.6,1,1)),(.8,(.68,.24,1,1)),(1,(1,.32,.12,1))]
for e in list(spectrum.color_ramp.elements)[1:-1]:spectrum.color_ramp.elements.remove(e)
spectrum.color_ramp.elements[0].color=colors[0][1];spectrum.color_ramp.elements[-1].color=colors[-1][1]
for pos,c in colors[1:-1]:spectrum.color_ramp.elements.new(pos).color=c
t.links.new(phase,spectrum.inputs[0]);t.links.new(spectrum.outputs[0],finish.inputs['Base Color'])
facing=node(t,'ShaderNodeLayerWeight','Grazing angle activation')
activation=ramp(t,facing.outputs['Facing'],.015,.38,'Tilt response')
activation=math_node(t,'ADD',.10,math_node(t,'MULTIPLY',activation,.90))
weight=math_node(t,'MULTIPLY',weight,activation,'Angle gated coating')
else:
t.links.new(tex.outputs['Color'],finish.inputs['Base Color'])
mix=node(t,'ShaderNodeMixShader','Printed ink plus reflective finish')
t.links.new(weight,mix.inputs[0]);t.links.new(ink.outputs[0],mix.inputs[1]);t.links.new(finish.outputs[0],mix.inputs[2])
out=node(t,'ShaderNodeOutputMaterial','Card surface');t.links.new(mix.outputs[0],out.inputs[0])
for i,n in enumerate(t.nodes):n.location=((i%7)*220,-(i//7)*240)
mat['Finish revision']='Reflective coating v3; protected print; no diffuse rainbow or thin-film outlines'
mat['CardRenderConfig.finishStrength']=strength.outputs[0].default_value
mat['CardRenderConfig.roughness']=finish.inputs['Roughness'].default_value
mat['CardRenderConfig.holo']=1.0 if holo else 0.0
mat['CardRenderConfig.holo_groove']=0.0
mat['CardRenderConfig.metallic']=1.0
# Low-power vertical strip intersects the existing demo's +12 degree tilt.
from mathutils import Vector
strip=bpy.data.objects['LIGHT_Specular_Strip']
strip.location=(.10,.012,.23)
strip.rotation_euler=(Vector((0,0,0))-strip.location).to_track_quat('-Z','Y').to_euler()
strip.data.energy=.65
strip.data.size=.018
strip.data.size_y=.14
return {'materials':[v[0] for v in variants],'mask':mask_group.name}

View File

@@ -0,0 +1,45 @@
"""Review at matched poses. Does not alter saved scene state."""
import bpy
import math
from pathlib import Path
s=bpy.context.scene
out=Path('/home/dkzver/dev/sanctification-tcg/blender_prototype/renders')
objs=[o for o in s.objects if o.name.startswith(('CARD_','LABEL_'))]
saved={o.name:(o.location.copy(),o.rotation_euler.copy(),o.hide_render) for o in objs}
cam=bpy.data.objects['CAM_Front_Inspection']
cs=(cam.location.copy(),cam.rotation_euler.copy(),cam.data.ortho_scale)
settings=(s.camera,s.render.resolution_x,s.render.resolution_y,s.render.filepath)
def isolate(name,angle):
for o in objs:o.hide_render=o.name!=name
card=bpy.data.objects[name];card.location=(0,0,0);card.rotation_euler=(0,math.radians(angle),0)
s.camera=cam;s.render.resolution_x=750;s.render.resolution_y=1050
return card
def shot(filename):
s.render.filepath=str(out/filename);bpy.ops.render.render(write_still=True)
try:
isolate('CARD_Timothy_LinenFoil',0);shot('timothy_linen_foil_head_on.png')
isolate('CARD_Timothy_LinenFoil',12);shot('timothy_linen_foil_reflection.png')
for name in ['Timothy_Linen','Timothy_LinenFoil']:
isolate('CARD_'+name,12);cam.data.ortho_scale=.036;cam.location.y=-.018
shot(name.lower()+'_macro.png');cam.location=cs[0];cam.data.ortho_scale=cs[2]
for o in objs:o.hide_render=True
for name,x,label_text in [('Timothy_Linen',-.038,'Linen / Matte'),('Timothy_LinenFoil',.038,'Linen / Foil')]:
card=bpy.data.objects['CARD_'+name];card.hide_render=False;card.location=(x,0,0);card.rotation_euler=(0,math.radians(12),0)
label=bpy.data.objects['LABEL_'+name];label.hide_render=False;label.location=(x,-.050,.0015)
s.camera=cam;cam.data.ortho_scale=.16;s.render.resolution_x=1400;s.render.resolution_y=1050
shot('timothy_linen_vs_linen_foil.png')
if globals().get('RENDER_MOTION',False):
card=isolate('CARD_Timothy_LinenFoil',0)
s.render.resolution_x=540;s.render.resolution_y=720;cam.data.ortho_scale=.108
frames=out/'linen_foil_frames';frames.mkdir(exist_ok=True)
for f in range(72):
angle=-18+36*(.5-.5*math.cos(math.pi*f/71))
card.rotation_euler=(0,math.radians(angle),0)
shot('linen_foil_frames/linen_foil_%04d.png'%(f+1))
finally:
for o in objs:
loc,rot,h=saved[o.name];o.location=loc;o.rotation_euler=rot;o.hide_render=h
cam.location,cam.rotation_euler,cam.data.ortho_scale=cs
s.camera,s.render.resolution_x,s.render.resolution_y,s.render.filepath=settings
result={'directory':str(out),'restored_scene':True}

View File

@@ -0,0 +1,57 @@
"""Run with Blender MCP. REVIEW_ANIMATION=True also regenerates demo frames."""
import bpy
import math
from pathlib import Path
scene=bpy.context.scene
out=Path('/home/dkzver/dev/sanctification-tcg/blender_prototype/renders')
cards=[o for o in scene.objects if o.name.startswith('CARD_')]
labels=[o for o in scene.objects if o.name.startswith('LABEL_')]
saved={o.name:(o.location.copy(),o.rotation_euler.copy(),o.hide_render) for o in cards+labels}
camera=scene.camera
resolution=(scene.render.resolution_x,scene.render.resolution_y)
frame=scene.frame_current
path=scene.render.filepath
def render(name,target=None,tilt=0):
for o in cards:o.hide_render=(o.name!=target if target else o.name in {'CARD_Holo_Demo','CARD_Wear_Test'})
for o in labels:o.hide_render=target is not None
scene.camera=bpy.data.objects['CAM_Front_Inspection' if target else 'CAM_Comparison']
scene.render.resolution_x=750 if target else 1280
scene.render.resolution_y=1050 if target else 900
if target:
o=bpy.data.objects[target];o.location=(0,0,0);o.rotation_euler=(0,math.radians(tilt),0)
scene.render.image_settings.file_format='PNG'
scene.render.filepath=str(out/name)
bpy.ops.render.render(write_still=True)
if target:
o.location=saved[target][0];o.rotation_euler=saved[target][1]
try:
render('comparison_lineup.png')
render('timothy_matte_closeup.png','CARD_Timothy_Matte')
render('timothy_linen_closeup.png','CARD_Timothy_Linen',-14)
render('timothy_gloss_closeup.png','CARD_Timothy_Gloss',8)
render('david_paper_closeup.png','CARD_David_Paper')
render('wear_test_timothy.png','CARD_Wear_Test')
render('universal_card_back.png','CARD_Timothy_Matte',180)
render('david_foil_closeup.png','CARD_David_Foil',0)
render('david_foil_reflection.png','CARD_David_Foil',12)
render('david_holographic_head_on.png','CARD_David_Holo',0)
render('david_holographic_spectrum_angle.png','CARD_David_Holo',12)
render('david_holographic_opposite_angle.png','CARD_David_Holo',-18)
render('card_three_quarter_thickness.png','CARD_David_Foil',55)
if globals().get('REVIEW_ANIMATION',False):
for o in cards:o.hide_render=o.name!='CARD_Holo_Demo'
for o in labels:o.hide_render=True
scene.camera=bpy.data.objects['CAM_Front_Inspection']
scene.render.resolution_x=540;scene.render.resolution_y=720
(out/'holo_frames').mkdir(exist_ok=True)
scene.render.filepath=str(out/'holo_frames'/'holo_')
bpy.ops.render.render(animation=True)
finally:
scene.frame_set(frame)
for o in cards+labels:
loc,rot,hidden=saved[o.name];o.location=loc;o.rotation_euler=rot;o.hide_render=hidden
scene.camera=camera
scene.render.resolution_x,scene.render.resolution_y=resolution
scene.render.filepath=path
result={'render_directory':str(out),'animation':globals().get('REVIEW_ANIMATION',False)}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1015 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1017 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1021 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1024 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Some files were not shown because too many files have changed in this diff Show More