Refine Gwanaksan summit marker proportions and inscription

This commit is contained in:
2026-10-31 09:49:29 +09:00
parent 28a45ff8e3
commit 1b8698565a
20 changed files with 543 additions and 10 deletions
+21
View File
@@ -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]]
]
}
+29
View File
@@ -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]]]
]}
]
}
+10
View File
@@ -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
}
+180
View File
@@ -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.')
+178
View File
@@ -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.')
+59
View File
@@ -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'])