"""Offline city meshes. Playback, GL submission and compositing remain MASM.""" from pathlib import Path import json import math import struct from collections import Counter ROOT = Path(__file__).resolve().parents[2] OUT = ROOT/"assets/animations/rooftop" ART = ROOT/"assets/sprites/rooftop" class Mesh: def __init__(self): self.triangles = [] def quad(self, material, points, uv=None, smooth=None): # Points use the game's X/Y ground plane and Z up. GL receives X/Z/Y. a,b,c = points[:3] u = [b[i]-a[i] for i in range(3)] v = [c[i]-a[i] for i in range(3)] normal = (u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]) size = math.sqrt(sum(x*x for x in normal)) if size < 1e-8: return normal = tuple(x/size for x in normal) uv = uv or [(0,0),(1,0),(1,1),(0,1)] for ids in ((0,1,2),(0,2,3)): if len({points[i] for i in ids}) < 3: continue vertices = [] for i in ids: x,y,z = points[i] nx,ny,nz = smooth[i] if smooth else normal # Vertical texture convention: the top of a facade is v=0. vertices.extend((x,z,y,nx,nz,ny,*uv[i])) self.triangles.append((material,vertices)) def box(self, x0,x1,y0,y1,z0,z1,material,roof=None,repeat_v=1): if min(x1-x0,y1-y0,z1-z0) <= 0: return # Explicit winding/normals face outwards; negative scale is never used. uv = [(0,repeat_v),(1,repeat_v),(1,0),(0,0)] self.quad(material,[(x0,y0,z0),(x1,y0,z0),(x1,y0,z1),(x0,y0,z1)],uv) self.quad(material,[(x1,y1,z0),(x0,y1,z0),(x0,y1,z1),(x1,y1,z1)],uv) self.quad(material,[(x0,y1,z0),(x0,y0,z0),(x0,y0,z1),(x0,y1,z1)],uv) self.quad(material,[(x1,y0,z0),(x1,y1,z0),(x1,y1,z1),(x1,y0,z1)],uv) self.horizontal(x0,x1,y0,y1,z1,roof or material) def horizontal(self,x0,x1,y0,y1,z,material,uv=None): points = [(x0,y1,z),(x1,y1,z),(x1,y0,z),(x0,y0,z)] uv = uv or [(0,0),(1,0),(1,1),(0,1)] self.quad(material,list(reversed(points)),list(reversed(uv))) def ellipsoid(self,x,y,z,rx,ry,rz,material,upper=False,rings=10,sides=24): end = math.pi/2 if upper else math.pi def point(t,p): nx,ny,nz = math.sin(t)*math.cos(p),math.sin(t)*math.sin(p),math.cos(t) n = (nx/rx,ny/ry,nz/rz) length = math.sqrt(sum(v*v for v in n)) return (x+rx*nx,y+ry*ny,z+rz*nz),tuple(v/length for v in n) for row in range(rings): t0,t1 = max(.0001,row*end/rings),(row+1)*end/rings for col in range(sides): p0,p1 = col*math.tau/sides,(col+1)*math.tau/sides samples = [point(t0,p0),point(t1,p0),point(t1,p1),point(t0,p1)] self.quad(material,[p for p,n in samples], [(col/sides,row/rings),(col/sides,(row+1)/rings), ((col+1)/sides,(row+1)/rings),((col+1)/sides,row/rings)], [n for p,n in samples]) def cylinder(self,x,y,z0,z1,radius,material,sides=16,repeat=1): for col in range(sides): a,b = col*math.tau/sides,(col+1)*math.tau/sides na,nb = (math.cos(a),math.sin(a),0),(math.cos(b),math.sin(b),0) self.quad(material,[(x+radius*na[0],y+radius*na[1],z0), (x+radius*nb[0],y+radius*nb[1],z0), (x+radius*nb[0],y+radius*nb[1],z1), (x+radius*na[0],y+radius*na[1],z1)], uv=[(col*repeat/sides,1),((col+1)*repeat/sides,1), ((col+1)*repeat/sides,0),(col*repeat/sides,0)], smooth=[na,nb,nb,na]) self.quad(material,[(x,y,z1),(x+radius*na[0],y+radius*na[1],z1), (x+radius*nb[0],y+radius*nb[1],z1), (x+radius*nb[0],y+radius*nb[1],z1)]) def hip(self,x0,x1,y0,y1,z,height,material=196): if x1-x0 >= y1-y0: ridge = [(x0+1,(y0+y1)/2,z+height),(x1-1,(y0+y1)/2,z+height)] self.quad(material,[(x0,y0,z),(x1,y0,z),ridge[1],ridge[0]],[(0,0),((x1-x0)/4,0),((x1-x0)/4,1),(0,1)]) self.quad(material,[(x1,y1,z),(x0,y1,z),ridge[0],ridge[1]],[(0,0),((x1-x0)/4,0),((x1-x0)/4,1),(0,1)]) self.quad(material,[(x0,y1,z),(x0,y0,z),ridge[0],ridge[0]]) self.quad(material,[(x1,y0,z),(x1,y1,z),ridge[1],ridge[1]]) else: # Transposing X/Y reverses winding; save() normalises every triangle. cy = (x0+x1)/2 a,b = (cy,y0+1,z+height),(cy,y1-1,z+height) self.quad(material,[(x0,y1,z),(x0,y0,z),a,b],[(0,0),((y1-y0)/4,0),((y1-y0)/4,1),(0,1)]) self.quad(material,[(x1,y0,z),(x1,y1,z),b,a],[(0,0),((y1-y0)/4,0),((y1-y0)/4,1),(0,1)]) self.quad(material,[(x0,y0,z),(x1,y0,z),a,a]) self.quad(material,[(x1,y1,z),(x0,y1,z),b,b]) def save(self,name,static=False): original_count = len(self.triangles) oriented = [] for mat,packed in self.triangles: vertices = [packed[i:i+8] for i in (0,8,16)] a,b,c = [v[:3] for v in vertices] u,v = [b[i]-a[i] for i in range(3)],[c[i]-a[i] for i in range(3)] normal = [u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]] if sum(x*x for x in normal) < 1e-16: continue expected = [sum(vertex[i+3] for vertex in vertices) for i in range(3)] if sum(x*y for x,y in zip(normal,expected)) < 0: vertices[1],vertices[2] = vertices[2],vertices[1] normal = [-x for x in normal] if static: # Union of every standing/seated/leaning eye position, in GL X/Z/Y axes. bounds = ((11.0,29.0),(1.05,1.8),(1.0,21.0)) maximum = sum(n*((hi if n >= 0 else lo)-p) for n,(lo,hi),p in zip(normal,bounds,a)) if maximum < -1e-7: continue oriented.append((mat,[value for vertex in vertices for value in vertex])) self.triangles = oriented self.triangles.sort(key=lambda t:t[0]) payload = b"".join(struct.pack("= x1 or y0 >= y1: return [rect] return [r for r in ((a,x0,c,d),(x1,b,c,d),(x0,x1,c,y0),(x0,x1,y1,d)) if r[1]-r[0] > .01 and r[3]-r[2] > .01] def road(rect,vertical=False): pieces = [rect] obstacles = [(-2000,2000,58,79),(68,2000,26,81)] if vertical: obstacles += [(-2000,2000,29+36*i-4,29+36*i+4) for i in range(-16,17)] for obstacle in obstacles: pieces = [p for r in pieces for p in subtract(r,obstacle)] for a,b,c,d in pieces: roads.append((a,b,c,d)) scene.horizontal(a,b,c,d,-59.98,197,world_uv(a,b,c,d,4)) roads = [] for i in range(-16,17): x = 38+36*i road((x-4,x+4,-600,600),vertical=True) y = 29+36*i road((-600,600,y-4,y+4)) # Small lane dashes and pedestrian crossings give the streets a readable scale. for gx in range(-6,7): x = 38+36*gx for y in range(-205,231,9): if not 54 < y < 82 and not (x > 68 and 24 < y < 82): scene.horizontal(x-.07,x+.07,y,y+3,-59.965,168) for gy in range(-6,7): y = 29+36*gy if 54 < y < 82 or (x > 68 and 24 < y < 82): continue for stripe in range(7): sx = x-3.2+stripe*.95 scene.horizontal(sx,sx+.45,y+5,y+7,-59.96,168) for index,(x0,x1,y0,y1,roof,mat,kind) in enumerate(city["buildings"]): modern = mat >= 199 repeat = max(1,round((roof+60)/26)) if modern else 1 scene.box(x0,x1,y0,y1,-60,roof,mat,repeat_v=repeat) scene.box(x0-.18,x1+.18,y0-.18,y1+.18,roof,roof+.18,165 if modern else 168) distance = math.hypot((x0+x1)/2-20,(y0+y1)/2-11) if not modern and distance < 220: scene.box(x0-.12,x1+.12,y0-.12,y1+.12,-60,-58.8,162) for z in (roof-.55,roof-.3): scene.box(x0-.25,x1+.25,y0-.25,y1+.25,z,z+.12,168) if distance < 150: for x in (x0,x1-.28): for y in (y0,y1-.28): scene.box(x-.08,x+.36,y-.08,y+.36,-58.8,roof-.5,168) scene.hip(x0-.25,x1+.25,y0-.25,y1+.25,roof+.18,2.4) scene.box(x0+.8,x0+1.45,y0+.8,y0+1.35,roof+1.2,roof+3.2,190,roof=162) # Recessed dormer volumes interrupt long, otherwise empty roof slopes. if x1-x0 > 12 and distance < 150: for x in range(math.ceil(x0+3),math.floor(x1-2),6): scene.box(x-.55,x+.55,y0+.8,y0+1.55,roof+.45,roof+1.45,168) scene.quad(200,[(x-.4,y0+.79,roof+.6),(x+.4,y0+.79,roof+.6), (x+.4,y0+.79,roof+1.25),(x-.4,y0+.79,roof+1.25)]) scene.hip(x-.65,x+.65,y0+.7,y0+1.65,roof+1.45,.4) else: scene.horizontal(x0,x1,y0,y1,roof+.19,196) # Static landmark records share their exact footprints with the MASM world. data = (ROOT/"src_graphics/rooftop_district_data.inc").read_text() section = data.split("roofLandmarkParts3D LABEL DWORD",1)[1].split("roofFog3D",1)[0] for line in section.splitlines(): if not line.strip().startswith("dd "): continue values = list(map(int,line.strip()[3:].split(","))) kind,coords,mat = values[0],[v/65536 for v in values[1:7]],values[7] # The dome's old rectangular drum is replaced with a proper circular one. if kind == 0 and 7 < coords[0] < 12 and 128 < coords[3] < 134 and coords[4] > -30: continue if kind == 0: x0,x1,y0,y1,z0,z1 = coords if x1-x0 < 1.1 and y1-y0 < 1.1 and z1-z0 > 3: scene.cylinder((x0+x1)/2,(y0+y1)/2,z0,z1,(x1-x0)/2,mat) scene.box(x0-.12,x1+.12,y0-.12,y1+.12,z1-.25,z1,mat) else: scene.box(*coords,mat) elif kind == 1: scene.ellipsoid(*coords,mat,upper=True,rings=14,sides=48) elif kind == 2: x,y,z,rx,ry,h = coords h *= .65 for i in range(8): a,b = i*math.tau/8,(i+1)*math.tau/8 scene.quad(mat,[(x+rx*math.cos(a),y+ry*math.sin(a),z), (x+rx*math.cos(b),y+ry*math.sin(b),z), (x,y,z+h),(x,y,z+h)]) # Cathedral drum, portico and a solid entablature under the copper dome. scene.cylinder(20,119,-22,-10,9.5,191,sides=48,repeat=6) for z,radius in ((-22,10.3),(-16,10.0),(-10.5,12.4)): scene.cylinder(20,119,z,z+.45,radius,168,sides=48,repeat=6) for step in range(4): scene.box(3+step*.4,37-step*.4,95+step*.5,104,-60+step*.35,-59.65+step*.35,162) for x in (6,11.5,17,22.5,28,33.5): scene.cylinder(x,99,-58.6,-40,.55,168,sides=16) scene.cylinder(x,99,-58.6,-57.8,.8,162,sides=16) scene.cylinder(x,99,-40,-39.5,.8,168,sides=16) scene.box(3,37,97,104,-39.5,-38.5,168) scene.quad(168,[(3,96.99,-38.5),(37,96.99,-38.5),(20,96.99,-33),(20,96.99,-33)]) scene.hip(2.8,37.2,96.8,104.2,-38.5,5.5) for x in sorted(set(city["bridges"]+city["river_bridges"])): start,end = (28,77) if x >= 70 else (59,77) scene.horizontal(x-3,x+3,start,end,-59.9,197,world_uv(x-3,x+3,start,end,4)) for side in (x-3,x+3): for i in range(24): y0,y1 = start+(end-start)*i/24,start+(end-start)*(i+1)/24 mid,radius = (start+end)/2,(end-start)/2 z0 = -62+1.8*math.sqrt(max(0,1-((y0-mid)/radius)**2)) z1 = -62+1.8*math.sqrt(max(0,1-((y1-mid)/radius)**2)) points = [(side,y0,z0),(side,y1,z1),(side,y1,-60),(side,y0,-60)] scene.quad(162,list(reversed(points)) if side < x else points) for y in range(start,end+1,2): scene.box(side-.1,side+.1,y-.1,y+.1,-60,-59,165) scene.box(side-.08,side+.08,start,end,-59.1,-59,165) # Raised embankments, a tree-lined promenade and small piers follow the river. for y0,y1 in ((26,29),(76,81)): scene.box(70,1100,y0,y1,-62,-59.75,162) for x in range(84,640,28): y = (y0+y1)/2 if min(abs(x-b) for b in city["river_bridges"]) < 6: continue scene.cylinder(x,y,-59.75,-56,.14,162,sides=6) scene.ellipsoid(x,y,-55,1.7,1.7,2.1,207,rings=4,sides=8) scene.box(x+3,x+5,y-.3,y+.3,-59.75,-59.3,162) for x in (156,300,444): scene.box(x,x+7,28,34,-62,-60.3,196) for x,y in city["parks"]: scene.horizontal(x-12,x+12,y-12,y+12,-59.9,207) scene.horizontal(x-1,x+1,y-12,y+12,-59.8,162) for dx in (-8,8): for dy in (-8,0,8): scene.cylinder(x+dx,y+dy,-60,-56,.15,162,sides=8) scene.ellipsoid(x+dx,y+dy,-55.5,2.0,2.0,2.4,207,rings=5,sides=10) for x,y in city.get("lamps",[]): scene.cylinder(x,y,-60,-55,.055,165,sides=6) scene.box(x-.22,x+.22,y-.22,y+.22,-55.15,-54.9,178) # A second ground pass multiplies the baked, geometry-derived light map. for a,b,c,d,water in patches(-512,512): uv = [((a+512)/1024,(d+512)/1024),((b+512)/1024,(d+512)/1024), ((b+512)/1024,(c+512)/1024),((a+512)/1024,(c+512)/1024)] scene.horizontal(a,b,c,d,-61.94 if water else -59.94,208,uv) scene.horizontal(a,b,c,d,-61.93 if water else -59.93,209,uv) car = Mesh() car.box(-1.9,1.9,-.8,.8,.25,.85,196) car.box(-.9,.9,-.65,.65,.85,1.35,200) for y in (-.57,.57): car.box(1.88,1.93,y-.12,y+.12,.5,.68,178) for x in (-1.2,1.2): for y in (-.83,.83): car.ellipsoid(x,y,.3,.32,.12,.32,197,rings=5,sides=10) boat = Mesh() boat.ellipsoid(0,0,-1.6,3,1,.5,196,rings=6,sides=16) boat.box(-1,1,-.6,.6,-1.4,-.7,168) counts = {"scene":scene.save("mesh-city-v1.bin",static=True),"car":car.save("mesh-car-v1.bin"), "boat":boat.save("mesh-boat-v1.bin")} (ART/"mesh-v1.json").write_text(json.dumps({"format":"RGM1: count/bytes + material/3*(position,normal,uv)", "gl_axes":"X/Z/Y; normals explicitly outward; no runtime model engine", "triangles":counts, "road_rectangles":roads,"river_patches":[p for p in patches(-2000,2000) if p[-1]], "removed_invisible_triangles":scene.removed,"front_face":"CCW"},indent=2)+"\n") print("Packed native GL meshes:",counts) if __name__ == "__main__": main()