Files
sunrise/scripts/detail-marker-inscription.py

181 lines
8.1 KiB
Python

"""Bake photo-guided vector outlines into the existing marker; run inside Blender.
Edits authored vector shapes, not the reference photograph. Existing stone and UVs
are reused. Color and tangent normal are baked for the same glTF material.
"""
import json
from pathlib import Path
import bpy
import numpy as np
ROOT = Path('D:/project/sunrise')
scene = bpy.data.scenes['Summit slab and marker']
bpy.context.window.scene = scene
body = bpy.data.objects['Photo matched summit boulder']
mat = body.data.materials[0]
nodes, links = mat.node_tree.nodes, mat.node_tree.links
bs = next(n for n in nodes if n.type == 'BSDF_PRINCIPLED')
atlas = next(n for n in nodes if n.type == 'UVMAP' and n.uv_map == 'Painted monument atlas')
spec = json.loads((ROOT / 'scripts/constants/marker-inscription.json').read_text(encoding='utf-8'))
N = 2048
mask = np.zeros((N, N), dtype=np.float32)
def atlas_point(x, z, vertical_offset):
ax, az = spec['layout']['anchor']
scale = spec['layout']['scale']
x = ax+(x-ax)*scale
z = az+(z-az)*scale+vertical_offset
return ((.02+.46*(x+1.3)/2.6)*N, (.02+.96*(z+.2)/2.3)*N)
def polygon(points, value):
p = np.array(points)
lo = np.maximum(0, np.floor(p.min(axis=0)).astype(int))
hi = np.minimum(N-1, np.ceil(p.max(axis=0)).astype(int))
X, Y = np.meshgrid(np.arange(lo[0],hi[0]+1)+.5, np.arange(lo[1],hi[1]+1)+.5)
inside = np.zeros(X.shape, dtype=bool)
for a, b in zip(p, np.roll(p, -1, axis=0)):
if abs(b[1]-a[1]) < 1e-9:
continue
inside ^= ((a[1]>Y) != (b[1]>Y)) & (X < (b[0]-a[0])*(Y-a[1])/(b[1]-a[1])+a[0])
patch = mask[lo[1]:hi[1]+1,lo[0]:hi[0]+1]
patch[inside] = value
for glyph_index, glyph in enumerate(spec['glyphs']):
x0,y0,x1,y1 = glyph['bounds']
cx,top,w,h = glyph['placement']
for shape in glyph['shapes']:
for i, ring in enumerate(shape):
points = []
for x,y in ring:
px = cx+((x-x0)/(x1-x0)-.5)*w
pz = top-(y-y0)/(y1-y0)*h
points.append(atlas_point(px,pz,spec['layout']['glyphVerticalOffsets'][glyph_index]))
polygon(points, 1 if i == 0 else 0)
# Rasterize small serif lettering into the same atlas; no font or text mesh is
# exported. Separate runs match the photo's smaller Korean label and unit.
caption = spec['caption']
font = bpy.data.fonts.load(caption['font'], check_existing=True)
left = caption['left']
for run in caption['runs']:
curve = bpy.data.curves.new('Temporary elevation lettering', type='FONT')
curve.body = run['text']
curve.font = font
curve.resolution_u = 16
obj = bpy.data.objects.new('Temporary elevation lettering', curve)
scene.collection.objects.link(obj)
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = evaluated.to_mesh()
try:
mesh.calc_loop_triangles()
coordinates = np.array([(v.co.x,v.co.y) for v in mesh.vertices])
low, high = coordinates.min(axis=0), coordinates.max(axis=0)
for tri in mesh.loop_triangles:
points = []
for vertex in tri.vertices:
x,y = (coordinates[vertex]-low)/(high-low)
px = left+x*run['width']
pz = caption['baseline']+y*run['height']
points.append(atlas_point(px,pz,spec['layout']['captionVerticalOffset']))
polygon(points,1)
finally:
evaluated.to_mesh_clear()
bpy.data.objects.remove(obj,do_unlink=True)
bpy.data.curves.remove(curve)
left += run['width']+run['gap']
if font.users == 0:
bpy.data.fonts.remove(font)
# A sub-texel soft edge preserves the chisel silhouette without stair steps.
soft = (mask*4 + np.roll(mask,1,0)+np.roll(mask,-1,0)+np.roll(mask,1,1)+np.roll(mask,-1,1))/8
rng = np.random.default_rng(194)
wear = rng.uniform(.94,1,mask.shape).astype(np.float32)
wear[rng.random(mask.shape)<.007] = .55
def write_image(name, values):
image = bpy.data.images.get(name) or bpy.data.images.new(name,width=N,height=N,alpha=False)
if tuple(image.size) != (N,N):
image.scale(N,N)
rgba = np.ones((N,N,4),dtype=np.float32)
rgba[:,:,:3] = values[:,:,None]
image.pixels.foreach_set(rgba.ravel())
image.update()
image.pack()
return image
write_image('Hanja pigment mask', soft*wear)
paint = next(n for n in nodes if n.type == 'MIX_RGB' and n.inputs[0].is_linked)
paint.inputs[2].default_value = (.008,.012,.014,1)
target = next(n for n in nodes if n.type == 'TEX_IMAGE' and n.image and n.image.name == 'Painted Hanja on granite')
links.new(paint.outputs[0], bs.inputs['Base Color'])
nodes.active = target
for obj in scene.objects:
obj.select_set(False)
body.select_set(True)
bpy.context.view_layer.objects.active = body
scene.render.bake.use_pass_direct = False
scene.render.bake.use_pass_indirect = False
scene.render.bake.use_pass_color = True
scene.render.bake.margin = 8
bake_types = bpy.ops.object.bake.get_rna_type().properties['type'].enum_items
bpy.ops.object.bake(type=next(i.identifier for i in bake_types if i.identifier == 'DIFFUSE'))
target.image.save()
target.image.pack()
links.new(target.outputs['Color'],bs.inputs['Base Color'])
# Bake the shallow engraving plus the original granite normal into the atlas.
# Reuse nodes on rerun; always start from the original granite normal.
def node(kind, label):
found = next((n for n in nodes if n.label == label),None)
if found is None:
found = nodes.new(kind)
found.label = label
return found
depth = node('ShaderNodeTexImage','Inscription depth source')
depth.image = write_image('Inscription recessed height',1-soft)
depth.image.colorspace_settings.name = 'Non-Color'
links.new(atlas.outputs['UV'],depth.inputs['Vector'])
original_normal = next(n for n in nodes if n.type == 'NORMAL_MAP' and n.label != 'Baked inscription normal')
# The old material used a default tangent basis; explicitly retain its grain UV.
original_normal.uv_map = 'Granite grain UV'
bump = node('ShaderNodeBump','Shallow inscription bevel')
bump.inputs['Strength'].default_value = .65
bump.inputs['Distance'].default_value = .0025
links.new(depth.outputs['Color'],bump.inputs['Height'])
links.new(original_normal.outputs['Normal'],bump.inputs['Normal'])
links.new(bump.outputs['Normal'],bs.inputs['Normal'])
normal_target = node('ShaderNodeTexImage','Baked inscription normal texture')
normal_image = bpy.data.images.get('Marker inscription normal') or bpy.data.images.new('Marker inscription normal',width=N,height=N,alpha=False)
normal_image.colorspace_settings.name = 'Non-Color'
normal_target.image = normal_image
links.new(atlas.outputs['UV'],normal_target.inputs['Vector'])
nodes.active = normal_target
# The edge material needs its own temporary bake target to keep its maps intact.
temporary = []
for side in list(body.data.materials)[1:]:
previous = side.node_tree.nodes.active
t = side.node_tree.nodes.new('ShaderNodeTexImage')
t.image = normal_image
side.node_tree.nodes.active = t
temporary.append((side,t,previous))
try:
bpy.ops.object.bake(type=next(i.identifier for i in bake_types if i.identifier == 'NORMAL'))
finally:
for side,t,previous in temporary:
side.node_tree.nodes.remove(t)
side.node_tree.nodes.active = previous
normal_image.filepath_raw = str(ROOT / 'public/terrain/materials/marker-inscription-normal.png')
normal_image.file_format = next(i.identifier for i in normal_image.bl_rna.properties['file_format'].enum_items if i.identifier == 'PNG')
normal_image.save()
normal_image.pack()
normal = node('ShaderNodeNormalMap','Baked inscription normal')
normal.uv_map = 'Painted monument atlas'
links.new(normal_target.outputs['Color'],normal.inputs['Color'])
links.new(normal.outputs['Normal'],bs.inputs['Normal'])
body['inscriptionStyle'] = 'Photo-guided silhouette polygons with negative spaces, dense charcoal pigment and shallow baked engraving'
scene.render.filepath = str(ROOT / 'assets/summit/blender-plateau-study/marker-detailed-inscription.png')
bpy.ops.render.render(write_still=True)
print('Detailed inscription baked:',int(np.count_nonzero(mask)),'texels, 3 glyphs +',caption['text'],'; color + tangent normal.')