feat: initialize sunrise with 100-player shared rooms
This commit is contained in:
@@ -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.')
|
||||
@@ -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.')
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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); });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pyshp==2.3.1
|
||||
shapely==2.1.2
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.')
|
||||
@@ -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')
|
||||
@@ -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.')
|
||||
@@ -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']))
|
||||
@@ -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'))
|
||||
@@ -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')
|
||||
@@ -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)}))
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.')
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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.');
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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});
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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.')
|
||||
@@ -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')
|
||||
@@ -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.')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -0,0 +1,2 @@
|
||||
rasterio==1.4.3
|
||||
pyproj==3.7.2
|
||||
@@ -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)
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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`);
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user