feat: initialize sunrise with 100-player shared rooms

This commit is contained in:
2026-10-30 16:36:35 +09:00
commit 31d6eac4c2
257 changed files with 66063 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
"""Hand-drawn, pressure-varied Hanja pigment on the existing stone atlas; no fonts."""
import bpy,numpy as np,math
s=bpy.data.scenes['Summit slab and marker'];bpy.context.window.scene=s
body=bpy.data.objects['Photo matched summit boulder'];mat=body.data.materials[0];nodes=mat.node_tree.nodes;links=mat.node_tree.links
N=2048;mask=np.zeros((N,N),dtype=np.float32)
# Each control point is x, y (down), brush width. Sweeps and hooked ends are individually drawn.
冠=[[(.13,.17,.045),(.10,.26,.078),(.08,.34,.018)],[(.12,.20,.035),(.48,.17,.062),(.87,.14,.085),(.83,.30,.015)],[(.22,.38,.025),(.39,.35,.055),(.54,.36,.018)],[(.16,.53,.022),(.35,.49,.065),(.56,.48,.027)],[(.29,.53,.072),(.26,.70,.052),(.18,.81,.032),(.11,.85,.007)],[(.45,.51,.073),(.42,.72,.062),(.46,.87,.047),(.66,.88,.066),(.87,.83,.076),(.90,.69,.010)],[(.57,.43,.025),(.74,.41,.055),(.91,.40,.012)],[(.77,.29,.032),(.78,.47,.069),(.76,.69,.048),(.69,.73,.014)],[(.59,.54,.024),(.65,.58,.070),(.67,.62,.012)]]
岳=[[(.73,.10,.025),(.52,.16,.072),(.29,.21,.027)],[(.28,.22,.074),(.26,.40,.063),(.24,.56,.036)],[(.30,.35,.020),(.50,.34,.057),(.72,.31,.021)],[(.68,.34,.059),(.66,.46,.068),(.65,.56,.027)],[(.12,.58,.017),(.43,.56,.066),(.86,.54,.020)],[(.49,.64,.035),(.50,.77,.080),(.48,.91,.037)],[(.22,.72,.035),(.23,.91,.072),(.45,.92,.049),(.78,.88,.044)],[(.79,.68,.029),(.80,.77,.074),(.77,.93,.018)]]
山=[[(.49,.12,.024),(.50,.32,.083),(.48,.56,.069),(.49,.82,.045)],[(.18,.39,.035),(.19,.62,.078),(.20,.86,.072),(.43,.85,.043),(.80,.81,.063)],[(.82,.34,.032),(.81,.56,.083),(.80,.78,.061),(.76,.87,.012)]]
def brush(points,cx,top,w,h,lean,seed):
rng=np.random.default_rng(seed)
controls=np.array(points)
# Catmull-Rom interpolation produces continuous hand motion rather than straight font stems.
padded=np.vstack([controls[0],controls,controls[-1]])
samples=[]
for i in range(1,len(padded)-2):
a,b,c,d=padded[i-1:i+3]
for t in np.linspace(0,1,28,endpoint=False):
samples.append(.5*((2*b)+(-a+c)*t+(2*a-5*b+4*c-d)*t*t+(-a+3*b-3*c+d)*t*t*t))
samples.append(controls[-1])
for k,(x,y,width) in enumerate(samples):
px=cx+(x-.5)*w+lean*(.5-y);pz=top-y*h
u=(.02+.46*(px+1.3)/2.6)*(N-1);v=(.02+.96*(pz+.2)/2.3)*(N-1)
rx=max(.45,width*w*.46/2.6*N*.5);ry=max(.6,width*h*.96/2.3*N*.5)
rx*=1+.08*math.sin(k*.73+seed)
x0=max(0,int(u-rx-2));x1=min(N,int(u+rx+3));y0=max(0,int(v-ry-2));y1=min(N,int(v+ry+3))
X,Y=np.meshgrid(np.arange(x0,x1)+.5,np.arange(y0,y1)+.5)
radius=np.sqrt(((X-u)/rx)**2+((Y-v)/ry)**2)
dab=np.clip((1.10-radius)*5,0,1)
mask[y0:y1,x0:x1]=np.maximum(mask[y0:y1,x0:x1],dab)
for char,cx,top,w,h,lean,seed in [(冠,.18,1.78,.48,.45,.025,13),(岳,.15,1.29,.43,.41,-.012,31),(山,.20,.83,.47,.40,.018,58)]:
for i,stroke in enumerate(char):brush(stroke,cx,top,w,h,lean,seed+i*7)
# Small mineral-shaped losses, sparse chips and uneven pigment opacity; retain legibility.
rng=np.random.default_rng(194);Y,X=np.indices(mask.shape)
wear=.89+.07*np.sin(X*.19+np.sin(Y*.27))+.035*rng.random(mask.shape)
chips=rng.random(mask.shape)<.025
mask*=np.where(chips,.12,wear)
for _ in range(55):
occupied=np.argwhere(mask>.7)
y,x=occupied[rng.integers(len(occupied))];rx=rng.uniform(.6,1.7);ry=rng.uniform(.8,2.5)
x0=max(0,x-4);x1=min(N,x+5);y0=max(0,y-5);y1=min(N,y+6)
yy,xx=np.indices((y1-y0,x1-x0));patch=((xx+x0-x)/rx)**2+((yy+y0-y)/ry)**2<1
mask[y0:y1,x0:x1]*=np.where(patch,.25,1)
image_mask=bpy.data.images['Hanja pigment mask'];rgba=np.ones((N,N,4),dtype=np.float32);rgba[:,:,:3]=mask[:,:,None]
image_mask.pixels.foreach_set(rgba.ravel());image_mask.update();image_mask.pack()
bs=next(n for n in nodes if n.type=='BSDF_PRINCIPLED')
paint=next(n for n in nodes if n.type=='MIX_RGB' and n.inputs[0].is_linked)
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 o in s.objects:o.select_set(False)
body.select_set(True);bpy.context.view_layer.objects.active=body
s.render.bake.use_pass_direct=False;s.render.bake.use_pass_indirect=False;s.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=='DIFFUSE'))
target.image.save();target.image.pack();links.new(target.outputs['Color'],bs.inputs['Base Color'])
body['inscriptionStyle']='Hand-drawn brush paths, pressure variation and worn pigment; no font rasterization'
s.render.filepath='D:/project/sunrise/assets/summit/blender-plateau-study/marker-brush-painted.png'
bpy.ops.render.render(write_still=True)
print('Painted 20 hand-drawn brush paths with tapered hooks and sparse pigment wear.')
+185
View File
@@ -0,0 +1,185 @@
"""Photo-guided Blender study. Run through Blender MCP; export only the rock collection.
Blender: X east, Y north, Z up, origin at radar base. Geometry and sizes are estimates.
The existing facility scene is retained. Source is saved to its own .blend file.
"""
import bpy, bmesh, math, json, random
from pathlib import Path
from mathutils import Vector, noise
ROOT = Path('D:/project/sunrise')
OUT = ROOT / 'assets/summit/blender-rock-study'
context = json.loads((OUT/'context.json').read_text(encoding='utf-8'))
SCENE_NAME = 'Photo-guided summit rocks'
if bpy.data.scenes.get(SCENE_NAME):
raise RuntimeError('Study already exists; edit it in place rather than rebuilding.')
scene = bpy.data.scenes.new(SCENE_NAME)
bpy.context.window.scene = scene
rocks = bpy.data.collections.new('Photo rocks - export')
study = bpy.data.collections.new('Terrain and radar - context only')
scene.collection.children.link(rocks)
scene.collection.children.link(study)
def enum(owner, prop, wanted):
return next(item.identifier for item in owner.bl_rna.properties[prop].enum_items if item.identifier == wanted)
def material(name, color):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
node = next(n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED')
node.inputs['Base Color'].default_value = (*color,1)
node.inputs['Roughness'].default_value = .94
mat.diffuse_color = (*color,1)
return mat
granite = material('Photo study / weathered granite', (.44,.43,.38))
nodes=granite.node_tree.nodes
bsdf=next(n for n in nodes if n.type=='BSDF_PRINCIPLED')
tex=nodes.new('ShaderNodeTexImage')
tex.image=bpy.data.images.load(str(ROOT/'public/terrain/materials/rock-color.jpg'),check_existing=True)
granite.node_tree.links.new(tex.outputs['Color'],bsdf.inputs['Base Color'])
def mesh_object(name, vertices, faces, collection, mat):
mesh=bpy.data.meshes.new(name)
mesh.from_pydata(vertices,[],faces);mesh.update()
obj=bpy.data.objects.new(name,mesh);collection.objects.link(obj)
mesh.materials.append(mat)
return obj
def sculpt_loft(name, outline, levels, seed, relief=.18):
# Non-convex plan retains deep vertical joints instead of rounding them into potatoes.
rng=random.Random(seed)
count=len(outline)
center=Vector((sum(p[0] for p in outline)/count,sum(p[1] for p in outline)/count))
verts=[]
offsets=[rng.uniform(-.75,.75) for _ in outline]
for level,(z,scale,dx,dy) in enumerate(levels):
for j,(x,y) in enumerate(outline):
verts.append((center.x+(x-center.x)*scale+dx,
center.y+(y-center.y)*scale+dy,z+offsets[j]*(.3 if level==0 else 1)))
faces=[]
for level in range(len(levels)-1):
for j in range(count):
k=(j+1)%count
faces.append((level*count+j,level*count+k,(level+1)*count+k,(level+1)*count+j))
faces.append(tuple(reversed(range(count))))
faces.append(tuple((len(levels)-1)*count+j for j in range(count)))
obj=mesh_object(name,verts,faces,rocks,granite)
bm=bmesh.new();bm.from_mesh(obj.data)
bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
bmesh.ops.triangulate(bm,faces=list(bm.faces))
bmesh.ops.subdivide_edges(bm,edges=list(bm.edges),cuts=3,use_grid_fill=True)
bm.normal_update()
for vertex in bm.verts:
p=vertex.co.copy()
# Small weathering preserves the authored fracture planes and crest silhouette.
amount=noise.noise_vector(p*.72+Vector((seed,0,0))).x*relief
vertex.co+=vertex.normal*amount
bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
bm.to_mesh(obj.data);bm.free();obj.data.update()
uv=obj.data.uv_layers.new(name='Granite metre UV')
colors=obj.data.color_attributes.new(name='Weathering',type=enum(bpy.types.Attribute,'data_type','FLOAT_COLOR'),domain=enum(bpy.types.Attribute,'domain','CORNER'))
for face in obj.data.polygons:
n=face.normal
# Stable projection per face, at the same 3m texture scale as the app ground.
axis=max(range(3),key=lambda a:abs(n[a]))
for loop_index in face.loop_indices:
p=obj.data.vertices[obj.data.loops[loop_index].vertex_index].co
uv.data[loop_index].uv=((p.y,p.z) if axis==0 else (p.x,p.z) if axis==1 else (p.x,p.y))
uv.data[loop_index].uv/=3
shade=.86+.10*noise.noise_vector(p*.22+Vector((seed,0,0))).x
colors.data[loop_index].color=(shade,shade*.99,shade*.95,1)
face.use_smooth=True
# Auto-smooth via sharp edges: small surface weathering smooth, major joints retain ridges.
bm=bmesh.new();bm.from_mesh(obj.data)
for edge in bm.edges:
if edge.is_manifold and edge.calc_face_angle()>math.radians(35):edge.smooth=False
bm.to_mesh(obj.data);bm.free()
obj['reference']='User photos; authored fracture outline, estimated metres'
return obj
# Radar buttress: a continuous narrow crown opening out into a broad fractured wall.
sculpt_loft('Radar / north granite wall',
[(-9,-4),(-5,-7),(4,-6),(9,-2),(10,5),(7,8),(4,7),(3,10),(-1,12),(-2,9),(-5,11),(-9,7)],
[(-34,1.5,1,7),(-20,1.25,.8,3),(-10,1.08,0,1),(-3,.91,0,0),(-.7,.67,0,-1)],11,.15)
# One connected western rock. Concave outline produces the vertical clefts in the photo.
sculpt_loft('Western outcrop / rooted jointed wall',
[(-25,0),(-20,-3),(-14,-2),(-11,2),(-12,6),(-14,7),(-13,10),(-16,12),(-17,9),(-20,13),(-23,11),(-22,7),(-25,6)],
[(-33,1.38,0,7),(-20,1.2,0,3),(-10,1,0,1),(-2,.86,0,0),(2,.57,.2,-1)],23,.19)
# The thin upper slab is rooted well inside the wall; a slanted crown, no symmetric apex.
sculpt_loft('Western outcrop / leaning crown slab',
[(-19,-1),(-16,-2),(-14,0),(-14,3),(-16,5),(-18,4),(-19,2)],
[(-7,1.1,0,0),(0,1,0,0),(5,.69,-.7,-.4),(9,.31,-1.7,-.8)],31,.10)
sculpt_loft('Western outcrop / broken lower shoulder',
[(-26,1),(-22,-1),(-20,2),(-20,6),(-22,8),(-25,7)],
[(-20,1.15,0,2),(-8,1,0,0),(0,.67,.4,-.4),(3,.27,1,-.7)],47,.16)
# Sloping foot is a ridge, not a row of freestanding pillars.
sculpt_loft('Western outcrop / descending rock apron',
[(-28,12),(-23,10),(-18,13),(-15,20),(-15,27),(-20,34),(-25,33),(-29,24)],
[(-38,1.15,0,0),(-27,1,0,0),(-20,.77,0,-2),(-13,.40,0,-5)],59,.25)
sculpt_loft('Western / fractured front ledge',[(-23,8),(-20,7),(-17,9),(-17,12),(-19,14),(-23,12)],[(-19,1.1,0,0),(-11,1,0,0),(-7,.72,0,-.3)],71,.22)
sculpt_loft('Western / angular shoulder plates',[(-16,6),(-13,5),(-11,8),(-12,11),(-15,12),(-17,10)],[(-15,1.12,0,0),(-6,1,0,0),(-2,.6,-.6,-.7)],81,.2)
sculpt_loft('Western / low bedding shelf',[(-26,16),(-22,14),(-19,17),(-20,21),(-24,23),(-27,20)],[(-29,1.12,0,0),(-20,1,0,0),(-16,.62,.3,-.4)],91,.23)
sculpt_loft('Radar / split foreground ledge',[(2,9),(5,7),(8,10),(7,14),(4,15),(1,13)],[(-26,1.15,0,0),(-16,1,0,0),(-11,.63,-.6,-1)],101,.17)
# Match actual terrain contact and camera. This collection is excluded from the GLB.
rows,cols=context['rows'],context['columns']
faces=[]
for row in range(rows-1):
for col in range(cols-1):
a=row*cols+col;faces.append((a,a+1,a+1+cols,a+cols))
terrain=mesh_object('App terrain / contact reference',context['vertices'],faces,study,material('Study terrain',(.21,.26,.16)))
root=bpy.data.objects.get('weather-radar')
copies={}
for source in [root,*root.children_recursive]:
obj=source.copy()
if source.data:obj.data=source.data.copy()
study.objects.link(obj);copies[source]=obj
for source,obj in copies.items():
obj.parent=copies.get(source.parent)
copies[root].location=(0,0,0)
camera_data=bpy.data.cameras.new('Photo comparison camera')
camera=bpy.data.objects.new(camera_data.name,camera_data);study.objects.link(camera)
camera.location=Vector(context['eye'])
camera.rotation_euler=(Vector((-8,7,-6))-camera.location).to_track_quat('-Z','Y').to_euler()
camera_data.lens=43;scene.camera=camera
scene.render.resolution_x=1200;scene.render.resolution_y=850;scene.render.resolution_percentage=100
scene.world=bpy.data.worlds.new('Photo study sky');scene.world.use_nodes=True
bg=next(n for n in scene.world.node_tree.nodes if n.type=='BACKGROUND')
bg.inputs['Color'].default_value=(.55,.64,.72,1);bg.inputs['Strength'].default_value=.65
sun_data=bpy.data.lights.new('Photo study sun',enum(bpy.types.Light,'type','SUN'))
sun_data.energy=2.2;sun_data.angle=.15
sun=bpy.data.objects.new(sun_data.name,sun_data);study.objects.link(sun)
sun.rotation_euler=(math.radians(32),math.radians(-25),math.radians(-45))
try:scene.render.engine='CYCLES'
except TypeError as exc:print(exc)
scene.cycles.samples=24
# Reference images remain local and are not exported with the model.
for i,path in enumerate([
'C:/Users/horoli/AppData/Local/Temp/codex-clipboard-453c752b-f410-4d6b-97ad-8056e3dac06d.png',
'C:/Users/horoli/AppData/Local/Temp/codex-clipboard-8cc46291-7018-4d33-a23e-553db9844d43.png']):
if Path(path).exists():
obj=bpy.data.objects.new('Photo reference '+str(i+1),None);study.objects.link(obj)
obj.empty_display_type=enum(bpy.types.Object,'empty_display_type','IMAGE')
obj.data=bpy.data.images.load(path,check_existing=True);obj.empty_display_size=30
obj.location=(45+i*35,0,5);obj.rotation_euler=(math.pi/2,0,0)
def export_rocks():
import io_scene_gltf2
for obj in scene.objects:obj.select_set(False)
for obj in rocks.objects:obj.select_set(True)
bpy.context.view_layer.objects.active=next(iter(rocks.objects))
fmt=next(item[0] for item in io_scene_gltf2.get_format_items(None,bpy.context) if item[0]=='GLB')
# App reuses its rock textures; UVs and vertex weathering travel with the geometry.
mats=next(item.identifier for item in bpy.ops.export_scene.gltf.get_rna_type().properties['export_materials'].enum_items if item.identifier=='NONE')
bpy.ops.export_scene.gltf(filepath=str(ROOT/'public/models/summit/radar-rocks.glb'),export_format=fmt,
use_selection=True,use_active_scene=True,export_yup=True,export_apply=True,
export_materials=mats,export_animations=False,export_cameras=False,export_lights=False,export_extras=True)
bpy.ops.wm.save_as_mainfile(filepath=str(OUT/'photo-guided-rocks.blend'))
print('Exported photo-guided rocks:',sum(len(o.data.polygons) for o in rocks.objects),'faces')
bpy.app.driver_namespace['photo_rocks']=globals()
print('Study ready:',len(rocks.objects),'rock meshes. Original scene retained.')
+443
View File
@@ -0,0 +1,443 @@
"""Run in Blender through MCP. Reuses the snapshot of src/constants/structures.ts.
Builds a separate scene, then exposes export_assets() and setup_studio().
No source scene objects or external textures are changed.
"""
import bpy
import bmesh
import json
import math
from pathlib import Path
from mathutils import Vector
ROOT = Path('D:/project/sunrise')
SOURCE = ROOT / 'assets/summit'
OUTPUT = ROOT / 'public/models/summit'
OUTPUT.mkdir(parents=True, exist_ok=True)
SPECS = json.loads((SOURCE / 'structure-specs.json').read_text(encoding='utf-8'))
SCENE = bpy.data.scenes.get('Summit Assets - refined') or bpy.data.scenes.new('Summit Assets - refined')
bpy.context.window.scene = SCENE
SCENE.unit_settings.system = 'METRIC'
SCENE.unit_settings.scale_length = 1
ASSETS = []
def enum_value(owner, prop, identifier):
return next(i.identifier for i in owner.bl_rna.properties[prop].enum_items if i.identifier == identifier)
def material(name, hex_color, roughness=0.65, metal=0):
existing = bpy.data.materials.get('Summit / ' + name)
if existing:
return existing
rgb = [int(hex_color[i:i+2], 16)/255 for i in (0, 2, 4)]
rgba = [v/12.92 if v <= .04045 else ((v+.055)/1.055)**2.4 for v in rgb] + [1]
mat = bpy.data.materials.new('Summit / ' + name)
mat.diffuse_color = rgba
mat.use_nodes = True
node = next(n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED')
node.inputs['Base Color'].default_value = rgba
node.inputs['Roughness'].default_value = roughness
node.inputs['Metallic'].default_value = metal
return mat
MATS = {
'galvanized': material('Galvanized steel', 'a5b0b5', .4, .7),
'red': material('Faded aviation red', 'a94f3d', .64, .25),
'dark': material('Graphite metal', '37413e', .55, .45),
'concrete': material('Warm concrete', 'b7b4a8', .92),
'green': material('Muted green cladding', '6e8b7f', .7, .15),
'seam': material('Panel joints', '8f9c98', .85),
'white': material('Antenna ivory', 'e2e4dc', .52, .08),
'panel0': material('Radome warm white', 'e8e9df', .65),
'panel1': material('Radome pearl', 'dce2da', .65),
'panel2': material('Radome light gray', 'd2dcd5', .68),
'wood0': material('Deck weathered oak', '9b896c', .88),
'wood1': material('Deck warm oak', 'aa9475', .88),
'wood2': material('Deck gray oak', '918774', .9),
'wood3': material('Deck dark oak', '84785f', .9),
'groove': material('Deck grooves', '6e6654', .95),
'rail': material('Dark timber railing', '454b42', .86),
}
class Meshes:
"""Accumulate disconnected primitives by material instead of thousands of objects."""
def __init__(self):
self.parts = {}
def add(self, mat, verts, faces):
vs, fs = self.parts.setdefault(mat, ([], []))
offset = len(vs)
vs.extend(tuple(v) for v in verts)
fs.extend(tuple(offset+i for i in face) for face in faces)
def box(self, mat, center, size):
x, y, z = center
a, b, c = (v/2 for v in size)
self.add(mat, [(x+dx*a, y+dy*b, z+dz*c) for dx, dy, dz in
[(-1,-1,-1),(1,-1,-1),(1,1,-1),(-1,1,-1),
(-1,-1,1),(1,-1,1),(1,1,1),(-1,1,1)]],
[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)])
def beam(self, mat, a, b, radius, sides=8, end_radius=None):
a, b = Vector(a), Vector(b)
direction = (b-a).normalized()
helper = Vector((0,0,1)) if abs(direction.z) < .95 else Vector((1,0,0))
u = direction.cross(helper).normalized()
v = direction.cross(u)
verts = []
for point, r in [(a, radius), (b, radius if end_radius is None else end_radius)]:
verts.extend(point + r*(math.cos(i*math.tau/sides)*u + math.sin(i*math.tau/sides)*v) for i in range(sides))
faces = [tuple(reversed(range(sides))), tuple(range(sides, 2*sides))]
faces.extend((i,(i+1)%sides,(i+1)%sides+sides,i+sides) for i in range(sides))
self.add(mat, verts, faces)
def finish(self, root):
for key, (verts, faces) in self.parts.items():
mesh = bpy.data.meshes.new(root.name + '/' + key)
mesh.from_pydata(verts, [], faces)
mesh.update()
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
bm.to_mesh(mesh)
bm.free()
obj = bpy.data.objects.new(mesh.name, mesh)
root.users_collection[0].objects.link(obj)
obj.parent = root
obj.data.materials.append(MATS[key])
if key.startswith('wood') or key == 'rail':
modifier = obj.modifiers.new('Subtle edge wear', enum_value(bpy.types.Modifier, 'type', 'BEVEL'))
modifier.width = .006
modifier.segments = 1
obj['materialBatch'] = key
def root_for(spec, kind, gallery):
coll = bpy.data.collections.new(spec['id'])
SCENE.collection.children.link(coll)
root = bpy.data.objects.new(spec['id'], None)
coll.objects.link(root)
root.location = gallery
root['assetId'] = spec['id']
root['dimensionsEstimated'] = True
root['source'] = spec['source']
root['kind'] = kind
root['units'] = 'metres'
root['generationSpecJson'] = json.dumps(spec)
root.asset_mark()
root.asset_data.description = 'Gwanaksan ' + kind + '; estimated dimensions; photo-informed approximation.'
ASSETS.append({'root': root, 'spec': spec, 'kind': kind, 'supports': []})
return root
def tower(spec, index):
root = root_for(spec, 'tower', ((index-2)*12, 17, 0))
m = Meshes()
h, w = spec['height'], spec['width']
levels = 12
def half(t):
return w/2 * (1-2.5*t if t < .28 else .3 - .1*(t-.28)/.72)
def corner(t, j):
r = half(t)
return Vector(([-r,r,r,-r][j],[-r,-r,r,r][j],t*h))
for i in range(levels):
t, t1 = i/levels, (i+1)/levels
color = 'galvanized' if index == 0 or index == 2 else ('red' if (i//2)%2 == 0 else 'galvanized')
for j in range(4):
k = (j+1)%4
a,b,c,d = corner(t,j),corner(t,k),corner(t1,j),corner(t1,k)
m.beam(color,a,c,.075 if index == 0 else .095)
m.beam(color,a,b,.055)
m.beam(color,a,d,.034)
m.beam(color,b,c,.034)
if i == levels-1:
m.beam(color,c,d,.055)
# Base plates and anchors; terrain foundation itself stays in the application.
for j in range(4):
x,y,_ = corner(0,j)
m.box('dark',(x,y,.035),(.38,.38,.07))
for dx in [-.135,.135]:
for dy in [-.135,.135]:
m.beam('galvanized',(x+dx,y+dy,.07),(x+dx,y+dy,.13),.026,6)
# Maintenance platforms, open in the centre, with railings.
for fraction in [.32,.64,.93]:
z = h*fraction
r = half(fraction)+.43
for side in [-1,1]:
m.box('galvanized',(0,side*(r-.13),z),(2*r,.26,.085))
m.box('galvanized',(side*(r-.13),0,z),(.26,2*r-.52,.085))
for j in range(4):
a = Vector(([-r,r,r,-r][j],[-r,-r,r,r][j],z))
b = Vector(([-r,r,r,-r][(j+1)%4],[-r,-r,r,r][(j+1)%4],z))
for v in [a,b,(a+b)/2]:
m.beam('galvanized',v,v+Vector((0,0,.95)),.022)
for dz in [.48,.95]:
m.beam('galvanized',a+Vector((0,0,dz)),b+Vector((0,0,dz)),.019)
# Continuous service ladder and cable trunk.
for x in [-.22,.22]:
m.beam('dark',(x,0,.15),(x,0,h-.1),.025)
for n in range(int(h/.32)):
z = .25+n*.32
m.beam('galvanized',(-.22,0,z),(.22,0,z),.017,6)
m.beam('dark',(.32,.08,.1),(.32,.08,h),.045)
# Dish covers and radial brackets, distributed by height and azimuth.
dish_count = 3 if index == 0 else 8
for n in range(dish_count):
fraction = (.40+n*.16) if index == 0 else (.17+n*.065)
z = h*fraction
theta = (n*137.5 + index*31)*math.pi/180
direction = Vector((math.cos(theta),math.sin(theta),0))
base = direction*half(fraction)+Vector((0,0,z))
center = base+direction*.65
radius = .27 if index == 0 else .42+(n%3)*.13
m.beam('galvanized',base,center,.05)
m.beam('dark',center-direction*.08,center+direction*.03,radius,20)
m.beam('white',center+direction*.03,center+direction*.16,radius*.98,24,radius*.9)
for t in [.56,.75,.87]:
x = half(t)+.18
m.box('white',(x,0,t*h),(.22,.28,1.45 if index == 0 else 1.9))
m.beam('galvanized',(half(t),0,t*h),(x,0,t*h),.04)
# Keep the original four-metre antenna extension.
for n in range(4):
m.beam('red' if index and n%2 == 0 else 'white',(0,0,h+n),(0,0,h+n+1),.11 if index == 0 else .17,12)
for z in [h+.4,h+1.4,h+2.4]:
m.beam('dark',(-.25,0,z),(.25,0,z),.024)
m.finish(root)
def clipped(poly, floor):
out = []
for a,b in zip(poly, poly[1:]+poly[:1]):
if a.z >= floor:
out.append(a)
if (a.z >= floor) != (b.z >= floor):
out.append(a+(b-a)*((floor-a.z)/(b.z-a.z)))
return out
def radar():
spec = {'id':'weather-radar', **SPECS['radar']}
root = root_for(spec,'radar',(-17,-8,0))
m = Meshes()
h,r = spec['buildingHeight'],spec['radius']
m.beam('concrete',(0,0,0),(0,0,h-.2),4,32,3.7)
# Ribbed green cladding accents and concrete belt courses.
for z in [.18,2.8,5.5]:
m.beam('concrete',(0,0,z),(0,0,z+.18),4-.3*z/h+.055,32)
for n in range(32):
a = n*math.tau/32
p = Vector((math.cos(a),math.sin(a),0))
m.beam('green',p*3.97+Vector((0,0,.35)),p*3.73+Vector((0,0,h-.25)),.07,6)
m.beam('green',(0,0,h-.22),(0,0,h+.02),4.4,48)
m.beam('white',(0,0,h+.02),(0,0,h+.15),4.43,48)
# Balcony ring rails.
for n in range(48):
a,b = n*math.tau/48,(n+1)*math.tau/48
p,q = Vector((4.36*math.cos(a),4.36*math.sin(a),h+.15)),Vector((4.36*math.cos(b),4.36*math.sin(b),h+.15))
m.beam('green',p,p+Vector((0,0,.85)),.022)
for dz in [.43,.85]:
m.beam('green',p+Vector((0,0,dz)),q+Vector((0,0,dz)),.019)
m.box('dark',(0,-3.96,1.2),(1.25,.12,2.4))
m.box('green',(0,-4.04,1.2),(1.08,.07,2.25))
m.beam('galvanized',(.38,-4.09,1.03),(.38,-4.09,1.35),.024)
m.box('concrete',(0,-4.18,.09),(1.5,.5,.18))
for n in range(9):
m.box('dark',(.95,-3.85,2.9+n*.075),(.65,.10,.035))
# Dual of an icosphere creates hexagonal/pentagonal panels as in photo-09.
bm = bmesh.new()
bmesh.ops.create_icosphere(bm,subdivisions=3,radius=1)
bm.verts.ensure_lookup_table()
center = Vector((0,0,h+r*.8))
for index,v in enumerate(bm.verts):
axis = v.co.normalized()
u = axis.cross(Vector((0,0,1)) if abs(axis.z)<.95 else Vector((1,0,0))).normalized()
w = axis.cross(u)
points = [f.calc_center_median().normalized() for f in v.link_faces]
points.sort(key=lambda p: math.atan2(p.dot(w),p.dot(u)))
polygon = clipped([center+p*r for p in points], h+.17)
if len(polygon)<3:
continue
centroid = sum(polygon,Vector())/len(polygon)
# Slight inset leaves readable dark joints with no photographic texture.
outer = [centroid+(p-centroid)*.985+axis*.018 for p in polygon]
inner = [p-axis*.04 for p in polygon]
n = len(polygon)
m.add('seam',polygon+inner,[tuple(range(n)),tuple(reversed(range(n,2*n)))] + [(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)])
m.add('panel'+str(index%3),outer,[tuple(range(n))])
bm.free()
m.finish(root)
def deck(spec,index):
root = root_for(spec,'deck', (index*15,-9,4))
m = Meshes()
w,L,run,drop = (spec[k] for k in ['width','length','stairLength','stairDrop'])
thickness = SPECS['deck']['thickness']
rail_h = SPECS['railing']['height']
supports = ASSETS[-1]['supports']
# Blender +Y corresponds to Three.js -Z (the stair direction).
def plank(y,z,depth,index):
m.box('wood'+str(index%4),(0,y,z-thickness/2),(w,depth,thickness))
# Shallow grooves and visible fasteners, all batched by material.
for offset in [-.28,0,.28]:
m.box('groove',(0,y+offset*depth,z+.0005),(w-.035,.004,.001))
for x in [-w/2+.12,w/2-.12]:
m.beam('galvanized',(x,y,z),(x,y,z+.006),.012,6)
count = math.ceil(L/SPECS['deck']['plankWidth'])
for i in range(count):
plank(-L/2+(i+.5)*L/count,0,L/count-.009,i)
steps = math.ceil(drop/.18)
for i in range(1,steps+1):
y,z = L/2+(i-.5)*run/steps,-i*drop/steps
plank(y,z,run/steps+.015,i)
m.box('dark',(0,y-run/(2*steps),z-.08),(w-.06,.025,.12))
# Joists and stair stringers remain fixed geometry.
for x in [-w*.37,0,w*.37]:
m.box('dark',(x,0,-thickness-.085),(.11,L,.17))
m.beam('dark',(x,L/2,-.27),(x,L/2+run,-drop-.27),.09,4)
for i in range(math.ceil(L/.9)+1):
y = -L/2+i*L/math.ceil(L/.9)
m.box('dark',(0,y,-.27),(w,.08,.13))
seen = set()
def railing(a,b):
a,b = Vector(a),Vector(b)
count = math.ceil((b-a).length/SPECS['railing']['maxSpan'])
for i in range(count+1):
p = a.lerp(b,i/count)
key = tuple(round(v,5) for v in p)
if key in seen:
continue
seen.add(key)
m.beam('rail',p+Vector((0,0,-.32)),p+Vector((0,0,rail_h)),.072,8)
m.beam('rail',p+Vector((0,0,rail_h)),p+Vector((0,0,rail_h+.025)),.078,8)
m.box('dark',p+Vector((0,0,-.2)),(.19,.19,.05))
supports.append({'position':[p.x,p.z-.32,-p.y],'postWidth':SPECS['railing']['postWidth']})
for dz in [.5,rail_h-.035]:
m.beam('rail',a+Vector((0,0,dz)),b+Vector((0,0,dz)),.045,4)
for x in [-w/2,w/2]:
railing((x,-L/2,0),(x,L/2,0))
railing((x,L/2,0),(x,L/2+run,-drop))
m.finish(root)
root['terrainSupportAnchorsJson'] = json.dumps(supports)
if not any(obj.get('assetId') for obj in SCENE.objects):
for index,spec in enumerate(SPECS['towers']):
tower(spec,index)
radar()
for index,spec in enumerate(SPECS['decks']):
deck(spec,index)
else:
specs = {s['id']:s for s in SPECS['towers'] + SPECS['decks']}
specs['weather-radar'] = {'id':'weather-radar', **SPECS['radar']}
for obj in SCENE.objects:
if obj.get('assetId'):
spec = specs[obj['assetId']]
if json.loads(obj['generationSpecJson']) != spec:
raise ValueError('Dimension snapshot changed: regenerate in a new Blender file; existing geometry was preserved.')
anchors = json.loads(obj.get('terrainSupportAnchorsJson', '[]'))
ASSETS.append({'root':obj,'spec':spec,'kind':obj['kind'],'supports':anchors})
bpy.context.view_layer.update()
print('Created',len(ASSETS),'assets;',sum(len(a['root'].children) for a in ASSETS),'material batches')
def export_assets():
manifest = {'version':1,'units':'metres','upAxis':'Y','northAxis':'-Z',
'dimensionsEstimated':True,'photographsEmbedded':False,
'sourceConstants':'src/constants/structures.ts','assets':[]}
# Blender 5.2 exposes these dynamic items through the exporter callback, not RNA.
import io_scene_gltf2
formats = io_scene_gltf2.get_format_items(None, bpy.context)
fmt = next(item[0] for item in formats if item[0] == 'GLB')
for asset in ASSETS:
root,spec = asset['root'],asset['spec']
original = root.location.copy()
root.location = (0,0,0)
bpy.context.view_layer.update()
for obj in SCENE.objects:
obj.select_set(False)
root.select_set(True)
for obj in root.children:
obj.select_set(True)
bpy.context.view_layer.objects.active = root
try:
bpy.ops.export_scene.gltf(filepath=str(OUTPUT/(root.name+'.glb')),export_format=fmt,
use_selection=True,use_active_scene=True,export_apply=True,export_yup=True,export_extras=True,
export_animations=False,export_cameras=False,export_lights=False)
finally:
root.location = original
manifest['assets'].append({'id':root.name,'file':root.name+'.glb','kind':asset['kind'],
'latitude':spec['latitude'],'longitude':spec['longitude'],'heading':spec.get('heading',0),
'dimensionsEstimated':True,'positionEstimated':spec.get('estimatedPosition',True),
'source':spec['source'],'dimensions':{k:spec[k] for k in ['height','width','length','radius','buildingHeight','stairLength','stairDrop'] if k in spec},
'origin':'upper deck surface' if asset['kind']=='deck' else 'base contact plane',
'terrainSupportAnchors':asset['supports']})
bpy.context.view_layer.update()
(OUTPUT/'manifest.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2),encoding='utf-8')
print('Exported',len(manifest['assets']),'GLBs and manifest')
def setup_studio():
if SCENE.camera:
return SCENE.camera
studio = bpy.data.collections.new('Presentation only - excluded from exports')
SCENE.collection.children.link(studio)
groundmat = material('Studio slate','23333d',.9)
mesh = bpy.data.meshes.new('Studio ground')
mesh.from_pydata([(-2000,-2000,-.45),(2000,-2000,-.45),(2000,2000,-.45),(-2000,2000,-.45)],[],[(0,1,2,3)])
ground = bpy.data.objects.new('Studio ground',mesh)
studio.objects.link(ground)
ground.data.materials.append(groundmat)
world = bpy.data.worlds.new('Summit studio world')
SCENE.world = world
world.use_nodes = True
bg = next(n for n in world.node_tree.nodes if n.type=='BACKGROUND')
bg.inputs['Color'].default_value = (.38,.46,.55,1)
bg.inputs['Strength'].default_value = .55
for name,loc,power,size in [('Key',(-25,-35,65),85000,35),('Fill',(40,-10,35),45000,25),('Rim',(0,45,55),110000,30)]:
data = bpy.data.lights.new('Studio '+name,enum_value(bpy.types.Light,'type','AREA'))
obj = bpy.data.objects.new(data.name,data)
studio.objects.link(obj)
obj.location = loc
obj.rotation_euler = (Vector((0,5,15))-obj.location).to_track_quat('-Z','Y').to_euler()
data.energy = power
data.shape = enum_value(bpy.types.AreaLight,'shape','DISK')
data.size = size
data = bpy.data.cameras.new('Asset preview camera')
camera = bpy.data.objects.new(data.name,data)
studio.objects.link(camera)
data.type = enum_value(bpy.types.Camera,'type','ORTHO')
data.lens = 50
SCENE.camera = camera
SCENE.render.resolution_percentage = 100
SCENE.render.image_settings.file_format = enum_value(bpy.types.ImageFormatSettings,'file_format','PNG')
try:
SCENE.render.engine = 'CYCLES'
SCENE.cycles.samples = 24
SCENE.cycles.use_denoising = True
except TypeError:
pass
SCENE.render.film_transparent = False
preview_camera((64,-92,64),(0,8,23),96,1600,1200)
return camera
def preview_camera(location,target,scale,width=1400,height=1100):
camera = SCENE.camera
camera.location = location
camera.rotation_euler = (Vector(target)-camera.location).to_track_quat('-Z','Y').to_euler()
camera.data.ortho_scale = scale
SCENE.render.resolution_x = width
SCENE.render.resolution_y = height
def save_source():
bpy.ops.wm.save_as_mainfile(filepath=str(SOURCE/'summit-structures.blend'))
print('Saved editable Blender source')
# MCP executes each request in a fresh namespace. Retain this task's helpers explicitly.
bpy.app.driver_namespace['summit_assets'] = globals()
+106
View File
@@ -0,0 +1,106 @@
"""Blender MCP study: shared summit slab, editable outline and summit stone."""
import bpy,bmesh,json,math
from pathlib import Path
from mathutils import Vector
ROOT=Path('D:/project/sunrise')
OUT=ROOT/'assets/summit/blender-plateau-study'
data=json.loads((OUT/'context.json').read_text(encoding='utf-8'))
if bpy.data.scenes.get('Summit slab and marker'):
raise RuntimeError('Edit the existing study; do not overwrite it by rebuilding.')
scene=bpy.data.scenes.new('Summit slab and marker');bpy.context.window.scene=scene
collection=bpy.data.collections.new('Summit slab authoring');scene.collection.children.link(collection)
def enum(owner,prop,value):
return next(i.identifier for i in owner.bl_rna.properties[prop].enum_items if i.identifier==value)
def mat(name,color):
m=bpy.data.materials.new(name);m.use_nodes=True
n=next(n for n in m.node_tree.nodes if n.type=='BSDF_PRINCIPLED')
n.inputs['Base Color'].default_value=(*color,1);n.inputs['Roughness'].default_value=.95
m.diffuse_color=(*color,1);return m
stone=mat('Summit pale weathered granite',(.58,.55,.47))
texture=stone.node_tree.nodes.new('ShaderNodeTexImage')
texture.image=bpy.data.images.load(str(ROOT/'public/terrain/materials/rock-color.jpg'),check_existing=True)
stone.node_tree.links.new(texture.outputs['Color'],next(n for n in stone.node_tree.nodes if n.type=='BSDF_PRINCIPLED').inputs['Base Color'])
earth=mat('Plateau transition context',(.19,.24,.14))
ink=mat('Recessed summit inscription',(.025,.025,.022))
def objmesh(name,verts,faces):
mesh=bpy.data.meshes.new(name);mesh.from_pydata(verts,[],faces);mesh.update()
obj=bpy.data.objects.new(name,mesh);collection.objects.link(obj);return obj
# X east, Y north, Z above 620m. Editable boundary is the single source of truth.
boundary=[(-17,-59),(-6,-61),(4,-61),(11,-56),(15,-49),(15,-40),(15,-33),
(12,-23),(11,-16),(10,-5),(9,6),(2,14),(-7,14),(-12,12),(-18,4),
(-22,-4),(-25,-13),(-27,-22),(-26,-31),(-25,-40),(-22,-50)]
outline=objmesh('WALKABLE_OUTLINE',[(x,-z,0) for x,z in boundary],[])
edges=[(i,(i+1)%len(boundary)) for i in range(len(boundary))]
outline.data.clear_geometry();outline.data.from_pydata([(x,-z,0) for x,z in boundary],edges,[])
outline.hide_render=True;outline.display_type=enum(bpy.types.Object,'display_type','WIRE')
def signed_distance(x,y):
hit=False;distance=1e9
points=[(a,-b) for a,b in boundary]
for i,(ax,ay) in enumerate(points):
bx,by=points[(i+1)%len(points)];dx=bx-ax;dy=by-ay
t=max(0,min(1,((x-ax)*dx+(y-ay)*dy)/(dx*dx+dy*dy)))
distance=min(distance,math.hypot(x-ax-t*dx,y-ay-t*dy))
if (ay>y)!=(by>y) and x<(bx-ax)*(y-ay)/(by-ay)+ax:hit=not hit
return -distance if hit else distance
def slab_height(x,north):
# Broad photograph-like shoulder and gentle exposed slope, with shallow joints.
ramp=max(-14,min(49,north))*.185
shoulder=.30*(math.tanh((north-27)/6)-math.tanh(-27/6))
weather=.10*math.sin(x*.25)*math.sin(north*.19)
joint=-.12*math.exp(-((x+.18*north+6)/1.1)**2)
return ramp+shoulder+weather+joint
vertices=[]
for x,north,elevation in data['vertices']:
d=signed_distance(x,north)
t=max(0,min(1,d/7));blend=1-t*t*(3-2*t)
height=(elevation-620)*(1-blend)+slab_height(x,north)*blend
vertices.append((x,north,height))
slab=objmesh('SLAB_SURFACE',vertices,data['faces']);slab.data.materials.append(stone);slab.data.materials.append(earth)
uv=slab.data.uv_layers.new(name='Granite UV metres')
for face in slab.data.polygons:
center=sum((slab.data.vertices[i].co for i in face.vertices),Vector())/len(face.vertices)
face.material_index=0 if signed_distance(center.x,center.y)<0 else 1
face.use_smooth=True
for li in face.loop_indices:
p=slab.data.vertices[slab.data.loops[li].vertex_index].co;uv.data[li].uv=(p.x/3,p.y/3)
slab['heightDatum']=620
slab['note']='App reuses these exact terrain vertices for rendered surface and walking height.'
root=bpy.data.objects.new('summit-marker',None);collection.objects.link(root)
root.location=(-12,22,slab_height(-12,22))
root['blockedRadius']=1.1
# Irregular broad stone with a cut face for the vertical inscription; no pedestal.
front=[(-.82,-.43,-.10),(.76,-.43,-.10),(.65,-.43,1.72),(.35,-.43,2.12),(-.50,-.43,1.94)]
back=[(-.65,.40,-.12),(.61,.36,-.12),(.53,.26,1.68),(.28,.17,2.05),(-.40,.28,1.86)]
faces=[tuple(reversed(range(5))),tuple(range(5,10))]+[(i,(i+1)%5,(i+1)%5+5,i+5) for i in range(5)]
body=objmesh('Marker granite body',front+back,faces);body.parent=root;body.data.materials.append(stone)
bpy.context.view_layer.objects.active=body;body.select_set(True)
bevel=body.modifiers.new('Weathered stone edges',enum(bpy.types.Modifier,'type','BEVEL'));bevel.width=.07;bevel.segments=2
bpy.ops.object.modifier_apply(modifier=bevel.name)
bm=bmesh.new();bm.from_mesh(body.data);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(body.data);bm.free()
uv=body.data.uv_layers.new(name='Stone UV')
for face in body.data.polygons:
for li in face.loop_indices:
p=body.data.vertices[body.data.loops[li].vertex_index].co;uv.data[li].uv=(p.x/1.3,p.z/1.3)
font=bpy.data.fonts.load('C:/Windows/Fonts/malgunbd.ttf')
for i,letter in enumerate('관악산'):
curve=bpy.data.curves.new('Inscription '+letter,next(t.identifier for t in bpy.data.curves.bl_rna.functions['new'].parameters['type'].enum_items if t.identifier=='FONT'))
curve.body=letter;curve.font=font;curve.size=.42;curve.extrude=.005
curve.align_x=enum(bpy.types.TextCurve,'align_x','CENTER')
o=bpy.data.objects.new('Inscription '+letter,curve);collection.objects.link(o);o.parent=root
o.location=(0,-.435,1.48-i*.47);o.rotation_euler=(math.pi/2,0,0);curve.materials.append(ink)
for other in scene.objects:other.select_set(False)
o.select_set(True);bpy.context.view_layer.objects.active=o
bpy.ops.object.convert(target=next(t.identifier for t in bpy.ops.object.convert.get_rna_type().properties['target'].enum_items if t.identifier=='MESH'))
camdata=bpy.data.cameras.new('Plateau inspection camera');cam=bpy.data.objects.new(camdata.name,camdata);collection.objects.link(cam)
cam.location=(12,-18,17);cam.rotation_euler=(Vector((-9,23,4))-cam.location).to_track_quat('-Z','Y').to_euler();camdata.lens=38;scene.camera=cam
scene.render.resolution_x=1200;scene.render.resolution_y=850;scene.render.resolution_percentage=80
scene.world=bpy.data.worlds.new('Plateau daylight');scene.world.use_nodes=True
background=next(n for n in scene.world.node_tree.nodes if n.type=='BACKGROUND');background.inputs['Color'].default_value=(.58,.66,.76,1);background.inputs['Strength'].default_value=.7
light=bpy.data.lights.new('Slab sun',enum(bpy.types.Light,'type','SUN'));light.energy=2;light.angle=.2
sun=bpy.data.objects.new(light.name,light);collection.objects.link(sun);sun.rotation_euler=(.6,-.4,-.5)
scene.render.engine='CYCLES';scene.cycles.samples=24
bpy.app.driver_namespace['summit_plateau']=globals()
print('Blender plateau:',len(vertices),'shared height vertices; boundary:',len(boundary),'points')
+29
View File
@@ -0,0 +1,29 @@
import { createReadStream, statSync } from 'node:fs';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { pipeline } from 'node:stream';
import { BUILDINGS } from '../src/constants/buildings.ts';
// Only serves the precomputed building buffer; other assets use Vite's defaults.
export function buildingCompressionMiddleware(publicDirectory: URL) {
const path = new URL(BUILDINGS.assetBufferUrl.slice(1) + '.gz', publicDirectory);
return (request: IncomingMessage, response: ServerResponse, next: () => void) => {
if (request.url?.split('?')[0] !== BUILDINGS.assetBufferUrl || !['GET', 'HEAD'].includes(request.method ?? '')) return next();
const vary = response.getHeader('Vary');
response.setHeader('Vary', vary ? `${vary}, Accept-Encoding` : 'Accept-Encoding');
const encodings = (request.headers['accept-encoding'] ?? '').toLowerCase().split(',').map(entry => {
const [name, ...parameters] = entry.trim().split(';');
const quality = parameters.find(value => value.trim().startsWith('q='));
return { name, quality: quality ? Number(quality.trim().slice(2)) : 1 };
});
const accepted = encodings.find(entry => entry.name === 'gzip') ?? encodings.find(entry => entry.name === '*');
if (!accepted || !(accepted.quality > 0) || request.headers.range) return next();
let size: number;
try { size = statSync(path).size; } catch { return next(); }
response.setHeader('Content-Encoding', 'gzip');
response.setHeader('Content-Type', 'application/octet-stream');
response.setHeader('Content-Length', size);
response.setHeader('Cache-Control', 'no-cache');
if (request.method === 'HEAD') { response.end(); return; }
pipeline(createReadStream(path), response, error => { if (error) response.destroy(error); });
};
}
+2
View File
@@ -0,0 +1,2 @@
pyshp==2.3.1
shapely==2.1.2
+33
View File
@@ -0,0 +1,33 @@
"""Save a small set of publicly displayed, recent structure reference photos."""
import html
import json
from pathlib import Path
import re
import time
import urllib.request
root=Path(__file__).resolve().parents[1]
page=(root/'.tools/sunrise-photo-page.html').read_text(encoding='utf-8')
records=[]
patterns=['송신소를 배경으로','송신소의 철탑 4개','안전목책이 설치된','연주대 전망데크에서 드디어','기상레이더 관측소 좌측 뒤로','관악산정상을 출발해 북쪽','중간의 전망데크에서 진행 방향']
for index,pattern in enumerate(patterns,1):
at=page.find(pattern)
figures=list(re.finditer(r'<figure\b.*?</figure>',page[:at],re.S))
if at<0 or not figures:
continue
figure=figures[-1].group()
url=html.unescape(re.search(r'<img[^>]+src="([^"]+)"',figure).group(1))
name=f'recent-{index:02d}.jpg'
target=root/'data/references/summit/photos'/name
try:
with urllib.request.urlopen(url, timeout=30) as response:
data=response.read()
if not data.startswith(b'\xff\xd8'):
raise ValueError('Not JPEG')
target.write_bytes(data)
records.append({'file':name,'title':f'2026-06-03 structure reference {index}','status':'downloaded','source':'https://ydk5118.tistory.com/16906972','download_url':url,'metadata':{'Artist':{'value':'칠갑산 사랑'},'DateTimeOriginal':{'value':'2026-06-03 (게시글 산행일 기준)'},'LicenseShortName':{'value':'공개 재사용 허락 미확인 — 로컬 참고용, 앱 배포 자산으로 사용하지 않음'}}})
print(name,'downloaded',flush=True)
except Exception as error:
print(name,str(error),flush=True)
time.sleep(1)
(root/'data/references/summit/photos/recent-downloads.json').write_text(json.dumps(records,ensure_ascii=False,indent=2),encoding='utf-8')
+41
View File
@@ -0,0 +1,41 @@
"""Collect Commons reference thumbnails; preserve attribution and download status."""
import json
from pathlib import Path
import time
import urllib.request
root = Path(__file__).resolve().parents[1] / 'data/references/summit/photos'
pages = json.loads((root / 'commons-metadata.json').read_text(encoding='utf-8-sig'))['query']['pages']
old = json.loads((root / 'manifest.json').read_text(encoding='utf-8-sig'))
by_title = {p['title']: p for p in pages.values()}
result = []
for index, entry in enumerate(old, 1):
info = by_title[entry['title']].get('imageinfo', [])
if not info:
result.append({'title': entry['title'], 'status': 'missing metadata'})
continue
info = info[0]
name = f'photo-{index:02d}.jpg'
original = root / entry['file']
target = root / name
record = dict(title=entry['title'], file=name, source=info['descriptionurl'], metadata=info['extmetadata'])
try:
if original.exists():
original.rename(target)
record['download_url'] = info['url']
elif not target.exists():
time.sleep(3)
url = info.get('thumburl', info['url'])
request = urllib.request.Request(url, headers={'User-Agent': 'SunriseReferenceCollector/1.0 (local research)'})
with urllib.request.urlopen(request, timeout=25) as response:
data = response.read()
if not data.startswith(b'\xff\xd8'):
raise ValueError('Not JPEG')
target.write_bytes(data)
record['download_url'] = url
record['status'] = 'downloaded'
except Exception as error:
record['status'] = str(error)
result.append(record)
print(name, record['status'], flush=True)
(root / 'downloads.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
+38
View File
@@ -0,0 +1,38 @@
{
"bbox": [
126.975,
37.41,
127.015,
37.46
],
"sourceCrs": "EPSG:5186",
"overlapAreaMeters2": 0.5,
"sourceUrl": "https://www.vworld.kr/dtmk/dtmk_ntads_s002.do?svcCde=NA&dsId=18",
"licensing": {
"license": "CC-BY-2.0-KR",
"licenses": {
"VWorld": "CC-BY-2.0-KR"
},
"licenseUrls": {
"VWorld": "https://creativecommons.org/licenses/by/2.0/kr/"
},
"distribution": {
"notice": "source.html",
"reviewedAt": "2026-10-30",
"note": "Buildings derived exclusively from VWorld GIS building data. Retain source attribution, CC BY 2.0 KR terms and modification notices."
}
},
"useTypes": {
"단독주택": "house",
"공동주택": "residential",
"제1종근린생활시설": "commercial",
"제2종근린생활시설": "commercial",
"업무시설": "commercial",
"판매시설": "retail",
"교육연구시설": "school",
"종교시설": "church",
"창고시설": "warehouse",
"의료시설": "hospital",
"공장": "industrial"
}
}
+244
View File
@@ -0,0 +1,244 @@
"""Blender MCP: continuous silhouette modeling of the approved red sheep."""
import bpy, math, os, bmesh, struct, json
from mathutils import Vector
ROOT = 'D:/project/sunrise'
for name in ('RedSheep','Body','Head','ArmLeft','ArmRight','LegLeft','LegRight'):
previous=bpy.data.objects.get(name)
if previous: previous.name='Previous_'+name
scene=bpy.data.scenes.new('Ivory baby lamb - soft features')
bpy.context.window.scene=scene
scene.view_settings.view_transform='Standard'
palette={'Red':'F5EDDD','Cream':'F0E1CD','Ink':'67564C','Gold':'BE6264'}
mats={}
for name,color in palette.items():
m=bpy.data.materials.new('SheepV2_'+name); m.use_nodes=True
rgb=[int(color[i:i+2],16)/255 for i in (0,2,4)]
rgb=[v/12.92 if v<.04045 else ((v+.055)/1.055)**2.4 for v in rgb]
m.diffuse_color=(*rgb,1)
nodes=m.node_tree.nodes; nodes.clear()
out=nodes.new('ShaderNodeOutputMaterial'); emission=nodes.new('ShaderNodeEmission')
emission.inputs['Color'].default_value=(*rgb,1)
m.node_tree.links.new(emission.outputs[0],out.inputs['Surface'])
mats[name]=m
def empty(name,loc,parent=None):
o=bpy.data.objects.new(name,None); scene.collection.objects.link(o)
o.parent=parent; o.location=loc
return o
root=empty('RedSheep',(0,0,0)); parts={}
detail_names={'Eye','Eye glint','Nose','Smile','Muzzle center','Horn spiral','Scarf end'}
def smooth_outline(points,steps=5):
result=[]
for i in range(len(points)):
p0,p1,p2,p3=[Vector(points[j%len(points)]) for j in (i-1,i,i+1,i+2)]
for k in range(steps):
t=k/steps
result.append(.5*((2*p1)+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t*t+(-p0+3*p1-3*p2+p3)*t*t*t))
return result
def puff(name,outline,center,depth,color,parent,rings=16):
"""A single closed rounded volume; silhouette lobes share vertices."""
outline=smooth_outline(outline); n=len(outline); verts=[(0,-depth,0)]
for row in range(1,rings):
a=math.pi*row/rings; r=math.sin(a)
verts.extend([(p.x*r,-depth*math.cos(a),p.y*r) for p in outline])
verts.append((0,depth,0)); faces=[(0,1+(i+1)%n,1+i) for i in range(n)]
for row in range(rings-2):
a=1+row*n; b=a+n
faces.extend([(a+i,a+(i+1)%n,b+(i+1)%n,b+i) for i in range(n)])
end=len(verts)-1; a=1+(rings-2)*n
faces.extend([(a+i,a+(i+1)%n,end) for i in range(n)])
mesh=bpy.data.meshes.new(name); mesh.from_pydata(verts,[],faces); mesh.update()
o=bpy.data.objects.new(name,mesh); scene.collection.objects.link(o)
o.parent=parent; o.location=center; mesh.materials.append(mats[color])
bm=bmesh.new(); bm.from_mesh(mesh)
bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces)); bm.to_mesh(mesh); bm.free()
offset=mesh.attributes.new('_outline_offset','FLOAT_VECTOR','POINT')
for v,item in zip(mesh.vertices,offset.data): item.vector=(v.co.x*.035,v.co.y*.04,v.co.z*.035)
for p in mesh.polygons: p.use_smooth=True
parts.setdefault((parent,name in detail_names),[]).append(o)
return o
def ellipse(rx,rz,n=16):
return [(rx*math.cos(2*math.pi*i/n),rz*math.sin(2*math.pi*i/n)) for i in range(n)]
def fuse_wool(base,clumps,parent):
bpy.ops.object.select_all(action='DESELECT')
base.select_set(True)
for obj in clumps:
parts[(parent,False)].remove(obj)
obj.select_set(True)
bpy.context.view_layer.objects.active=base
bpy.ops.object.join()
remesh=base.modifiers.new('Joined fleece','REMESH')
remesh.mode='VOXEL'; remesh.voxel_size=.022; remesh.use_smooth_shade=True
bpy.ops.object.modifier_apply(modifier=remesh.name)
smooth=base.modifiers.new('Soft fleece roots','SMOOTH')
smooth.factor=1; smooth.iterations=3
bpy.ops.object.modifier_apply(modifier=smooth.name)
base.data.update()
old=base.data.attributes.get('_outline_offset')
if old: base.data.attributes.remove(old)
offset=base.data.attributes.new('_outline_offset','FLOAT_VECTOR','POINT')
for vertex,item in zip(base.data.vertices,offset.data): item.vector=vertex.normal*.014
def stroke(name,coords,radius,color,parent):
bpy.ops.object.select_all(action='DESELECT')
curve=bpy.data.curves.new(name,'CURVE'); curve.dimensions='3D'
curve.bevel_depth=radius; curve.bevel_resolution=3
spline=curve.splines.new('BEZIER'); spline.bezier_points.add(len(coords)-1)
for point,xyz in zip(spline.bezier_points,coords):
point.co=xyz; point.handle_left_type='AUTO'; point.handle_right_type='AUTO'
o=bpy.data.objects.new(name,curve); scene.collection.objects.link(o)
o.data.materials.append(mats[color]); o.parent=parent
bpy.context.view_layer.objects.active=o; o.select_set(True)
bpy.ops.object.convert(target='MESH')
parts.setdefault((parent,name in detail_names),[]).append(bpy.context.object)
body=empty('Body',(0,0,0),root)
body.scale.y=1.20
body_obj=puff('Fleece core',ellipse(.47,.49),(0,0,1.00),.40,'Red',body)
body_clumps=[]
for row,(z,rx,ry,count) in enumerate([(.66,.34,.28,8),(.96,.43,.35,10),(1.25,.34,.28,8)]):
for i in range(count):
a=2*math.pi*(i+.43*row)/count
size=[.24,.265,.23,.25][(i+row)%4]
body_clumps.append(puff('Body fleece lock',ellipse(size,size*1.08),
(rx*math.cos(a),ry*math.sin(a),z+.025*math.sin(i*2.3)),size*.94,'Red',body))
fuse_wool(body_obj,body_clumps,body)
puff('Tucked tail',ellipse(.10,.135),(0,.52,.79),.115,'Red',body)
for side,sign in [('Left',-1),('Right',1)]:
leg=empty('Leg'+side,(sign*.29,0,.22),root)
leg.scale.y=1.15
puff('Hidden wool leg',ellipse(.175,.12),(0,0,-.025),.21,'Red',leg)
puff('Small hoof tip',ellipse(.16,.075),(0,-.025,-.145),.19,'Ink',leg)
arm=empty('Arm'+side,(sign*.51,0,1.43),root)
arm.scale.y=1.15
puff('Soft wool arm',[(0,.17),(.16,.10),(.19,-.12),(.16,-.30),(.09,-.36),(-.09,-.36),(-.16,-.26),(-.17,-.06),(-.14,.11)],(sign*.08,0,-.10),.22,'Red',arm)
puff('Small hand hoof',ellipse(.13,.085),(sign*.10,-.015,-.465),.15,'Ink',arm)
head=empty('Head',(0,0,1.91),root)
head.scale=(.88,1.04,.88)
crown_obj=puff('Crown fleece core',ellipse(.63,.57),(0,0,0),.48,'Red',head)
head_clumps=[]
for row,(z,rx,ry,count) in enumerate([(-.29,.48,.37,9),(.08,.51,.41,10),(.43,.39,.30,8)]):
for i in range(count):
a=2*math.pi*(i+.38*row)/count
x,y=rx*math.cos(a),ry*math.sin(a)
if y<-.10 and z<.3: continue
size=[.25,.28,.24,.265][(i+2*row)%4]
head_clumps.append(puff('Crown fleece lock',ellipse(size,size*.95),
(x,y,z+.02*math.sin(i*1.8)),size,'Red',head))
for x,y,z,size in [(-.34,-.28,.43,.28),(.02,-.32,.48,.30),(.36,-.25,.43,.26)]:
head_clumps.append(puff('Forehead fleece lock',ellipse(size,size*.9),(x,y,z),size,'Red',head))
fuse_wool(crown_obj,head_clumps,head)
face=[(0,.29),(.15,.31),(.25,.39),(.35,.29),(.51,.23),(.57,.03),(.59,-.20),(.48,-.41),(.25,-.50),(0,-.52),(-.25,-.50),(-.48,-.41),(-.59,-.20),(-.57,.03),(-.51,.23),(-.35,.29),(-.25,.39),(-.15,.31)]
face_obj=puff('Cream face',face,(0,-.36,0),.23,'Cream',head)
def face_y(x,z):
hit,loc,normal,index=face_obj.ray_cast(Vector((x,-10,z))-face_obj.location,Vector((0,1,0)))
assert hit, (x,z)
return loc.y+face_obj.location.y
for sign in (-1,1):
ear=[(0,.15),(.17,.12),(.28,.01),(.37,-.13),(.31,-.23),(.12,-.24),(-.06,-.16),(-.13,-.02)]
puff('Drooping ear',[(sign*x*.8,z*.8) for x,z in ear],(sign*.72,-.08,-.035),.17,'Cream',head)
puff('Ram horn',ellipse(.16,.18),(sign*.72,.035,.54),.18,'Ink',head)
spiral=[]
for i in range(48):
a=.4+i/47*math.pi*1.65; r=.105-i/47*.07
spiral.append((sign*(.72+math.cos(a)*r),-.147,.54+math.sin(a)*r))
stroke('Horn spiral',spiral,.014,'Cream',head)
eye=puff('Eye',ellipse(.062,.074),(sign*.23,0,-.12),.01,'Ink',head)
# Flush markings follow the face instead of protruding like beads.
for v in eye.data.vertices:
p=v.co+eye.location
v.co.y=face_y(p.x,p.z)-.004
puff('Nose',ellipse(.035,.023),(0,face_y(0,-.26)-.004,-.26),.008,'Ink',head)
stroke('Smile',[(x,face_y(x,z)-.006,z) for x,z in [(-.055,-.30),(-.028,-.316),(0,-.30),(.028,-.316),(.055,-.30)]],.009,'Ink',head)
stroke('Muzzle center',[(0,face_y(0,z)-.006,z) for z in (-.275,-.30)],.009,'Ink',head)
# Red scarf: rounded collar and softly rounded tails on the plump chest.
bpy.ops.mesh.primitive_torus_add(major_segments=64,minor_segments=12,location=(0,0,1.47),major_radius=.45,minor_radius=.10)
collar=bpy.context.object; collar.name='Scarf collar'; collar.scale.y=1.05
bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
collar.parent=body; collar.data.materials.append(mats['Gold'])
for p in collar.data.polygons: p.use_smooth=True
parts.setdefault((body,True),[]).append(collar)
for x,z,rx,rz in [(.25,1.20,.11,.27),(.43,1.30,.09,.19)]:
end=puff('Scarf end',[(rx,rz*.8),(rx,-rz*.8),(rx*.6,-rz),(-rx*.6,-rz),(-rx,-rz*.8),(-rx,rz*.8)],(x,0,z),.035,'Gold',body)
for vertex in end.data.vertices:
p=vertex.co+end.location
hit,location,normal,index=body_obj.ray_cast(Vector((p.x,-10,p.z))-body_obj.location,Vector((0,1,0)))
vertex.co.y+=(location.y+body_obj.location.y-.025) if hit else -.42
# Lower the intact wool body onto the hoof tips; no exposed leg columns.
for child in root.children:
if not child.name.startswith('Leg'): child.location.z-=.34
for (pivot,detail),objects in parts.items():
bpy.ops.object.select_all(action='DESELECT')
for o in objects: o.select_set(True)
bpy.context.view_layer.objects.active=objects[0]; bpy.ops.object.join()
bpy.context.object.name=pivot.name+('Details' if detail else 'Mesh')
bpy.context.object['sheepOutline']=not detail
bpy.ops.object.select_all(action='DESELECT')
for o in scene.objects: o.select_set(True)
bpy.context.view_layer.objects.active=root
bpy.ops.export_scene.gltf(filepath=ROOT+'/public/characters/red-sheep.glb',use_selection=True,use_active_scene=True,export_format='GLB',export_yup=True,export_extras=True,export_attributes=True)
# Blender exports pure emission as PBR emissive. Store the intended flat colors
# as standard glTF unlit materials, also understood by the existing web loader.
path=ROOT+'/public/characters/red-sheep.glb'
data=open(path,'rb').read(); size=struct.unpack_from('<I',data,12)[0]
doc=json.loads(data[20:20+size]); binary=data[20+size:]
doc.setdefault('extensionsUsed',[]).append('KHR_materials_unlit')
for material in doc['materials']:
material['pbrMetallicRoughness']={'baseColorFactor':material.pop('emissiveFactor')+[1],'metallicFactor':0,'roughnessFactor':1}
material.setdefault('extensions',{})['KHR_materials_unlit']={}
encoded=json.dumps(doc,separators=(',',':')).encode(); encoded+=b' '*((-len(encoded))%4)
with open(path,'wb') as f:
f.write(struct.pack('<III',0x46546C67,2,20+len(encoded)+len(binary)))
f.write(struct.pack('<II',len(encoded),0x4E4F534A)); f.write(encoded); f.write(binary)
# Preview-only backface shells; the application creates its own equivalent outlines.
outline=bpy.data.materials.new('Preview ink outline'); outline.use_nodes=True
nodes=outline.node_tree.nodes; nodes.clear()
out=nodes.new('ShaderNodeOutputMaterial'); mix=nodes.new('ShaderNodeMixShader')
geo=nodes.new('ShaderNodeNewGeometry'); trans=nodes.new('ShaderNodeBsdfTransparent'); ink=nodes.new('ShaderNodeEmission')
ink.inputs['Color'].default_value=mats['Ink'].diffuse_color
links=outline.node_tree.links
links.new(geo.outputs['Backfacing'],mix.inputs[0]); links.new(trans.outputs[0],mix.inputs[1]); links.new(ink.outputs[0],mix.inputs[2]); links.new(mix.outputs[0],out.inputs['Surface'])
outline.surface_render_method='DITHERED'
for o in list(scene.objects):
if o.type!='MESH' or not o.get('sheepOutline',True): continue
hull=o.copy(); hull.data=o.data.copy(); hull.name='PreviewOutline_'+o.name
scene.collection.objects.link(hull)
offset=hull.data.attributes.get('_outline_offset')
for v,item in zip(hull.data.vertices,offset.data): v.co+=item.vector
bm=bmesh.new(); bm.from_mesh(hull.data)
dark=[f for f in bm.faces if hull.data.materials[f.material_index]==mats['Ink']]
bmesh.ops.delete(bm,geom=dark,context='FACES'); bm.to_mesh(hull.data); bm.free()
hull.data.materials.clear(); hull.data.materials.append(outline)
for p in hull.data.polygons: p.material_index=0
scene.world=bpy.data.worlds.new('Sheep cream backdrop'); scene.world.use_nodes=True
background=next(n for n in scene.world.node_tree.nodes if n.type=='BACKGROUND')
background.inputs['Color'].default_value=mats['Cream'].diffuse_color
background.inputs['Strength'].default_value=1
camdata=bpy.data.cameras.new('Sheep presentation'); cam=bpy.data.objects.new('Sheep presentation',camdata)
scene.collection.objects.link(cam); scene.camera=cam
cam.location=(3,-7,2.8); target=Vector((0,0,1.50)); cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler()
camdata.type='ORTHO'; camdata.ortho_scale=3.8
scene.render.engine='CYCLES'; scene.cycles.samples=16
scene.cycles.transparent_max_bounces=32
scene.render.resolution_x=900; scene.render.resolution_y=1000; scene.render.resolution_percentage=100
scene.render.image_settings.file_format='PNG'
bpy.ops.object.select_all(action='DESELECT'); bpy.context.view_layer.objects.active=root
for area in bpy.context.screen.areas:
if area.type=='VIEW_3D':
area.spaces.active.overlay.show_overlays=False
area.spaces.active.shading.type='MATERIAL'
area.spaces.active.region_3d.view_distance=5
area.spaces.active.region_3d.view_location=(0,0,1.5)
area.spaces.active.region_3d.view_rotation=cam.rotation_euler.to_quaternion()
bpy.data.libraries.write(ROOT+'/assets/characters/ivory-lamb-v12.blend',{scene})
print('Rounded ivory lamb exported; dedicated source saved.')
+179
View File
@@ -0,0 +1,179 @@
"""Run with Blender MCP. Separate cartoon rain gear for the rounded v10 sheep."""
import bpy, bmesh, math, json, struct
from mathutils import Vector, Matrix
ROOT='D:/project/sunrise'
with bpy.data.libraries.load(ROOT+'/assets/characters/ivory-lamb-v10.blend',link=False) as (src,dst):
dst.scenes=src.scenes
reference=dst.scenes[0]
reference.view_layers[0].update()
scene=bpy.data.scenes.new('Cartoon rain gear')
bpy.context.window.scene=scene
def material(name,hexcolor):
mat=bpy.data.materials.new(name); mat.use_nodes=True
rgb=[int(hexcolor[i:i+2],16)/255 for i in (0,2,4)]
rgb=[v/12.92 if v<.04045 else ((v+.055)/1.055)**2.4 for v in rgb]
mat.diffuse_color=(*rgb,1)
nodes=mat.node_tree.nodes; nodes.clear()
out=nodes.new('ShaderNodeOutputMaterial'); node=nodes.new('ShaderNodeEmission')
node.inputs['Color'].default_value=(*rgb,1)
mat.node_tree.links.new(node.outputs[0],out.inputs['Surface'])
return mat
yellow=material('Raincoat yellow','E9BE69')
red=material('Umbrella red','BE6264')
cream=material('Umbrella ivory','F5EDDD')
ink=material('SheepV2_Ink_Gear','67564C')
def empty(name,parent=None):
obj=bpy.data.objects.new(name,None);scene.collection.objects.link(obj);obj.parent=parent
return obj
def mesh(name,vertices,faces,mat,parent):
data=bpy.data.meshes.new(name);data.from_pydata(vertices,[],faces);data.update()
obj=bpy.data.objects.new(name,data);scene.collection.objects.link(obj)
obj.parent=parent;data.materials.append(mat)
return obj
def stroke(name,points,radius,mat,parent):
curve=bpy.data.curves.new(name,'CURVE');curve.dimensions='3D'
curve.bevel_depth=radius;curve.bevel_resolution=3
spline=curve.splines.new('POLY');spline.points.add(len(points)-1)
for p,co in zip(spline.points,points):p.co=(*co,1)
obj=bpy.data.objects.new(name,curve);scene.collection.objects.link(obj);obj.parent=parent
curve.materials.append(mat)
bpy.ops.object.select_all(action='DESELECT');obj.select_set(True);bpy.context.view_layer.objects.active=obj
bpy.ops.object.convert(target='MESH')
return bpy.context.object
def sphere(name,location,scale,mat,parent):
bpy.ops.mesh.primitive_uv_sphere_add(segments=24,ring_count=12,location=location)
obj=bpy.context.object;obj.name=name;obj.scale=scale;obj.parent=parent
obj.data.materials.append(mat)
bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
return obj
umbrella=empty('Umbrella')
# The grip is in the raised right hoof; the tilted shaft centers the canopy above the head.
grip=Vector((.58,-.40,.82)); top=Vector((0,-.05,3.03))
stroke('Umbrella shaft',[grip,top],.022,ink,umbrella)
for sector in range(8):
vertices=[];faces=[]
for row in range(9):
theta=(math.pi/2)*row/8
for col in range(5):
a=(sector+col/4)*math.pi/4
vertices.append((1.28*math.sin(theta)*math.cos(a),-.05+1.28*math.sin(theta)*math.sin(a),2.53+.50*math.cos(theta)))
for row in range(8):
for col in range(4):
i=row*5+col;faces.append((i,i+1,i+6,i+5))
panel=mesh('Canopy panel',vertices,faces,red if sector%2==0 else cream,umbrella)
bpy.context.view_layer.objects.active=panel
solid=panel.modifiers.new('Canopy thickness','SOLIDIFY');solid.thickness=.018
bpy.ops.object.modifier_apply(modifier=solid.name)
a=sector*math.pi/4
stroke('Canopy rib',[(1.28*math.sin(t)*math.cos(a),-.05+1.28*math.sin(t)*math.sin(a),2.54+.50*math.cos(t)) for t in [i*math.pi/32 for i in range(17)]],.009,ink,umbrella)
stroke('Canopy rim',[(1.28*math.cos(a),-.05+1.28*math.sin(a),2.53) for a in [i*math.tau/96 for i in range(97)]],.016,ink,umbrella)
sphere('Umbrella tip',(0,-.05,3.055),(.04,.04,.07),ink,umbrella)
sphere('Grip',grip,(.052,.052,.12),red,umbrella)
coat=empty('Raincoat')
for joint_name in ('Body','ArmLeft','ArmRight'):
pivot=next(o for o in reference.objects if o.name.split('.')[0]==joint_name)
source=next(o for o in reference.objects if o.parent==pivot and o.type=='MESH' and not o.name.startswith('Preview') and not o.get('sheepOutline')==False)
data=source.data.copy(); bm=bmesh.new();bm.from_mesh(data)
dark=[f for f in bm.faces if 'Ink' in source.data.materials[f.material_index].name]
bmesh.ops.delete(bm,geom=dark,context='FACES')
if joint_name=='Body':
# Keep the torso component; do not turn the wool tail into a coat bubble.
unseen=set(bm.verts);components=[]
while unseen:
pending=[unseen.pop()];component=set(pending)
while pending:
v=pending.pop()
for edge in v.link_edges:
n=edge.other_vert(v)
if n in unseen:unseen.remove(n);component.add(n);pending.append(n)
components.append(component)
keep=max(components,key=len)
bmesh.ops.delete(bm,geom=[v for v in bm.verts if v not in keep],context='VERTS')
bm.to_mesh(data);bm.free()
data.materials.clear();data.materials.append(yellow)
anchor=empty('Coat'+joint_name,coat)
if joint_name!='Body':anchor.matrix_local=pivot.matrix_world.copy()
obj=bpy.data.objects.new('Raincoat '+joint_name,data);scene.collection.objects.link(obj);obj.parent=anchor
transform=source.matrix_world if joint_name=='Body' else pivot.matrix_world.inverted()@source.matrix_world
center=Vector((0,0,.68)) if joint_name=='Body' else Vector((.08 if joint_name=='ArmRight' else -.08,0,-.20))
for v in data.vertices:
v.co=center+(transform@v.co-center)*1.075
if joint_name=='Body':
# A short raincoat hem leaves both wool legs and hoof tips exposed.
bm=bmesh.new();bm.from_mesh(data)
bmesh.ops.bisect_plane(bm,geom=list(bm.verts)+list(bm.edges)+list(bm.faces),
plane_co=(0,0,.42),plane_no=(0,0,1),clear_inner=True)
boundary=[edge for edge in bm.edges if edge.is_boundary]
bmesh.ops.holes_fill(bm,edges=boundary,sides=0)
for v in bm.verts:
flare=1+.06*max(0,1-(v.co.z-.42)/.25)
v.co.x*=flare;v.co.y*=flare
bm.to_mesh(data);bm.free()
for p in data.polygons:p.material_index=0
# A folded hood behind the neck, plus simple front fasteners.
sphere('CoatFoldedHood',(0,.48,1.18),(.38,.20,.17),yellow,coat)
hood=empty('CoatHood',coat)
head_pivot=next(o for o in reference.objects if o.name.split('.')[0]=='Head')
hood.matrix_local=head_pivot.matrix_world.copy()
vertices=[];faces=[]
def hood_point(theta,phi):
x=.98*math.sin(theta)*math.cos(phi)
y=-.85*math.cos(theta)
z=.02+.96*math.sin(theta)*math.sin(phi)
z+=.12*math.exp(-(x/.22)**2-((theta-1.5)/.35)**2)*max(0,math.sin(phi))**8
return (x,y,z)
for row in range(17):
theta=1.12+(math.pi-1.12)*row/16
for col in range(49):vertices.append(hood_point(theta,col*math.tau/48))
for row in range(16):
for col in range(48):
i=row*49+col;faces.append((i,i+1,i+50,i+49))
hood_shell=mesh('Rain hood shell',vertices,faces,yellow,hood)
bpy.context.view_layer.objects.active=hood_shell
solid=hood_shell.modifiers.new('Hood fabric','SOLIDIFY');solid.thickness=.025
bpy.ops.object.modifier_apply(modifier=solid.name)
stroke('Hood opening rim',[hood_point(1.12,i*math.tau/96) for i in range(97)],.025,yellow,hood)
stroke('Coat placket',[(0,-.72,z) for z in (.47,.65,.90,1.07)],.012,ink,coat)
for z in (.54,.77,1.00):sphere('Coat button',(0,-.735,z),(.032,.022,.032),cream,coat)
for obj in scene.objects:
if obj.type!='MESH':continue
bm=bmesh.new();bm.from_mesh(obj.data);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(obj.data);bm.free()
for p in obj.data.polygons:p.use_smooth=True
obj.data.update()
old=obj.data.attributes.get('_outline_offset')
if old:obj.data.attributes.remove(old)
offsets=obj.data.attributes.new('_outline_offset','FLOAT_VECTOR','POINT')
for v,item in zip(obj.data.vertices,offsets.data):item.vector=v.normal*.012
def export(root,path):
bpy.ops.object.select_all(action='DESELECT')
root.select_set(True)
for obj in root.children_recursive:obj.select_set(True)
bpy.context.view_layer.objects.active=root
bpy.ops.export_scene.gltf(filepath=path,use_selection=True,use_active_scene=True,export_format='GLB',export_yup=True,export_extras=True,export_attributes=True)
data=open(path,'rb').read();size=struct.unpack_from('<I',data,12)[0]
doc=json.loads(data[20:20+size]);binary=data[20+size:]
# Repeated runs in a live Blender session must retain the runtime attachment names.
for node in doc.get('nodes',[]):
base=node.get('name','').split('.')[0]
if base in ('Umbrella','Raincoat','CoatBody','CoatArmLeft','CoatArmRight','CoatHood','CoatFoldedHood'):node['name']=base
doc.setdefault('extensionsUsed',[]).append('KHR_materials_unlit')
for mat in doc['materials']:
mat['pbrMetallicRoughness']={'baseColorFactor':mat.pop('emissiveFactor')+[1],'metallicFactor':0,'roughnessFactor':1}
mat.setdefault('extensions',{})['KHR_materials_unlit']={}
encoded=json.dumps(doc,separators=(',',':')).encode();encoded+=b' '*((-len(encoded))%4)
with open(path,'wb') as f:
f.write(struct.pack('<III',0x46546C67,2,20+len(encoded)+len(binary)))
f.write(struct.pack('<II',len(encoded),0x4E4F534A));f.write(encoded);f.write(binary)
export(umbrella,ROOT+'/public/characters/umbrella.glb')
export(coat,ROOT+'/public/characters/raincoat.glb')
bpy.data.libraries.write(ROOT+'/assets/characters/rain-gear.blend',{scene})
print('Umbrella and articulated raincoat exported')
+194
View File
@@ -0,0 +1,194 @@
"""Blender MCP: continuous silhouette modeling of the approved red sheep."""
import bpy, math, os, bmesh, struct, json
from mathutils import Vector
ROOT = 'D:/project/sunrise'
for name in ('RedSheep','Body','Head','ArmLeft','ArmRight','LegLeft','LegRight'):
previous=bpy.data.objects.get(name)
if previous: previous.name='Previous_'+name
scene=bpy.data.scenes.new('Red Sheep - concept matched')
bpy.context.window.scene=scene
scene.view_settings.view_transform='Standard'
palette={'Red':'D94B3D','Cream':'FFF0D6','Ink':'352724','Gold':'EAAF45'}
mats={}
for name,color in palette.items():
m=bpy.data.materials.new('SheepV2_'+name); m.use_nodes=True
rgb=[int(color[i:i+2],16)/255 for i in (0,2,4)]
rgb=[v/12.92 if v<.04045 else ((v+.055)/1.055)**2.4 for v in rgb]
m.diffuse_color=(*rgb,1)
nodes=m.node_tree.nodes; nodes.clear()
out=nodes.new('ShaderNodeOutputMaterial'); emission=nodes.new('ShaderNodeEmission')
emission.inputs['Color'].default_value=(*rgb,1)
m.node_tree.links.new(emission.outputs[0],out.inputs['Surface'])
mats[name]=m
def empty(name,loc,parent=None):
o=bpy.data.objects.new(name,None); scene.collection.objects.link(o)
o.parent=parent; o.location=loc
return o
root=empty('RedSheep',(0,0,0)); parts={}
detail_names={'Eye','Eye glint','Nose','Smile','Muzzle center','Horn spiral','Sun emblem','Sun ray'}
def smooth_outline(points,steps=5):
result=[]
for i in range(len(points)):
p0,p1,p2,p3=[Vector(points[j%len(points)]) for j in (i-1,i,i+1,i+2)]
for k in range(steps):
t=k/steps
result.append(.5*((2*p1)+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t*t+(-p0+3*p1-3*p2+p3)*t*t*t))
return result
def puff(name,outline,center,depth,color,parent,rings=16):
"""A single closed rounded volume; silhouette lobes share vertices."""
outline=smooth_outline(outline); n=len(outline); verts=[(0,-depth,0)]
for row in range(1,rings):
a=math.pi*row/rings; r=math.sin(a)
verts.extend([(p.x*r,-depth*math.cos(a),p.y*r) for p in outline])
verts.append((0,depth,0)); faces=[(0,1+(i+1)%n,1+i) for i in range(n)]
for row in range(rings-2):
a=1+row*n; b=a+n
faces.extend([(a+i,a+(i+1)%n,b+(i+1)%n,b+i) for i in range(n)])
end=len(verts)-1; a=1+(rings-2)*n
faces.extend([(a+i,a+(i+1)%n,end) for i in range(n)])
mesh=bpy.data.meshes.new(name); mesh.from_pydata(verts,[],faces); mesh.update()
o=bpy.data.objects.new(name,mesh); scene.collection.objects.link(o)
o.parent=parent; o.location=center; mesh.materials.append(mats[color])
bm=bmesh.new(); bm.from_mesh(mesh)
bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces)); bm.to_mesh(mesh); bm.free()
offset=mesh.attributes.new('_outline_offset','FLOAT_VECTOR','POINT')
for v,item in zip(mesh.vertices,offset.data): item.vector=(v.co.x*.035,v.co.y*.04,v.co.z*.035)
for p in mesh.polygons: p.use_smooth=True
parts.setdefault((parent,name in detail_names),[]).append(o)
return o
def ellipse(rx,rz,n=16):
return [(rx*math.cos(2*math.pi*i/n),rz*math.sin(2*math.pi*i/n)) for i in range(n)]
def stroke(name,coords,radius,color,parent):
bpy.ops.object.select_all(action='DESELECT')
curve=bpy.data.curves.new(name,'CURVE'); curve.dimensions='3D'
curve.bevel_depth=radius; curve.bevel_resolution=3
spline=curve.splines.new('BEZIER'); spline.bezier_points.add(len(coords)-1)
for point,xyz in zip(spline.bezier_points,coords):
point.co=xyz; point.handle_left_type='AUTO'; point.handle_right_type='AUTO'
o=bpy.data.objects.new(name,curve); scene.collection.objects.link(o)
o.data.materials.append(mats[color]); o.parent=parent
bpy.context.view_layer.objects.active=o; o.select_set(True)
bpy.ops.object.convert(target='MESH')
parts.setdefault((parent,name in detail_names),[]).append(bpy.context.object)
body=empty('Body',(0,0,0),root)
torso=[(0,.61),(.34,.57),(.49,.40),(.56,.18),(.61,-.10),(.58,-.34),(.48,-.44),(.43,-.55),(.25,-.60),(0,-.54),(-.25,-.60),(-.43,-.55),(-.48,-.44),(-.58,-.34),(-.61,-.10),(-.56,.18),(-.49,.40),(-.34,.57)]
body_obj=puff('Continuous wool body',torso,(0,0,1.12),.40,'Red',body)
tail=[(0,.23),(.16,.21),(.22,.08),(.20,-.05),(.12,-.18),(0,-.21),(-.16,-.16),(-.23,-.02),(-.20,.13)]
puff('Round tail',tail,(0,.41,.92),.21,'Red',body)
for side,sign in [('Left',-1),('Right',1)]:
leg=empty('Leg'+side,(sign*.28,0,.66),root)
puff('Short leg',[(.18,.06),(.20,-.13),(.18,-.40),(-.18,-.40),(-.20,-.13),(-.18,.06)],(0,0,-.04),.19,'Cream',leg)
puff('Broad hoof',[(0,.13),(.17,.13),(.21,.04),(.21,-.10),(.14,-.14),(-.15,-.14),(-.21,-.10),(-.21,.04),(-.15,.13)],(0,-.04,-.51),.23,'Ink',leg)
arm=empty('Arm'+side,(sign*.48,0,1.55),root)
puff('Wool sleeve',[(0,.13),(.18,.06),(.23,-.13),(.18,-.30),(.02,-.34),(-.16,-.28),(-.20,-.08),(-.16,.08)],(sign*.06,0,-.10),.23,'Red',arm)
puff('Cream wrist',ellipse(.18,.23),(sign*.14,-.035,-.42),.18,'Cream',arm)
puff('Cloven mitten',[(0,.14),(.10,.16),(.19,.07),(.19,-.08),(.09,-.17),(-.08,-.17),(-.18,-.08),(-.18,.07),(-.10,.15)],(sign*.15,-.06,-.58),.18,'Ink',arm)
head=empty('Head',(0,0,2.20),root)
crown=[(0,.80),(.19,.76),(.30,.64),(.49,.66),(.66,.53),(.69,.33),(.80,.23),(.82,.03),(.76,-.14),(.81,-.30),(.72,-.48),(.54,-.51),(.43,-.64),(.23,-.68),(0,-.65),(-.23,-.68),(-.43,-.64),(-.54,-.51),(-.72,-.48),(-.81,-.30),(-.76,-.14),(-.82,.03),(-.80,.23),(-.69,.33),(-.66,.53),(-.49,.66),(-.30,.64),(-.19,.76)]
puff('Continuous wool crown',crown,(0,0,0),.48,'Red',head)
face=[(0,.29),(.15,.31),(.25,.39),(.35,.29),(.51,.23),(.57,.03),(.59,-.20),(.48,-.41),(.25,-.50),(0,-.52),(-.25,-.50),(-.48,-.41),(-.59,-.20),(-.57,.03),(-.51,.23),(-.35,.29),(-.25,.39),(-.15,.31)]
face_obj=puff('Cream face',face,(0,-.36,0),.29,'Cream',head)
def face_y(x,z):
hit,loc,normal,index=face_obj.ray_cast(Vector((x,-10,z))-face_obj.location,Vector((0,1,0)))
assert hit, (x,z)
return loc.y+face_obj.location.y
for sign in (-1,1):
ear=[(0,.15),(.17,.12),(.28,.01),(.37,-.13),(.31,-.23),(.12,-.24),(-.06,-.16),(-.13,-.02)]
puff('Drooping ear',[(sign*x,z) for x,z in ear],(sign*.70,-.015,-.18),.12,'Cream',head)
puff('Ram horn',ellipse(.29,.32),(sign*.80,-.18,.29),.23,'Ink',head)
spiral=[]
for i in range(48):
a=.4+i/47*math.pi*1.65; r=.195-i/47*.135
spiral.append((sign*(.80+math.cos(a)*r),-.413,.29+math.sin(a)*r))
stroke('Horn spiral',spiral,.025,'Cream',head)
eye_y=face_y(sign*.235,-.075)-.008
puff('Eye',ellipse(.087,.112),(sign*.235,eye_y,-.075),.025,'Ink',head)
puff('Eye glint',ellipse(.019,.022),(sign*.235+.025,eye_y-.026,-.045),.006,'Cream',head)
puff('Nose',[(0,-.035),(.058,.016),(-.058,.016)],(0,face_y(0,-.245)-.012,-.245),.016,'Ink',head)
stroke('Smile',[(x,face_y(x,z)-.012,z) for x,z in [(-.13,-.31),(-.10,-.34),(-.06,-.345),(0,-.30),(.06,-.345),(.10,-.34),(.13,-.31)]],.018,'Ink',head)
stroke('Muzzle center',[(0,face_y(0,z)-.012,z) for z in (-.25,-.30)],.016,'Ink',head)
puff('Sun emblem',[(.14,0),(.13,.06),(.09,.11),(0,.14),(-.09,.11),(-.13,.06),(-.14,0)],(0,-.402,1.34),.012,'Gold',body)
for a in (0,math.pi/4,math.pi/2,3*math.pi/4,math.pi):
stroke('Sun ray',[(r*math.cos(a),-.408,1.34+r*math.sin(a)) for r in (.20,.27)],.023,'Gold',body)
# Conform the emblem to the chest rather than leaving a floating flat decal.
for o in parts[(body,True)]:
for v in o.data.vertices:
p=v.co+o.location
hit,loc,normal,index=body_obj.ray_cast(Vector((p.x,-10,p.z))-body_obj.location,Vector((0,1,0)))
if hit: v.co.y=loc.y+body_obj.location.y-.008-o.location.y
for (pivot,detail),objects in parts.items():
bpy.ops.object.select_all(action='DESELECT')
for o in objects: o.select_set(True)
bpy.context.view_layer.objects.active=objects[0]; bpy.ops.object.join()
bpy.context.object.name=pivot.name+('Details' if detail else 'Mesh')
bpy.context.object['sheepOutline']=not detail
bpy.ops.object.select_all(action='DESELECT')
for o in scene.objects: o.select_set(True)
bpy.context.view_layer.objects.active=root
bpy.ops.export_scene.gltf(filepath=ROOT+'/public/characters/red-sheep.glb',use_selection=True,use_active_scene=True,export_format='GLB',export_yup=True,export_extras=True,export_attributes=True)
# Blender exports pure emission as PBR emissive. Store the intended flat colors
# as standard glTF unlit materials, also understood by the existing web loader.
path=ROOT+'/public/characters/red-sheep.glb'
data=open(path,'rb').read(); size=struct.unpack_from('<I',data,12)[0]
doc=json.loads(data[20:20+size]); binary=data[20+size:]
doc.setdefault('extensionsUsed',[]).append('KHR_materials_unlit')
for material in doc['materials']:
material['pbrMetallicRoughness']={'baseColorFactor':material.pop('emissiveFactor')+[1],'metallicFactor':0,'roughnessFactor':1}
material.setdefault('extensions',{})['KHR_materials_unlit']={}
encoded=json.dumps(doc,separators=(',',':')).encode(); encoded+=b' '*((-len(encoded))%4)
with open(path,'wb') as f:
f.write(struct.pack('<III',0x46546C67,2,20+len(encoded)+len(binary)))
f.write(struct.pack('<II',len(encoded),0x4E4F534A)); f.write(encoded); f.write(binary)
# Preview-only backface shells; the application creates its own equivalent outlines.
outline=bpy.data.materials.new('Preview ink outline'); outline.use_nodes=True
nodes=outline.node_tree.nodes; nodes.clear()
out=nodes.new('ShaderNodeOutputMaterial'); mix=nodes.new('ShaderNodeMixShader')
geo=nodes.new('ShaderNodeNewGeometry'); trans=nodes.new('ShaderNodeBsdfTransparent'); ink=nodes.new('ShaderNodeEmission')
ink.inputs['Color'].default_value=mats['Ink'].diffuse_color
links=outline.node_tree.links
links.new(geo.outputs['Backfacing'],mix.inputs[0]); links.new(trans.outputs[0],mix.inputs[1]); links.new(ink.outputs[0],mix.inputs[2]); links.new(mix.outputs[0],out.inputs['Surface'])
outline.surface_render_method='DITHERED'
for o in list(scene.objects):
if o.type!='MESH' or not o.get('sheepOutline',True): continue
hull=o.copy(); hull.data=o.data.copy(); hull.name='PreviewOutline_'+o.name
scene.collection.objects.link(hull)
offset=hull.data.attributes.get('_outline_offset')
for v,item in zip(hull.data.vertices,offset.data): v.co+=item.vector
bm=bmesh.new(); bm.from_mesh(hull.data)
dark=[f for f in bm.faces if hull.data.materials[f.material_index]==mats['Ink']]
bmesh.ops.delete(bm,geom=dark,context='FACES'); bm.to_mesh(hull.data); bm.free()
hull.data.materials.clear(); hull.data.materials.append(outline)
for p in hull.data.polygons: p.material_index=0
scene.world=bpy.data.worlds.new('Sheep cream backdrop'); scene.world.use_nodes=True
background=next(n for n in scene.world.node_tree.nodes if n.type=='BACKGROUND')
background.inputs['Color'].default_value=mats['Cream'].diffuse_color
background.inputs['Strength'].default_value=1
camdata=bpy.data.cameras.new('Sheep presentation'); cam=bpy.data.objects.new('Sheep presentation',camdata)
scene.collection.objects.link(cam); scene.camera=cam
cam.location=(3,-7,2.8); target=Vector((0,0,1.50)); cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler()
camdata.type='ORTHO'; camdata.ortho_scale=3.8
scene.render.engine='CYCLES'; scene.cycles.samples=16
scene.cycles.transparent_max_bounces=32
scene.render.resolution_x=900; scene.render.resolution_y=1000; scene.render.resolution_percentage=100
scene.render.image_settings.file_format='PNG'
bpy.ops.object.select_all(action='DESELECT'); bpy.context.view_layer.objects.active=root
for area in bpy.context.screen.areas:
if area.type=='VIEW_3D':
area.spaces.active.overlay.show_overlays=False
area.spaces.active.shading.type='MATERIAL'
area.spaces.active.region_3d.view_distance=5
area.spaces.active.region_3d.view_location=(0,0,1.5)
area.spaces.active.region_3d.view_rotation=cam.rotation_euler.to_quaternion()
bpy.ops.wm.save_as_mainfile(filepath=ROOT+'/assets/characters/red-sheep-v2.blend',copy=True)
print('V2 exported, continuous surfaces, four unlit materials. Preview source saved.')
+121
View File
@@ -0,0 +1,121 @@
"""Run through Blender MCP. Separate scene, four colors, articulated rigid parts."""
import bpy, math, os
from mathutils import Vector
ROOT = 'D:/project/sunrise'
scene = bpy.data.scenes.new('Red Sheep Character')
bpy.context.window.scene = scene
palette = {'Red': 'D94B3D', 'Cream': 'FFF0D6', 'Ink': '352724', 'Gold': 'EAAF45'}
mats = {}
for name, color in palette.items():
m = bpy.data.materials.new('Sheep_' + name)
m.use_nodes = True
rgb = [int(color[i:i+2], 16) / 255 for i in (0, 2, 4)]
linear = [v / 12.92 if v < .04045 else ((v + .055) / 1.055) ** 2.4 for v in rgb]
m.diffuse_color = (*linear, 1)
node = next(n for n in m.node_tree.nodes if n.type == 'BSDF_PRINCIPLED')
node.inputs['Base Color'].default_value = (*linear, 1)
node.inputs['Roughness'].default_value = 1
mats[name] = m
def empty(name, loc, parent=None):
obj = bpy.data.objects.new(name, None)
scene.collection.objects.link(obj)
obj.parent = parent
obj.location = loc
return obj
root = empty('RedSheep', (0, 0, 0))
parts = {}
def ball(name, loc, scale, color, parent=root):
bpy.ops.mesh.primitive_uv_sphere_add(segments=20, ring_count=12, location=loc)
obj = bpy.context.object
obj.name = name
obj.scale = scale
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
obj.data.materials.append(mats[color])
for p in obj.data.polygons: p.use_smooth = True
obj.parent = parent
parts.setdefault(parent, []).append(obj)
return obj
def stroke(name, coords, radius, color, parent=root):
curve = bpy.data.curves.new(name, 'CURVE')
curve.dimensions = '3D'
curve.bevel_depth = radius
curve.bevel_resolution = 2
spline = curve.splines.new('POLY')
spline.points.add(len(coords)-1)
for point, xyz in zip(spline.points, coords): point.co = (*xyz, 1)
obj = bpy.data.objects.new(name, curve)
scene.collection.objects.link(obj)
obj.data.materials.append(mats[color])
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.convert(target='MESH')
obj = bpy.context.object
obj.parent = parent
parts.setdefault(parent, []).append(obj)
obj.select_set(False)
return obj
body = empty('Body', (0, 0, 0), root)
ball('Wool body', (0, 0, 1.25), (.55, .37, .58), 'Red', body)
for x in (-.32, .32): ball('Hip wool', (x, 0, .96), (.28, .32, .29), 'Red', body)
ball('Tail', (0, .42, 1.02), (.22, .19, .22), 'Red', body)
for side, x in [('Left', -.27), ('Right', .27)]:
leg = empty('Leg' + side, (x, 0, .85), root)
ball('Cream leg', (0, 0, -.35), (.18, .18, .36), 'Cream', leg)
ball('Hoof', (0, -.055, -.70), (.21, .24, .15), 'Ink', leg)
arm = empty('Arm' + side, (x / .27 * .52, 0, 1.65), root)
ball('Sleeve', (x / .27 * .065, 0, -.18), (.21, .22, .27), 'Red', arm)
ball('Forearm', (x / .27 * .12, -.02, -.40), (.145, .15, .23), 'Cream', arm)
ball('Hand', (x / .27 * .14, -.04, -.58), (.17, .17, .17), 'Ink', arm)
head = empty('Head', (0, 0, 2.38), root)
ball('Head wool', (0, 0, 0), (.66, .43, .56), 'Red', head)
ball('Cream face', (0, -.315, -.05), (.47, .20, .39), 'Cream', head)
for x,z,s in [(-.44,.28,.25),(-.23,.46,.26),(0,.50,.27),(.25,.44,.25),(.46,.25,.24),(-.52,-.22,.19),(.52,-.22,.19)]:
ball('Wool tuft', (x, -.10, z), (s, .33, s), 'Red', head)
for sign in (-1, 1):
ear = ball('Ear', (sign*.70, -.035, -.12), (.30, .12, .15), 'Cream', head)
ear.rotation_euler.y = sign * .35
ball('Horn', (sign*.64, -.06, .25), (.24, .25, .27), 'Ink', head)
spiral = []
for i in range(32):
t = i / 31 * math.pi * 1.7
r = .16 - i / 31 * .11
spiral.append((sign*(.64 + math.cos(t)*r), -.305, .25+math.sin(t)*r))
stroke('Horn curl', spiral, .022, 'Cream', head)
ball('Eye', (sign*.18, -.503, -.035), (.048, .025, .065), 'Ink', head)
ball('Nose', (0, -.522, -.16), (.048, .025, .03), 'Ink', head)
stroke('Mouth', [(-.11,-.51,-.21),(-.075,-.527,-.24),(-.035,-.535,-.235),(0,-.537,-.20),(.035,-.535,-.235),(.075,-.527,-.24),(.11,-.51,-.21)], .015, 'Ink', head)
# Small flat sunrise badge, facing -Y.
verts = [(0, -.374, 1.43)] + [(.13*math.cos(i*math.pi/16), -.374, 1.43+.13*math.sin(i*math.pi/16)) for i in range(17)]
mesh = bpy.data.meshes.new('Sun badge')
mesh.from_pydata(verts, [], [(0,i+1,i+2) for i in range(16)])
badge = bpy.data.objects.new('Sun badge', mesh)
scene.collection.objects.link(badge)
badge.data.materials.append(mats['Gold'])
badge.parent = body
parts[body].append(badge)
for a in (0, math.pi/4, math.pi/2, 3*math.pi/4, math.pi):
stroke('Sun ray', [(r*math.cos(a), -.378, 1.43+r*math.sin(a)) for r in (.19,.25)], .018, 'Gold', body)
# Merge static pieces per joint so the runtime only needs six meshes plus outlines.
for pivot, objects in parts.items():
bpy.ops.object.select_all(action='DESELECT')
for obj in objects: obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
bpy.ops.object.join()
obj = bpy.context.object
obj.name = pivot.name + 'Mesh'
# Joining preserves each mesh in the pivot's local coordinate system.
bpy.ops.object.select_all(action='DESELECT')
for obj in scene.objects: obj.select_set(True)
bpy.context.view_layer.objects.active = root
os.makedirs(ROOT + '/public/characters', exist_ok=True)
os.makedirs(ROOT + '/assets/characters', exist_ok=True)
bpy.ops.export_scene.gltf(filepath=ROOT+'/public/characters/red-sheep.glb', use_selection=True, use_active_scene=True, export_format='GLB', export_yup=True)
bpy.ops.wm.save_as_mainfile(filepath=ROOT+'/assets/characters/red-sheep.blend', copy=True)
print('Exported red-sheep.glb; meshes:', len([o for o in scene.objects if o.type == 'MESH']))
+20
View File
@@ -0,0 +1,20 @@
"""Re-export edited photo-guided-rocks.blend via Blender MCP, without regenerating it."""
import bpy
import io_scene_gltf2
from pathlib import Path
root=Path('D:/project/sunrise')
scene=bpy.data.scenes['Photo-guided summit rocks']
bpy.context.window.scene=scene
rocks=bpy.data.collections['Photo rocks - export']
for obj in scene.objects:obj.select_set(False)
for obj in rocks.objects:obj.select_set(True)
bpy.context.view_layer.objects.active=next(iter(rocks.objects))
fmt=next(item[0] for item in io_scene_gltf2.get_format_items(None,bpy.context) if item[0]=='GLB')
rna=bpy.ops.export_scene.gltf.get_rna_type().properties
materials=next(item.identifier for item in rna['export_materials'].enum_items if item.identifier=='NONE')
colors=next(item.identifier for item in rna['export_vertex_color'].enum_items if item.identifier=='ACTIVE')
bpy.ops.export_scene.gltf(filepath=str(root/'public/models/summit/radar-rocks.glb'),export_format=fmt,
use_selection=True,use_active_scene=True,export_yup=True,export_apply=True,
export_materials=materials,export_vertex_color=colors,export_active_vertex_color_when_no_material=True,
export_animations=False,export_cameras=False,export_lights=False,export_extras=True)
bpy.ops.wm.save_as_mainfile(filepath=str(root/'assets/summit/blender-rock-study/photo-guided-rocks.blend'))
+35
View File
@@ -0,0 +1,35 @@
"""Export Blender-authored slab heights + one shared boundary, and the marker GLB."""
import bpy,json
import io_scene_gltf2
from pathlib import Path
ROOT=Path('D:/project/sunrise')
scene=bpy.data.scenes['Summit slab and marker'];bpy.context.window.scene=scene
slab=bpy.data.objects['SLAB_SURFACE'];outline=bpy.data.objects['WALKABLE_OUTLINE'];marker=bpy.data.objects['summit-marker']
heights=[[round(v.co.x,6),round(-v.co.y,6),round(v.co.z+620,6)] for v in slab.data.vertices]
boundary=[[round(v.co.x,4),round(-v.co.y,4)] for v in outline.data.vertices]
spec={'boundary':boundary,'heights':heights,'marker':{'assetId':'summit-marker','offset':[round(marker.location.x,4),round(-marker.location.y,4)],'blockedRadius':marker['blockedRadius']}}
spec['atlasBounds']=list(slab['atlasBounds'])
(ROOT/'src/constants/summitPlateau.ts').write_text('/** Exported from Blender. Photo-estimated slab; local east/south metres from SUMMIT_TERRAIN.start. */\nexport const SUMMIT_PLATEAU: { boundary: [number,number][]; heights: [number,number,number][]; atlasBounds: [number,number,number,number]; marker: {assetId: string; offset: [number,number]; blockedRadius: number} } = '+json.dumps(spec,separators=(',',':'))+';\n',encoding='utf-8')
for obj in scene.objects:obj.select_set(False)
for obj in [marker,*marker.children_recursive]:obj.select_set(True)
bpy.context.view_layer.objects.active=marker
location=marker.location.copy();marker.location=(0,0,0)
photo_body=bpy.data.objects.get('Photo matched summit boulder')
restore=None
if photo_body and not photo_body.get('paintedInscription'):
mat=photo_body.data.materials[0]
bs=next(n for n in mat.node_tree.nodes if n.type=='BSDF_PRINCIPLED')
socket=bs.inputs['Base Color'];restore=(mat,socket,socket.links[0].from_socket)
scan=next(n for n in mat.node_tree.nodes if n.type=='TEX_IMAGE' and n.image and n.image.name.startswith('rock-color'))
mat.node_tree.links.new(scan.outputs['Color'],socket)
try:
fmt=next(item[0] for item in io_scene_gltf2.get_format_items(None,bpy.context) if item[0]=='GLB')
mode=next(i.identifier for i in bpy.ops.export_scene.gltf.get_rna_type().properties['export_materials'].enum_items if i.identifier=='EXPORT')
color_choice='NONE' if photo_body and photo_body.get('paintedInscription') else 'ACTIVE'
color_mode=next(i.identifier for i in bpy.ops.export_scene.gltf.get_rna_type().properties['export_vertex_color'].enum_items if i.identifier==color_choice)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'public/models/summit/summit-marker.glb'),export_format=fmt,use_selection=True,use_active_scene=True,export_yup=True,export_apply=True,export_materials=mode,export_vertex_color=color_mode,export_all_vertex_colors=False,export_animations=False,export_cameras=False,export_lights=False)
finally:
marker.location=location
if restore:restore[0].node_tree.links.new(restore[2],restore[1])
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'assets/summit/blender-plateau-study/summit-slab-and-marker.blend'))
print('Exported',len(heights),'heights,',len(boundary),'boundary points and summit marker')
+87
View File
@@ -0,0 +1,87 @@
"""Build a local reference index and GeoJSON from the downloaded OSM extract."""
import csv
import hashlib
import html
import json
from pathlib import Path
import xml.etree.ElementTree as ET
from PIL import Image, ImageDraw
root = Path(__file__).resolve().parents[1] / 'data/references/summit'
osm = ET.parse(root / 'maps/summit.osm').getroot()
nodes = {n.attrib['id']: [float(n.attrib['lon']), float(n.attrib['lat'])] for n in osm.findall('node')}
features = []
for element in osm:
tags = {t.attrib['k']: t.attrib['v'] for t in element.findall('tag')}
if not tags or element.tag not in ('node', 'way'):
continue
if element.tag == 'node':
lon,lat = nodes[element.attrib['id']]
if not (126.950 <= lon <= 126.975 and 37.430 <= lat <= 37.453):
continue
geometry = {'type': 'Point', 'coordinates': nodes[element.attrib['id']]}
else:
coordinates = [nodes[n.attrib['ref']] for n in element.findall('nd') if n.attrib['ref'] in nodes]
if len(coordinates) != len(element.findall('nd')) or len(coordinates) < 2:
continue
area = coordinates[0] == coordinates[-1] and (tags.get('area') == 'yes' or 'building' in tags or 'landuse' in tags)
geometry = {'type': 'Polygon' if area else 'LineString', 'coordinates': [coordinates] if area else coordinates}
features.append({'type': 'Feature', 'id': element.tag + '/' + element.attrib['id'], 'properties': tags, 'geometry': geometry})
(root / 'maps/summit.geojson').write_text(json.dumps({'type': 'FeatureCollection', 'features': features}, ensure_ascii=False), encoding='utf-8')
structures = [f for f in features if any(k in f['properties'] for k in ('building', 'man_made', 'barrier')) or f['properties'].get('tourism') == 'viewpoint']
with (root / 'maps/structure-inventory.csv').open('w', encoding='utf-8-sig', newline='') as out:
writer = csv.writer(out)
writer.writerow(['osm_id', 'name', 'tags', 'geometry'])
for f in structures:
writer.writerow([f['id'], f['properties'].get('name', ''), json.dumps(f['properties'], ensure_ascii=False), json.dumps(f['geometry'])])
def xy(c):
return ((c[0]-126.950)/.025*850, (37.453-c[1])/.023*985)
svg = ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 850 985"><rect width="850" height="985" fill="#eef3ec"/>']
for f in features:
g, p = f['geometry'], f['properties']
if g['type'] == 'Point':
if f not in structures and 'name' not in p:
continue
x,y = xy(g['coordinates'])
svg.append(f'<circle cx="{x}" cy="{y}" r="3" fill="#b94331"/>')
if 'name' in p:
svg.append(f'<text x="{x+5}" y="{y}" font-size="11">{html.escape(p["name"])}</text>')
else:
coords = g['coordinates'][0] if g['type']=='Polygon' else g['coordinates']
points = ' '.join(f'{x:.1f},{y:.1f}' for x,y in map(xy, coords))
building = 'building' in p
svg.append(f'<polyline points="{points}" fill="{"#a5b9cf" if building else "none"}" stroke="{"#345575" if building else "#899b85"}" stroke-width="1.3"/>')
svg.append('<text x="10" y="20" font-size="14">N ↑ | OSM reference map | © OpenStreetMap contributors, ODbL</text></svg>')
(root/'maps/overview.svg').write_text(''.join(svg), encoding='utf-8')
downloads = json.loads((root/'photos/downloads.json').read_text(encoding='utf-8'))
recent_path = root/'photos/recent-downloads.json'
if recent_path.exists():
downloads += json.loads(recent_path.read_text(encoding='utf-8'))
cards=[]
valid=[]
for item in downloads:
if item['status'] != 'downloaded':
continue
if item['file'] in {f'photo-{i:02d}.jpg' for i in range(2,9)} | {'recent-01.jpg','recent-03.jpg','recent-06.jpg'}:
item['relevance'] = '주요 구조물 참고사진에서 제외: 인물, 원경, 계곡 또는 음식'
continue
path=root/'photos'/item['file']
with Image.open(path) as im:
im.verify()
item['sha256']=hashlib.sha256(path.read_bytes()).hexdigest()
valid.append(item)
meta=item['metadata']
details='<br>'.join(html.escape(k)+': '+meta.get(k,{}).get('value','미상') for k in ['Artist','DateTimeOriginal','LicenseShortName','LicenseUrl'])
cards.append(f'<article><h3>{html.escape(item["file"])} — {html.escape(item["title"])}</h3><a href="photos/{item["file"]}"><img src="photos/{item["file"]}"></a><p>{details}</p><a href="{html.escape(item["source"])}">원문 / 이용조건</a></article>')
(root/'photos/verified-index.json').write_text(json.dumps(downloads,ensure_ascii=False,indent=2),encoding='utf-8')
(root/'index.html').write_text('<!doctype html><html lang="ko"><meta charset="utf-8"><title>관악산 정상 구조물 참고자료</title><style>body{font:16px sans-serif;max-width:1100px;margin:30px auto;padding:20px;background:#f5f6f8}img{max-width:100%;max-height:650px}article{background:white;padding:20px;margin:20px 0}a{color:#245f98}</style><h1>관악산 정상 구조물 참고자료</h1><p>수집일 2026-10-29. OSM은 국가 수치지도 대체 참고자료이며 측량도면이 아닙니다. 사진은 촬영 시점의 모습으로 현재 형태와 차이가 있을 수 있습니다. 치수 미확정.</p><p><a href="README.md">자료 범위와 한계</a> · <a href="maps/summit.geojson">GeoJSON</a> · <a href="maps/structure-inventory.csv">구조물 목록</a> · <a href="https://www.openstreetmap.org/copyright">OSM 출처·ODbL</a></p><img src="maps/overview.svg">'+''.join(cards)+'</html>',encoding='utf-8')
sheet=Image.new('RGB',(1000,210*((len(valid)+3)//4)),'white')
draw=ImageDraw.Draw(sheet)
for i,item in enumerate(valid):
with Image.open(root/'photos'/item['file']) as im:
im.thumbnail((245,180))
x,y=(i%4)*250,(i//4)*210
sheet.paste(im,(x,y))
draw.text((x+5,y+183),item['file'],fill='black')
sheet.save(root/'photos/contact-sheet.jpg')
print(json.dumps({'features':len(features),'structure_candidates':len(structures),'verified_photos':len(valid)}))
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { once } from 'node:events';
import { setTimeout as delay } from 'node:timers/promises';
import { monitorEventLoopDelay } from 'node:perf_hooks';
import { cpus, totalmem } from 'node:os';
import { writeFile, mkdir } from 'node:fs/promises';
import { io, type Socket } from 'socket.io-client';
import { createApp } from '../server/app.ts';
import { MemoryVisitStore } from '../server/visits.ts';
import { MULTIPLAYER } from '../src/constants/multiplayer.ts';
import type { ClientEvents, ServerEvents, JoinResult, PlayerUpdate } from '../src/multiplayer/protocol.ts';
const duration = Number(process.env.LOAD_SECONDS ?? 300);
assert.ok(Number.isFinite(duration) && duration >= 1 && duration <= 3600);
const store = new MemoryVisitStore();
const errors: string[] = [];
const app = createApp({ visitStore: store, weather: false, onError: error => errors.push(String(error)) });
const sockets: Socket<ServerEvents, ClientEvents>[] = [];
let timer: ReturnType<typeof setInterval> | undefined;
const lag = monitorEventLoopDelay({ resolution: 20 });
try {
app.httpServer.listen(0, '127.0.0.1');
await once(app.httpServer, 'listening');
const url = `http://127.0.0.1:${(app.httpServer.address() as { port: number }).port}`;
const connect = async () => {
const socket: Socket<ServerEvents, ClientEvents> = io(url, { transports: ['websocket'], forceNew: true, reconnection: false });
sockets.push(socket);
await Promise.race([once(socket, 'connect'), once(socket, 'connect_error').then(([error]) => { throw error; })]);
const reply: JoinResult = await socket.timeout(10000).emitWithAck('join', { ...MULTIPLAYER, visitorId: randomUUID(), nickname: '부하검증' });
return { socket, reply };
};
const participants = await Promise.all(Array.from({ length: 100 }, connect));
assert.ok(participants.every(p => p.reply.ok));
const overflow = await connect();
assert.deepEqual(overflow.reply, { ok: false, code: 'ROOM_FULL' });
overflow.socket.disconnect();
const positions: PlayerUpdate[] = participants.map(p => {
if (!p.reply.ok) throw new Error('Admission failed');
const self = p.reply.state.players.find(player => player.id === p.reply.playerId)!;
return { ...self, equipment: { umbrella: false, raincoat: false, hood: false } };
});
const latencies: number[] = [];
let snapshots = 0, corrections = 0, unexpectedDisconnects = 0;
for (const { socket } of participants) {
socket.on('player:correction', () => corrections++);
socket.on('disconnect', () => unexpectedDisconnects++);
}
participants[0].socket.on('room:state', state => {
snapshots++;
latencies.push(Math.max(0, Date.now() - state.serverTime));
});
lag.enable();
const cpuStart = process.cpuUsage(), start = performance.now(), initialMemory = process.memoryUsage();
let tick = 0;
timer = setInterval(() => {
tick++;
participants.forEach(({ socket }, i) => {
const base = positions[i];
// Sub-metre movement on the common spawn platform, well below walking speed.
socket.emit('player:update', { ...base, x: base.x + Math.sin(tick / 10 + i / 20) * .15,
z: base.z + Math.cos(tick / 10 + i / 20) * .15, yaw: Math.sin(tick / 30), moving: true,
equipment: { umbrella: tick % 40 < 20, raincoat: i % 2 === 0, hood: false } });
});
}, MULTIPLAYER.tickMs);
await delay(duration * 1000);
clearInterval(timer); timer = undefined;
assert.equal(unexpectedDisconnects, 0);
assert.equal(app.room.size, 100);
participants[99].socket.disconnect();
for (let n = 0; app.room.size !== 99 && n < 100; n++) await delay(10);
const replacement = await connect();
assert.ok(replacement.reply.ok, 'released slot can be reused');
latencies.sort((a, b) => a - b);
const cpu = process.cpuUsage(cpuStart);
const result = { durationSeconds: (performance.now() - start) / 1000, clients: 100, tickHz: 10,
capacityAndReplacement: true, corrections, snapshots, errors,
snapshotDeliveryP95Ms: latencies[Math.floor(latencies.length * .95)] ?? null,
snapshotDeliveryMaxMs: latencies.at(-1) ?? null,
eventLoopP95Ms: lag.percentile(95) / 1e6,
cpuSeconds: (cpu.user + cpu.system) / 1e6,
rssStartMB: initialMemory.rss / 1048576, rssEndMB: process.memoryUsage().rss / 1048576,
hardware: { cpu: cpus()[0]?.model, logicalCpus: cpus().length, ramGB: totalmem() / 1073741824 },
limits: 'Local server AND 100 synthetic clients share one Node process; memory visit store, no browser rendering. Snapshot latency starts at broadcast, excludes input-to-broadcast waiting.' };
assert.equal(errors.length, 0);
assert.equal(corrections, 0, 'load motion should be accepted');
await mkdir('artifacts/multiplayer', { recursive: true });
await writeFile('artifacts/multiplayer/load-report.json', JSON.stringify(result, null, 2));
console.log(JSON.stringify(result, null, 2));
} finally {
if (timer) clearInterval(timer);
lag.disable();
sockets.forEach(socket => socket.disconnect());
await app.close();
}
+68
View File
@@ -0,0 +1,68 @@
"""Rasterize existing Hanja strokes into the stone's own albedo; no letter geometry exported."""
import bpy, numpy as np, math
from pathlib import Path
s=bpy.data.scenes['Summit slab and marker'];bpy.context.window.scene=s
body=bpy.data.objects['Photo matched summit boulder'];root=bpy.data.objects['summit-marker'];mesh=body.data
if body.get('paintedInscription'):raise RuntimeError('Already painted; edit the texture rather than baking it twice.')
mat=mesh.materials[0];nodes=mat.node_tree.nodes;links=mat.node_tree.links
bs=next(n for n in nodes if n.type=='BSDF_PRINCIPLED');old_color=bs.inputs['Base Color'].links[0].from_socket
old_uv=nodes.new('ShaderNodeUVMap');old_uv.uv_map='Granite grain UV'
for n in list(nodes):
if n.type=='TEX_IMAGE':links.new(old_uv.outputs['UV'],n.inputs['Vector'])
atlas=mesh.uv_layers.new(name='Painted monument atlas')
# Separate front and rear islands; preserve the existing stone geometry and normals.
xmin=-1.3;xspan=2.6;zmin=-.2;zspan=2.3
half=len(mesh.vertices)//2
for f in mesh.polygons:
rear=sum(1 for i in f.vertices if i>=half)>1
for li in f.loop_indices:
v=mesh.vertices[mesh.loops[li].vertex_index].co
atlas.data[li].uv=((.52 if rear else .02)+.46*(v.x-xmin)/xspan,.02+.96*(v.z-zmin)/zspan)
mesh.uv_layers.active=atlas
for layer in mesh.uv_layers:layer.active_render=(layer==atlas)
N=2048;mask=np.zeros((N,N),dtype=np.float32)
letters=[o for o in root.children if o.name.startswith('Carved ')]
for o in letters:
o.data.calc_loop_triangles()
for tri in o.data.loop_triangles:
p=[o.matrix_basis@o.data.vertices[i].co for i in tri.vertices]
xy=np.array([[(.02+.46*(v.x-xmin)/xspan)*(N-1),(.02+.96*(v.z-zmin)/zspan)*(N-1)] for v in p])
lo=np.maximum(0,np.floor(xy.min(axis=0)).astype(int));hi=np.minimum(N-1,np.ceil(xy.max(axis=0)).astype(int))
if np.any(hi<lo):continue
A,B,C=xy;den=(B[1]-C[1])*(A[0]-C[0])+(C[0]-B[0])*(A[1]-C[1])
if abs(den)<1e-8:continue
X,Y=np.meshgrid(np.arange(lo[0],hi[0]+1)+.5,np.arange(lo[1],hi[1]+1)+.5)
u=((B[1]-C[1])*(X-C[0])+(C[0]-B[0])*(Y-C[1]))/den
v=((C[1]-A[1])*(X-C[0])+(A[0]-C[0])*(Y-C[1]))/den
hit=(u>=0)&(v>=0)&(u+v<=1)
mask[lo[1]:hi[1]+1,lo[0]:hi[0]+1]=np.maximum(mask[lo[1]:hi[1]+1,lo[0]:hi[0]+1],hit)
# Slight pigment wear lets mineral grain remain visible through the painted strokes.
rng=np.random.default_rng(91);mask*=rng.uniform(.88,1,mask.shape)
rgba=np.ones((N,N,4),dtype=np.float32);rgba[:,:,:3]=mask[:,:,None]
im=bpy.data.images.new('Hanja pigment mask',width=N,height=N,alpha=False);im.pixels.foreach_set(rgba.ravel());im.update()
tex=nodes.new('ShaderNodeTexImage');tex.image=im
atlas_node=nodes.new('ShaderNodeUVMap');atlas_node.uv_map=atlas.name;links.new(atlas_node.outputs['UV'],tex.inputs['Vector'])
mix=nodes.new('ShaderNodeMixRGB');links.new(tex.outputs['Color'],mix.inputs[0]);links.new(old_color,mix.inputs[1]);mix.inputs[2].default_value=(.012,.014,.010,1);links.new(mix.outputs[0],bs.inputs['Base Color'])
image=bpy.data.images.new('Painted Hanja on granite',width=N,height=N,alpha=False)
target=nodes.new('ShaderNodeTexImage');target.image=image;nodes.active=target
for o in s.objects:o.select_set(False)
body.select_set(True);bpy.context.view_layer.objects.active=body
s.render.bake.use_pass_direct=False;s.render.bake.use_pass_indirect=False;s.render.bake.use_pass_color=True;s.render.bake.margin=8
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=='DIFFUSE'))
image.filepath_raw='D:/project/sunrise/public/terrain/materials/marker-painted-color.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()
links.new(atlas_node.outputs['UV'],target.inputs['Vector']);links.new(target.outputs['Color'],bs.inputs['Base Color'])
for o in letters:
world=o.matrix_world.copy();o.parent=None;o.matrix_world=world;o.hide_render=True;o.hide_set(True);o.name='Paint source - '+o.name
body['paintedInscription']=True
side=mat.copy();side.name='Unpainted stone edge'
edge_bs=next(n for n in side.node_tree.nodes if n.type=='BSDF_PRINCIPLED')
scan=next(n for n in side.node_tree.nodes if n.type=='TEX_IMAGE' and n.image and n.image.name.startswith('rock-color'))
for socket in [edge_bs.inputs['Base Color'],edge_bs.inputs['Normal']]:
for link in list(socket.links):side.node_tree.links.remove(link)
edge_bs.inputs['Base Color'].default_value=(.23,.215,.18,1);mesh.materials.append(side)
for p in mesh.polygons:
if any(i<half for i in p.vertices) and any(i>=half for i in p.vertices):p.material_index=1
s.render.filepath='D:/project/sunrise/assets/summit/blender-plateau-study/marker-painted.png'
bpy.ops.render.render(write_still=True)
print('Painted',int(np.count_nonzero(mask)),'texels. Export hierarchy contains only the stone; glyph meshes retained hidden as paint sources.')
+92
View File
@@ -0,0 +1,92 @@
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { gzipSync, gunzipSync } from 'node:zlib';
import { Mesh } from 'three';
import { createBuildings } from '../src/scene/buildings.ts';
import { BUILDINGS } from '../src/constants/buildings.ts';
import type { BuildingAssetManifest } from '../src/scene/buildingAssets.ts';
import type { BuildingHeightSource } from '../src/scene/buildingScene.ts';
const root = new URL('../', import.meta.url);
const file = (path: string) => new URL(path, root);
const hash = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex');
const inputs = [
'public/buildings/gwacheon.geojson', 'public/terrain/gwanaksan.json', 'public/terrain/gwanaksan.f32',
'src/scene/buildings.ts', 'src/scene/buildingLightSpill.ts', 'src/scene/buildingScene.ts',
'src/scene/buildingAssets.ts', 'src/scene/structures.ts', 'src/scene/terrainGeometry.ts',
'src/constants/buildings.ts', 'src/constants/terrain.ts', 'package-lock.json',
'scripts/prepare-building-assets.ts', 'src/controls/summitCoordinates.ts',
];
const sourceDigest = createHash('sha256');
for (const path of inputs) {
const bytes = readFileSync(file(path));
// Checkouts may convert LF/CRLF without changing generation inputs.
sourceDigest.update(path).update('\0').update(path.endsWith('.f32') ? bytes : bytes.toString('utf8').replace(/\r\n/g, '\n'));
}
const sourceHash = sourceDigest.digest('hex');
const manifestPath = file(`public${BUILDINGS.assetManifestUrl}`);
const binaryPath = file(`public${BUILDINGS.assetBufferUrl}`);
const gzipPath = file(`public${BUILDINGS.assetBufferUrl}.gz`);
if (process.argv.includes('--check')) {
try {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as BuildingAssetManifest;
const buffer = readFileSync(binaryPath);
if (manifest.version !== BUILDINGS.assetVersion || manifest.sourceHash !== sourceHash ||
manifest.byteLength !== buffer.byteLength || manifest.bufferHash !== hash(buffer)) throw new Error('stale');
if (!gunzipSync(readFileSync(gzipPath)).equals(buffer)) throw new Error('stale gzip');
console.log('Prebuilt buildings are up to date.');
} catch {
console.error('건물 사전 생성 파일이 없거나 오래되었습니다. npm run prepare:buildings 를 실행해 주세요.');
process.exitCode = 1;
}
} else {
const metadata = JSON.parse(readFileSync(file('public/terrain/gwanaksan.json'), 'utf8'));
const heights = readFileSync(file('public/terrain/gwanaksan.f32'));
const data = JSON.parse(readFileSync(file(`public${BUILDINGS.url}`), 'utf8'));
const city = createBuildings(data, { metadata, heights: new Float32Array(heights.buffer, heights.byteOffset, heights.byteLength / 4) });
try {
const chunks: Buffer[] = [];
let byteLength = 0;
const append = (array: ArrayBufferView) => {
const padding = (4 - byteLength % 4) % 4;
if (padding) { chunks.push(Buffer.alloc(padding)); byteLength += padding; }
const offset = byteLength;
const bytes = Buffer.from(array.buffer, array.byteOffset, array.byteLength);
chunks.push(bytes); byteLength += bytes.byteLength;
return offset;
};
const meshes: BuildingAssetManifest['meshes'] = city.group.children.map(child => {
const mesh = child as Mesh, geometry = mesh.geometry;
geometry.computeBoundingBox(); geometry.computeBoundingSphere();
const box = geometry.boundingBox!, sphere = geometry.boundingSphere!;
return {
source: mesh.name.replace('building-height-', '') as BuildingHeightSource,
vertices: geometry.getAttribute('position').count,
position: append(geometry.getAttribute('position').array),
normal: append(geometry.getAttribute('normal').array),
lightHeight: append(geometry.getAttribute('buildingLightHeight').array),
index: append(geometry.index!.array), indexCount: geometry.index!.count,
indexType: geometry.index!.array instanceof Uint32Array ? 'uint32' : 'uint16',
box: [...box.min.toArray(), ...box.max.toArray()],
sphere: [...sphere.center.toArray(), sphere.radius],
};
});
const spill = { offset: append(city.spill.texture.image.data), size: city.spill.texture.image.width,
bounds: city.spill.bounds.toArray() as [number, number, number, number] };
const buffer = Buffer.concat(chunks);
const manifest: BuildingAssetManifest = {
version: BUILDINGS.assetVersion, sourceHash, bufferHash: hash(buffer), byteLength,
terrain: { origin: metadata.origin, observerSurfaceElevation: metadata.observerSurfaceElevation },
counts: city.counts, target: city.target.toArray(), meshes, spill,
};
writeFileSync(binaryPath, buffer);
const compressed = gzipSync(buffer, { level: 9 });
writeFileSync(gzipPath, compressed);
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
console.log(`Saved ${data.features.length} buildings: ${byteLength.toLocaleString()} bytes (including light map).`);
console.log(`Gzip transfer: ${compressed.byteLength.toLocaleString()} bytes.`);
console.log(fileURLToPath(manifestPath));
} finally { city.dispose(); }
}
+29
View File
@@ -0,0 +1,29 @@
import { writeFileSync } from 'node:fs';
import { SUMMIT_TRAILS } from '../src/constants/summitTrails.ts';
import { SUMMIT_TERRAIN } from '../src/constants/summitTerrain.ts';
import { STRUCTURES } from '../src/constants/structures.ts';
import { GISAMUN_HARBOUR } from '../src/constants/harbour.ts';
// Publish the geographic inputs separately; never regenerate the terrain here.
const data = {
attribution: '© OpenStreetMap contributors',
sourceUrl: 'https://www.openstreetmap.org/copyright',
license: 'ODbL-1.0',
licenseUrl: 'https://opendatacommons.org/licenses/odbl/1-0/',
scope: 'Geographic input collection below, including our estimated additions. Not a relicensing of Copernicus elevation grids, VWorld buildings, photographs, or model artwork.',
processing: 'OSM route selection and facility positions; estimated widths, footprints, dimensions and terrain control elevations. These are not surveyed values.',
summit: {
routes: SUMMIT_TRAILS.routes,
structures: STRUCTURES,
terrainControls: SUMMIT_TERRAIN.controls,
rockBoundary: SUMMIT_TRAILS.rockPolygon,
rockOutcrops: SUMMIT_TRAILS.rockOutcrops,
},
gisamunHarbour: {
source: 'OSM way/583161999; inland closure and dimensions estimated',
coordinateSystem: 'Local east X, south Z, metres; origin 38.00555 N, 128.73195 E',
parameters: GISAMUN_HARBOUR,
},
};
writeFileSync(new URL('../public/osm-derived.json', import.meta.url), JSON.stringify(data, null, 2) + '\n');
console.log('OSM geographic inputs and licence notice exported; terrain unchanged.');
+67
View File
@@ -0,0 +1,67 @@
"""OSM XML snapshots -> attributed footprint GeoJSON, with no fabricated source heights."""
import collections
import argparse
import hashlib
import json
from pathlib import Path
import xml.etree.ElementTree as ET
root = Path(__file__).resolve().parents[1]
items = {}
sources = []
boxes = ['126.975,37.410,126.995,37.435','126.995,37.410,127.015,37.435','126.975,37.435,126.995,37.460','126.995,37.435,127.015,37.460']
for i, bbox in enumerate(boxes):
path = root / f'data/raw/gwacheon/tile-{i}.osm'
tree = ET.parse(path).getroot()
sources.append({'file':path.name,'url':'https://www.openstreetmap.org/api/0.6/map?bbox='+bbox,'sha256':hashlib.sha256(path.read_bytes()).hexdigest()})
for element in tree:
items[(element.tag,element.get('id'))] = element
nodes = {key[1]:[float(e.get('lon')),float(e.get('lat'))] for key,e in items.items() if key[0]=='node'}
def tags(e):
return {t.get('k'):t.get('v') for t in e.findall('tag')}
def ring(e):
refs = [n.get('ref') for n in e.findall('nd')]
if len(refs)<4 or refs[0]!=refs[-1] or any(r not in nodes for r in refs):
return None
return [nodes[r] for r in refs]
features=[]
skipped=[]
consumed=set()
for (kind,identity),e in items.items():
p=tags(e)
if kind!='relation' or 'building' not in p:
continue
outer=[]; inner=[]; members=[]; valid=True
for m in e.findall('member'):
way=items.get(('way',m.get('ref')))
r=ring(way) if way is not None else None
if m.get('type')!='way' or r is None:
valid=False; break
(inner if m.get('role')=='inner' else outer).append(r)
members.append(m.get('ref'))
if valid and len(outer)==1:
features.append({'type':'Feature','id':'relation/'+identity,'properties':p,'geometry':{'type':'Polygon','coordinates':outer+inner}})
consumed.update(members)
else:
skipped.append({'id':'relation/'+identity,'reason':'multipart or incomplete relation; not silently filled'})
for (kind,identity),e in items.items():
p=tags(e)
if kind!='way' or 'building' not in p or identity in consumed:
continue
if p['building'] in ['no','construction','ruins'] or p.get('location')=='underground':
skipped.append({'id':'way/'+identity,'reason':'not an existing above-ground building'}); continue
r=ring(e)
if r is None:
skipped.append({'id':'way/'+identity,'reason':'open or incomplete footprint'}); continue
features.append({'type':'Feature','id':'way/'+identity,'properties':p,'geometry':{'type':'Polygon','coordinates':[r]}})
# API returns ways crossing the requested bounds: retain complete footprints only if center is in AOI.
features=[f for f in features if 126.975 <= sum(c[0] for c in f['geometry']['coordinates'][0][:-1])/len(f['geometry']['coordinates'][0][:-1]) <= 127.015 and 37.410 <= sum(c[1] for c in f['geometry']['coordinates'][0][:-1])/len(f['geometry']['coordinates'][0][:-1]) <= 37.460]
data={'type':'FeatureCollection','attribution':'© OpenStreetMap contributors','license':'ODbL-1.0','features':features}
parser = argparse.ArgumentParser()
parser.add_argument('--output-dir', type=Path, default=root/'public/buildings')
out=parser.parse_args().output_dir
out.mkdir(exist_ok=True,parents=True)
(out/'gwacheon.geojson').write_text(json.dumps(data,ensure_ascii=False,separators=(',',':')),encoding='utf-8')
report={'downloadedAt':'2026-10-29','bbox':[126.975,37.410,127.015,37.460],'count':len(features),'types':dict(collections.Counter(f['properties']['building'] for f in features)),'heightTags':sum('height' in f['properties'] for f in features),'levelTags':sum('building:levels' in f['properties'] for f in features),'sources':sources,'skipped':skipped,'license':'https://www.openstreetmap.org/copyright'}
(out/'metadata.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
print(json.dumps(report,ensure_ascii=False))
+151
View File
@@ -0,0 +1,151 @@
"""Extract Hajodae/Gisamun DSM and OSM coast using .tools/terrain dependencies.
Run: python scripts/prepare-hajodae.py [hajodae|gisamun]
The 10m grid is interpolated from 30m DSM, not higher-accuracy survey data.
"""
import hashlib
import json
from pathlib import Path
import subprocess
import sys
from contextlib import ExitStack
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / '.tools/terrain'))
import numpy as np
import rasterio
from rasterio.features import rasterize
from rasterio.merge import merge
from rasterio.transform import from_origin
from rasterio.warp import reproject, Resampling
from pyproj import Transformer
def main():
site = sys.argv[1] if len(sys.argv) > 1 else 'hajodae'
if site not in ('hajodae', 'gisamun'):
raise ValueError('Expected hajodae or gisamun')
config = json.loads(subprocess.check_output(['node', '--input-type=module', '-e',
"import {COAST} from './src/constants/coast.ts'; console.log(JSON.stringify(COAST))"], cwd=ROOT))
if site == 'gisamun':
config.update(json.loads(subprocess.check_output(['node', '--input-type=module', '-e',
"import {GISAMUN} from './src/constants/gisamun.ts'; console.log(JSON.stringify(GISAMUN))"], cwd=ROOT)))
lat, lon = config['latitude'], config['longitude']
radius, step = config['radiusMeters'], config['spacingMeters']
size = 2 * radius // step + 1
projection = f'+proj=aeqd +lat_0={lat} +lon_0={lon} +datum=WGS84 +units=m +no_defs'
forward = Transformer.from_crs('EPSG:4326', projection, always_xy=True)
inverse = Transformer.from_crs(projection, 'EPSG:4326', always_xy=True)
features = json.loads((ROOT / f'data/references/{site}/coastal-features.geojson').read_text(encoding='utf-8'))['features']
coast = [f for f in features if f['properties']['category'] == 'coastline']
mainland = next(f['geometry']['coordinates'] for f in coast if f['id'] == 'way/583161999')
mainland_ids = {'way/583161999'}
if site == 'gisamun':
south = next(f['geometry']['coordinates'] for f in coast if f['id'] == 'way/22784123')
assert south[-1] == mainland[0], 'Mainland segments must join exactly'
mainland = south[:-1] + mainland
mainland_ids.add('way/22784123')
# This source runs south -> north; land is on its west side. Close well west
# of the crop. These artificial closing edges never cross the published area.
mainland = mainland + [[128.6, mainland[-1][1]], [128.6, mainland[0][1]], mainland[0]]
geographic_land = [mainland] + [f['geometry']['coordinates'] for f in coast if f['id'] not in mainland_ids]
beaches = [f['geometry']['coordinates'][0] for f in features if f['properties']['category'] == 'beach']
def project(ring):
return [list(forward.transform(*p)) for p in ring]
land = [project(r) for r in geographic_land]
sand = [project(r) for r in beaches]
if site == 'gisamun':
# Missing OSM beach polygon: approximate only this known beach segment.
edge = project([p for p in mainland if config['sandLatitudeMin'] <= p[1] <= config['sandLatitudeMax']])
assert len(edge) >= 2
sand.append(edge + [[x-config['sandWidthMeters'], y] for x,y in reversed(edge)] + [edge[0]])
lines = [project(f['geometry']['coordinates']) for f in coast]
def shapes(rings):
return [({'type': 'Polygon', 'coordinates': [r]}, 1) for r in rings]
def mask(rings, spacing):
n = 2 * radius // spacing + 1
return rasterize(shapes(rings), out_shape=(n, n),
transform=from_origin(-radius-spacing/2, radius+spacing/2, spacing, spacing), dtype='uint8')
land_grid = mask(land, step)
beach_grid = mask(sand, step)
sources = [ROOT / f'data/raw/hajodae/Copernicus_DSM_COG_10_N{n}_00_E128_00_DEM.tif' for n in ([37,38] if site == 'gisamun' else [38])]
heights = np.full((size, size), np.nan, dtype=np.float32)
with ExitStack() as stack:
datasets = [stack.enter_context(rasterio.open(p)) for p in sources]
mosaic, transform = merge(datasets)
reproject(mosaic[0], heights, src_transform=transform, src_crs=datasets[0].crs,
dst_transform=from_origin(-radius-step/2, radius+step/2, step, step),
dst_crs=projection, dst_nodata=np.nan, resampling=Resampling.bilinear)
assert np.isfinite(heights).all(), 'Missing DSM coverage'
# Preserve the resampled Copernicus heights independently of OSM masks.
heights = heights.astype('<f4')
assert land_grid[size//2, size//2], 'Origin must lie on mapped land'
if site == 'gisamun':
assert beach_grid[size//2, size//2], 'Gisamun origin must lie on the mapped beach'
output = ROOT / f'public/terrain/{site}'
output.mkdir(parents=True, exist_ok=True)
heights.tofile(output/'heights.f32')
fine_land, fine_sand = mask(land, config['maskSpacingMeters']), mask(sand, config['maskSpacingMeters'])
# B encodes signed distance to vector coastline (positive inland), not
# distance to raster stair steps. Work only within each segment's band.
spacing = config['maskSpacingMeters']
limit = config['distanceRangeMeters']
n = fine_land.shape[0]
fine_distance = np.full((n, n), float(limit))
for line in lines:
for (ax, ay), (bx, by) in zip(line, line[1:]):
dx, dy = bx-ax, by-ay
length2 = dx*dx+dy*dy
if not length2:
continue
left = max(0, int(np.floor((min(ax,bx)-limit+radius)/spacing)))
right = min(n, int(np.ceil((max(ax,bx)+limit+radius)/spacing))+1)
top = max(0, int(np.floor((radius-max(ay,by)-limit)/spacing)))
bottom = min(n, int(np.ceil((radius-min(ay,by)+limit)/spacing))+1)
if right <= left or bottom <= top:
continue
fx, fy = np.meshgrid(np.arange(left,right)*spacing-radius, radius-np.arange(top,bottom)*spacing)
t = np.clip(((fx-ax)*dx+(fy-ay)*dy)/length2, 0, 1)
fine_distance[top:bottom,left:right] = np.minimum(fine_distance[top:bottom,left:right], np.hypot(fx-ax-t*dx,fy-ay-t*dy))
signed = fine_distance * np.where(fine_land, 1, -1)
encoded = np.rint(np.clip(.5+signed/(2*limit),0,1)*255).astype('uint8')
# R land, G sand, B signed coast distance. Existing RG consumers stay valid.
with rasterio.open(output/'surface.png', 'w', driver='PNG', width=fine_land.shape[1], height=fine_land.shape[0], count=3, dtype='uint8') as png:
png.write(np.stack([fine_land*255, fine_sand*255, encoded]))
manifest = json.loads((ROOT/f'data/references/{site}/metadata.json').read_text(encoding='utf-8'))
used = [s for s in manifest['sources'] if Path(s['file']).name in [p.name for p in sources] or s['file'].endswith('.osm')]
for item in used:
assert hashlib.sha256((ROOT/item['file']).read_bytes()).hexdigest() == item['sha256']
osm_license = {'license': 'ODbL-1.0', 'licenseUrl': 'https://opendatacommons.org/licenses/odbl/1-0/',
'attribution': '© OpenStreetMap contributors', 'sourceUrl': 'https://www.openstreetmap.org/copyright'}
meta = {
'version': 1, 'dataset': f'Copernicus GLO-30 + OSM {site} coast',
'surfaceType': 'Resampled Copernicus DSM; no OSM height correction', 'sourceResolution': '1 arc-second (~30m); interpolated to 10m',
'verticalDatum': 'EGM2008 orthometric height (m); sea level approximated as 0m',
'origin': {'latitude': lat, 'longitude': lon}, 'projection': projection,
'width': size, 'height': size, 'spacingMeters': step, 'radiusMeters': radius,
'observerSurfaceElevation': float(heights[size//2,size//2]),
'minElevation': float(heights.min()), 'maxElevation': float(heights.max()), 'missingSamples': 0,
'geographicCorners': [inverse.transform(x,y) for x,y in [(-radius,-radius),(radius,-radius),(radius,radius),(-radius,radius)]],
'sources': used, 'attribution': 'produced using Copernicus WorldDEM-30 © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved; © OpenStreetMap contributors (ODbL-1.0)',
'corrections': {'osmHeightCorrection': False, 'note': 'Heights use Copernicus reprojection and bilinear resampling only. OSM masks affect rendering and movement, not stored elevations. Offshore values are not bathymetry. No tide model.'},
'files': {
'heights.f32': {'source': 'Copernicus GLO-30', 'processing': 'AEQD projection and bilinear resampling only; no OSM height adjustment',
'licenseUrl': 'https://dataspace.copernicus.eu/sites/default/files/media/files/2025-06/copernicus_contributing_mission_data_access_v2_cop_dem_licenses.pdf', 'licenseSection': 'GLO-30-F, Articles 4 and 6', 'notice': 'source.html'},
'coast.json': {**osm_license, 'processing': 'Projected coastline and beach polygons; estimated beach strip for Gisamun'},
'surface.png': {**osm_license, 'processing': 'R: land mask; G: sand mask; B: signed coast distance; contains no Copernicus elevations'},
},
}
if site == 'gisamun':
meta['sandApproximation'] = {key: config[key] for key in ['sandLatitudeMin','sandLatitudeMax','sandWidthMeters']}
meta['sandApproximation']['note'] = 'OSM beach polygon absent. Visual strip west of OSM shoreline; not surveyed beach limits.'
(output/'metadata.json').write_text(json.dumps(meta, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
def local(rings):
return [[[round(x,3),round(-y,3)] for x,y in r] for r in rings]
(output/'coast.json').write_text(json.dumps({**osm_license, 'origin': meta['origin'], 'radiusMeters': radius,
'land': local(land), 'beaches': local(sand), 'shorelines': local(lines)}, separators=(',',':')), encoding='utf-8')
print(json.dumps({'grid': size, 'elevation': meta['observerSurfaceElevation'], 'files': {p.name:p.stat().st_size for p in output.iterdir()}}, indent=2))
if __name__ == '__main__':
main()
+13
View File
@@ -0,0 +1,13 @@
import {readFileSync,writeFileSync} from 'node:fs';
import {createTerrainGeometry} from '../src/scene/terrainGeometry.ts';
import {applySummitTerrain} from '../src/scene/summitTerrain.ts';
import {structurePosition} from '../src/scene/structures.ts';
import {SUMMIT_TERRAIN} from '../src/constants/summitTerrain.ts';
import {SUMMIT_TRAILS} from '../src/constants/summitTrails.ts';
const meta=JSON.parse(readFileSync('public/terrain/gwanaksan.json','utf8'));const b=readFileSync('public/terrain/gwanaksan.f32');
const g=createTerrainGeometry(meta,new Float32Array(b.buffer,b.byteOffset,b.byteLength/4),true);applySummitTerrain(g,meta);
const start=structurePosition(SUMMIT_TERRAIN.start.latitude,SUMMIT_TERRAIN.start.longitude,meta.origin),p=g.getAttribute('position'),idx=g.getIndex();
const vertices=[],faces=[],indices=new Map();
for(let i=0;i<p.count;i++){const x=p.getX(i)-start.x,z=p.getZ(i)-start.z;if(x>=-42&&x<=32&&z>=-75&&z<=30){indices.set(i,vertices.length);vertices.push([x,-z,p.getY(i)+meta.observerSurfaceElevation+(p.getX(i)**2+p.getZ(i)**2)/(2*6371008.8)]);}}
for(let i=0;i<idx.count;i+=3){const tri=[idx.getX(i),idx.getX(i+1),idx.getX(i+2)];if(tri.every(j=>indices.has(j)))faces.push(tri.map(j=>indices.get(j)));}
writeFileSync('assets/summit/blender-plateau-study/context.json',JSON.stringify({vertices,faces,boundary:SUMMIT_TRAILS.rockPolygon,start:SUMMIT_TERRAIN.start}));console.log({vertices:vertices.length,faces:faces.length});
+19
View File
@@ -0,0 +1,19 @@
import {readFileSync,writeFileSync} from 'node:fs';
import {createTerrainGeometry,terrainGeometryHeightAt} from '../src/scene/terrainGeometry.ts';
import {applySummitTerrain} from '../src/scene/summitTerrain.ts';
import {structurePosition} from '../src/scene/structures.ts';
import {STRUCTURES} from '../src/constants/structures.ts';
import {SUMMIT_TERRAIN} from '../src/constants/summitTerrain.ts';
const meta=JSON.parse(readFileSync('public/terrain/gwanaksan.json','utf8'));
const bytes=readFileSync('public/terrain/gwanaksan.f32');
const terrain=createTerrainGeometry(meta,new Float32Array(bytes.buffer,bytes.byteOffset,bytes.byteLength/4),true);
applySummitTerrain(terrain,meta);
const radar=structurePosition(STRUCTURES.radar.latitude,STRUCTURES.radar.longitude,meta.origin);
radar.y=Math.max(...[-4,0,4].flatMap(x=>[-4,0,4].map(z=>terrainGeometryHeightAt(terrain,radar.x+x,radar.z+z))))+.2;
const eye=structurePosition(SUMMIT_TERRAIN.start.latitude,SUMMIT_TERRAIN.start.longitude,meta.origin);
eye.y=terrainGeometryHeightAt(terrain,eye.x,eye.z)+1.7;
const vertices=[];
for(let north=-20;north<=125;north+=2.5)for(let east=-55;east<=65;east+=2.5)vertices.push([east,north,terrainGeometryHeightAt(terrain,radar.x+east,radar.z-north)-radar.y]);
writeFileSync('assets/summit/blender-rock-study/context.json',JSON.stringify({radar:radar.toArray(),eye:[eye.x-radar.x,radar.z-eye.z,eye.y-radar.y],columns:49,rows:59,vertices}));
console.log({radar:radar.toArray(),eye:eye.toArray(),samples:vertices.length});
terrain.dispose();
+48
View File
@@ -0,0 +1,48 @@
import { createHash } from 'node:crypto';
import { readFileSync,writeFileSync } from 'node:fs';
import { gzipSync,gunzipSync } from 'node:zlib';
import { createTerrainGeometry } from '../src/scene/terrainGeometry.ts';
import { applySummitTerrain } from '../src/scene/summitTerrain.ts';
import { createSummitWalking } from '../src/controls/summitWalking.ts';
import { buildSummitGeometry } from '../src/scene/summitGeometry.ts';
import { encodeSummitGeometry,decodeSummitGeometry } from '../src/scene/summitAssets.ts';
import { SUMMIT_ASSETS } from '../src/constants/summitAssets.ts';
const file=(path:string)=>new URL('../'+path,import.meta.url);
const hash=(data:Uint8Array)=>createHash('sha256').update(data).digest('hex');
const inputs=['public/terrain/gwanaksan.json','public/terrain/gwanaksan.f32','src/scene/terrainGeometry.ts',
'src/scene/summitTerrain.ts','src/scene/summitGeometry.ts','src/scene/summitAssets.ts','src/scene/structures.ts',
'src/controls/summitWalking.ts','src/controls/summitCoordinates.ts','src/constants/summitAssets.ts','src/constants/summitMaterials.ts',
'src/constants/summitTerrain.ts','src/constants/summitTrails.ts','src/constants/structures.ts','src/constants/terrain.ts',
'src/constants/summitPlateau.ts','scripts/prepare-summit-assets.ts','package-lock.json'];
const digest=createHash('sha256');
for(const path of inputs) {const bytes=readFileSync(file(path));digest.update(path).update('\0').update(path.endsWith('.f32')?bytes:bytes.toString('utf8').replace(/\r\n/g,'\n'));}
const sourceHash=digest.digest('hex');
const manifestPath=file('public'+SUMMIT_ASSETS.manifestUrl),binaryPath=file('public'+SUMMIT_ASSETS.bufferUrl),gzipPath=file('public'+SUMMIT_ASSETS.bufferUrl+'.gz');
if(process.argv.includes('--check')) {
try {
const manifest=JSON.parse(readFileSync(manifestPath,'utf8')),bytes=readFileSync(binaryPath);
if(manifest.version!==SUMMIT_ASSETS.version||manifest.sourceHash!==sourceHash||manifest.bufferHash!==hash(bytes)||manifest.byteLength!==bytes.length||!gunzipSync(readFileSync(gzipPath)).equals(bytes))throw new Error('stale');
console.log('Prebuilt summit is up to date.');
} catch {console.error('정상 지면 사전 생성 파일이 오래되었습니다. npm run prepare:summit 을 실행해 주세요.');process.exitCode=1;}
} else {
const meta=JSON.parse(readFileSync(file('public/terrain/gwanaksan.json'),'utf8'));
const bytes=readFileSync(file('public/terrain/gwanaksan.f32'));
const terrain=createTerrainGeometry(meta,new Float32Array(bytes.buffer,bytes.byteOffset,bytes.byteLength/4),true);
applySummitTerrain(terrain,meta);
const started=performance.now();
const original=buildSummitGeometry(createSummitWalking(meta.origin),terrain,true);
const generateMs=performance.now()-started;
const encoded=encodeSummitGeometry(original),buffer=Buffer.from(encoded),compressed=gzipSync(buffer,{level:9});
const decodedAt=performance.now(),restored=decodeSummitGeometry(encoded),decodeMs=performance.now()-decodedAt;
// Verify all triangles, including every normal, UV and material distance, after indexing.
for(const [name,size] of SUMMIT_ASSETS.attributes) {
const a=original.getAttribute(name),b=restored.getAttribute(name),index=restored.getIndex()!;
for(let i=0;i<a.count;i++)for(let j=0;j<size;j++)if(a.array[i*size+j]!==b.array[index.getX(i)*size+j])throw new Error('Attribute changed: '+name);
}
const manifest={version:SUMMIT_ASSETS.version,sourceHash,bufferHash:hash(buffer),byteLength:buffer.length,gzipBytes:compressed.length,
originalVertices:original.getAttribute('position').count,vertices:restored.getAttribute('position').count,
triangles:restored.getIndex()!.count/3,originalAttributeBytes:Object.values(original.attributes).reduce((n,a)=>n+a.array.byteLength,0),generateMs,decodeMs};
writeFileSync(binaryPath,buffer);writeFileSync(gzipPath,compressed);writeFileSync(manifestPath,JSON.stringify(manifest,null,2)+'\n');
console.log(JSON.stringify(manifest,null,2));original.dispose();restored.dispose();terrain.dispose();
}
+93
View File
@@ -0,0 +1,93 @@
"""Prepare the two downloaded Copernicus COGs; no browser/runtime GIS dependency.
python -m pip install --target .tools/terrain -r scripts/terrain-requirements.txt
python scripts/prepare-terrain.py
"""
import hashlib
import json
from pathlib import Path
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / '.tools' / 'terrain'))
import numpy as np
import rasterio
from rasterio.merge import merge
from rasterio.warp import reproject, Resampling
from rasterio.transform import from_origin
from pyproj import CRS, Transformer
RADIUS = 12000
SPACING = 30
SIZE = 2 * RADIUS // SPACING + 1
ATTRIBUTION = ('produced using Copernicus WorldDEM-30 © DLR e.V. 2010-2014 and '
'© Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS '
'by the European Union and ESA; all rights reserved')
def main():
# The app remains the single source of observer coordinates.
result = subprocess.run(['node', '--input-type=module', '-e',
"import {observer} from './src/config/observer.ts'; console.log(JSON.stringify(observer))"],
cwd=ROOT, capture_output=True, text=True, check=True)
observer = json.loads(result.stdout)
lat, lon = observer['latitude'], observer['longitude']
projection = f'+proj=aeqd +lat_0={lat} +lon_0={lon} +datum=WGS84 +units=m +no_defs'
local_crs = CRS.from_proj4(projection)
to_geo = Transformer.from_crs(local_crs, 'EPSG:4326', always_xy=True)
transform = from_origin(-RADIUS - SPACING / 2, RADIUS + SPACING / 2, SPACING, SPACING)
output = ROOT / 'public' / 'terrain'
output.mkdir(parents=True, exist_ok=True)
sources, manifest = [], []
try:
for east in (126, 127):
name = f'Copernicus_DSM_COG_10_N37_00_E{east}_00_DEM'
path = ROOT / 'data' / 'raw' / 'copernicus' / f'{name}.tif'
dataset = rasterio.open(path)
sources.append(dataset)
if dataset.crs.to_epsg() != 4326 or dataset.shape != (3600, 3600):
raise ValueError(f'Unexpected source grid: {path}')
manifest.append({
'file': path.name, 'url': f'https://copernicus-dem-30m.s3.amazonaws.com/{name}/{name}.tif',
'bytes': path.stat().st_size, 'sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
'crs': dataset.crs.to_string(), 'bounds': list(dataset.bounds),
'shape': list(dataset.shape), 'pixelSizeDegrees': list(dataset.res),
'nodata': dataset.nodata,
})
# Merge before resampling, so bilinear interpolation spans the 127° seam.
mosaic, mosaic_transform = merge(sources, nodata=-9999)
heights = np.full((SIZE, SIZE), -9999, dtype=np.float32)
reproject(mosaic[0], heights, src_transform=mosaic_transform, src_crs=sources[0].crs,
src_nodata=-9999, dst_transform=transform, dst_crs=local_crs,
dst_nodata=-9999, resampling=Resampling.bilinear)
missing = int(np.count_nonzero(~np.isfinite(heights) | (heights == -9999)))
if missing:
raise ValueError(f'Refusing to publish a grid with {missing} missing values')
heights.astype('<f4').tofile(output / 'gwanaksan.f32')
bounds = [to_geo.transform(x, y) for x, y in
[(-RADIUS, -RADIUS), (RADIUS, -RADIUS), (RADIUS, RADIUS), (-RADIUS, RADIUS)]]
metadata = {
'version': 1, 'dataset': 'Copernicus DEM GLO-30 Public (2021 release)',
'surfaceType': 'DSM', 'sourceResolution': '1 arc-second (~30m)',
'verticalDatum': 'EGM2008 orthometric height (m)',
'origin': {'latitude': lat, 'longitude': lon},
'projection': projection, 'projectionWkt': local_crs.to_wkt(), 'width': SIZE, 'height': SIZE,
'spacingMeters': SPACING, 'radiusMeters': RADIUS,
'layout': 'row-major north-to-south, west-to-east, float32 little-endian',
'heightFile': 'gwanaksan.f32', 'geographicCorners': bounds,
'observerSurfaceElevation': float(heights[SIZE // 2, SIZE // 2]),
'minElevation': float(heights.min()), 'maxElevation': float(heights.max()),
'missingSamples': missing, 'sources': manifest, 'attribution': ATTRIBUTION,
}
(output / 'gwanaksan.json').write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(json.dumps({k: metadata[k] for k in ['width', 'height', 'observerSurfaceElevation', 'minElevation', 'maxElevation', 'missingSamples']}, indent=2))
for item in manifest:
print(item['file'], item['sha256'])
finally:
for dataset in sources:
dataset.close()
if __name__ == '__main__':
main()
+173
View File
@@ -0,0 +1,173 @@
"""Prepare buildings exclusively from the user-supplied VWorld SHP archive.
python scripts/prepare-vworld-buildings.py PATH_TO_ZIP
Requires the existing .tools/terrain environment plus building-requirements.txt.
"""
import argparse
import collections
import copy
import hashlib
import json
from pathlib import Path
import struct
import sys
import zipfile
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / '.tools/terrain'))
import shapefile
from pyproj import CRS, Transformer
from shapely.geometry import shape, box
from shapely.ops import transform
from shapely.strtree import STRtree
CONFIG = json.loads((ROOT / 'scripts/constants/gwacheon-buildings.json').read_text(encoding='utf-8'))
TO_LOCAL = Transformer.from_crs(4326, CONFIG['sourceCrs'], always_xy=True).transform
TO_GEO = Transformer.from_crs(CONFIG['sourceCrs'], 4326, always_xy=True).transform
def positive(value, maximum):
try:
number = float(value)
return number if 0 < number <= maximum else None
except (ValueError, TypeError):
return None
def extract_archive(path):
"""Read SHPs sequentially and only decompress DBF up to selected records.
pyshp.record() rewinds its DBF on every call; doing that on a compressed
1.8 GB member repeatedly is prohibitive. Read the selected fixed-width rows
in ascending order instead. Source files remain in the supplied ZIP.
"""
bounds = transform(TO_LOCAL, box(*CONFIG['bbox'])).buffer(20).bounds
features = []
with zipfile.ZipFile(path) as archive:
for name in archive.namelist():
if not name.lower().endswith('.shp'):
continue
stem = name[:-4]
source_crs = CRS.from_wkt(archive.read(stem + '.prj').decode())
expected_crs = CRS(CONFIG['sourceCrs'])
# ESRI WKT uses a different datum spelling; verify the actual TM parameters.
if (source_crs.coordinate_operation.method_name != expected_crs.coordinate_operation.method_name
or [p.value for p in source_crs.coordinate_operation.params] != [p.value for p in expected_crs.coordinate_operation.params]
or source_crs.ellipsoid.semi_major_metre != expected_crs.ellipsoid.semi_major_metre
or source_crs.ellipsoid.inverse_flattening != expected_crs.ellipsoid.inverse_flattening
or any(axis.unit_conversion_factor != 1 for axis in source_crs.axis_info)):
raise ValueError('Unexpected source CRS: ' + stem)
with archive.open(name) as shp, archive.open(stem + '.shx') as shx, archive.open(stem + '.dbf') as dbf:
reader = shapefile.Reader(shp=shp, shx=shx)
selected = list(reader.iterShapes(bbox=bounds))
header = dbf.read(32)
header_size, row_size = struct.unpack_from('<HH', header, 8)
fields = []
for _ in range((header_size - 33) // 32):
field = dbf.read(32)
fields.append((field[:11].split(b'\0')[0].decode(), field[16]))
for item in selected:
dbf.seek(header_size + item.oid * row_size)
row = dbf.read(row_size)
if row[:1] == b'*':
continue
properties, offset = {}, 1
for key, length in fields:
properties[key] = row[offset:offset + length].decode('cp949').strip(' \x00')
offset += length
geometry = transform(TO_GEO, shape(item.__geo_interface__))
features.append({'type': 'Feature', 'id': stem + ':' + str(item.oid),
'properties': properties, 'geometry': geometry.__geo_interface__})
print(f'{stem}: {len(selected)} regional shapes', flush=True)
return features
def official_features(raw):
output = []
area = box(*CONFIG['bbox'])
for item in raw:
p = item['properties']
geometry = shape(item['geometry'])
if not geometry.is_valid or geometry.is_empty:
raise ValueError('Invalid source polygon: ' + item['id'])
polygons = list(geometry.geoms) if geometry.geom_type == 'MultiPolygon' else [geometry]
for part, polygon in enumerate(polygons):
if not area.covers(polygon.centroid):
continue
tags = {'building': CONFIG['useTypes'].get(p.get('A9'), 'yes'),
'source': 'VWorld', 'source:geometry': 'VWorld',
'source:id': p.get('A1') or item['id'], 'source:record': item['id'],
'source:date': p.get('A22', ''), 'source:use': p.get('A9', '')}
if p.get('A24'):
tags['name'] = p['A24']
for field, tag, maximum in [('A16', 'height', 400), ('A26', 'building:levels', 100)]:
value = positive(p.get(field), maximum)
if value is not None and (field != 'A26' or value.is_integer()):
tags[tag] = f'{value:g}'
tags['source:' + tag] = 'VWorld'
output.append({'type': 'Feature', 'id': 'vworld/' + (p.get('A1') or item['id']) + f'/{part}',
'properties': tags, 'geometry': polygon.__geo_interface__})
return output
def select_buildings(official):
output = []
official_shapes = [transform(TO_LOCAL, shape(f['geometry'])) for f in official]
additions = STRtree(official_shapes)
accepted = set()
report = {'officialAdded': 0, 'officialOverlapsSkipped': []}
# Prefer records with actual height/levels over empty duplicate footprints.
order = sorted(range(len(official)), key=lambda i: (
'height' not in official[i]['properties'], 'building:levels' not in official[i]['properties'], official[i]['id']))
for index in order:
feature, polygon = official[index], official_shapes[index]
if any(int(i) in accepted and polygon.intersection(official_shapes[i]).area > CONFIG['overlapAreaMeters2']
for i in additions.query(polygon)):
report['officialOverlapsSkipped'].append(feature['id'])
continue
output.append(copy.deepcopy(feature))
accepted.add(index)
report['officialAdded'] += 1
return output, report
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('archive', type=Path)
args = parser.parse_args()
with args.archive.open('rb') as source_file:
digest = hashlib.file_digest(source_file, 'sha256').hexdigest()
cache = ROOT / 'data/raw/gwacheon/vworld-extract.geojson'
cache_hash = cache.with_suffix('.sha256')
if cache.exists() and cache_hash.exists() and cache_hash.read_text() == digest:
raw = json.loads(cache.read_text(encoding='utf-8'))['features']
else:
raw = extract_archive(args.archive)
cache.write_text(json.dumps({'type': 'FeatureCollection', 'features': raw}, ensure_ascii=False), encoding='utf-8')
cache_hash.write_text(digest)
data, report = select_buildings(official_features(raw))
counts = collections.Counter('height' if positive(f['properties'].get('height'), 400) else
'levels' if positive(f['properties'].get('building:levels'), 100) else 'fallback' for f in data)
manifest = {'bbox': CONFIG['bbox'], 'count': len(data),
'types': dict(collections.Counter(f['properties']['building'] for f in data)),
**CONFIG['licensing'],
'heightTags': sum('height' in f['properties'] for f in data),
'levelTags': sum('building:levels' in f['properties'] for f in data),
'heightSources': dict(counts), 'selection': report,
'official': {'archive': args.archive.name, 'sha256': digest, 'crs': CONFIG['sourceCrs'],
'url': CONFIG['sourceUrl'], 'license': CONFIG['licensing']['licenses']['VWorld'],
'rawRegionalFeatures': len(raw), 'fieldDefinitions': 'data/references/gwacheon/vworld-columns.xlsx'},
'limitations': ['VWorld-only footprints and attributes. Snapshot can contain older footprints.',
'Overlaps >0.5m² between VWorld records are excluded, preferring measured height or levels.',
'Missing heights remain explicitly estimated; terrain is still Copernicus DSM, not surveyed ground.']}
out = ROOT / 'public/buildings'
(out / 'gwacheon.geojson').write_text(json.dumps({'type': 'FeatureCollection',
'attribution': '국토교통부 GIS건물통합정보 (VWorld)',
**CONFIG['licensing'], 'features': data}, ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
(out / 'metadata.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps({'count': len(data), 'heightSources': dict(counts), 'selection': {k: len(v) if isinstance(v, list) else v for k,v in report.items()}}, indent=2))
if __name__ == '__main__':
sys.stdout.reconfigure(encoding='utf-8')
main()
+57
View File
@@ -0,0 +1,57 @@
"""Non-destructive Blender color studies; not exported into the game."""
import bpy, math
from mathutils import Vector
base=bpy.data.scenes['Red Sheep - concept matched.003']
ROOT='D:/project/sunrise/assets/characters/'
def linear(hex):
values=[int(hex[i:i+2],16)/255 for i in (0,2,4)]
return tuple(v/12.92 if v<.04045 else ((v+.055)/1.055)**2.4 for v in values)+(1,)
for label,palette in [
('pink',{'Red':'E8B4BA','Cream':'FFF0DC','Ink':'554344','Gold':'E7BB74'}),
('ivory-scarf',{'Red':'F5EDDD','Cream':'E9D5BB','Ink':'493D39','Gold':'BE6264'})
]:
s=base.copy(); s.name='Sheep study - '+label
mapping={}; materials={}
for old in list(s.objects):
new=old.copy()
if old.type=='MESH':
new.data=old.data.copy()
for i,mat in enumerate(old.data.materials):
if mat not in materials:
copy=mat.copy(); materials[mat]=copy
key=next((k for k in palette if 'SheepV2_'+k in mat.name),'Ink')
color=linear(palette[key]); copy.diffuse_color=color
for node in copy.node_tree.nodes:
if node.type=='EMISSION': node.inputs['Color'].default_value=color
new.data.materials[i]=materials[mat]
s.collection.objects.link(new); s.collection.objects.unlink(old); mapping[old]=new
for old,new in mapping.items():
if old.parent: new.parent=mapping[old.parent]
s.camera=mapping[base.camera]
s.camera.location=(3,-7,2.8)
s.camera.rotation_euler=(Vector((0,0,1.5))-s.camera.location).to_track_quat('-Z','Y').to_euler()
if label=='ivory-scarf':
bpy.context.window.scene=s
body=next(o for o in s.objects if o.type=='EMPTY' and o.name.startswith('Body'))
for o in s.objects:
if o.name.startswith('BodyDetails'): o.hide_render=True; o.hide_viewport=True
red=next(m for original,m in materials.items() if 'SheepV2_Gold' in original.name)
# Rounded collar with two hanging scarf ends, following the chest.
bpy.ops.mesh.primitive_torus_add(major_segments=64,minor_segments=12,location=(0,0,1.64),major_radius=.43,minor_radius=.10)
collar=bpy.context.object; collar.name='Scarf collar'; collar.scale.y=.78
collar.data.materials.append(red); collar.parent=body
for p in collar.data.polygons: p.use_smooth=True
for name,loc,scale,angle in [('Scarf long end',(.28,-.385,1.34),(.115,.06,.31),-.16),('Scarf short end',(.45,-.29,1.43),(.10,.07,.22),.30)]:
bpy.ops.mesh.primitive_cube_add(size=2,location=loc)
o=bpy.context.object; o.name=name; o.scale=scale
bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
bevel=o.modifiers.new('Rounded cloth edge','BEVEL'); bevel.width=.055; bevel.segments=5
bpy.context.view_layer.objects.active=o; bpy.ops.object.modifier_apply(modifier=bevel.name)
o.rotation_euler.y=angle; o.parent=body; o.data.materials.append(red)
for p in o.data.polygons: p.use_smooth=True
s.render.filepath=ROOT+'sheep-'+label+'.png'
bpy.ops.render.render(write_still=True,scene=s.name)
bpy.data.libraries.write(ROOT+'sheep-'+label+'-study.blend',{s})
print(label,s.render.filepath)
+105
View File
@@ -0,0 +1,105 @@
"""Run in the existing Blender studies via MCP; preserve walking geometry and facilities.
Photo-estimated weathering, not photogrammetry. Existing scan maps are reused.
"""
import bpy, math, json
from pathlib import Path
from mathutils import Vector, noise
ROOT = Path('D:/project/sunrise')
OUT = ROOT/'assets/summit/near-route-refinement'
TILE = 1.8
rocks = bpy.data.collections['Photo rocks - export']
slab = bpy.data.objects['SLAB_SURFACE']
before_slab = [tuple(v.co) for v in slab.data.vertices]
report = []
def joint(p):
# Uneven, slightly wandering vertical fractures; avoid horizontal stacked bands.
warp = .16*math.sin(p.z*.43+p.y*.27)
distance = min(abs(p.x+.16*p.y+.07*p.z+warp-k) for k in (-23.4,-19.1,-14.7,-11.2,-5.2,.8,6.5))
return math.exp(-(distance/.30)**2)
for obj in rocks.objects:
if obj.get('route_detail_pass'):
raise RuntimeError('Refinement already applied: edit the existing result, do not accumulate.')
mesh = obj.data
maximum = 0
for v in mesh.vertices:
p = v.co.copy()
fade = max(0,min(1,(p.z+28)/6))
# Retain supporting tops and overall silhouette; recess the exposed side faces.
side = max(0,1-abs(v.normal.z))
amount = -.14*joint(p)*side*fade
v.co += v.normal*amount
maximum = max(maximum,abs(amount))
mesh.update()
uv = mesh.uv_layers.active
colors = mesh.color_attributes.active_color
for face in mesh.polygons:
axis = max(range(3),key=lambda a:abs(face.normal[a]))
for li in face.loop_indices:
p = mesh.vertices[mesh.loops[li].vertex_index].co
uv.data[li].uv = Vector((p.y,p.z) if axis==0 else (p.x,p.z) if axis==1 else (p.x,p.y))/TILE
patch = noise.noise_vector(p*.26).x
stain = max(0,noise.noise_vector(p*.61+Vector((3,9,2))).y)
shade = .93+.055*patch-.10*joint(p)-.045*stain
colors.data[li].color = (shade,shade*.985,shade*.95,1)
obj['route_detail_pass'] = True
obj['texture_tile_metres'] = TILE
report.append({'object':obj.name,'vertices':len(mesh.vertices),'maxRecessMetres':maximum})
def setup_material(mat, vertex_colors=False):
nodes,links = mat.node_tree.nodes,mat.node_tree.links
bsdf = next(n for n in nodes if n.type=='BSDF_PRINCIPLED')
color = next(n for n in nodes if n.type=='TEX_IMAGE' and n.image and 'rock-color' in n.image.name)
# Use identical two-scale sampling in Blender and the application's ground shader.
texcoord = nodes.new('ShaderNodeTexCoord')
mapping = nodes.new('ShaderNodeVectorMath');mapping.operation = next(i.identifier for i in mapping.bl_rna.properties['operation'].enum_items if i.identifier=='MULTIPLY_ADD')
mapping.inputs[1].default_value=(1.37,1.37,1.37)
mapping.inputs[2].default_value=(.31,.73,0)
links.new(texcoord.outputs['UV'],mapping.inputs[0])
separate=nodes.new('ShaderNodeSeparateXYZ');links.new(texcoord.outputs['UV'],separate.inputs[0])
def math_node(operation,a,b=0):
node=nodes.new('ShaderNodeMath')
node.operation=next(i.identifier for i in node.bl_rna.properties['operation'].enum_items if i.identifier==operation)
for socket,value in zip(node.inputs,(a,b)):
if isinstance(value,(int,float)):socket.default_value=value
else:links.new(value,socket)
return node.outputs[0]
blend=math_node('ADD',.5,math_node('MULTIPLY',.3,math_node('SINE',math_node('ADD',math_node('MULTIPLY',separate.outputs['X'],.71),math_node('SINE',math_node('MULTIPLY',separate.outputs['Y'],.53))))))
def mixed(image, original=None):
a = original or nodes.new('ShaderNodeTexImage');a.image=image
b = nodes.new('ShaderNodeTexImage');b.image=image
links.new(mapping.outputs[0],b.inputs['Vector'])
mix=nodes.new('ShaderNodeMixRGB');links.new(blend,mix.inputs[0])
links.new(a.outputs['Color'],mix.inputs[1]);links.new(b.outputs['Color'],mix.inputs[2])
return mix.outputs[0]
base=mixed(color.image,color)
if vertex_colors:
attr=nodes.new('ShaderNodeVertexColor');attr.layer_name='Weathering'
mul=nodes.new('ShaderNodeMixRGB');mul.blend_type=next(i.identifier for i in mul.bl_rna.properties['blend_type'].enum_items if i.identifier=='MULTIPLY');mul.inputs[0].default_value=1
links.new(base,mul.inputs[1]);links.new(attr.outputs['Color'],mul.inputs[2]);base=mul.outputs[0]
links.new(base,bsdf.inputs['Base Color'])
for kind in ('normal','roughness'):
img=bpy.data.images.load(str(ROOT/f'public/terrain/materials/rock-{kind}.jpg'),check_existing=True)
img.colorspace_settings.name='Non-Color'
signal=mixed(img)
if kind=='normal':
normal=nodes.new('ShaderNodeNormalMap');normal.inputs['Strength'].default_value=.55
links.new(signal,normal.inputs['Color']);links.new(normal.outputs['Normal'],bsdf.inputs['Normal'])
else:
links.new(signal,bsdf.inputs['Roughness'])
setup_material(bpy.data.materials['Photo study / weathered granite'],True)
# Copy the material so the summit marker retains its existing authored finish.
surface=slab.data.materials[0].copy();surface.name='Route slab / weathered granite detail'
slab.data.materials[0]=surface
setup_material(surface)
for face in slab.data.polygons:
for li in face.loop_indices:
p=slab.data.vertices[slab.data.loops[li].vertex_index].co
slab.data.uv_layers.active.data[li].uv=(p.x/TILE,p.y/TILE)
assert before_slab==[tuple(v.co) for v in slab.data.vertices]
(OUT/'refinement.json').write_text(json.dumps({'walkingVerticesUnchanged':True,'tileMetres':TILE,'normalStrength':.55,'rocks':report},indent=2))
print('Refined',len(report),'rock meshes; walking surface vertices unchanged.')
+76
View File
@@ -0,0 +1,76 @@
"""Refine the existing Blender slab, preserving a shared regular height lattice."""
import bpy, math, json
import numpy as np
from pathlib import Path
from mathutils import Vector
from mathutils.bvhtree import BVHTree
ROOT=Path('D:/project/sunrise')
s=bpy.data.scenes['Summit slab and marker'];bpy.context.window.scene=s
slab=bpy.data.objects['SLAB_SURFACE']
if slab.get('jointStudyVersion'):raise RuntimeError('Study already refined; edit it in place.')
backup=slab.copy();backup.data=slab.data.copy();backup.name='Slab before joint refinement';s.collection.objects.link(backup);backup.hide_render=True;backup.hide_set(True)
bvh=BVHTree.FromObject(slab,bpy.context.evaluated_depsgraph_get())
outline=bpy.data.objects['WALKABLE_OUTLINE'];poly=[(v.co.x,v.co.y) for v in outline.data.vertices]
joints=[ [(-26,7),(-18,10),(-9,12),(0,11),(12,15)], [(-26,22),(-17,25),(-8,26),(1,29),(14,28)], [(-24,39),(-14,40),(-4,43),(6,42),(14,46)], [(-20,54),(-10,51),(0,54),(11,53)], [(-18,-7),(-15,4),(-11,12),(-14,23),(-9,33),(-11,43),(-7,57)], [(3,-9),(1,3),(3,11),(-1,21),(2,29),(0,40),(5,53)] ]
def distances(x,y,lines):
out=np.full(np.broadcast(x,y).shape,1e5)
for line in lines:
for (ax,ay),(bx,by) in zip(line,line[1:]):
dx=bx-ax;dy=by-ay;t=np.clip(((x-ax)*dx+(y-ay)*dy)/(dx*dx+dy*dy),0,1)
out=np.minimum(out,np.hypot(x-ax-t*dx,y-ay-t*dy))
return out
def edge_distance(x,y):
d=distances(x,y,[poly+[poly[0]]]);hit=np.zeros(np.broadcast(x,y).shape,dtype=bool)
for (ax,ay),(bx,by) in zip(poly,poly[1:]+poly[:1]):
if ay!=by:hit^=((ay>y)!=(by>y))&(x<(bx-ax)*(y-ay)/(by-ay)+ax)
return np.where(hit,-d,d)
def sculpt(x,y):
d=edge_distance(x,y);fade=1-np.clip((d+1)/5,0,1);fade=fade*fade*(3-2*fade)
cracks=distances(x,y,joints)
roll=.72*np.sin(x*.19+y*.055)*np.cos(y*.13)+.36*np.sin(y*.33+x*.09)
shoulders=.42*np.tanh((y-18-.18*x)/.85)+.32*np.tanh((y-38+.13*x)/.7)
cut=.50*np.exp(-(cracks/.38)**2)+.15*np.exp(-(cracks/1.2)**2)
lip=-.7*np.exp(-((d+.25)/1.1)**2)
return fade*(roll+shoulders-cut+lip)
# Ten subdivisions per original 5m interval. Retain every original XY coordinate.
xs=sorted(set(round(v.co.x,6) for v in slab.data.vertices));ys=sorted(set(round(v.co.y,6) for v in slab.data.vertices))
xs=[a+(b-a)*j/10 for a,b in zip(xs,xs[1:]) for j in range(10)]+[xs[-1]]
ys=[a+(b-a)*j/10 for a,b in zip(ys,ys[1:]) for j in range(10)]+[ys[-1]]
verts=[]
for y in ys:
for x in xs:
hit=bvh.ray_cast(Vector((x,y,100)),Vector((0,0,-1)))[0]
if hit is None:hit=bvh.find_nearest(Vector((x,y,0)))[0]
verts.append((x,y,hit.z+float(sculpt(np.asarray(x),np.asarray(y)))))
nx=len(xs);faces=[]
for j in range(len(ys)-1):
for i in range(nx-1):
a=j*nx+i;faces.extend([(a,a+1,a+nx),(a+1,a+nx+1,a+nx)])
mesh=bpy.data.meshes.new('Sculpted jointed granite lattice');mesh.from_pydata(verts,[],faces);mesh.update();slab.data=mesh
# Unique atlas: warm granite grains, large weathering patches and matching fissures.
N=1536
X,Y=np.meshgrid(np.linspace(xs[0],xs[-1],N),np.linspace(ys[0],ys[-1],N))
D=distances(X,Y,joints)
rng=np.random.default_rng(720)
weather=.035*np.sin(X*.41+np.sin(Y*.17))+.025*np.cos(Y*.57-X*.13)
grain=rng.normal(0,.018,(N,N));fleck=(rng.random((N,N))>.96)*-.055
crack=.22*np.exp(-(D/.16)**2)+.065*np.exp(-(D/.7)**2)
rgb=np.stack([.64+weather+grain+fleck-crack,.59+weather+grain+fleck-crack,.49+weather+grain+fleck-crack],axis=-1)
rgba=np.concatenate([np.clip(rgb,0,1),np.ones((N,N,1))],axis=2).astype(np.float32)
im=bpy.data.images.new('Summit unique weathered granite',width=N,height=N,alpha=True);im.pixels.foreach_set(rgba.ravel());im.update()
im.filepath_raw=str(ROOT/'public/terrain/materials/plateau-color.png')
im.file_format=next(i.identifier for i in im.bl_rna.properties['file_format'].enum_items if i.identifier=='PNG');im.save()
mat=bpy.data.materials.new('Summit unique granite atlas');mat.use_nodes=True
bsdf=next(n for n in mat.node_tree.nodes if n.type=='BSDF_PRINCIPLED');bsdf.inputs['Roughness'].default_value=.92
tex=mat.node_tree.nodes.new('ShaderNodeTexImage');tex.image=im;mat.node_tree.links.new(tex.outputs['Color'],bsdf.inputs['Base Color']);mesh.materials.append(mat)
uv=mesh.uv_layers.new(name='Unique plateau atlas')
for p in mesh.polygons:
p.use_smooth=True
for li in p.loop_indices:
v=mesh.vertices[mesh.loops[li].vertex_index].co;uv.data[li].uv=((v.x-xs[0])/(xs[-1]-xs[0]),(v.y-ys[0])/(ys[-1]-ys[0]))
slab['jointStudyVersion']=1
slab['atlasBounds']=[xs[0],-ys[-1],xs[-1],-ys[0]]
marker=bpy.data.objects['summit-marker'];marker.location.z+=float(sculpt(np.asarray(marker.location.x),np.asarray(marker.location.y)))
print('Sculpted',len(verts),'vertices;',len(faces),'triangles; six intersecting fracture traces and weathered edge lip')
+60
View File
@@ -0,0 +1,60 @@
"""Sculpt a weathered, thick side and rear while preserving the painted front."""
import bpy,bmesh,math
from mathutils import Vector
from mathutils.noise import noise_vector
s=bpy.data.scenes['Summit slab and marker'];bpy.context.window.scene=s
body=bpy.data.objects['Photo matched summit boulder'];m=body.data
if body.get('weatheredSides'):raise RuntimeError('Side study already sculpted; edit it in place.')
backup=m.copy();backup.name='Monument before side sculpt';backup.use_fake_user=True
half=len(m.vertices)//2
# A fuller foot and a rear face that retreats under the narrow shoulder.
for v in m.vertices:
if v.index>=half:
v.co.y+=.15+.20*max(0,1-v.co.z/2.1)
for p in m.polygons:
if all(i>=half for i in p.vertices):p.material_index=1
bm=bmesh.new();bm.from_mesh(m)
edges={e for f in bm.faces if f.material_index==1 and any(v.co.y<0 for v in f.verts) for e in f.edges}
bmesh.ops.subdivide_edges(bm,edges=list(edges),cuts=9,use_grid_fill=True)
bm.normal_update()
for v in bm.verts:
# Only change the connecting rock wall, with zero displacement at the painted face.
if not all(f.material_index==1 for f in v.link_faces):continue
x,y,z=v.co
if y<-.48+.13*x+.05*z+.03:continue
noise=noise_vector(Vector((x*5,y*6,z*5)))[0]
bulge=.055*noise+.035*math.sin(z*9+y*4)
# Shallow oblique joints break the broad plane; no repeated vertical striping.
seam=.055*math.exp(-((z-.64-.32*y)/.045)**2)+.035*math.exp(-((z-1.35+.22*y)/.055)**2)
amount=bulge-seam
if abs(v.normal.x)>.25:v.co.x+=v.normal.x*amount
if v.normal.y>.3:v.co.y+=v.normal.y*amount
v.co.z+=v.normal.z*amount*.5
bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(m);bm.free();m.update()
# Side and rear each receive spatially scaled UVs instead of collapsed front projections.
uv=m.uv_layers['Granite grain UV']
for p in m.polygons:
if p.material_index!=1:continue
n=p.normal
for li in p.loop_indices:
v=m.vertices[m.loops[li].vertex_index].co
if abs(n.z)>.65:coord=(v.x*.9,v.y*.9)
elif abs(n.x)>abs(n.y):coord=(v.y*.9,v.z*.9)
else:coord=(v.x*.9,v.z*.9)
uv.data[li].uv=coord
p.use_smooth=True
side=m.materials[1];nodes=side.node_tree.nodes;links=side.node_tree.links
bs=next(n for n in nodes if n.type=='BSDF_PRINCIPLED')
scan=next(n for n in nodes if n.type=='TEX_IMAGE' and n.image and n.image.name.startswith('rock-color'))
normal=next(n for n in nodes if n.type=='TEX_IMAGE' and n.image and n.image.name.startswith('rock-normal'))
uvnode=next(n for n in nodes if n.type=='UVMAP' and n.uv_map=='Granite grain UV')
links.new(uvnode.outputs['UV'],scan.inputs['Vector']);links.new(uvnode.outputs['UV'],normal.inputs['Vector'])
links.new(scan.outputs['Color'],bs.inputs['Base Color'])
nm=next(n for n in nodes if n.type=='NORMAL_MAP');nm.uv_map='Granite grain UV';nm.inputs['Strength'].default_value=.48
links.new(normal.outputs['Color'],nm.inputs['Color']);links.new(nm.outputs['Normal'],bs.inputs['Normal'])
bs.inputs['Roughness'].default_value=.96
body['weatheredSides']=True
r=bpy.data.objects['summit-marker'];s.camera.location=r.location+Vector((4.6,-4.8,2.6));s.camera.rotation_euler=(r.location+Vector((0,.1,.9))-s.camera.location).to_track_quat('-Z','Y').to_euler();s.camera.data.lens=55
s.render.filepath='D:/project/sunrise/assets/summit/blender-plateau-study/marker-weathered-side.png'
bpy.ops.render.render(write_still=True)
print('Sculpted sides:',len(m.vertices),'vertices;',len(m.polygons),'faces. Front paint UVs preserved; sides and rear retextured.')
+80
View File
@@ -0,0 +1,80 @@
"""Photo-guided summit monument: broad natural boulder, recessed face and Hanja."""
import bpy,bmesh,math,random
from mathutils import Vector
from mathutils.noise import noise_vector
from pathlib import Path
s=bpy.data.scenes['Summit slab and marker'];bpy.context.window.scene=s
root=bpy.data.objects['summit-marker'];collection=root.users_collection[0]
if root.get('photoMonument'):raise RuntimeError('Edit the photo monument in place; do not rebuild cumulatively.')
for child in list(root.children):
child.parent=None;child.name='Previous marker - '+child.name;child.hide_render=True;child.hide_set(True)
# Deliberately lopsided shoulder and broad, flared foot traced from supplied reference.
outline=[(-1.24,-.10),(-1.18,.22),(-1.04,.55),(-.91,.90),(-.66,1.24),(-.43,1.51),(-.16,1.76),(.03,2.02),(.26,1.98),(.48,1.70),(.61,1.36),(.75,1.00),(.88,.62),(1.04,.28),(1.22,-.06),(.65,-.14),(-.10,-.12),(-.70,-.16)]
center=Vector((.06,.85));segments=8;edge=[]
for a,b in zip(outline,outline[1:]+outline[:1]):
for j in range(segments):
t=j/segments;edge.append((a[0]*(1-t)+b[0]*t,a[1]*(1-t)+b[1]*t))
def grain(x,z,scale=18):return noise_vector(Vector((x*scale,z*scale,2.7)))[0]
def front(x,z):
# A lightly dressed, leaning face, recessed into the original uncut rock.
width=max(.16,.57-.18*z);panel=abs((x-.18)/width)
rim=min(1,max(0,(panel-.76)/.45))
seam=math.exp(-((x+.35-.19*z)/.045)**2)*.045
return -.48+.13*x+.05*z + rim*(-.13+.075*grain(x,z,5))+.007*grain(x,z,27)+seam
verts=[];faces=[];colors=[]
N=len(edge);R=36
for back in [False,True]:
for k in range(R+1):
r=max(.0001,k/R)
for ex,ez in edge:
x=center.x+(ex-center.x)*r;z=center.y+(ez-center.y)*r
y=(.15+.43*(1-r*r)+.075*grain(x,z,7)) if back else front(x,z)
verts.append((x,y,z))
panel=abs((x-.18)/max(.16,.57-.18*z))
smooth=min(1,max(0,(panel-.8)/.4));smooth=smooth*smooth*(3-2*smooth)
if back:smooth=1
weather=.035*grain(x,z,9)+.022*grain(x,z,75)+.018*grain(x,z,210)
stain=.075*math.exp(-((x+.32-.18*z)/.07)**2)
base=(.48,.43,.33) if smooth<.5 else (.24,.235,.19)
# Continuous lighter inscription field and darker naturally weathered margins.
base=tuple(a*(1-smooth)+b*smooth for a,b in zip((.53,.48,.37),(.255,.25,.205)))
colors.append((*[max(.06,c+weather-stain) for c in base],1))
offset=(R+1)*N if back else 0
for k in range(R):
for j in range(N):
a=offset+k*N+j;b=offset+k*N+(j+1)%N;c=a+N;d=b+N
faces.extend([(a,b,c),(b,d,c)])
offset=(R+1)*N
for j in range(N):
a=R*N+j;b=R*N+(j+1)%N;faces.extend([(a,a+offset,b),(b,a+offset,b+offset)])
mesh=bpy.data.meshes.new('Photo-traced natural granite');mesh.from_pydata(verts,[],faces);mesh.update()
body=bpy.data.objects.new('Photo matched summit boulder',mesh);collection.objects.link(body);body.parent=root
bm=bmesh.new();bm.from_mesh(mesh);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(mesh);bm.free()
def en(owner,p,v):return next(i.identifier for i in owner.bl_rna.properties[p].enum_items if i.identifier==v)
col=mesh.color_attributes.new(name='Granite weathering',type=en(bpy.types.Attribute,'data_type','FLOAT_COLOR'),domain=en(bpy.types.Attribute,'domain','POINT'))
for i,c in enumerate(colors):col.data[i].color=c
for p in mesh.polygons:p.use_smooth=True
mat=bpy.data.materials.new('Monument authored granite');mat.use_nodes=True
bs=next(n for n in mat.node_tree.nodes if n.type=='BSDF_PRINCIPLED');bs.inputs['Roughness'].default_value=.96
vc=mat.node_tree.nodes.new('ShaderNodeVertexColor');vc.layer_name=col.name;mat.node_tree.links.new(vc.outputs['Color'],bs.inputs['Base Color']);mesh.materials.append(mat)
ink=bpy.data.materials.new('Dark carved Hanja');ink.use_nodes=True
bs=next(n for n in ink.node_tree.nodes if n.type=='BSDF_PRINCIPLED');bs.inputs['Base Color'].default_value=(.012,.014,.010,1);bs.inputs['Roughness'].default_value=.93
font=bpy.data.fonts.load('C:/Windows/Fonts/HANBatangB.ttf')
for i,ch in enumerate('冠岳山'):
c=bpy.data.curves.new('Carved '+ch,next(i.identifier for i in bpy.data.curves.bl_rna.functions['new'].parameters['type'].enum_items if i.identifier=='FONT'))
c.body=ch;c.font=font;c.size=.36;c.align_x=en(bpy.types.TextCurve,'align_x','CENTER');c.extrude=.001
o=bpy.data.objects.new(c.name,c);collection.objects.link(o);o.parent=root;o.location=(.17,0,1.44-i*.43);o.rotation_euler=(math.pi/2,0,0);c.materials.append(ink)
for other in s.objects:other.select_set(False)
o.select_set(True);bpy.context.view_layer.objects.active=o
bpy.ops.object.convert(target=next(i.identifier for i in bpy.ops.object.convert.get_rna_type().properties['target'].enum_items if i.identifier=='MESH'))
# Conform every glyph vertex to the dressed rock face, no floating flat sign.
transform=o.matrix_basis.copy()
for v in o.data.vertices:
p=transform@v.co;p.y=front(p.x,p.z)-.003;v.co=p
o.location=(0,0,0);o.rotation_euler=(0,0,0)
root['photoMonument']=True
s.camera.location=root.location+Vector((.9,-6.3,2.8));s.camera.rotation_euler=(root.location+Vector((0,0,.95))-s.camera.location).to_track_quat('-Z','Y').to_euler();s.camera.data.lens=55
s.render.resolution_x=1000;s.render.resolution_y=950;s.render.resolution_percentage=100
s.render.filepath='D:/project/sunrise/assets/summit/blender-plateau-study/marker-photo-match.png'
bpy.ops.render.render(write_still=True)
print('Photo marker:',len(verts),'vertices;',len(faces),'faces; natural shoulders, flared foot, conformed Hanja')
+33
View File
@@ -0,0 +1,33 @@
"""Finishing pass for the photo study, executed once in Blender through MCP."""
import bpy, bmesh, math
from mathutils import Vector, noise
ns=bpy.app.driver_namespace['photo_rocks']
for obj in ns['rocks'].objects:
if obj.get('joint_pass'): continue
bm=bmesh.new();bm.from_mesh(obj.data)
bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.001)
# Sample the joints closely enough to avoid a texture-only crack on a flat wall.
for _ in range(3):
long=[edge for edge in bm.edges if edge.calc_length()>.7]
if not long:break
bmesh.ops.subdivide_edges(bm,edges=long,cuts=1,use_grid_fill=True)
bm.normal_update()
for v in bm.verts:
p=v.co.copy()
if p.z < -28:continue
bedding=min(abs(p.z+.13*p.x-h) for h in [-22,-15,-9,-3,3])
vertical=min(abs(p.x+.16*p.y+.09*p.z-h) for h in [-24,-20,-16,-12,-5,1,6])
cut=.42*math.exp(-(bedding/.23)**2)+.29*math.exp(-(vertical/.2)**2)
weather=.065*noise.noise_vector(p*2.1).x
v.co+=v.normal*(weather-cut)
bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
bm.to_mesh(obj.data);bm.free();obj.data.update()
# Restore projected UVs after subdivisions; color layers interpolate in bmesh.
uv=obj.data.uv_layers.active
for face in obj.data.polygons:
axis=max(range(3),key=lambda a:abs(face.normal[a]))
for li in face.loop_indices:
p=obj.data.vertices[obj.data.loops[li].vertex_index].co
uv.data[li].uv=Vector((p.y,p.z) if axis==0 else (p.x,p.z) if axis==1 else (p.x,p.y))/3
obj['joint_pass']=True
print('Finished joints:',sum(len(o.data.polygons) for o in ns['rocks'].objects),'faces')
+2
View File
@@ -0,0 +1,2 @@
rasterio==1.4.3
pyproj==3.7.2
+12
View File
@@ -0,0 +1,12 @@
"""Attach real granite grain to the photo-traced monument's vertex weathering."""
import bpy
s=bpy.data.scenes['Summit slab and marker'];bpy.context.window.scene=s
body=bpy.data.objects['Photo matched summit boulder'];mesh=body.data
uv=mesh.uv_layers.get('Granite grain UV') or mesh.uv_layers.new(name='Granite grain UV')
for face in mesh.polygons:
for li in face.loop_indices:
p=mesh.vertices[mesh.loops[li].vertex_index].co;uv.data[li].uv=(p.x*.85,p.z*.85)
# The saved study already contains the authored color multiply and normal map.
# Export temporarily connects the scan directly; glTF multiplies COLOR_0 itself.
s.render.filepath='D:/project/sunrise/assets/summit/blender-plateau-study/marker-photo-match.png'
bpy.ops.render.render(write_still=True)
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { MongoClient } from 'mongodb';
import { MongoVisitStore } from '../server/visits.ts';
// Isolated disposable database: never uses the application's MONGODB_DATABASE.
const database = `sunrise_verify_${randomUUID().replaceAll('-', '')}`;
const uri = process.env.MONGODB_URI ?? 'mongodb://172.16.0.7:27017';
const reader = new MongoClient(uri, { serverSelectionTimeoutMS: 5000 });
let store: MongoVisitStore | undefined;
try {
store = await MongoVisitStore.connect(uri, database);
const id = randomUUID(), now = new Date();
await store.insert({ _id: id, visitorId: randomUUID(), nickname: '연결 검증', locationId: 'gwanaksan',
roomId: 'gwanaksan-001', joinedAt: now, lastSeenAt: now, leftAt: null, endReason: null, serverInstanceId: randomUUID() });
await store.heartbeat([id], new Date(+now + 1000));
// A second connection must not rewrite an existing open visit.
const second = await MongoVisitStore.connect(uri, database);
await second.close();
await reader.connect();
const open = await reader.db(database).collection<{ _id: string; endReason: string | null }>('visitSessions').findOne({ _id: id });
assert.equal(open?.endReason, null, 'startup must not rewrite existing open visits');
await store.finish(id, new Date(+now + 2000), 'disconnect');
await reader.connect();
const record = await reader.db(database).collection<{ _id: string; lastSeenAt: Date; leftAt: Date; endReason: string }>('visitSessions').findOne({ _id: id });
assert.equal(record?.endReason, 'disconnect');
assert.equal(record?.leftAt.getTime(), +now + 2000);
assert.equal(record?.lastSeenAt.getTime(), +now + 2000);
assert.ok(!('equipment' in record!) && !('x' in record!), 'no transient player state in visit log');
console.log(JSON.stringify({ ok: true, tested: ['insert', 'heartbeat', 'finish', 'read'], isolatedDatabase: database }));
} finally {
await store?.close();
// This exact random database was created by this verification run alone.
if (store) await reader.db(database).dropDatabase();
await reader.close();
}
+30
View File
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import {readFileSync,writeFileSync} from 'node:fs';
import {Box3,Mesh,MeshStandardMaterial,Vector3} from 'three';
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
import {createSummitRelief} from '../src/scene/summitRelief.ts';
const bytes=readFileSync('public/models/summit/radar-rocks.glb');
const json=JSON.parse(bytes.toString('utf8',20,20+bytes.readUInt32LE(12)));
assert.equal(json.images?.length??0,0,'reference photos must not be embedded');
assert.equal(json.cameras?.length??0,0,'study camera is not exported');
const asset=(await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset,bytes.byteOffset+bytes.byteLength),'')).scene;
const bounds=new Box3().setFromObject(asset);
assert.ok(bounds.min.x<-25&&bounds.max.x>5,'west outcrop and radar wall are both present');
assert.ok(bounds.min.y<-25&&bounds.max.y>7&&bounds.max.y<12,'metres and Y-up conversion');
let triangles=0,meshes=0,disposed=0;
asset.traverse(o=>{if(o instanceof Mesh){
meshes++;assert.ok(o.geometry.hasAttribute('uv'));assert.ok(o.geometry.hasAttribute('normal'));
for(const a of Object.values(o.geometry.attributes))assert.ok(a.array.every(Number.isFinite));
triangles+=(o.geometry.index?.count??o.geometry.getAttribute('position').count)/3;
o.geometry.addEventListener('dispose',()=>disposed++);
}});
assert.ok(triangles>1000&&triangles<100000);
const material=new MeshStandardMaterial(),anchor=new Vector3(20,30,50);
const relief=createSummitRelief(anchor,()=>0,material,asset);
assert.deepEqual(asset.position.toArray(),anchor.toArray());
assert.equal(relief.group.children.filter(o=>o===asset).length,1,'authored model placed once');
relief.dispose();material.dispose();assert.equal(disposed,meshes);
const report={passed:true,bytes:bytes.length,meshes,triangles,localBounds:{min:bounds.min.toArray(),max:bounds.max.toArray()},embeddedPhotos:false};
writeFileSync('assets/summit/blender-rock-study/verification.json',JSON.stringify(report,null,2));
console.log(report);
+81
View File
@@ -0,0 +1,81 @@
// Verify the exported artifacts using the same Three.js loader as the application.
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { Box3, Raycaster, Vector3 } from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { STRUCTURES } from '../src/constants/structures.ts';
const directory = resolve('public/models/summit');
const manifest = JSON.parse(readFileSync(resolve(directory, 'manifest.json'), 'utf8'));
const specs = [...STRUCTURES.towers, { id: 'weather-radar', ...STRUCTURES.radar }, ...STRUCTURES.decks];
assert.deepEqual(manifest.assets.map(a => a.id).sort(), specs.map(s => s.id).sort());
const report = { loader: 'Three.js GLTFLoader', checks: 'passed', assets: [] };
for (const asset of manifest.assets) {
const spec = specs.find(s => s.id === asset.id);
const bytes = readFileSync(resolve(directory, asset.file));
assert.equal(bytes.toString('ascii', 0, 4), 'glTF');
assert.equal(bytes.readUInt32LE(4), 2);
assert.equal(bytes.readUInt32LE(8), bytes.length);
const jsonLength = bytes.readUInt32LE(12);
const json = JSON.parse(bytes.toString('utf8', 20, 20 + jsonLength));
assert.equal(json.scenes.length, 1, `${asset.id}: exactly one scene`);
assert.equal(json.scenes[0].nodes.length, 1, `${asset.id}: exactly one asset root`);
assert.ok(!(json.images?.length), 'No reference images or external textures');
assert.ok(!json.buffers.some(b => b.uri), 'Embedded geometry only');
assert.ok(!(json.cameras?.length), 'No studio camera');
assert.ok(!json.nodes.some(n => /Cube|Studio|Camera/.test(n.name ?? '')), 'No unrelated scene objects');
assert.equal(asset.latitude, spec.latitude);
assert.equal(asset.longitude, spec.longitude);
assert.equal(asset.heading, spec.heading ?? 0);
for (const [key, value] of Object.entries(asset.dimensions)) assert.equal(value, spec[key]);
const data = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
const gltf = await new GLTFLoader().parseAsync(data, '');
const root = gltf.scene.getObjectByName(asset.id);
assert.ok(root, `${asset.id}: named root survives export`);
assert.ok(root.position.length() < 1e-6, 'No gallery offset baked into GLB');
assert.equal(root.userData.dimensionsEstimated, true);
let triangles = 0, meshes = 0;
root.traverse(object => {
if (!object.isMesh) return;
meshes++;
const geometry = object.geometry;
const positions = geometry.getAttribute('position');
assert.ok(positions.count > 0);
assert.ok([...positions.array].every(Number.isFinite), 'Finite vertex coordinates');
const normals = geometry.getAttribute('normal');
assert.ok(normals && [...normals.array].every(Number.isFinite), 'Finite normals');
if (geometry.index) {
assert.ok([...geometry.index.array].every(i => i < positions.count));
triangles += geometry.index.count / 3;
} else triangles += positions.count / 3;
assert.ok(object.material.isMeshStandardMaterial, 'PBR material survives');
});
gltf.scene.updateMatrixWorld(true);
const bounds = new Box3().setFromObject(root);
const size = bounds.getSize(new Vector3());
assert.ok([...bounds.min.toArray(), ...bounds.max.toArray()].every(Number.isFinite));
if (asset.kind === 'tower') {
assert.ok(Math.abs(bounds.max.y - (spec.height + 4)) < .01, 'Original tower height + 4m antenna');
assert.ok(Math.abs(bounds.min.y) < .15, 'Base at origin');
} else if (asset.kind === 'radar') {
assert.ok(Math.abs(bounds.max.y - (spec.buildingHeight + 1.8 * spec.radius)) < .1);
const ray = new Raycaster(new Vector3(0, 3, 20), new Vector3(0, 0, -1));
assert.ok(ray.intersectObject(root, true).length, 'Building is visible to app raycasts');
} else {
const ray = new Raycaster(new Vector3(.31, 2, .037), new Vector3(0, -1, 0));
const hits = ray.intersectObject(root, true);
assert.ok(hits.length && Math.abs(hits[0].point.y) < .02, 'Upper walking surface at y=0');
assert.ok(bounds.min.z < -spec.length / 2 - spec.stairLength, 'Stairs extend toward -Z');
assert.ok(asset.terrainSupportAnchors.length > 0);
assert.ok(asset.terrainSupportAnchors.every(a => a.position.every(Number.isFinite)));
}
report.assets.push({ id: asset.id, bytes: bytes.length, meshes, triangles,
bounds: { min: bounds.min.toArray(), max: bounds.max.toArray() },
sizeMetres: size.toArray().map(n => Number(n.toFixed(3))) });
}
report.totalBytes = report.assets.reduce((n, a) => n + a.bytes, 0);
report.totalTriangles = report.assets.reduce((n, a) => n + a.triangles, 0);
writeFileSync(resolve('assets/summit/verification.json'), JSON.stringify(report, null, 2));
console.table(report.assets.map(({ id, bytes, meshes, triangles, sizeMetres }) => ({ id, KiB: +(bytes / 1024).toFixed(1), meshes, triangles, sizeMetres: sizeMetres.join(' × ') })));
console.log(`PASS: ${report.assets.length} assets; ${report.totalTriangles} triangles; ${(report.totalBytes / 1024 / 1024).toFixed(2)} MiB`);
+50
View File
@@ -0,0 +1,50 @@
"""Round-trip the eight GLBs in a disposable Blender scene; preserve the authoring scene."""
import bpy
import json
from pathlib import Path
from mathutils import Vector
root = Path('D:/project/sunrise')
manifest = json.loads((root/'public/models/summit/manifest.json').read_text(encoding='utf-8'))
expected = json.loads((root/'assets/summit/verification.json').read_text(encoding='utf-8'))
expected = {a['id']:a for a in expected['assets']}
original_scene = bpy.context.window.scene
report = {'blenderVersion':bpy.app.version_string,'checks':'passed','assets':[]}
check_scene = bpy.data.scenes.new('Temporary GLB round-trip validation')
try:
bpy.context.window.scene = check_scene
with bpy.context.temp_override(scene=check_scene,view_layer=check_scene.view_layers[0]):
for asset in manifest['assets']:
bpy.ops.import_scene.gltf(filepath=str(root/'public/models/summit'/asset['file']))
objects = list(check_scene.objects)
roots = [obj for obj in objects if obj.get('assetId') == asset['id']]
assert len(roots)==1, asset['id']
assert roots[0].location.length < 1e-5
meshes = [obj for obj in objects if obj.type=='MESH']
assert len(meshes)==expected[asset['id']]['meshes']
bpy.context.view_layer.update()
corners = [obj.matrix_world @ Vector(p) for obj in meshes for p in obj.bound_box]
lower = [min(p[i] for p in corners) for i in range(3)]
upper = [max(p[i] for p in corners) for i in range(3)]
# Blender X,Y,Z -> GLB X,Y,Z = X,Z,-Y.
actual_min = [lower[0],lower[2],-upper[1]]
actual_max = [upper[0],upper[2],-lower[1]]
target = expected[asset['id']]['bounds']
assert max(abs(a-b) for a,b in zip(actual_min,target['min'])) < .001
assert max(abs(a-b) for a,b in zip(actual_max,target['max'])) < .001
assert all(obj.data.materials for obj in meshes)
report['assets'].append({'id':asset['id'],'meshes':len(meshes),'boundsMatchThreeWithinMetres':.001})
imported_materials = {mat for obj in meshes for mat in obj.data.materials if mat}
for obj in objects:
data = obj.data if obj.type=='MESH' else None
bpy.data.objects.remove(obj,do_unlink=True)
if data is not None and data.users==0:
bpy.data.meshes.remove(data)
for mat in imported_materials:
if mat.users==0:
bpy.data.materials.remove(mat)
finally:
bpy.context.window.scene = original_scene
bpy.data.scenes.remove(check_scene)
(root/'assets/summit/blender-roundtrip.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
print('PASS: all 8 GLBs re-imported; bounds match Three.js within 1 mm; authoring scene restored')
+14
View File
@@ -0,0 +1,14 @@
"""Add non-repeating mineral/weathering patches to the authored granite atlas."""
import bpy,numpy as np
im=bpy.data.images['Summit unique weathered granite'];w,h=im.size
p=np.asarray(im.pixels[:],dtype=np.float32).reshape(h,w,4)
rng=np.random.default_rng(141)
def cloud(n):
grid=rng.uniform(-1,1,(n,n));xs=np.linspace(0,n-1,w);ys=np.linspace(0,n-1,h)
rows=np.array([np.interp(xs,np.arange(n),row) for row in grid])
return np.array([np.interp(ys,np.arange(n),rows[:,i]) for i in range(w)]).T
patch=cloud(12)*.065+cloud(48)*.045+cloud(170)*.025
p[:,:,:3]=np.clip(p[:,:,:3]+patch[:,:,None],0,1)
im.pixels.foreach_set(p.ravel());im.update();im.save()
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
print('Saved non-repeating mineral patches and weathering atlas',w,h)