Refine Gwanaksan summit marker proportions and inscription
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"resolution": 2048,
|
||||
"grainScale": 145,
|
||||
"crystalScale": 75,
|
||||
"fineScale": 600,
|
||||
"roughness": 0.91,
|
||||
"relief": 0.0018,
|
||||
"faceTint": 1.0,
|
||||
"rimTint": 0.76,
|
||||
"terrainBlend": 0.82,
|
||||
"terrainSaturation": 0.65,
|
||||
"terrainBrightness": 1.15,
|
||||
"groundPaletteBlend": 0.55,
|
||||
"grainColors": [
|
||||
[0.26, [0.045, 0.049, 0.052, 1]],
|
||||
[0.39, [0.18, 0.19, 0.19, 1]],
|
||||
[0.46, [0.49, 0.50, 0.49, 1]],
|
||||
[0.56, [0.72, 0.73, 0.71, 1]],
|
||||
[0.72, [0.88, 0.89, 0.86, 1]]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"description": "Hand-traced polygon silhouettes from the user supplied 191x377 reference. Coordinates follow the photograph; depth is an artistic estimate. Rings after the first are holes.",
|
||||
"layout": {"scale": 0.65, "anchor": [0.18, 1.1], "glyphVerticalOffsets": [0, -0.12, -0.24], "captionVerticalOffset": -0.24},
|
||||
"caption": {
|
||||
"text": "해발 629 m",
|
||||
"font": "C:/Windows/Fonts/HANBatangB.ttf",
|
||||
"left": -0.125,
|
||||
"baseline": 0.20,
|
||||
"runs": [
|
||||
{"text":"해발", "width":0.20, "height":0.080, "gap":0.04},
|
||||
{"text":"629", "width":0.24, "height":0.105, "gap":0.05},
|
||||
{"text":"m", "width":0.10, "height":0.075, "gap":0}
|
||||
]
|
||||
},
|
||||
"glyphs": [
|
||||
{"character":"冠", "bounds":[60,20,142,99], "placement":[0.18,1.78,0.53,0.45], "shapes":[
|
||||
[[[67,36],[75,31],[88,29],[104,25],[117,24],[126,20],[132,25],[140,29],[142,34],[134,37],[125,34],[117,31],[102,33],[89,37],[82,44],[76,49],[68,50],[65,45]]],
|
||||
[[[86,42],[94,38],[102,38],[105,43],[100,49],[97,54],[104,56],[105,61],[98,65],[100,71],[95,76],[98,82],[106,86],[118,87],[127,82],[129,72],[129,60],[133,66],[135,77],[138,84],[136,90],[124,94],[108,97],[96,93],[87,87],[81,94],[72,94],[66,91],[63,86],[69,81],[73,75],[70,71],[73,66],[80,64],[85,60],[82,55],[79,54],[81,48]], [[86,71],[82,76],[82,81],[87,77],[90,72]]],
|
||||
[[[112,35],[118,34],[125,40],[124,44],[119,48],[120,53],[127,51],[129,56],[125,60],[120,61],[119,74],[116,82],[110,84],[105,78],[101,75],[103,71],[110,74],[112,68],[111,62],[105,65],[101,61],[104,56],[112,54],[112,46],[108,44]]]
|
||||
]},
|
||||
{"character":"岳", "bounds":[60,129,142,214], "placement":[0.15,1.29,0.55,0.46], "shapes":[
|
||||
[[[77,153],[86,145],[99,138],[109,131],[117,130],[122,135],[119,141],[110,145],[98,147],[91,152],[90,158],[97,157],[108,151],[116,149],[124,153],[125,159],[119,162],[116,171],[128,170],[134,173],[139,174],[142,181],[136,183],[128,180],[117,181],[105,184],[91,186],[78,190],[68,189],[62,186],[60,181],[67,180],[76,179],[80,175],[79,169],[75,164]], [[92,164],[98,160],[105,161],[103,171],[97,175],[91,175]]],
|
||||
[[[83,191],[88,188],[93,188],[95,194],[92,201],[100,199],[102,190],[108,185],[115,187],[116,192],[111,199],[119,197],[121,190],[127,187],[132,190],[130,197],[126,203],[122,208],[115,211],[107,209],[103,214],[97,211],[95,207],[86,211],[80,212],[77,207],[76,201],[73,198],[75,194]]]
|
||||
]},
|
||||
{"character":"山", "bounds":[57,250,145,313], "placement":[0.20,0.83,0.57,0.38], "shapes":[
|
||||
[[[58,305],[64,298],[68,287],[72,277],[78,277],[81,282],[80,289],[78,296],[87,294],[91,282],[95,270],[99,257],[105,255],[110,251],[115,255],[118,262],[113,268],[109,272],[108,281],[105,289],[107,293],[114,292],[115,281],[119,275],[125,277],[129,281],[129,287],[125,293],[131,294],[135,291],[140,294],[143,302],[145,307],[138,307],[127,306],[117,307],[106,308],[94,308],[83,311],[71,313],[62,311],[57,309]]]
|
||||
]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"scale": [1.30, 1.30, 1.20],
|
||||
"leftBowlDepth": 0.42,
|
||||
"cleftDepth": 0.19,
|
||||
"leftShoulderLift": 0.20,
|
||||
"rightFaceOffset": 0.10,
|
||||
"bedrockRadius": 2.15,
|
||||
"bedrockBlend": 0.8,
|
||||
"bedrockOverlap": 0.12
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"""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.')
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Author and bake a dedicated light-grey granite material in Blender.
|
||||
|
||||
The procedural material is retained for editing; the temporary bake plane is
|
||||
removed. Other terrain textures and the monument's silhouette stay unchanged.
|
||||
Run detail-marker-inscription.py afterwards to rebake the preserved lettering.
|
||||
"""
|
||||
import bpy, json, math
|
||||
from pathlib import Path
|
||||
from mathutils import Vector
|
||||
from mathutils.noise import noise_vector
|
||||
|
||||
ROOT = Path('D:/project/sunrise')
|
||||
spec = json.loads((ROOT/'scripts/constants/marker-granite.json').read_text())
|
||||
scene = bpy.data.scenes['Summit slab and marker']
|
||||
bpy.context.window.scene = scene
|
||||
body = bpy.data.objects['Photo matched summit boulder']
|
||||
mat = bpy.data.materials.get('Marker grey granite authoring') or bpy.data.materials.new('Marker grey granite authoring')
|
||||
mat.use_nodes = True
|
||||
mat.use_fake_user = True
|
||||
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
||||
nodes.clear()
|
||||
bs = nodes.new('ShaderNodeBsdfPrincipled')
|
||||
out = nodes.new('ShaderNodeOutputMaterial')
|
||||
links.new(bs.outputs['BSDF'],out.inputs['Surface'])
|
||||
uv = nodes.new('ShaderNodeTexCoord')
|
||||
|
||||
def noise(scale):
|
||||
n = nodes.new('ShaderNodeTexNoise')
|
||||
n.inputs['Scale'].default_value = scale
|
||||
n.inputs['Detail'].default_value = 2
|
||||
links.new(uv.outputs['UV'],n.inputs['Vector'])
|
||||
return n
|
||||
|
||||
grain = noise(spec['grainScale'])
|
||||
fine = noise(spec['fineScale'])
|
||||
cloud = noise(18)
|
||||
ramp = nodes.new('ShaderNodeValToRGB')
|
||||
for i,(position,color) in enumerate(spec['grainColors']):
|
||||
element = ramp.color_ramp.elements[i] if i < 2 else ramp.color_ramp.elements.new(position)
|
||||
element.position = position
|
||||
element.color = color
|
||||
links.new(grain.outputs['Fac'],ramp.inputs['Fac'])
|
||||
crystal = nodes.new('ShaderNodeTexVoronoi')
|
||||
crystal.inputs['Scale'].default_value = spec['crystalScale']
|
||||
links.new(uv.outputs['UV'],crystal.inputs['Vector'])
|
||||
mica = nodes.new('ShaderNodeValToRGB')
|
||||
mica.color_ramp.elements[0].position = .12
|
||||
mica.color_ramp.elements[0].color = (.11,.12,.13,1)
|
||||
mica.color_ramp.elements[1].position = .26
|
||||
mica.color_ramp.elements[1].color = (1,1,1,1)
|
||||
links.new(crystal.outputs['Distance'],mica.inputs['Fac'])
|
||||
mix = nodes.new('ShaderNodeMixRGB')
|
||||
mix.blend_type = next(i.identifier for i in mix.bl_rna.properties['blend_type'].enum_items if i.identifier == 'MULTIPLY')
|
||||
mix.inputs[0].default_value = .8
|
||||
links.new(ramp.outputs['Color'],mix.inputs[1])
|
||||
links.new(mica.outputs['Color'],mix.inputs[2])
|
||||
cloud_ramp = nodes.new('ShaderNodeValToRGB')
|
||||
cloud_ramp.color_ramp.elements[0].position = .3
|
||||
cloud_ramp.color_ramp.elements[0].color = (.36,.38,.37,1)
|
||||
cloud_ramp.color_ramp.elements[1].position = .65
|
||||
cloud_ramp.color_ramp.elements[1].color = (1,1,1,1)
|
||||
links.new(cloud.outputs['Fac'],cloud_ramp.inputs['Fac'])
|
||||
weather = nodes.new('ShaderNodeMixRGB')
|
||||
weather.blend_type = mix.blend_type
|
||||
weather.inputs[0].default_value = .5
|
||||
links.new(mix.outputs[0],weather.inputs[1])
|
||||
links.new(cloud_ramp.outputs[0],weather.inputs[2])
|
||||
# The same source as the underlying summit rock anchors the monument's palette.
|
||||
terrain = nodes.new('ShaderNodeTexImage')
|
||||
terrain.image = bpy.data.images.load(str(ROOT/'public/terrain/materials/rock-color.jpg'),check_existing=True)
|
||||
links.new(uv.outputs['UV'],terrain.inputs['Vector'])
|
||||
tone = nodes.new('ShaderNodeHueSaturation')
|
||||
tone.inputs['Saturation'].default_value = spec['terrainSaturation']
|
||||
tone.inputs['Value'].default_value = spec['terrainBrightness']
|
||||
links.new(terrain.outputs['Color'],tone.inputs['Color'])
|
||||
slab = bpy.data.objects['SLAB_SURFACE']
|
||||
root = bpy.data.objects['summit-marker']
|
||||
nearest = min(slab.data.vertices,key=lambda v:(v.co.x-root.location.x)**2+(v.co.y-root.location.y)**2)
|
||||
loop = next(l for l in slab.data.loops if l.vertex_index == nearest.index)
|
||||
ground_uv = slab.data.uv_layers.active.data[loop.index].uv
|
||||
ground = nodes.new('ShaderNodeTexImage')
|
||||
ground.image = bpy.data.images['Summit unique weathered granite']
|
||||
ground.inputs['Vector'].default_value = (ground_uv.x,ground_uv.y,0)
|
||||
palette = nodes.new('ShaderNodeMixRGB')
|
||||
palette.inputs[0].default_value = spec['groundPaletteBlend']
|
||||
links.new(tone.outputs['Color'],palette.inputs[1])
|
||||
links.new(ground.outputs['Color'],palette.inputs[2])
|
||||
surface = nodes.new('ShaderNodeMixRGB')
|
||||
surface.inputs[0].default_value = spec['terrainBlend']
|
||||
links.new(weather.outputs[0],surface.inputs[1])
|
||||
links.new(palette.outputs[0],surface.inputs[2])
|
||||
links.new(surface.outputs[0],bs.inputs['Base Color'])
|
||||
bs.inputs['Roughness'].default_value = spec['roughness']
|
||||
bump = nodes.new('ShaderNodeBump')
|
||||
bump.inputs['Distance'].default_value = spec['relief']
|
||||
bump.inputs['Strength'].default_value = .65
|
||||
links.new(grain.outputs['Fac'],bump.inputs['Height'])
|
||||
micro = nodes.new('ShaderNodeBump')
|
||||
micro.inputs['Distance'].default_value = spec['relief']*.18
|
||||
micro.inputs['Strength'].default_value = .4
|
||||
terrain_normal = nodes.new('ShaderNodeTexImage')
|
||||
terrain_normal.image = bpy.data.images.load(str(ROOT/'public/terrain/materials/rock-normal.jpg'),check_existing=True)
|
||||
terrain_normal.image.colorspace_settings.name = 'Non-Color'
|
||||
links.new(uv.outputs['UV'],terrain_normal.inputs['Vector'])
|
||||
normal = nodes.new('ShaderNodeNormalMap')
|
||||
normal.inputs['Strength'].default_value = .65
|
||||
links.new(terrain_normal.outputs['Color'],normal.inputs['Color'])
|
||||
links.new(normal.outputs['Normal'],bump.inputs['Normal'])
|
||||
links.new(fine.outputs['Fac'],micro.inputs['Height'])
|
||||
links.new(bump.outputs['Normal'],micro.inputs['Normal'])
|
||||
links.new(micro.outputs['Normal'],bs.inputs['Normal'])
|
||||
|
||||
mesh = bpy.data.meshes.new('Temporary granite bake plane')
|
||||
mesh.from_pydata([(0,0,0),(1,0,0),(1,1,0),(0,1,0)],[],[(0,1,2,3)])
|
||||
mesh.uv_layers.new(name='UVMap')
|
||||
for loop in mesh.loops:
|
||||
mesh.uv_layers.active.data[loop.index].uv = mesh.vertices[loop.vertex_index].co.xy
|
||||
plane = bpy.data.objects.new(mesh.name,mesh)
|
||||
scene.collection.objects.link(plane)
|
||||
mesh.materials.append(mat)
|
||||
for obj in scene.objects:
|
||||
obj.select_set(False)
|
||||
plane.select_set(True)
|
||||
bpy.context.view_layer.objects.active = plane
|
||||
target = nodes.new('ShaderNodeTexImage')
|
||||
nodes.active = target
|
||||
images = {}
|
||||
try:
|
||||
for suffix,bake_type in [('color','DIFFUSE'),('normal','NORMAL')]:
|
||||
name = 'marker-grey-granite-'+suffix
|
||||
image = bpy.data.images.get(name) or bpy.data.images.new(name,width=spec['resolution'],height=spec['resolution'],alpha=False)
|
||||
if suffix == 'normal':
|
||||
image.colorspace_settings.name = 'Non-Color'
|
||||
target.image = image
|
||||
scene.render.bake.use_pass_direct = False
|
||||
scene.render.bake.use_pass_indirect = False
|
||||
scene.render.bake.use_pass_color = True
|
||||
bpy.ops.object.bake(type=next(i.identifier for i in bpy.ops.object.bake.get_rna_type().properties['type'].enum_items if i.identifier == bake_type))
|
||||
image.filepath_raw = str(ROOT/'public/terrain/materials'/(name+'.png'))
|
||||
image.file_format = next(i.identifier for i in image.bl_rna.properties['file_format'].enum_items if i.identifier == 'PNG')
|
||||
image.save()
|
||||
image.pack()
|
||||
images[suffix] = image
|
||||
finally:
|
||||
bpy.data.objects.remove(plane,do_unlink=True)
|
||||
bpy.data.meshes.remove(mesh)
|
||||
|
||||
# Replace only this monument's source textures; surrounding rocks keep theirs.
|
||||
for i,material in enumerate(list(body.data.materials)):
|
||||
if material.users > 1:
|
||||
material = material.copy()
|
||||
body.data.materials[i] = material
|
||||
for n in material.node_tree.nodes:
|
||||
if n.type != 'TEX_IMAGE' or not n.image:
|
||||
continue
|
||||
if n.image.name.startswith(('rock-color','marker-grey-granite-color')):
|
||||
n.image = images['color']
|
||||
elif n.image.name.startswith(('rock-normal','marker-grey-granite-normal')):
|
||||
n.image = images['normal']
|
||||
shader = next(n for n in material.node_tree.nodes if n.type == 'BSDF_PRINCIPLED')
|
||||
shader.inputs['Roughness'].default_value = spec['roughness']
|
||||
|
||||
# Recompute neutral weathering, rather than repeatedly brightening the old tint.
|
||||
colors = body.data.color_attributes['Granite weathering']
|
||||
for v in body.data.vertices:
|
||||
x,y,z = v.co
|
||||
panel = abs((x-.18)/max(.16,.57-.18*z))
|
||||
rim = min(1,max(0,(panel-.8)/.4))
|
||||
rim = rim*rim*(3-2*rim)
|
||||
weathering = .045*noise_vector(Vector((x*9,z*9,2.7)))[0]
|
||||
seam = .05*math.exp(-((x+.32-.18*z)/.07)**2)
|
||||
flecks = noise_vector(Vector((x*31,z*31,y*31)))[0]
|
||||
patina = .20*max(0,min(1,(flecks-.05)*3))*rim
|
||||
cleft = .12*math.exp(-((x+.18-.12*z)/.085)**2)
|
||||
grey = spec['faceTint']*(1-rim)+spec['rimTint']*rim+weathering-seam-patina-cleft
|
||||
colors.data[v.index].color = (grey,grey,grey,1)
|
||||
body['graniteFinish'] = 'Light neutral grey quartz/feldspar, dark mica specks, granular microrelief; photo-guided'
|
||||
print('Dedicated grey granite baked; marker source nodes updated, geometry and inscription unchanged.')
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Shape the left hollow and deep cleft from the supplied oblique photos.
|
||||
|
||||
Restores a saved mesh baseline before every run so edits never accumulate.
|
||||
The front UV atlas and inscription remain attached to their original vertices.
|
||||
"""
|
||||
import bpy, math, json
|
||||
from mathutils import Vector
|
||||
from pathlib import Path
|
||||
ROOT = Path('D:/project/sunrise')
|
||||
spec = json.loads((ROOT/'scripts/constants/marker-shape.json').read_text())
|
||||
scene = bpy.data.scenes['Summit slab and marker']
|
||||
bpy.context.window.scene = scene
|
||||
body = bpy.data.objects['Photo matched summit boulder']
|
||||
root = bpy.data.objects['summit-marker']
|
||||
baseline = bpy.data.meshes.get('Marker before asymmetric hollow')
|
||||
if baseline is None:
|
||||
baseline = body.data.copy()
|
||||
baseline.name = 'Marker before asymmetric hollow'
|
||||
baseline.use_fake_user = True
|
||||
mesh = body.data
|
||||
for vertex, source in zip(mesh.vertices,baseline.vertices):
|
||||
x,y,z = source.co
|
||||
front = max(0,min(1,(.15-y)/.5))
|
||||
bowl = math.exp(-((x+.55)/.34)**2-((z-.86)/.67)**4)
|
||||
# A narrow, slanting cleft between the sunken left bowl and inscribed slab.
|
||||
cleft = math.exp(-((x+.18-.12*z)/.065)**2)*math.exp(-((z-.86)/.88)**4)
|
||||
shoulder = math.exp(-((x+.44)/.48)**2)*max(0,min(1,(z-.8)/.7))
|
||||
vertex.co = (x-.13*shoulder+spec['rightFaceOffset']*max(0,min(1,(x+.12)/.6)),
|
||||
y+front*(spec['leftBowlDepth']*bowl+spec['cleftDepth']*cleft),
|
||||
z+spec['leftShoulderLift']*shoulder)
|
||||
mesh.update()
|
||||
root.scale = spec['scale']
|
||||
root['blockedRadius'] = round(max(math.hypot(v.co.x*root.scale.x,v.co.y*root.scale.y) for v in mesh.vertices)+.12,2)
|
||||
body['asymmetricHollow'] = True
|
||||
# Close the local fissure under the enlarged stone using the shared terrain mesh.
|
||||
# This surface is exported for both rendering and walking; no separate pedestal.
|
||||
bpy.context.view_layer.update()
|
||||
slab = bpy.data.objects['SLAB_SURFACE']
|
||||
ground_baseline = bpy.data.meshes.get('Slab before marker seating')
|
||||
if ground_baseline is None:
|
||||
ground_baseline = slab.data.copy()
|
||||
ground_baseline.name = 'Slab before marker seating'
|
||||
ground_baseline.use_fake_user = True
|
||||
base = min((body.matrix_world@v.co).z for v in mesh.vertices)
|
||||
for vertex,source in zip(slab.data.vertices,ground_baseline.vertices):
|
||||
vertex.co = source.co
|
||||
d = math.hypot(vertex.co.x-root.location.x,vertex.co.y-root.location.y)
|
||||
if d >= spec['bedrockRadius']+spec['bedrockBlend']:
|
||||
continue
|
||||
t = max(0,min(1,(d-spec['bedrockRadius'])/spec['bedrockBlend']))
|
||||
weight = 1-t*t*(3-2*t)
|
||||
target = base+spec['bedrockOverlap']+.025*math.sin(vertex.co.x*3+vertex.co.y*2)
|
||||
if vertex.co.z < target:
|
||||
vertex.co.z += (target-vertex.co.z)*weight
|
||||
slab.data.update()
|
||||
scene.camera.location = root.location+Vector((1.7,-10,3.5))
|
||||
scene.camera.rotation_euler = (root.location+Vector((0,0,1.5))-scene.camera.location).to_track_quat('-Z','Y').to_euler()
|
||||
scene.camera.data.lens = 55
|
||||
print('Asymmetric left hollow sculpted; scale',spec['scale'],'blocked radius',root['blockedRadius'])
|
||||
Reference in New Issue
Block a user