feat: initialize sunrise with 100-player shared rooms

This commit is contained in:
2026-10-30 16:36:35 +09:00
commit 31d6eac4c2
257 changed files with 66063 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
"""Photo-guided Blender study. Run through Blender MCP; export only the rock collection.
Blender: X east, Y north, Z up, origin at radar base. Geometry and sizes are estimates.
The existing facility scene is retained. Source is saved to its own .blend file.
"""
import bpy, bmesh, math, json, random
from pathlib import Path
from mathutils import Vector, noise
ROOT = Path('D:/project/sunrise')
OUT = ROOT / 'assets/summit/blender-rock-study'
context = json.loads((OUT/'context.json').read_text(encoding='utf-8'))
SCENE_NAME = 'Photo-guided summit rocks'
if bpy.data.scenes.get(SCENE_NAME):
raise RuntimeError('Study already exists; edit it in place rather than rebuilding.')
scene = bpy.data.scenes.new(SCENE_NAME)
bpy.context.window.scene = scene
rocks = bpy.data.collections.new('Photo rocks - export')
study = bpy.data.collections.new('Terrain and radar - context only')
scene.collection.children.link(rocks)
scene.collection.children.link(study)
def enum(owner, prop, wanted):
return next(item.identifier for item in owner.bl_rna.properties[prop].enum_items if item.identifier == wanted)
def material(name, color):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
node = next(n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED')
node.inputs['Base Color'].default_value = (*color,1)
node.inputs['Roughness'].default_value = .94
mat.diffuse_color = (*color,1)
return mat
granite = material('Photo study / weathered granite', (.44,.43,.38))
nodes=granite.node_tree.nodes
bsdf=next(n for n in nodes if n.type=='BSDF_PRINCIPLED')
tex=nodes.new('ShaderNodeTexImage')
tex.image=bpy.data.images.load(str(ROOT/'public/terrain/materials/rock-color.jpg'),check_existing=True)
granite.node_tree.links.new(tex.outputs['Color'],bsdf.inputs['Base Color'])
def mesh_object(name, vertices, faces, collection, mat):
mesh=bpy.data.meshes.new(name)
mesh.from_pydata(vertices,[],faces);mesh.update()
obj=bpy.data.objects.new(name,mesh);collection.objects.link(obj)
mesh.materials.append(mat)
return obj
def sculpt_loft(name, outline, levels, seed, relief=.18):
# Non-convex plan retains deep vertical joints instead of rounding them into potatoes.
rng=random.Random(seed)
count=len(outline)
center=Vector((sum(p[0] for p in outline)/count,sum(p[1] for p in outline)/count))
verts=[]
offsets=[rng.uniform(-.75,.75) for _ in outline]
for level,(z,scale,dx,dy) in enumerate(levels):
for j,(x,y) in enumerate(outline):
verts.append((center.x+(x-center.x)*scale+dx,
center.y+(y-center.y)*scale+dy,z+offsets[j]*(.3 if level==0 else 1)))
faces=[]
for level in range(len(levels)-1):
for j in range(count):
k=(j+1)%count
faces.append((level*count+j,level*count+k,(level+1)*count+k,(level+1)*count+j))
faces.append(tuple(reversed(range(count))))
faces.append(tuple((len(levels)-1)*count+j for j in range(count)))
obj=mesh_object(name,verts,faces,rocks,granite)
bm=bmesh.new();bm.from_mesh(obj.data)
bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
bmesh.ops.triangulate(bm,faces=list(bm.faces))
bmesh.ops.subdivide_edges(bm,edges=list(bm.edges),cuts=3,use_grid_fill=True)
bm.normal_update()
for vertex in bm.verts:
p=vertex.co.copy()
# Small weathering preserves the authored fracture planes and crest silhouette.
amount=noise.noise_vector(p*.72+Vector((seed,0,0))).x*relief
vertex.co+=vertex.normal*amount
bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
bm.to_mesh(obj.data);bm.free();obj.data.update()
uv=obj.data.uv_layers.new(name='Granite metre UV')
colors=obj.data.color_attributes.new(name='Weathering',type=enum(bpy.types.Attribute,'data_type','FLOAT_COLOR'),domain=enum(bpy.types.Attribute,'domain','CORNER'))
for face in obj.data.polygons:
n=face.normal
# Stable projection per face, at the same 3m texture scale as the app ground.
axis=max(range(3),key=lambda a:abs(n[a]))
for loop_index in face.loop_indices:
p=obj.data.vertices[obj.data.loops[loop_index].vertex_index].co
uv.data[loop_index].uv=((p.y,p.z) if axis==0 else (p.x,p.z) if axis==1 else (p.x,p.y))
uv.data[loop_index].uv/=3
shade=.86+.10*noise.noise_vector(p*.22+Vector((seed,0,0))).x
colors.data[loop_index].color=(shade,shade*.99,shade*.95,1)
face.use_smooth=True
# Auto-smooth via sharp edges: small surface weathering smooth, major joints retain ridges.
bm=bmesh.new();bm.from_mesh(obj.data)
for edge in bm.edges:
if edge.is_manifold and edge.calc_face_angle()>math.radians(35):edge.smooth=False
bm.to_mesh(obj.data);bm.free()
obj['reference']='User photos; authored fracture outline, estimated metres'
return obj
# Radar buttress: a continuous narrow crown opening out into a broad fractured wall.
sculpt_loft('Radar / north granite wall',
[(-9,-4),(-5,-7),(4,-6),(9,-2),(10,5),(7,8),(4,7),(3,10),(-1,12),(-2,9),(-5,11),(-9,7)],
[(-34,1.5,1,7),(-20,1.25,.8,3),(-10,1.08,0,1),(-3,.91,0,0),(-.7,.67,0,-1)],11,.15)
# One connected western rock. Concave outline produces the vertical clefts in the photo.
sculpt_loft('Western outcrop / rooted jointed wall',
[(-25,0),(-20,-3),(-14,-2),(-11,2),(-12,6),(-14,7),(-13,10),(-16,12),(-17,9),(-20,13),(-23,11),(-22,7),(-25,6)],
[(-33,1.38,0,7),(-20,1.2,0,3),(-10,1,0,1),(-2,.86,0,0),(2,.57,.2,-1)],23,.19)
# The thin upper slab is rooted well inside the wall; a slanted crown, no symmetric apex.
sculpt_loft('Western outcrop / leaning crown slab',
[(-19,-1),(-16,-2),(-14,0),(-14,3),(-16,5),(-18,4),(-19,2)],
[(-7,1.1,0,0),(0,1,0,0),(5,.69,-.7,-.4),(9,.31,-1.7,-.8)],31,.10)
sculpt_loft('Western outcrop / broken lower shoulder',
[(-26,1),(-22,-1),(-20,2),(-20,6),(-22,8),(-25,7)],
[(-20,1.15,0,2),(-8,1,0,0),(0,.67,.4,-.4),(3,.27,1,-.7)],47,.16)
# Sloping foot is a ridge, not a row of freestanding pillars.
sculpt_loft('Western outcrop / descending rock apron',
[(-28,12),(-23,10),(-18,13),(-15,20),(-15,27),(-20,34),(-25,33),(-29,24)],
[(-38,1.15,0,0),(-27,1,0,0),(-20,.77,0,-2),(-13,.40,0,-5)],59,.25)
sculpt_loft('Western / fractured front ledge',[(-23,8),(-20,7),(-17,9),(-17,12),(-19,14),(-23,12)],[(-19,1.1,0,0),(-11,1,0,0),(-7,.72,0,-.3)],71,.22)
sculpt_loft('Western / angular shoulder plates',[(-16,6),(-13,5),(-11,8),(-12,11),(-15,12),(-17,10)],[(-15,1.12,0,0),(-6,1,0,0),(-2,.6,-.6,-.7)],81,.2)
sculpt_loft('Western / low bedding shelf',[(-26,16),(-22,14),(-19,17),(-20,21),(-24,23),(-27,20)],[(-29,1.12,0,0),(-20,1,0,0),(-16,.62,.3,-.4)],91,.23)
sculpt_loft('Radar / split foreground ledge',[(2,9),(5,7),(8,10),(7,14),(4,15),(1,13)],[(-26,1.15,0,0),(-16,1,0,0),(-11,.63,-.6,-1)],101,.17)
# Match actual terrain contact and camera. This collection is excluded from the GLB.
rows,cols=context['rows'],context['columns']
faces=[]
for row in range(rows-1):
for col in range(cols-1):
a=row*cols+col;faces.append((a,a+1,a+1+cols,a+cols))
terrain=mesh_object('App terrain / contact reference',context['vertices'],faces,study,material('Study terrain',(.21,.26,.16)))
root=bpy.data.objects.get('weather-radar')
copies={}
for source in [root,*root.children_recursive]:
obj=source.copy()
if source.data:obj.data=source.data.copy()
study.objects.link(obj);copies[source]=obj
for source,obj in copies.items():
obj.parent=copies.get(source.parent)
copies[root].location=(0,0,0)
camera_data=bpy.data.cameras.new('Photo comparison camera')
camera=bpy.data.objects.new(camera_data.name,camera_data);study.objects.link(camera)
camera.location=Vector(context['eye'])
camera.rotation_euler=(Vector((-8,7,-6))-camera.location).to_track_quat('-Z','Y').to_euler()
camera_data.lens=43;scene.camera=camera
scene.render.resolution_x=1200;scene.render.resolution_y=850;scene.render.resolution_percentage=100
scene.world=bpy.data.worlds.new('Photo study sky');scene.world.use_nodes=True
bg=next(n for n in scene.world.node_tree.nodes if n.type=='BACKGROUND')
bg.inputs['Color'].default_value=(.55,.64,.72,1);bg.inputs['Strength'].default_value=.65
sun_data=bpy.data.lights.new('Photo study sun',enum(bpy.types.Light,'type','SUN'))
sun_data.energy=2.2;sun_data.angle=.15
sun=bpy.data.objects.new(sun_data.name,sun_data);study.objects.link(sun)
sun.rotation_euler=(math.radians(32),math.radians(-25),math.radians(-45))
try:scene.render.engine='CYCLES'
except TypeError as exc:print(exc)
scene.cycles.samples=24
# Reference images remain local and are not exported with the model.
for i,path in enumerate([
'C:/Users/horoli/AppData/Local/Temp/codex-clipboard-453c752b-f410-4d6b-97ad-8056e3dac06d.png',
'C:/Users/horoli/AppData/Local/Temp/codex-clipboard-8cc46291-7018-4d33-a23e-553db9844d43.png']):
if Path(path).exists():
obj=bpy.data.objects.new('Photo reference '+str(i+1),None);study.objects.link(obj)
obj.empty_display_type=enum(bpy.types.Object,'empty_display_type','IMAGE')
obj.data=bpy.data.images.load(path,check_existing=True);obj.empty_display_size=30
obj.location=(45+i*35,0,5);obj.rotation_euler=(math.pi/2,0,0)
def export_rocks():
import io_scene_gltf2
for obj in scene.objects:obj.select_set(False)
for obj in rocks.objects:obj.select_set(True)
bpy.context.view_layer.objects.active=next(iter(rocks.objects))
fmt=next(item[0] for item in io_scene_gltf2.get_format_items(None,bpy.context) if item[0]=='GLB')
# App reuses its rock textures; UVs and vertex weathering travel with the geometry.
mats=next(item.identifier for item in bpy.ops.export_scene.gltf.get_rna_type().properties['export_materials'].enum_items if item.identifier=='NONE')
bpy.ops.export_scene.gltf(filepath=str(ROOT/'public/models/summit/radar-rocks.glb'),export_format=fmt,
use_selection=True,use_active_scene=True,export_yup=True,export_apply=True,
export_materials=mats,export_animations=False,export_cameras=False,export_lights=False,export_extras=True)
bpy.ops.wm.save_as_mainfile(filepath=str(OUT/'photo-guided-rocks.blend'))
print('Exported photo-guided rocks:',sum(len(o.data.polygons) for o in rocks.objects),'faces')
bpy.app.driver_namespace['photo_rocks']=globals()
print('Study ready:',len(rocks.objects),'rock meshes. Original scene retained.')