import { BoxGeometry, BufferGeometry, Float32BufferAttribute, Group, MathUtils, Mesh, MeshStandardMaterial, Vector3 } from 'three'; import type { Material } from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { STRUCTURES, STRUCTURE_ASSETS } from '../constants/structures.ts'; import { SUMMIT_RELIEF } from '../constants/summitRelief.ts'; import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts'; import { summitPosition } from '../controls/summitCoordinates.ts'; export type StructureAssets = Map; type SupportAnchor = { position: [number, number, number]; postWidth: number }; export async function loadStructures(includeRelief = false): Promise { const loader = new GLTFLoader(); const ids = [...STRUCTURES.towers.map(s => s.id), 'weather-radar', ...STRUCTURES.decks.map(s => s.id), ...(includeRelief ? [SUMMIT_RELIEF.assetId, SUMMIT_PLATEAU.marker.assetId] : [])]; return new Map(await Promise.all(ids.map(async id => { const model = await loader.loadAsync(STRUCTURE_ASSETS.baseUrl + '/' + id + '.glb'); return [id, model.scene] as const; }))); } /** Local WGS84 tangent approximation, sufficient for the < 1 km structure area. +z south. */ export function structurePosition(latitude: number, longitude: number, origin: { latitude: number; longitude: number }) { const { x, z } = summitPosition(latitude, longitude, origin); return new Vector3(x, 0, z); } export function createStructures(origin: { latitude: number; longitude: number }, groundAt: (x: number, z: number) => number, assets: StructureAssets) { const group = new Group(); group.name = 'Refined summit assets (estimated dimensions)'; const geometries = new Set(); const materials = new Set(); const wall = new MeshStandardMaterial({ color: STRUCTURE_ASSETS.foundationColor, roughness: 1 }); const rail = new MeshStandardMaterial({ color: STRUCTURE_ASSETS.supportColor, roughness: .86 }); materials.add(wall); materials.add(rail); function box(parent: Group, width: number, height: number, depth: number, x: number, y: number, z: number, material: Material) { const geometry = new BoxGeometry(width, height, depth); geometries.add(geometry); const result = new Mesh(geometry, material); result.position.set(x, y, z); parent.add(result); } function site(id: string, latitude: number, longitude: number) { const parent = new Group(); parent.name = id; parent.position.copy(structurePosition(latitude, longitude, origin)); parent.position.y = groundAt(parent.position.x, parent.position.z); const model = assets.get(id)!; model.traverse(object => { if (object instanceof Mesh) { geometries.add(object.geometry); (Array.isArray(object.material) ? object.material : [object.material]).forEach(m => materials.add(m)); if (object.userData.materialBatch === 'groove') { const material = object.material as Material; material.polygonOffset = true; material.polygonOffsetFactor = -1; material.polygonOffsetUnits = -1; } } }); parent.add(model); group.add(parent); return parent; } // Solid foundations span the local terrain relief instead of floating on a single sample. function foundation(parent: Group, width: number, depth: number) { const samples = [-width / 2, width / 2].flatMap(x => [-depth / 2, depth / 2].map(z => groundAt(parent.position.x + x, parent.position.z + z))); const top = Math.max(parent.position.y, ...samples) + .2; const bottom = Math.min(parent.position.y, ...samples) - .4; parent.position.y = top; box(parent, width, top - bottom, depth, 0, -(top - bottom) / 2, 0, wall); } for (const spec of STRUCTURES.towers) { const tower = site(spec.id, spec.latitude, spec.longitude); tower.userData = { source: spec.source, dimensionsEstimated: true, positionEstimated: spec.estimatedPosition }; foundation(tower, spec.width + 1, spec.width + 1); } const radarSpec = STRUCTURES.radar; const radar = site('weather-radar', radarSpec.latitude, radarSpec.longitude); radar.userData = { source: radarSpec.source, dimensionsEstimated: true, positionEstimated: true }; foundation(radar, 8, 8); for (const spec of STRUCTURES.decks) { const deck = site(spec.id, spec.latitude, spec.longitude); deck.rotation.y = MathUtils.degToRad(spec.heading); deck.userData = { source: spec.source, dimensionsEstimated: true, positionEstimated: true }; const modelRoot = assets.get(spec.id)!.getObjectByName(spec.id)!; const anchors = JSON.parse(modelRoot.userData.terrainSupportAnchorsJson) as SupportAnchor[]; const localGround = (x: number, z: number) => { const v = new Vector3(x, 0, z).applyAxisAngle(new Vector3(0, 1, 0), deck.rotation.y).add(deck.position); return groundAt(v.x, v.z); }; const bottomZ = -spec.length / 2 - spec.stairLength; const samples = [-spec.width / 2, spec.width / 2].flatMap(x => [-spec.length / 2, 0, spec.length / 2].map(z => localGround(x, z))); samples.push(localGround(-spec.width / 2, bottomZ) + spec.stairDrop, localGround(spec.width / 2, bottomZ) + spec.stairDrop); // Only the walking surface needs clearance; buried post feet must not lift the entire deck. deck.position.y = Math.max(...samples) + STRUCTURES.deck.clearance; const supports = new Group(); supports.name = 'terrain-supports'; deck.add(supports); for (const { position: [x, y, z], postWidth } of anchors) { const bottom = localGround(x, z) - deck.position.y - .1; const top = y + .02; if (top > bottom) box(supports, postWidth, top - bottom, postWidth, x, (top + bottom) / 2, z, rail); } let wood: Material = wall; modelRoot.traverse(object => { if (object instanceof Mesh && object.userData.materialBatch === 'wood0') wood = object.material as Material; }); function connectEnd(name: string, startZ: number, topY: number, direction: number) { const config = STRUCTURE_ASSETS.access; const access = new Group(); access.name = name; deck.add(access); const ground = (x: number, z: number) => localGround(x, z) - deck.position.y; let count = 1; let landingY = topY; for (; count <= config.maxSteps; count++) { const z = startZ + direction * count * config.treadDepth; landingY = Math.max(ground(-spec.width / 2, z), ground(0, z), ground(spec.width / 2, z)) + .02; if (Math.abs(topY - landingY) <= count * config.maxRiser) break; } count = Math.min(count, config.maxSteps); const rise = (topY - landingY) / count; for (let i = 0; i < count; i++) { const z = startZ + direction * (i + .5) * config.treadDepth; const y = topY - (i + 1) * rise; const bottom = Math.min(...[-spec.width / 2, spec.width / 2].flatMap(x => [-.5, .5].map(dz => ground(x, z + dz * config.treadDepth)))) - config.groundOverlap; // Closed footing beneath each tread reaches the slope instead of ending in mid-air. box(access, spec.width, Math.max(.02, y - .04 - bottom), config.treadDepth, 0, (y - .04 + bottom) / 2, z, wall); box(access, spec.width, .04, config.treadDepth + .002, 0, y - .02, z, wood); } const z0 = startZ + direction * count * config.treadDepth; const endZ = z0 + direction * config.landingLength; // The outer landing edge follows the cross-slope too, not just its highest corner. const vertices = [-spec.width / 2, spec.width / 2].flatMap(x => [x, landingY, z0, x, ground(x, endZ) + .02, endZ]); const geometry = new BufferGeometry(); geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3)); geometry.setIndex(direction > 0 ? [0, 1, 2, 2, 1, 3] : [0, 2, 1, 2, 3, 1]); geometry.computeVertexNormals(); geometries.add(geometry); access.add(new Mesh(geometry, wood)); access.userData = { startZ, endZ, topY, stepCount: count }; } if (STRUCTURE_ASSETS.access.deckIds.includes(spec.id)) { connectEnd('entry-access', spec.length / 2, 0, 1); connectEnd('exit-access', bottomZ, -spec.stairDrop, -1); } } return { group, dispose() { geometries.forEach(g => g.dispose()); materials.forEach(m => m.dispose()); } }; }