Spaces:
Running
Running
| // Earthquake House Collapse Simulator v3 | |
| // Fixed physics + realistic building appearance | |
| let scene, camera, renderer; | |
| let buildingParts = []; | |
| let isSimulating = false; | |
| let simulationTime = 0; | |
| let capturedImages = []; | |
| let rescueMarkers = []; | |
| let groundMesh, pathMesh; | |
| let cameraBasePos = { x: 0, y: 0, z: 0 }; | |
| let quakeDir = 0; // random earthquake primary direction each run | |
| let quakeSeed = 0; // random seed per simulation for variation | |
| let config = { | |
| buildingStyle: 'european', | |
| stories: 2, | |
| material: 'concrete', | |
| foundation: 'rock', | |
| magnitude: 7.0, | |
| distance: 5, | |
| duration: 15, | |
| buildingAge: 30 | |
| }; | |
| const materialProps = { | |
| concrete: { density: 2400, strength: 30, brittleness: 0.6, color: 0xd4cfc4, wallColor: 0xe8e4dc }, | |
| unreinforcedMasonry: { density: 1800, strength: 8, brittleness: 0.9, color: 0xc4956a, wallColor: 0xd4a574 }, | |
| wood: { density: 600, strength: 12, brittleness: 0.3, color: 0x8B6914, wallColor: 0xf5e6d0 }, | |
| steel: { density: 7800, strength: 250, brittleness: 0.2, color: 0x708090, wallColor: 0xdcdcdc }, | |
| adobe: { density: 1500, strength: 3, brittleness: 0.95, color: 0xc9a86c, wallColor: 0xd4b896 } | |
| }; | |
| const foundationFactors = { rock: 1.0, stiffSoil: 1.3, softSoil: 1.8, liquefiable: 2.5 }; | |
| const styleConfigs = { | |
| european: { roofType: 'pitched', roofColor: 0x3d3d3d, hasShutters: true }, | |
| chinese: { roofType: 'curved', roofColor: 0x4a4a4a, hasShutters: false }, | |
| japanese: { roofType: 'hip', roofColor: 0x2a3a5a, hasShutters: false }, | |
| american: { roofType: 'gable', roofColor: 0x4a4a4a, hasShutters: true }, | |
| middleEast: { roofType: 'flat', roofColor: 0xc9a86c, hasShutters: false } | |
| }; | |
| // ==================== SCENE SETUP ==================== | |
| function initScene() { | |
| const viewport = document.getElementById('viewport'); | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x87CEEB); | |
| scene.fog = new THREE.Fog(0x87CEEB, 60, 120); | |
| camera = new THREE.PerspectiveCamera(50, viewport.clientWidth / viewport.clientHeight, 0.1, 500); | |
| camera.position.set(18, 14, 18); | |
| camera.lookAt(0, 3, 0); | |
| renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); | |
| renderer.setSize(viewport.clientWidth, viewport.clientHeight); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| viewport.appendChild(renderer.domElement); | |
| // Lights | |
| scene.add(new THREE.AmbientLight(0x606060, 0.6)); | |
| const sun = new THREE.DirectionalLight(0xfff8e0, 1.0); | |
| sun.position.set(8, 15, 10); | |
| sun.castShadow = true; | |
| sun.shadow.mapSize.set(2048, 2048); | |
| sun.shadow.camera.left = -20; sun.shadow.camera.right = 20; | |
| sun.shadow.camera.top = 20; sun.shadow.camera.bottom = -20; | |
| scene.add(sun); | |
| scene.add(new THREE.HemisphereLight(0x87CEEB, 0x3a5f0b, 0.3)); | |
| // Ground | |
| groundMesh = new THREE.Mesh( | |
| new THREE.PlaneGeometry(80, 80), | |
| new THREE.MeshStandardMaterial({ color: 0x4a7c3f, roughness: 0.95 }) | |
| ); | |
| groundMesh.rotation.x = -Math.PI / 2; | |
| groundMesh.receiveShadow = true; | |
| scene.add(groundMesh); | |
| // Pathway | |
| pathMesh = new THREE.Mesh( | |
| new THREE.PlaneGeometry(2.5, 10), | |
| new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.9 }) | |
| ); | |
| pathMesh.rotation.x = -Math.PI / 2; | |
| pathMesh.position.set(0, 0.01, 8); | |
| scene.add(pathMesh); | |
| setupCameraControls(viewport); | |
| } | |
| function setupCameraControls(el) { | |
| let dragging = false; | |
| let prev = { x: 0, y: 0 }; | |
| let theta = Math.PI / 4; // horizontal angle | |
| let phi = Math.PI / 3.5; // vertical angle (0=top, PI/2=horizon) | |
| let radius = 25; | |
| let target = new THREE.Vector3(0, 3, 0); // orbit center | |
| function updateCameraOrbit() { | |
| camera.position.set( | |
| target.x + radius * Math.sin(phi) * Math.cos(theta), | |
| target.y + radius * Math.cos(phi), | |
| target.z + radius * Math.sin(phi) * Math.sin(theta) | |
| ); | |
| camera.lookAt(target); | |
| } | |
| // Expose for earthquake shaking to read current position | |
| window.getCameraOrbitPos = function() { | |
| return { | |
| x: target.x + radius * Math.sin(phi) * Math.cos(theta), | |
| y: target.y + radius * Math.cos(phi), | |
| z: target.z + radius * Math.sin(phi) * Math.sin(theta) | |
| }; | |
| }; | |
| window.resetCameraOrbit = function() { | |
| updateCameraOrbit(); | |
| }; | |
| el.addEventListener('mousedown', function(e) { | |
| if (e.target.tagName !== 'CANVAS') return; | |
| dragging = true; | |
| prev = { x: e.clientX, y: e.clientY }; | |
| e.preventDefault(); | |
| }); | |
| el.addEventListener('mousemove', function(e) { | |
| if (!dragging) return; | |
| var dx = e.clientX - prev.x; | |
| var dy = e.clientY - prev.y; | |
| prev = { x: e.clientX, y: e.clientY }; | |
| if (e.shiftKey || e.buttons === 4) { | |
| // Pan (shift+drag or middle mouse) | |
| var panSpeed = 0.03 * radius / 25; | |
| var right = new THREE.Vector3(); | |
| var up = new THREE.Vector3(0, 1, 0); | |
| right.crossVectors(camera.getWorldDirection(new THREE.Vector3()), up).normalize(); | |
| target.add(right.multiplyScalar(-dx * panSpeed)); | |
| target.y += dy * panSpeed; | |
| } else { | |
| // Orbit: horizontal drag rotates around Y axis | |
| theta += dx * 0.008; | |
| // Vertical drag: drag up = go higher, drag down = go lower | |
| phi = Math.max(0.2, Math.min(Math.PI / 2 - 0.05, phi - dy * 0.006)); | |
| } | |
| updateCameraOrbit(); | |
| }); | |
| el.addEventListener('mouseup', function() { dragging = false; }); | |
| el.addEventListener('mouseleave', function() { dragging = false; }); | |
| el.addEventListener('wheel', function(e) { | |
| if (e.target.tagName !== 'CANVAS') return; | |
| radius = Math.max(8, Math.min(55, radius + e.deltaY * 0.025)); | |
| updateCameraOrbit(); | |
| e.preventDefault(); | |
| }); | |
| // Touch support for mobile | |
| var lastTouch = null; | |
| el.addEventListener('touchstart', function(e) { | |
| if (e.touches.length === 1) { | |
| lastTouch = { x: e.touches[0].clientX, y: e.touches[0].clientY }; | |
| } | |
| }); | |
| el.addEventListener('touchmove', function(e) { | |
| if (!lastTouch || e.touches.length !== 1) return; | |
| var dx = e.touches[0].clientX - lastTouch.x; | |
| var dy = e.touches[0].clientY - lastTouch.y; | |
| lastTouch = { x: e.touches[0].clientX, y: e.touches[0].clientY }; | |
| theta += dx * 0.008; | |
| phi = Math.max(0.2, Math.min(Math.PI / 2 - 0.05, phi - dy * 0.006)); | |
| updateCameraOrbit(); | |
| e.preventDefault(); | |
| }); | |
| el.addEventListener('touchend', function() { lastTouch = null; }); | |
| updateCameraOrbit(); | |
| } | |
| // ==================== BUILD HOUSE ==================== | |
| function buildHouse() { | |
| clearAll(); | |
| const style = styleConfigs[config.buildingStyle]; | |
| const mat = materialProps[config.material]; | |
| const stories = parseInt(config.stories); | |
| const H = 3.0, W = 10, D = 7; | |
| // Foundation (never collapses) | |
| addBlock(0, 0.15, 0, W+0.4, 0.3, D+0.4, 0x555555, 'foundation', -1); | |
| // Build each story | |
| for (let f = 0; f < stories; f++) { | |
| const by = f * H + 0.3; | |
| buildStory(by, H, W, D, mat, style, f); | |
| } | |
| // Roof | |
| buildRoof(stories * H + 0.3, W, D, style); | |
| // Exterior details | |
| buildDetails(W, D, stories, H, style); | |
| // Room layout for rescue | |
| var layout = { | |
| groundFloor: [ | |
| { name: 'Living Room', x: -2, z: 0, w: 4, d: 5 }, | |
| { name: 'Kitchen', x: 3, z: -1, w: 3, d: 3 }, | |
| { name: 'Bathroom', x: 3, z: 2, w: 2, d: 2 } | |
| ], | |
| floors: [] | |
| }; | |
| for (var fi = 1; fi < stories; fi++) { | |
| layout.floors.push([ | |
| { name: 'Bedroom ' + fi, x: -2, z: 0, w: 4, d: 4, floor: fi }, | |
| { name: (fi === 1 ? 'Master Bedroom' : 'Bedroom ' + (fi + 1)), x: 3, z: 0, w: 3, d: 3, floor: fi }, | |
| { name: 'Hallway F' + (fi + 1), x: 0.5, z: 3, w: 8, d: 1.5, floor: fi } | |
| ]); | |
| } | |
| window.roomLayout = layout; | |
| } | |
| function buildStory(by, H, W, D, mat, style, floorIdx) { | |
| if (config.buildingStyle === 'chinese') { | |
| buildChineseStory(by, H, W, D, floorIdx); | |
| } else if (config.buildingStyle === 'japanese') { | |
| buildJapaneseStory(by, H, W, D, floorIdx); | |
| } else if (config.buildingStyle === 'american') { | |
| buildAmericanStory(by, H, W, D, floorIdx); | |
| } else { | |
| buildWesternStory(by, H, W, D, mat, style, floorIdx); | |
| } | |
| } | |
| function buildChineseStory(by, H, W, D, floorIdx) { | |
| var colColor = 0xaa2222; // vermilion red columns | |
| var beamColor = 0x6b2020; // dark red beams | |
| var latticeColor = 0x1a5c5c; // teal/green lattice panels | |
| var baseColor = 0x888888; // gray stone base | |
| var bandColor = 0x1a7a5c; // green decorative band | |
| var colR = 0.2; // column radius (using box) | |
| var colSpacing = W / 4; // 5 columns across front | |
| // Stone platform base (for ground floor only) | |
| if (floorIdx === 0) { | |
| addBlock(0, by + 0.15, 0, W + 1.0, 0.3, D + 1.0, baseColor, 'foundation', -1); | |
| } | |
| // Floor slab | |
| if (floorIdx > 0) { | |
| addBlock(0, by + 0.1, 0, W + 0.5, 0.2, D + 0.5, 0x777777, 'floor', floorIdx); | |
| } | |
| // ---- COLUMNS (the main structural system) ---- | |
| // Front and back rows of columns | |
| for (var i = 0; i <= 4; i++) { | |
| var cx = -W/2 + i * colSpacing; | |
| // Front columns | |
| addBlock(cx, by + H/2, D/2, colR*2, H, colR*2, colColor, 'column', floorIdx); | |
| // Back columns | |
| addBlock(cx, by + H/2, -D/2, colR*2, H, colR*2, colColor, 'column', floorIdx); | |
| } | |
| // Side columns (middle) | |
| addBlock(-W/2, by + H/2, 0, colR*2, H, colR*2, colColor, 'column', floorIdx); | |
| addBlock(W/2, by + H/2, 0, colR*2, H, colR*2, colColor, 'column', floorIdx); | |
| // ---- BEAMS (horizontal, connecting columns at top) ---- | |
| // Front beam | |
| addBlock(0, by + H - 0.15, D/2, W, 0.3, colR*2, beamColor, 'beam', floorIdx); | |
| // Back beam | |
| addBlock(0, by + H - 0.15, -D/2, W, 0.3, colR*2, beamColor, 'beam', floorIdx); | |
| // Side beams | |
| addBlock(-W/2, by + H - 0.15, 0, colR*2, 0.3, D, beamColor, 'beam', floorIdx); | |
| addBlock(W/2, by + H - 0.15, 0, colR*2, 0.3, D, beamColor, 'beam', floorIdx); | |
| // ---- DECORATIVE BAND (green/teal) under beams ---- | |
| addBlock(0, by + H - 0.45, D/2, W, 0.2, 0.05, bandColor, 'trim', floorIdx); | |
| addBlock(0, by + H - 0.45, -D/2, W, 0.2, 0.05, bandColor, 'trim', floorIdx); | |
| // ---- LATTICE PANELS between columns (front) ---- | |
| for (var i = 0; i < 4; i++) { | |
| var px = -W/2 + i * colSpacing + colSpacing/2; | |
| var panelH = H * 0.7; | |
| var panelY = by + panelH/2 + 0.3; | |
| if (floorIdx === 0 && i === 2) { | |
| // Door opening in center | |
| addBlock(px, by + 1.2, D/2, colSpacing - colR*3, 2.4, 0.08, 0x8b3a1a, 'door', floorIdx); | |
| } else { | |
| // Lattice window panel | |
| addBlock(px, panelY, D/2, colSpacing - colR*3, panelH, 0.06, latticeColor, 'lattice', floorIdx); | |
| // Small bottom wall panel (kickplate) | |
| addBlock(px, by + 0.15, D/2, colSpacing - colR*3, 0.3, 0.1, baseColor, 'front_wall', floorIdx); | |
| } | |
| } | |
| // ---- LATTICE PANELS (back wall - more solid) ---- | |
| for (var i = 0; i < 4; i++) { | |
| var px = -W/2 + i * colSpacing + colSpacing/2; | |
| addBlock(px, by + H*0.4, -D/2, colSpacing - colR*3, H*0.7, 0.15, 0xd4a574, 'back_wall', floorIdx); | |
| } | |
| // ---- SIDE WALLS (solid with small windows) ---- | |
| addBlock(-W/2, by + H/2, 0, 0.15, H, D - 0.5, 0xd4a574, 'left_wall', floorIdx); | |
| addBlock(W/2, by + H/2, 0, 0.15, H, D - 0.5, 0xd4a574, 'right_wall', floorIdx); | |
| // Ceiling/floor beams (cross beams visible from below) | |
| addBlock(0, by + H - 0.05, 0, W, 0.1, D, 0x8b5a2b, 'ceiling', floorIdx); | |
| } | |
| function buildJapaneseStory(by, H, W, D, floorIdx) { | |
| var frameColor = 0x3d2b1f; // dark brown timber frame | |
| var shojiColor = 0xf5e8d0; // warm cream shoji panels | |
| var wallColor = 0xe8dcc8; // light plaster walls | |
| var balconyColor = 0x6b4c2a; // medium brown wood | |
| var colR = 0.15; | |
| var colSpacing = W / 5; // 6 columns across | |
| // Floor platform | |
| if (floorIdx === 0) { | |
| addBlock(0, by + 0.1, 0, W + 0.6, 0.2, D + 0.6, 0x5c4030, 'floor', floorIdx); | |
| } else { | |
| addBlock(0, by + 0.1, 0, W + 0.8, 0.15, D + 0.8, balconyColor, 'floor', floorIdx); | |
| } | |
| // ---- COLUMNS (dark timber frame) ---- | |
| for (var i = 0; i <= 5; i++) { | |
| var cx = -W/2 + i * colSpacing; | |
| addBlock(cx, by + H/2, D/2, colR*2, H, colR*2, frameColor, 'column', floorIdx); | |
| addBlock(cx, by + H/2, -D/2, colR*2, H, colR*2, frameColor, 'column', floorIdx); | |
| } | |
| // Corner and middle side columns | |
| addBlock(-W/2, by + H/2, 0, colR*2, H, colR*2, frameColor, 'column', floorIdx); | |
| addBlock(W/2, by + H/2, 0, colR*2, H, colR*2, frameColor, 'column', floorIdx); | |
| // ---- HORIZONTAL BEAMS (dark frame) ---- | |
| // Top beam (front/back) | |
| addBlock(0, by + H - 0.1, D/2, W, 0.2, colR*2, frameColor, 'beam', floorIdx); | |
| addBlock(0, by + H - 0.1, -D/2, W, 0.2, colR*2, frameColor, 'beam', floorIdx); | |
| // Mid-height beam (front) - nageshi | |
| addBlock(0, by + H*0.65, D/2, W, 0.1, colR, frameColor, 'beam', floorIdx); | |
| // Bottom rail | |
| addBlock(0, by + 0.25, D/2, W, 0.1, colR, frameColor, 'beam', floorIdx); | |
| // Side beams | |
| addBlock(-W/2, by + H - 0.1, 0, colR*2, 0.2, D, frameColor, 'beam', floorIdx); | |
| addBlock(W/2, by + H - 0.1, 0, colR*2, 0.2, D, frameColor, 'beam', floorIdx); | |
| // ---- SHOJI / WALL PANELS (front) ---- | |
| for (var i = 0; i < 5; i++) { | |
| var px = -W/2 + i * colSpacing + colSpacing/2; | |
| var panelH = H * 0.62; | |
| var panelY = by + 0.3 + panelH/2; | |
| if (floorIdx === 0 && (i === 2 || i === 3)) { | |
| // Sliding door opening - translucent shoji | |
| var shojiMat = new THREE.MeshStandardMaterial({ | |
| color: 0xfff8e8, transparent: true, opacity: 0.6, roughness: 0.9, side: THREE.DoubleSide | |
| }); | |
| var shoji = new THREE.Mesh(new THREE.BoxGeometry(colSpacing - colR*3, panelH, 0.04), shojiMat); | |
| shoji.position.set(px, panelY, D/2); | |
| shoji.castShadow = true; shoji.receiveShadow = true; | |
| scene.add(shoji); | |
| buildingParts.push(makePart(shoji, 'shoji', floorIdx)); | |
| // Shoji grid lines (horizontal) | |
| addBlock(px, panelY, D/2 + 0.025, colSpacing - colR*3, 0.02, 0.01, frameColor, 'shoji_frame', floorIdx); | |
| addBlock(px, panelY - panelH*0.3, D/2 + 0.025, colSpacing - colR*3, 0.02, 0.01, frameColor, 'shoji_frame', floorIdx); | |
| addBlock(px, panelY + panelH*0.3, D/2 + 0.025, colSpacing - colR*3, 0.02, 0.01, frameColor, 'shoji_frame', floorIdx); | |
| } else { | |
| // Upper portion: translucent shoji or window | |
| var upperH = panelH * 0.55; | |
| var upperY = by + H*0.65 - upperH/2 - 0.1; | |
| var shojiMat2 = new THREE.MeshStandardMaterial({ | |
| color: shojiColor, transparent: true, opacity: 0.7, roughness: 0.95 | |
| }); | |
| var panel = new THREE.Mesh(new THREE.BoxGeometry(colSpacing - colR*3, upperH, 0.04), shojiMat2); | |
| panel.position.set(px, upperY, D/2); | |
| panel.castShadow = true; scene.add(panel); | |
| buildingParts.push(makePart(panel, 'shoji', floorIdx)); | |
| // Lower portion: opaque wall panel | |
| var lowerH = panelH * 0.4; | |
| addBlock(px, by + 0.3 + lowerH/2, D/2, colSpacing - colR*3, lowerH, 0.08, wallColor, 'front_wall', floorIdx); | |
| } | |
| } | |
| // ---- BACK WALL (more solid) ---- | |
| addBlock(0, by + H/2, -D/2, W, H*0.9, 0.12, wallColor, 'back_wall', floorIdx); | |
| // ---- SIDE WALLS (solid plaster with dark frame band at top) ---- | |
| addBlock(-W/2, by + H*0.45, 0, 0.12, H*0.8, D - 0.5, wallColor, 'left_wall', floorIdx); | |
| addBlock(W/2, by + H*0.45, 0, 0.12, H*0.8, D - 0.5, wallColor, 'right_wall', floorIdx); | |
| // Dark band at top of side walls | |
| addBlock(-W/2, by + H*0.85, 0, 0.14, H*0.12, D - 0.3, frameColor, 'trim', floorIdx); | |
| addBlock(W/2, by + H*0.85, 0, 0.14, H*0.12, D - 0.3, frameColor, 'trim', floorIdx); | |
| // ---- ENGAWA (veranda/balcony) on upper floors ---- | |
| if (floorIdx > 0) { | |
| // Balcony floor extending out front | |
| addBlock(0, by + 0.05, D/2 + 0.5, W + 0.4, 0.08, 1.0, balconyColor, 'balcony', floorIdx); | |
| // Railing | |
| addBlock(0, by + 0.7, D/2 + 0.95, W + 0.3, 0.05, 0.05, balconyColor, 'railing', floorIdx); | |
| // Railing posts | |
| for (var i = 0; i <= 4; i++) { | |
| addBlock(-W/2 + i*(W/4), by + 0.35, D/2 + 0.95, 0.06, 0.6, 0.06, balconyColor, 'railing', floorIdx); | |
| } | |
| } | |
| // Ceiling | |
| addBlock(0, by + H - 0.05, 0, W, 0.1, D, 0x5c4a3a, 'ceiling', floorIdx); | |
| } | |
| function buildAmericanStory(by, H, W, D, floorIdx) { | |
| var siding = 0xf0ece4; // white/cream clapboard siding | |
| var trimColor = 0xffffff; // bright white trim | |
| var windowFrame = 0x2a2a2a; // dark/black window frames | |
| var doorColor = 0x1a3a4a; // dark teal/navy door | |
| var t = 0.2; // wall thickness | |
| var winH = 1.6; // window height | |
| var winW = 1.1; // window width | |
| var winBot = H * 0.3; // window bottom from floor | |
| // Floor | |
| if (floorIdx > 0) { | |
| addBlock(0, by + 0.1, 0, W, 0.2, D, 0x999999, 'floor', floorIdx); | |
| } | |
| // ---- FRONT WALL (+Z) ---- | |
| if (floorIdx === 0) { | |
| // Build wall with door opening and 2 window openings | |
| var doorW = 1.3, doorH = 2.3; | |
| // Left of left window | |
| addBlock(-W/2 + 0.7, by + H/2, D/2, 1.4, H, t, siding, 'front_wall', floorIdx); | |
| // Between left window and door | |
| addBlock(-1.0, by + H/2, D/2, 1.0, H, t, siding, 'front_wall', floorIdx); | |
| // Between door and right window | |
| addBlock(1.0, by + H/2, D/2, 1.0, H, t, siding, 'front_wall', floorIdx); | |
| // Right of right window | |
| addBlock(W/2 - 0.7, by + H/2, D/2, 1.4, H, t, siding, 'front_wall', floorIdx); | |
| // Above door | |
| addBlock(0, by + doorH + 0.35, D/2, doorW + 0.2, H - doorH - 0.2, t, siding, 'front_wall', floorIdx); | |
| // Above left window | |
| addBlock(-2.5, by + winBot + winH + (H - winBot - winH)/2, D/2, winW + 0.3, H - winBot - winH, t, siding, 'front_wall', floorIdx); | |
| // Below left window | |
| addBlock(-2.5, by + winBot/2, D/2, winW + 0.3, winBot, t, siding, 'front_wall', floorIdx); | |
| // Above right window | |
| addBlock(2.5, by + winBot + winH + (H - winBot - winH)/2, D/2, winW + 0.3, H - winBot - winH, t, siding, 'front_wall', floorIdx); | |
| // Below right window | |
| addBlock(2.5, by + winBot/2, D/2, winW + 0.3, winBot, t, siding, 'front_wall', floorIdx); | |
| // Door | |
| addBlock(0, by + doorH/2, D/2 + 0.05, doorW, doorH, 0.08, doorColor, 'door', floorIdx); | |
| // Windows (visible because wall has openings) | |
| addBlock(-2.5, by + winBot + winH/2, D/2 + 0.05, winW, winH, 0.05, 0x6daed0, 'glass', floorIdx); | |
| addBlock(2.5, by + winBot + winH/2, D/2 + 0.05, winW, winH, 0.05, 0x6daed0, 'glass', floorIdx); | |
| // Window frames (dark) | |
| addBlock(-2.5, by + winBot + winH/2, D/2 + 0.1, winW + 0.15, winH + 0.15, 0.03, windowFrame, 'trim', floorIdx); | |
| addBlock(2.5, by + winBot + winH/2, D/2 + 0.1, winW + 0.15, winH + 0.15, 0.03, windowFrame, 'trim', floorIdx); | |
| // Window sills (white) | |
| addBlock(-2.5, by + winBot - 0.05, D/2 + 0.12, winW + 0.3, 0.08, 0.12, trimColor, 'trim', floorIdx); | |
| addBlock(2.5, by + winBot - 0.05, D/2 + 0.12, winW + 0.3, 0.08, 0.12, trimColor, 'trim', floorIdx); | |
| } else { | |
| // Upper floor front wall with 3 windows | |
| var winPositions = [-2.5, 0, 2.5]; | |
| // Wall segments between windows | |
| addBlock(-W/2 + 0.5, by + H/2, D/2, 1.0, H, t, siding, 'front_wall', floorIdx); | |
| addBlock(-1.25, by + H/2, D/2, 1.3, H, t, siding, 'front_wall', floorIdx); | |
| addBlock(1.25, by + H/2, D/2, 1.3, H, t, siding, 'front_wall', floorIdx); | |
| addBlock(W/2 - 0.5, by + H/2, D/2, 1.0, H, t, siding, 'front_wall', floorIdx); | |
| // Above and below each window | |
| winPositions.forEach(function(wx) { | |
| addBlock(wx, by + winBot/2, D/2, winW + 0.1, winBot, t, siding, 'front_wall', floorIdx); | |
| addBlock(wx, by + winBot + winH + (H - winBot - winH)/2, D/2, winW + 0.1, H - winBot - winH, t, siding, 'front_wall', floorIdx); | |
| // Glass | |
| addBlock(wx, by + winBot + winH/2, D/2 + 0.05, winW, winH, 0.05, 0x6daed0, 'glass', floorIdx); | |
| // Frame | |
| addBlock(wx, by + winBot + winH/2, D/2 + 0.1, winW + 0.12, winH + 0.12, 0.03, windowFrame, 'trim', floorIdx); | |
| // Sill | |
| addBlock(wx, by + winBot - 0.05, D/2 + 0.12, winW + 0.25, 0.08, 0.1, trimColor, 'trim', floorIdx); | |
| // Mullion (cross divider) | |
| addBlock(wx, by + winBot + winH/2, D/2 + 0.12, 0.04, winH, 0.02, windowFrame, 'trim', floorIdx); | |
| addBlock(wx, by + winBot + winH/2, D/2 + 0.12, winW, 0.04, 0.02, windowFrame, 'trim', floorIdx); | |
| }); | |
| } | |
| // ---- BACK WALL (-Z) with 2 windows ---- | |
| addBlock(-W/2 + 1.2, by + H/2, -D/2, 2.4, H, t, siding, 'back_wall', floorIdx); | |
| addBlock(0, by + H/2, -D/2, W - 5.6, H, t, siding, 'back_wall', floorIdx); | |
| addBlock(W/2 - 1.2, by + H/2, -D/2, 2.4, H, t, siding, 'back_wall', floorIdx); | |
| addBlock(-2.2, by + winBot/2, -D/2, winW + 0.1, winBot, t, siding, 'back_wall', floorIdx); | |
| addBlock(-2.2, by + winBot + winH + (H-winBot-winH)/2, -D/2, winW+0.1, H-winBot-winH, t, siding, 'back_wall', floorIdx); | |
| addBlock(2.2, by + winBot/2, -D/2, winW + 0.1, winBot, t, siding, 'back_wall', floorIdx); | |
| addBlock(2.2, by + winBot + winH + (H-winBot-winH)/2, -D/2, winW+0.1, H-winBot-winH, t, siding, 'back_wall', floorIdx); | |
| addBlock(-2.2, by + winBot + winH/2, -D/2 - 0.05, winW, winH, 0.05, 0x6daed0, 'glass', floorIdx); | |
| addBlock(2.2, by + winBot + winH/2, -D/2 - 0.05, winW, winH, 0.05, 0x6daed0, 'glass', floorIdx); | |
| // ---- SIDE WALLS with windows ---- | |
| // Left wall: 2 window openings | |
| addBlock(-W/2, by + H/2, -D/2 + 1.0, t, H, 2.0, siding, 'left_wall', floorIdx); | |
| addBlock(-W/2, by + H/2, 0, t, H, 1.0, siding, 'left_wall', floorIdx); | |
| addBlock(-W/2, by + H/2, D/2 - 1.0, t, H, 2.0, siding, 'left_wall', floorIdx); | |
| addBlock(-W/2, by + winBot/2, -1.5, t, winBot, 1.2, siding, 'left_wall', floorIdx); | |
| addBlock(-W/2, by + winBot+winH+(H-winBot-winH)/2, -1.5, t, H-winBot-winH, 1.2, siding, 'left_wall', floorIdx); | |
| addBlock(-W/2, by + winBot/2, 1.5, t, winBot, 1.2, siding, 'left_wall', floorIdx); | |
| addBlock(-W/2, by + winBot+winH+(H-winBot-winH)/2, 1.5, t, H-winBot-winH, 1.2, siding, 'left_wall', floorIdx); | |
| addBlock(-W/2 - 0.05, by + winBot + winH/2, -1.5, 0.05, winH, 1.0, 0x6daed0, 'glass', floorIdx); | |
| addBlock(-W/2 - 0.05, by + winBot + winH/2, 1.5, 0.05, winH, 1.0, 0x6daed0, 'glass', floorIdx); | |
| // Right wall: same pattern | |
| addBlock(W/2, by + H/2, -D/2 + 1.0, t, H, 2.0, siding, 'right_wall', floorIdx); | |
| addBlock(W/2, by + H/2, 0, t, H, 1.0, siding, 'right_wall', floorIdx); | |
| addBlock(W/2, by + H/2, D/2 - 1.0, t, H, 2.0, siding, 'right_wall', floorIdx); | |
| addBlock(W/2, by + winBot/2, -1.5, t, winBot, 1.2, siding, 'right_wall', floorIdx); | |
| addBlock(W/2, by + winBot+winH+(H-winBot-winH)/2, -1.5, t, H-winBot-winH, 1.2, siding, 'right_wall', floorIdx); | |
| addBlock(W/2, by + winBot/2, 1.5, t, winBot, 1.2, siding, 'right_wall', floorIdx); | |
| addBlock(W/2, by + winBot+winH+(H-winBot-winH)/2, 1.5, t, H-winBot-winH, 1.2, siding, 'right_wall', floorIdx); | |
| addBlock(W/2 + 0.05, by + winBot + winH/2, -1.5, 0.05, winH, 1.0, 0x6daed0, 'glass', floorIdx); | |
| addBlock(W/2 + 0.05, by + winBot + winH/2, 1.5, 0.05, winH, 1.0, 0x6daed0, 'glass', floorIdx); | |
| // ---- WHITE CORNER TRIM ---- | |
| addBlock(-W/2 - 0.08, by + H/2, D/2, 0.12, H, 0.12, trimColor, 'trim', floorIdx); | |
| addBlock(W/2 + 0.08, by + H/2, D/2, 0.12, H, 0.12, trimColor, 'trim', floorIdx); | |
| addBlock(-W/2 - 0.08, by + H/2, -D/2, 0.12, H, 0.12, trimColor, 'trim', floorIdx); | |
| addBlock(W/2 + 0.08, by + H/2, -D/2, 0.12, H, 0.12, trimColor, 'trim', floorIdx); | |
| // Internal wall | |
| addBlock(0, by + H/2, -D/6, 0.15, H, D*0.5, siding, 'int_wall', floorIdx); | |
| // Ceiling | |
| addBlock(0, by + H - 0.1, 0, W, 0.15, D, 0x999999, 'ceiling', floorIdx); | |
| } | |
| function buildWesternStory(by, H, W, D, mat, style, floorIdx) { | |
| const wc = mat.wallColor; | |
| const t = 0.3; | |
| if (floorIdx === 0) { | |
| addBlock(-W/2 + 1.5, by + H/2, D/2, W/2 - 1.5, H, t, wc, 'front_wall', floorIdx); | |
| addBlock(1.5, by + H/2, D/2, W/2 - 1.5, H, t, wc, 'front_wall', floorIdx); | |
| addBlock(0, by + 2.6, D/2, 2.0, 0.4, t, wc, 'front_wall', floorIdx); | |
| addBlock(0, by + 1.15, D/2 + 0.05, 1.8, 2.3, 0.08, 0x5c3a1e, 'door', floorIdx); | |
| } else { | |
| buildWallWithWindows(-W/2, by, D/2, W, H, t, wc, 'front_wall', floorIdx, 3, true); | |
| } | |
| buildWallWithWindows(-W/2, by, -D/2 - t, W, H, t, wc, 'back_wall', floorIdx, 3, true); | |
| buildWallWithWindows(-W/2 - t, by, -D/2, t, H, D, wc, 'left_wall', floorIdx, 2, false); | |
| buildWallWithWindows(W/2, by, -D/2, t, H, D, wc, 'right_wall', floorIdx, 2, false); | |
| addBlock(0, by + H/2, -D/6, 0.2, H, D*0.5, wc, 'int_wall', floorIdx); | |
| if (floorIdx > 0) { | |
| addBlock(0, by + 0.1, 0, W, 0.2, D, 0x999999, 'floor', floorIdx); | |
| } | |
| addBlock(0, by + H - 0.1, 0, W, 0.2, D, 0x999999, 'ceiling', floorIdx); | |
| } | |
| function buildWallWithWindows(x, y, z, w, h, d, color, label, floor, numWin, isXWall) { | |
| // Wall runs along X (isXWall=true) or along Z (isXWall=false) | |
| const len = isXWall ? w : d; | |
| const sections = numWin * 2 + 1; | |
| const secLen = len / sections; | |
| const winH = 1.3, winBottom = h * 0.35; | |
| for (let i = 0; i < sections; i++) { | |
| const isWin = (i % 2 === 1); | |
| if (isXWall) { | |
| const sx = x + i * secLen; | |
| const cx = sx + secLen / 2; | |
| const cz = z + d / 2; | |
| if (isWin) { | |
| // Below window | |
| addBlock(cx, y + winBottom/2, cz, secLen*0.98, winBottom, d, color, label, floor); | |
| // Above window | |
| const aboveH = h - winBottom - winH; | |
| addBlock(cx, y + winBottom + winH + aboveH/2, cz, secLen*0.98, aboveH, d, color, label, floor); | |
| // Window glass | |
| addBlock(cx, y + winBottom + winH/2, cz, secLen*0.7, winH*0.9, 0.03, 0x88ccdd, 'glass', floor); | |
| } else { | |
| addBlock(cx, y + h/2, cz, secLen*0.98, h*0.98, d, color, label, floor); | |
| } | |
| } else { | |
| const sz = z + i * secLen; | |
| const cz = sz + secLen / 2; | |
| const cx = x + w / 2; | |
| if (isWin) { | |
| addBlock(cx, y + winBottom/2, cz, w, winBottom, secLen*0.98, color, label, floor); | |
| const aboveH = h - winBottom - winH; | |
| addBlock(cx, y + winBottom + winH + aboveH/2, cz, w, aboveH, secLen*0.98, color, label, floor); | |
| addBlock(cx, y + winBottom + winH/2, cz, 0.03, winH*0.9, secLen*0.7, 0x88ccdd, 'glass', floor); | |
| } else { | |
| addBlock(cx, y + h/2, cz, w, h*0.98, secLen*0.98, color, label, floor); | |
| } | |
| } | |
| } | |
| } | |
| function buildRoof(baseY, W, D, style) { | |
| const rc = style.roofColor; | |
| const oh = 0.8; // overhang | |
| if (style.roofType === 'flat') { | |
| addBlock(0, baseY + 0.15, 0, W + oh*2, 0.3, D + oh*2, rc, 'roof', 2); | |
| } else if (style.roofType === 'pitched' || style.roofType === 'gable') { | |
| const rh = 2.5, segs = 8; | |
| for (let side = -1; side <= 1; side += 2) { | |
| for (let i = 0; i < segs; i++) { | |
| const t = (i + 0.5) / segs; | |
| const panelD = (D/2 + oh) / segs; | |
| const py = baseY + rh * (1 - t); | |
| const pz = side * t * (D/2 + oh); | |
| const angle = side * Math.atan2(rh, D/2 + oh); | |
| const m = createBoxMesh(W + oh*2, 0.12, panelD, rc); | |
| m.position.set(0, py, pz); | |
| m.rotation.x = angle; | |
| m.castShadow = true; m.receiveShadow = true; | |
| scene.add(m); | |
| buildingParts.push(makePart(m, 'roof', 2)); | |
| } | |
| } | |
| // Ridge | |
| addBlock(0, baseY + rh, 0, W + oh*2, 0.15, 0.15, 0x4a3728, 'roof', 2); | |
| } else if (style.roofType === 'hip') { | |
| // Japanese hip roof (irimoya-yane): deep blue-gray tiles, wide overhang, gentle slope | |
| const rh = 2.2; | |
| const roofW = W + 2.5; // wide overhang | |
| const roofD = D + 2.0; | |
| const segs = 8; | |
| const tileColor = 0x2a3a5a; // deep blue-gray | |
| // Front and back slopes | |
| for (let side = -1; side <= 1; side += 2) { | |
| for (let i = 0; i < segs; i++) { | |
| const t = (i + 0.5) / segs; | |
| const panelD = (roofD/2) / segs; | |
| // Slight concave curve for elegance | |
| const curveT = Math.pow(t, 0.85); | |
| const py = baseY + rh * (1 - curveT); | |
| const pz = side * t * (roofD/2); | |
| const panelW = roofW * (1 - t * 0.25); // narrows toward top | |
| const m = createBoxMesh(panelW, 0.1, panelD, tileColor); | |
| m.position.set(0, py, pz); | |
| m.rotation.x = side * Math.atan2(rh, roofD/2) * 0.85; | |
| m.castShadow = true; m.receiveShadow = true; | |
| scene.add(m); | |
| buildingParts.push(makePart(m, 'roof', 2)); | |
| } | |
| } | |
| // Ridge | |
| addBlock(0, baseY + rh + 0.05, 0, roofW * 0.6, 0.15, 0.15, 0x333333, 'roof', 2); | |
| // Ridge end ornament (鬼瓦 onigawara) | |
| addBlock(0, baseY + rh + 0.2, 0, 0.3, 0.3, 0.3, 0x333333, 'ornament', 2); | |
| // Fascia boards (dark trim at eave edges) | |
| addBlock(0, baseY + 0.1, roofD/2 - 0.1, roofW, 0.15, 0.08, 0x2a1f14, 'trim', 2); | |
| addBlock(0, baseY + 0.1, -roofD/2 + 0.1, roofW, 0.15, 0.08, 0x2a1f14, 'trim', 2); | |
| } else if (style.roofType === 'curved') { | |
| // Chinese traditional roof: solid curved surface with wide overhang | |
| const rh = 2.2; | |
| const ovW = W + 3.5; // generous side overhang | |
| const ovD = D + 3.0; // generous front/back overhang | |
| const slopeSegs = 5; // fewer, wider segments for solid look | |
| // Two slope sides (front slope and back slope) | |
| for (let side = -1; side <= 1; side += 2) { | |
| for (let i = 0; i < slopeSegs; i++) { | |
| const t0 = i / slopeSegs; | |
| const t1 = (i + 1) / slopeSegs; | |
| const tMid = (t0 + t1) / 2; | |
| // Concave curve: drops quickly at first, flattens, then kicks up at eave | |
| const y0 = rh * (1 - Math.pow(t0, 0.7)); | |
| const y1 = rh * (1 - Math.pow(t1, 0.7)); | |
| // Eave upturn for last segment | |
| const upturn = i === slopeSegs - 1 ? 0.3 : 0; | |
| const segDepth = (ovD / 2) / slopeSegs; | |
| const segY = baseY + (y0 + y1) / 2 + upturn; | |
| const segZ = side * tMid * (ovD / 2); | |
| const segW = ovW * (1 - tMid * 0.08); // slightly narrows toward ridge | |
| // Angle: slope of the curve at this point | |
| const angle = side * Math.atan2(y0 - y1 + upturn * 2, segDepth); | |
| const m = createBoxMesh(segW, 0.15, segDepth * 1.05, 0x4a4a4a); | |
| m.position.set(0, segY, segZ); | |
| m.rotation.x = angle; | |
| m.castShadow = true; m.receiveShadow = true; | |
| scene.add(m); | |
| buildingParts.push(makePart(m, 'roof', 2)); | |
| } | |
| } | |
| // Ridge beam on top (thick, decorative) | |
| addBlock(0, baseY + rh + 0.15, 0, ovW * 0.85, 0.25, 0.3, 0x3a3a3a, 'roof', 2); | |
| // Ridge end ornaments (chiwen / dragon tail) | |
| addBlock(-ovW * 0.42, baseY + rh + 0.35, 0, 0.3, 0.4, 0.3, 0x333333, 'ornament', 2); | |
| addBlock(ovW * 0.42, baseY + rh + 0.35, 0, 0.3, 0.4, 0.3, 0x333333, 'ornament', 2); | |
| // Red fascia/trim board under the eave edge (front and back) | |
| addBlock(0, baseY - 0.1, ovD/2 - 0.2, ovW, 0.2, 0.12, 0xaa2222, 'trim', 2); | |
| addBlock(0, baseY - 0.1, -ovD/2 + 0.2, ovW, 0.2, 0.12, 0xaa2222, 'trim', 2); | |
| // Side trim | |
| addBlock(-ovW/2 + 0.1, baseY - 0.1, 0, 0.12, 0.2, ovD, 0xaa2222, 'trim', 2); | |
| addBlock(ovW/2 - 0.1, baseY - 0.1, 0, 0.12, 0.2, ovD, 0xaa2222, 'trim', 2); | |
| } | |
| } | |
| function buildDetails(W, D, stories, H, style) { | |
| if (config.buildingStyle === 'chinese') { | |
| buildChineseDetails(W, D, stories, H); | |
| } else if (config.buildingStyle === 'japanese') { | |
| buildJapaneseDetails(W, D, stories, H); | |
| } else if (config.buildingStyle === 'american') { | |
| buildAmericanDetails(W, D, stories, H); | |
| } else { | |
| buildWesternDetails(W, D, stories, H, style); | |
| } | |
| } | |
| function buildChineseDetails(W, D, stories, H) { | |
| // Raised stone platform steps (wider, grander) | |
| for (var i = 0; i < 4; i++) { | |
| addBlock(0, 0.3 - i*0.08, D/2 + 0.6 + i*0.35, W*0.4, 0.1, 0.4, 0x888888, 'steps', -1); | |
| } | |
| // Red lanterns hanging from front beam | |
| var lanternColor = 0xff2200; | |
| var lanternPositions = [-W/4, 0, W/4]; | |
| lanternPositions.forEach(function(lx) { | |
| // Lantern body (sphere-ish box) | |
| addBlock(lx, stories*H - 0.3, D/2 + 0.3, 0.4, 0.5, 0.4, lanternColor, 'lantern', stories - 1); | |
| // Lantern top | |
| addBlock(lx, stories*H + 0.0, D/2 + 0.3, 0.15, 0.1, 0.15, 0xffcc00, 'lantern', stories - 1); | |
| }); | |
| // Stone base wall/fence in front | |
| addBlock(0, 0.3, D/2 + 2.5, W + 2, 0.6, 0.25, 0x999999, 'fence', -1); | |
| // Gate pillars | |
| addBlock(-1.5, 0.6, D/2 + 2.5, 0.4, 1.2, 0.4, 0x777777, 'gate_pillar', -1); | |
| addBlock(1.5, 0.6, D/2 + 2.5, 0.4, 1.2, 0.4, 0x777777, 'gate_pillar', -1); | |
| // Decorative roof ridge ornaments | |
| var roofTop = stories * H + 2.8; | |
| addBlock(0, roofTop, 0, 0.3, 0.4, 0.3, 0x444444, 'ornament', stories); | |
| addBlock(-W/2 - 0.5, stories*H + 0.3, D/2 + 1.0, 0.25, 0.3, 0.25, 0x444444, 'ornament', stories); | |
| addBlock(W/2 + 0.5, stories*H + 0.3, D/2 + 1.0, 0.25, 0.3, 0.25, 0x444444, 'ornament', stories); | |
| // Potted plants | |
| var bushGeo = new THREE.SphereGeometry(0.35, 8, 6); | |
| var bushMat = new THREE.MeshStandardMaterial({ color: 0x2d5a27, roughness: 0.9 }); | |
| [[-W/2 - 1, 0.5, D/2 + 1], [W/2 + 1, 0.5, D/2 + 1]].forEach(function(pos) { | |
| var b = new THREE.Mesh(bushGeo, bushMat); | |
| b.position.set(pos[0], pos[1], pos[2]); | |
| b.castShadow = true; | |
| scene.add(b); | |
| }); | |
| } | |
| function buildJapaneseDetails(W, D, stories, H) { | |
| // Genkan (entry area) - stepping stones | |
| for (var i = 0; i < 3; i++) { | |
| addBlock(0, 0.05, D/2 + 1.0 + i*0.8, 0.8, 0.1, 0.6, 0x666666, 'steps', -1); | |
| } | |
| // Stone garden elements | |
| addBlock(-W/2 - 1.5, 0.2, 0, 0.5, 0.4, 0.5, 0x777777, 'stone', -1); | |
| addBlock(W/2 + 1.2, 0.15, -1, 0.4, 0.3, 0.4, 0x888888, 'stone', -1); | |
| // Bamboo/garden plants | |
| var bushGeo = new THREE.SphereGeometry(0.3, 8, 6); | |
| var bushMat = new THREE.MeshStandardMaterial({ color: 0x3a6b2a, roughness: 0.85 }); | |
| [[-W/2 - 1, 0.6, 2], [W/2 + 1.2, 0.5, 1], [-W/2 - 0.8, 0.4, -2]].forEach(function(pos) { | |
| var b = new THREE.Mesh(bushGeo, bushMat); | |
| b.position.set(pos[0], pos[1], pos[2]); | |
| b.scale.set(0.8, 1.5, 0.8); // tall and narrow like bamboo | |
| b.castShadow = true; | |
| scene.add(b); | |
| }); | |
| // Small stone lantern (tōrō) | |
| addBlock(W/2 + 1.5, 0.25, 2, 0.3, 0.5, 0.3, 0x999999, 'ornament', -1); | |
| addBlock(W/2 + 1.5, 0.55, 2, 0.4, 0.1, 0.4, 0x888888, 'ornament', -1); | |
| addBlock(W/2 + 1.5, 0.7, 2, 0.25, 0.3, 0.25, 0xaaaa88, 'ornament', -1); | |
| // Wooden fence/wall section on one side | |
| addBlock(-W/2 - 0.3, 0.5, D/2 + 0.5, 0.08, 1.0, 3, 0x5c4030, 'fence', -1); | |
| } | |
| function buildAmericanDetails(W, D, stories, H) { | |
| var trimColor = 0xffffff; | |
| var porchW = W + 1.0; | |
| // ---- WRAP-AROUND FRONT PORCH ---- | |
| // Porch deck | |
| addBlock(0, 0.35, D/2 + 1.2, porchW, 0.12, 2.4, 0x8b7355, 'porch', 0); | |
| // Porch roof | |
| addBlock(0, H * 0.95 + 0.3, D/2 + 1.2, porchW + 0.3, 0.1, 2.8, 0x3a3a3a, 'porch_roof', 0); | |
| // Porch columns (thick white square columns) | |
| var colPositions = [-porchW/2 + 0.4, -porchW/4, 0, porchW/4, porchW/2 - 0.4]; | |
| colPositions.forEach(function(cx) { | |
| addBlock(cx, H*0.5 + 0.3, D/2 + 2.2, 0.25, H*0.9, 0.25, trimColor, 'porch_column', 0); | |
| }); | |
| // Porch railing between columns | |
| addBlock(0, 0.75, D/2 + 2.3, porchW - 1, 0.06, 0.06, trimColor, 'railing', 0); | |
| // Railing balusters | |
| for (var i = 0; i < 12; i++) { | |
| var bx = -porchW/2 + 1.0 + i * (porchW - 2) / 11; | |
| addBlock(bx, 0.55, D/2 + 2.3, 0.04, 0.4, 0.04, trimColor, 'railing', 0); | |
| } | |
| // Front steps (centered, wide) | |
| for (var i = 0; i < 3; i++) { | |
| addBlock(0, 0.3 - i*0.12, D/2 + 2.5 + i*0.3, 2.0, 0.12, 0.35, 0x888888, 'steps', -1); | |
| } | |
| // ---- CHIMNEY (stone/brick on the side) ---- | |
| var ch = stories * H + 3.5; | |
| addBlock(W/2 - 0.5, ch/2, -D/4, 0.8, ch, 0.8, 0xa08060, 'chimney', stories); | |
| addBlock(W/2 - 0.5, ch + 0.1, -D/4, 0.9, 0.2, 0.9, 0x666666, 'chimney', stories); | |
| // ---- LANDSCAPING ---- | |
| // Foundation shrubs (low boxwood hedge) | |
| var bushGeo = new THREE.SphereGeometry(0.35, 8, 6); | |
| var bushMat = new THREE.MeshStandardMaterial({ color: 0x2a5a22, roughness: 0.9 }); | |
| for (var i = 0; i < 6; i++) { | |
| var bx = -W/2 + 0.8 + i * (W - 1.6) / 5; | |
| var b = new THREE.Mesh(bushGeo, bushMat); | |
| b.position.set(bx, 0.3, D/2 + 0.3); | |
| b.scale.set(1.0, 0.7, 0.8); | |
| b.castShadow = true; | |
| scene.add(b); | |
| } | |
| // Corner accent plants | |
| var tallBush = new THREE.Mesh(new THREE.SphereGeometry(0.5, 8, 6), bushMat); | |
| tallBush.position.set(-W/2 - 0.5, 0.6, D/2 + 0.5); | |
| tallBush.scale.set(0.7, 1.2, 0.7); | |
| tallBush.castShadow = true; | |
| scene.add(tallBush); | |
| // Flower beds (small colored spheres) | |
| var flowerColors = [0xcc2233, 0xff6644, 0xee4488]; | |
| for (var i = 0; i < 8; i++) { | |
| var fx = -2.5 + i * 0.7; | |
| var fc = flowerColors[i % 3]; | |
| var flower = new THREE.Mesh( | |
| new THREE.SphereGeometry(0.15, 6, 6), | |
| new THREE.MeshStandardMaterial({ color: fc, roughness: 0.8 }) | |
| ); | |
| flower.position.set(fx, 0.2, D/2 + 2.8 + Math.sin(i)*0.2); | |
| scene.add(flower); | |
| } | |
| // White trim fascia on gable | |
| if (stories >= 2) { | |
| addBlock(0, stories*H + 0.3, D/2 + 0.12, W + 0.4, 0.12, 0.06, trimColor, 'trim', stories); | |
| } | |
| // Gable triangle face (white siding filling the front gable) | |
| var gableBaseY = stories * H + 0.3; | |
| var gableH = 2.5; | |
| // Build gable as stacked narrowing panels | |
| for (var g = 0; g < 6; g++) { | |
| var gt = g / 6; | |
| var gw = (W + 0.8) * (1 - gt); | |
| var gy = gableBaseY + gt * gableH + gableH/12; | |
| addBlock(0, gy, D/2, gw, gableH/6 * 0.95, 0.12, 0xf0ece4, 'gable', stories); | |
| } | |
| // Gable window (upper story window in the gable) | |
| addBlock(0, gableBaseY + gableH*0.4, D/2 + 0.05, 1.0, 1.2, 0.04, 0x88bbcc, 'glass', stories); | |
| addBlock(0, gableBaseY + gableH*0.4, D/2 + 0.07, 1.1, 1.3, 0.02, 0x2a2a2a, 'trim', stories); | |
| } | |
| function buildWesternDetails(W, D, stories, H, style) { | |
| // Front steps | |
| for (let i = 0; i < 3; i++) { | |
| addBlock(0, 0.3 - i*0.1, D/2 + 0.5 + i*0.3, 2.5, 0.12, 0.35, 0x777777, 'steps', -1); | |
| } | |
| // Chimney | |
| if (style.roofType !== 'flat' && style.roofType !== 'curved') { | |
| const ch = stories * H + 4; | |
| addBlock(-W/2 + 1.2, ch/2, 0, 0.7, ch, 0.7, 0x8B4513, 'chimney', 2); | |
| addBlock(-W/2 + 1.2, ch + 0.1, 0, 0.9, 0.2, 0.9, 0x555555, 'chimney', 2); | |
| } | |
| // Porch columns | |
| if (style.hasShutters) { | |
| addBlock(-1.3, 1.6, D/2 + 0.7, 0.18, 2.6, 0.18, 0xf0f0f0, 'porch', 0); | |
| addBlock(1.3, 1.6, D/2 + 0.7, 0.18, 2.6, 0.18, 0xf0f0f0, 'porch', 0); | |
| addBlock(0, 3.0, D/2 + 0.7, 3.2, 0.1, 1.0, 0x555555, 'porch_roof', 0); | |
| } | |
| // Bushes | |
| const bushGeo = new THREE.SphereGeometry(0.45, 8, 6); | |
| const bushMat = new THREE.MeshStandardMaterial({ color: 0x2d5a27, roughness: 0.9 }); | |
| [[-3.5, 0.35, D/2+1.5], [3.5, 0.35, D/2+1.5], [-W/2-1, 0.35, 1], [-W/2-1, 0.35, -1.5]].forEach(p => { | |
| const b = new THREE.Mesh(bushGeo, bushMat); | |
| b.position.set(p[0], p[1], p[2]); | |
| b.scale.y = 0.7; | |
| b.castShadow = true; | |
| scene.add(b); | |
| }); | |
| } | |
| // ==================== BLOCK HELPERS ==================== | |
| function createBoxMesh(w, h, d, color) { | |
| const geo = new THREE.BoxGeometry(w, h, d); | |
| const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.75, metalness: 0.05 }); | |
| return new THREE.Mesh(geo, mat); | |
| } | |
| function addBlock(x, y, z, w, h, d, color, label, floor) { | |
| const mesh = createBoxMesh(w, h, d, color); | |
| mesh.position.set(x, y, z); | |
| mesh.castShadow = true; | |
| mesh.receiveShadow = true; | |
| scene.add(mesh); | |
| buildingParts.push(makePart(mesh, label, floor)); | |
| } | |
| function makePart(mesh, label, floor) { | |
| return { | |
| mesh: mesh, | |
| label: label, | |
| floor: floor, | |
| ox: mesh.position.x, | |
| oy: mesh.position.y, | |
| oz: mesh.position.z, | |
| orx: mesh.rotation.x, | |
| ory: mesh.rotation.y, | |
| orz: mesh.rotation.z, | |
| vx: 0, vy: 0, vz: 0, | |
| avx: 0, avy: 0, avz: 0, | |
| state: 'static' // static | shaking | falling | landed | |
| }; | |
| } | |
| function clearAll() { | |
| buildingParts.forEach(p => scene.remove(p.mesh)); | |
| buildingParts = []; | |
| rescueMarkers.forEach(m => scene.remove(m)); | |
| rescueMarkers = []; | |
| rescueAnimObjects = []; | |
| document.getElementById('analysis-panel').style.display = 'none'; | |
| } | |
| // ==================== EARTHQUAKE SIMULATION ==================== | |
| // Ground motion is THE CAUSE. Parts accumulate stress from ground acceleration. | |
| // When stress > strength threshold → part breaks off. | |
| function startEarthquake() { | |
| if (isSimulating) return; | |
| isSimulating = true; | |
| simulationTime = 0; | |
| // Random earthquake characteristics - unique every run | |
| quakeDir = Math.random() * Math.PI * 2; | |
| quakeSeed = Math.random() * 1000; | |
| // Compute Peak Ground Acceleration (PGA in g's) - properly scaled so magnitude matters | |
| var mag = config.magnitude; | |
| var dist = config.distance; | |
| var foundAmp = foundationFactors[config.foundation]; | |
| // Realistic-ish attenuation. Gives clear visible differences across the full M4-M9 range: | |
| // M4/dist5: ~0.02g (barely felt), M5.5/dist5: ~0.12g (light damage) | |
| // M6.5/dist5: ~0.4g (moderate), M7.5/dist5: ~0.8g (heavy), M9/dist5: ~1.8g (catastrophic) | |
| var logPGA = 0.45 * mag - 1.0 * Math.log10(dist + 3) - 1.8; | |
| var pga = Math.pow(10, logPGA) * foundAmp; | |
| document.getElementById('status').textContent = 'Earthquake! PGA: ' + pga.toFixed(2) + 'g | Shaking...'; | |
| updateSimInfo(pga); | |
| // Store camera position for shaking | |
| var orbitPos = window.getCameraOrbitPos ? window.getCameraOrbitPos() : { x: camera.position.x, y: camera.position.y, z: camera.position.z }; | |
| cameraBasePos.x = orbitPos.x; | |
| cameraBasePos.y = orbitPos.y; | |
| cameraBasePos.z = orbitPos.z; | |
| // Initialize each part's stress tracking | |
| var weakCorner = { x: (Math.random()-0.5)*10, z: (Math.random()-0.5)*7 }; | |
| var weakSide = quakeDir; // the earthquake push direction is the weak side | |
| buildingParts.forEach(function(p) { | |
| if (p.floor < 0) { p.state = 'static'; return; } | |
| p.state = 'shaking'; | |
| p.stress = 0; // accumulated stress from ground motion | |
| // Each part has a random strength threshold | |
| var baseStrength = getPartStrength(p); | |
| // Add randomness: ±40% variation | |
| p.threshold = baseStrength * (0.6 + Math.random() * 0.8); | |
| // Parts closer to weak corner are weaker | |
| var dw = Math.sqrt(Math.pow(p.ox - weakCorner.x, 2) + Math.pow(p.oz - weakCorner.z, 2)); | |
| p.threshold *= (0.7 + dw / 15 * 0.5); | |
| // Random per-part frequency sensitivity (resonance) | |
| p.resonanceFreq = 5 + Math.random() * 15; | |
| p.resonanceAmp = 0.8 + Math.random() * 0.4; | |
| }); | |
| // Main simulation interval | |
| var simInterval = setInterval(function() { | |
| if (!isSimulating) { clearInterval(simInterval); return; } | |
| stepSimulation(pga, simInterval); | |
| }, 16); | |
| } | |
| function getPartStrength(p) { | |
| var mat = materialProps[config.material]; | |
| var matFactor = (mat.strength / 8) * (1.2 - mat.brittleness * 0.7); | |
| var ageFactor = Math.max(0.5, 1.0 - config.buildingAge / 200); | |
| var base = matFactor * ageFactor; | |
| // Structural role determines threshold | |
| if (p.label === 'glass') return base * 0.5; | |
| if (p.label === 'shoji') return base * 0.4; // paper panels very fragile | |
| if (p.label === 'shoji_frame') return base * 0.5; | |
| if (p.label === 'lattice') return base * 0.6; // lattice panels are light | |
| if (p.label === 'lantern') return base * 0.3; // lanterns fall easily | |
| if (p.label === 'railing') return base * 0.8; | |
| if (p.label === 'balcony') return base * 2.5; | |
| if (p.label === 'chimney') return base * 1.5; | |
| if (p.label === 'door') return base * 1.2; | |
| if (p.label === 'ornament') return base * 0.8; | |
| if (p.label === 'trim') return base * 0.7; | |
| if (p.label === 'porch' || p.label === 'porch_roof') return base * 1.8; | |
| if (p.label === 'porch_column') return base * 2.5; // porch columns are sturdy | |
| if (p.label === 'gable') return base * 3.0; // gable face attached to structure | |
| if (p.label === 'column') return base * 8.0; // columns are very strong | |
| if (p.label === 'beam') return base * 6.0; // beams are strong | |
| if (p.label === 'roof') return base * 4.0; | |
| if (p.label === 'int_wall') return base * 6.0; | |
| if (p.label.includes('wall')) return base * (7.0 - p.floor * 1.5); | |
| if (p.label === 'ceiling' || p.label === 'floor') return base * 5.0; | |
| return base * 4.0; | |
| } | |
| function stepSimulation(pga, interval) { | |
| var dt = 0.02; | |
| simulationTime += dt; | |
| var dur = config.duration; | |
| var quakeActive = simulationTime < dur; | |
| // ===== COMPUTE GROUND MOTION (this is the cause of everything) ===== | |
| var groundAccelX = 0, groundAccelZ = 0, groundAccelY = 0; | |
| var intensity = 0; | |
| if (quakeActive) { | |
| // Envelope: ramp up quickly, sustain, decay | |
| var env; | |
| var rampUp = Math.min(dur * 0.15, 2.0); | |
| var decay = dur * 0.3; | |
| if (simulationTime < rampUp) env = simulationTime / rampUp; | |
| else if (simulationTime < dur - decay) env = 1.0; | |
| else env = Math.max(0, (dur - simulationTime) / decay); | |
| intensity = env * pga; | |
| // Ground acceleration: sum of multiple frequency components (like real seismogram) | |
| var dirX = Math.cos(quakeDir); | |
| var dirZ = Math.sin(quakeDir); | |
| // Primary motion along earthquake direction | |
| var primary = Math.sin(simulationTime * 6.3 + quakeSeed) * 0.5 | |
| + Math.sin(simulationTime * 14.1 + quakeSeed * 0.3) * 0.3 | |
| + Math.sin(simulationTime * 2.7 + quakeSeed * 1.7) * 0.2; | |
| // Secondary (perpendicular) motion | |
| var secondary = Math.sin(simulationTime * 8.7 + quakeSeed * 2.1) * 0.3 | |
| + Math.cos(simulationTime * 4.5 + quakeSeed * 0.5) * 0.2; | |
| // Vertical (smaller) | |
| var vertical = Math.sin(simulationTime * 19 + quakeSeed * 3) * 0.15; | |
| groundAccelX = intensity * (dirX * primary + (-dirZ) * secondary); | |
| groundAccelZ = intensity * (dirZ * primary + dirX * secondary); | |
| groundAccelY = intensity * vertical; | |
| } | |
| // ===== APPLY GROUND MOTION VISUALLY ===== | |
| var gDispScale = 0.2; // displacement scale for visual | |
| var gx = groundAccelX * gDispScale; | |
| var gz = groundAccelZ * gDispScale; | |
| var gy = groundAccelY * gDispScale * 0.5; | |
| if (quakeActive) { | |
| groundMesh.position.set(gx, gy, gz); | |
| if (pathMesh) pathMesh.position.set(gx, 0.01 + gy, 8 + gz); | |
| // Camera shakes WITH the ground (you're standing on it) | |
| camera.position.x = cameraBasePos.x + gx * 0.7; | |
| camera.position.y = cameraBasePos.y + gy * 1.5; | |
| camera.position.z = cameraBasePos.z + gz * 0.7; | |
| camera.lookAt(gx * 0.3, 3 + gy * 0.5, gz * 0.3); | |
| } else { | |
| groundMesh.position.set(0, 0, 0); | |
| if (pathMesh) pathMesh.position.set(0, 0.01, 8); | |
| if (window.resetCameraOrbit) window.resetCameraOrbit(); | |
| } | |
| // ===== BUILDING PARTS: stress from ground motion causes collapse ===== | |
| var anyMoving = false; | |
| for (var i = 0; i < buildingParts.length; i++) { | |
| var p = buildingParts[i]; | |
| if (p.state === 'static' || p.state === 'landed') continue; | |
| if (p.state === 'shaking') { | |
| anyMoving = true; | |
| if (quakeActive) { | |
| // Part moves with the ground (it's attached to the building on the ground) | |
| // But upper parts amplify ground motion (whip effect) | |
| var heightAmp = 1.0 + p.oy * 0.15; // higher = more amplification | |
| var resonance = 1.0 + 0.3 * Math.sin(simulationTime * p.resonanceFreq) * p.resonanceAmp; | |
| var partDispX = gx * heightAmp * resonance; | |
| var partDispZ = gz * heightAmp * resonance; | |
| p.mesh.position.x = p.ox + partDispX; | |
| p.mesh.position.z = p.oz + partDispZ; | |
| p.mesh.position.y = p.oy + gy * heightAmp * 0.3; | |
| // ===== ACCUMULATE STRESS from ground acceleration ===== | |
| // Stress = integral of |acceleration| over time, weighted by height amplification | |
| var accelMag = Math.sqrt(groundAccelX * groundAccelX + groundAccelZ * groundAccelZ + groundAccelY * groundAccelY); | |
| p.stress += accelMag * heightAmp * resonance * dt; | |
| // ===== CHECK FAILURE: stress exceeds threshold → part breaks ===== | |
| if (p.stress > p.threshold) { | |
| p.state = 'falling'; | |
| // Ejection velocity proportional to current ground acceleration | |
| var ejectForce = 1.5 + intensity * 2.0; | |
| p.vx = groundAccelX * ejectForce * (0.7 + Math.random() * 0.6) + (Math.random()-0.5) * intensity; | |
| p.vy = Math.random() * 1.5 + groundAccelY * 2; | |
| p.vz = groundAccelZ * ejectForce * (0.7 + Math.random() * 0.6) + (Math.random()-0.5) * intensity; | |
| p.avx = (Math.random()-0.5) * 3 * intensity; | |
| p.avy = (Math.random()-0.5) * 2; | |
| p.avz = (Math.random()-0.5) * 3 * intensity; | |
| // Visual damage | |
| if (p.mesh.material && p.mesh.material.color) { | |
| p.mesh.material = p.mesh.material.clone(); | |
| p.mesh.material.color.multiplyScalar(0.5 + Math.random() * 0.2); | |
| } | |
| } | |
| } else { | |
| // Earthquake ended, return to rest if not broken | |
| p.mesh.position.x = p.ox; | |
| p.mesh.position.y = p.oy; | |
| p.mesh.position.z = p.oz; | |
| } | |
| } | |
| if (p.state === 'falling') { | |
| anyMoving = true; | |
| p.vy -= 15.0 * dt; | |
| p.vx *= 0.99; p.vz *= 0.99; | |
| p.mesh.position.x += p.vx * dt; | |
| p.mesh.position.y += p.vy * dt; | |
| p.mesh.position.z += p.vz * dt; | |
| p.mesh.rotation.x += p.avx * dt; | |
| p.mesh.rotation.y += p.avy * dt; | |
| p.mesh.rotation.z += p.avz * dt; | |
| var gnd = getGroundAt(p); | |
| if (p.mesh.position.y <= gnd) { | |
| p.mesh.position.y = gnd; | |
| if (Math.abs(p.vy) < 1.0) { | |
| p.state = 'landed'; | |
| p.vx = 0; p.vy = 0; p.vz = 0; | |
| p.avx = 0; p.avy = 0; p.avz = 0; | |
| } else { | |
| p.vy = -p.vy * 0.15; | |
| p.vx *= 0.4; p.vz *= 0.4; | |
| p.avx *= 0.3; p.avy *= 0.3; p.avz *= 0.3; | |
| } | |
| } | |
| } | |
| } | |
| // ===== SUPPORT CHECK: slabs/ceilings/roofs fall if walls below are gone ===== | |
| for (var i = 0; i < buildingParts.length; i++) { | |
| var p = buildingParts[i]; | |
| if (p.state !== 'shaking') continue; | |
| // Only check slabs, ceilings, floors, roofs, beams | |
| var isHorizontal = (p.label === 'ceiling' || p.label === 'floor' || p.label === 'roof' || p.label === 'porch_roof' || p.label === 'beam'); | |
| if (!isHorizontal) continue; | |
| if (p.floor < 0) continue; | |
| // Count supporting parts: must be on SAME floor (or floor below for roof), vertical, and still intact | |
| var supportCount = 0; | |
| for (var j = 0; j < buildingParts.length; j++) { | |
| var s = buildingParts[j]; | |
| if (s === p || s.state !== 'shaking') continue; | |
| // Must be a vertical structural element | |
| var isSupport = (s.label === 'column' || s.label.includes('wall') || s.label === 'porch_column' || s.label === 'int_wall'); | |
| if (!isSupport) continue; | |
| // Must be on same floor OR one floor below (roof sits on top floor walls) | |
| if (s.floor !== p.floor && s.floor !== p.floor - 1) continue; | |
| // Must be below this slab | |
| if (s.oy >= p.oy) continue; | |
| supportCount++; | |
| } | |
| // Slabs need at least 3 wall segments to stay up (a real building has many) | |
| if (supportCount < 3) { | |
| p.state = 'falling'; | |
| p.vx = (Math.random() - 0.5) * 0.3; | |
| p.vy = -1.0; | |
| p.vz = (Math.random() - 0.5) * 0.3; | |
| p.avx = (Math.random() - 0.5) * 0.3; | |
| p.avy = 0; | |
| p.avz = (Math.random() - 0.5) * 0.3; | |
| if (p.mesh.material && p.mesh.material.color) { | |
| p.mesh.material = p.mesh.material.clone(); | |
| p.mesh.material.color.multiplyScalar(0.6); | |
| } | |
| anyMoving = true; | |
| } | |
| } | |
| // Stop when everything settled after quake ends | |
| if (!quakeActive && !anyMoving) { | |
| isSimulating = false; | |
| clearInterval(interval); | |
| document.getElementById('status').textContent = 'Simulation complete - Capture images or analyze rescue paths'; | |
| } | |
| } | |
| function getGroundAt(part) { | |
| // Simple debris stacking: check if fallen parts are below | |
| var ground = 0.1; | |
| for (var i = 0; i < buildingParts.length; i++) { | |
| var o = buildingParts[i]; | |
| if (o === part || o.state !== 'landed') continue; | |
| var dx = Math.abs(part.mesh.position.x - o.mesh.position.x); | |
| var dz = Math.abs(part.mesh.position.z - o.mesh.position.z); | |
| if (dx < 1.2 && dz < 1.2) { | |
| var top = o.mesh.position.y + 0.25; | |
| if (top > ground) ground = top; | |
| } | |
| } | |
| return ground; | |
| } | |
| // ==================== INFO / ANALYSIS ==================== | |
| function updateSimInfo(pga) { | |
| var info = document.getElementById('sim-info'); | |
| var level = pga < 0.1 ? 'Light (I-V)' : pga < 0.3 ? 'Moderate (VI-VII)' : pga < 0.6 ? 'Strong (VII-VIII)' : pga < 1.0 ? 'Severe (IX)' : 'Extreme (X+)'; | |
| var pattern = pga < 0.1 ? 'Cosmetic damage, glass cracking' : pga < 0.3 ? 'Non-structural damage, chimney fall' : pga < 0.6 ? 'Structural damage, partial collapse' : pga < 1.0 ? 'Heavy damage, story collapse' : 'Catastrophic, total collapse possible'; | |
| info.innerHTML = '<p><b>PGA:</b> ' + pga.toFixed(2) + 'g — ' + level + '</p>' + | |
| '<p><b>Expected:</b> ' + pattern + '</p>' + | |
| '<p><b>Style:</b> ' + config.buildingStyle + ' | <b>Material:</b> ' + config.material + '</p>' + | |
| '<p><b>Foundation:</b> ' + config.foundation + ' (×' + foundationFactors[config.foundation] + ' amplification)</p>' + | |
| '<p><b>Quake:</b> M' + config.magnitude + ' at ' + config.distance + 'km | Duration: ' + config.duration + 's</p>'; | |
| } | |
| function analyzeRescuePaths() { | |
| var panel = document.getElementById('analysis-panel'); | |
| panel.style.display = 'block'; | |
| // Estimate damage from how many parts actually fell | |
| var totalParts = 0, fallenParts = 0; | |
| buildingParts.forEach(function(p) { if (p.floor >= 0) { totalParts++; if (p.state === 'landed') fallenParts++; } }); | |
| var damage = totalParts > 0 ? fallenParts / totalParts : 0; | |
| var rooms = window.roomLayout; | |
| var html = '<h3 style="display:flex;justify-content:space-between;align-items:center;">🔍 Rescue Analysis <span id="closeAnalysis" style="cursor:pointer;font-size:18px;color:#aaa;padding:0 5px;">✕</span></h3>'; | |
| html += '<div class="finding"><b>Actual Collapse: ' + (damage*100).toFixed(0) + '% of structure collapsed</b></div>'; | |
| html += '<div class="finding"><b>Probable Survivor Locations:</b></div>'; | |
| var locs = getSurvivorLocations(damage, rooms); | |
| locs.forEach(function(l) { | |
| html += '<div class="finding"><span class="' + l.pri + '">[' + l.pri.toUpperCase() + ']</span> ' + l.desc + '</div>'; | |
| }); | |
| html += '<div class="finding" style="margin-top:8px"><b>Rescue Approach:</b></div>'; | |
| var paths = getRescuePaths(damage); | |
| paths.forEach(function(p) { | |
| html += '<div class="finding"><span class="' + p.s + '">[' + p.s.toUpperCase() + ']</span> ' + p.d + '</div>'; | |
| }); | |
| html += '<div class="finding" style="margin-top:8px"><b>⚠️ Aftershock Risks:</b></div>'; | |
| getAftershockRisks(damage).forEach(function(r) { | |
| html += '<div class="finding"><span class="danger">[DANGER]</span> ' + r + '</div>'; | |
| }); | |
| panel.innerHTML = html; | |
| document.getElementById('closeAnalysis').onclick = function() { | |
| panel.style.display = 'none'; | |
| rescueMarkers.forEach(function(m) { scene.remove(m); }); | |
| rescueMarkers = []; | |
| rescueAnimObjects = []; | |
| }; | |
| showRescueMarkers(locs); | |
| } | |
| function getSurvivorLocations(damage, rooms) { | |
| var locs = []; | |
| var H = 3.0; | |
| rooms.groundFloor.forEach(function(r) { | |
| var pri = damage < 0.4 ? 'safe' : damage < 0.7 ? 'warning' : 'danger'; | |
| var desc = r.name + ' (Ground) - '; | |
| if (damage < 0.3) desc += 'Structure intact, self-evacuation likely.'; | |
| else if (r.name.includes('Bathroom')) desc += 'Small room void space - priority search area.'; | |
| else desc += 'Check near furniture for void spaces.'; | |
| var debrisY = getDebrisHeight(r.x, r.z); | |
| locs.push({ pri: pri, desc: desc, x: r.x, y: debrisY + 0.5, z: r.z }); | |
| }); | |
| if (rooms.floors) { | |
| rooms.floors.forEach(function(floorRooms, fi) { | |
| floorRooms.forEach(function(r) { | |
| var floorNum = fi + 1; | |
| var pri = damage > 0.5 ? 'danger' : 'warning'; | |
| var desc = r.name + ' (Floor ' + (floorNum + 1) + ') - '; | |
| if (r.name.includes('Bedroom')) desc += 'HIGH PRIORITY (night occupancy). '; | |
| if (damage > 0.7) desc += 'Pancake collapse - search bed-level voids.'; | |
| else if (damage > 0.4) desc += 'Lean-to collapse - check wall-adjacent areas.'; | |
| else desc += 'Check under doorframes.'; | |
| var debrisY = getDebrisHeight(r.x, r.z); | |
| locs.push({ pri: pri, desc: desc, x: r.x, y: debrisY + 0.5, z: r.z }); | |
| }); | |
| }); | |
| } | |
| return locs; | |
| } | |
| // Scan fallen debris to find the top surface height at a given x,z position | |
| function getDebrisHeight(x, z) { | |
| var maxY = 0.1; | |
| buildingParts.forEach(function(p) { | |
| if (p.state !== 'landed') return; | |
| var dx = Math.abs(p.mesh.position.x - x); | |
| var dz = Math.abs(p.mesh.position.z - z); | |
| if (dx < 2.0 && dz < 2.0) { | |
| var top = p.mesh.position.y + 0.3; | |
| if (top > maxY) maxY = top; | |
| } | |
| }); | |
| return maxY; | |
| } | |
| function getRescuePaths(damage) { | |
| var p = []; | |
| p.push({ s: 'safe', d: 'Approach from ' + (damage > 0.5 ? 'least-collapsed side' : 'main entrance') + '.' }); | |
| if (damage > 0.4) p.push({ s: 'warning', d: 'Shore before entry. Use listening devices.' }); | |
| if (config.foundation === 'liquefiable') p.push({ s: 'danger', d: 'Ground instability - watch for sinkholes.' }); | |
| if (config.stories >= 2 && damage > 0.5) p.push({ s: 'warning', d: 'Use exterior ladder for upper floors (' + config.stories + ' stories).' }); | |
| p.push({ s: 'safe', d: 'Priority: 1) Bedrooms 2) Bathrooms 3) Kitchen' }); | |
| return p; | |
| } | |
| function getAftershockRisks(damage) { | |
| var r = []; | |
| if (damage > 0.3) r.push('Standing walls may topple - keep 1.5× wall height distance.'); | |
| if (config.material === 'unreinforcedMasonry' || config.material === 'adobe') r.push('Brittle debris unpredictable - overhead protection required.'); | |
| if (config.stories >= 2 && damage > 0.5) r.push('Upper slabs (' + config.stories + ' floors) may be precariously balanced.'); | |
| if (config.foundation === 'liquefiable') r.push('Liquefaction continues during aftershocks.'); | |
| r.push('Establish exclusion zone. Assign movement spotter.'); | |
| return r; | |
| } | |
| function showRescueMarkers(locs) { | |
| rescueMarkers.forEach(function(m) { scene.remove(m); }); | |
| rescueMarkers = []; | |
| rescueAnimObjects = []; | |
| // Survivor location markers (animated pulsing) | |
| locs.forEach(function(l, idx) { | |
| var color = l.pri === 'safe' ? 0x40916c : l.pri === 'warning' ? 0xf4a261 : 0xe94560; | |
| var sphereMat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.7 }); | |
| var sphere = new THREE.Mesh(new THREE.SphereGeometry(0.35, 12, 12), sphereMat); | |
| sphere.position.set(l.x, l.y, l.z); | |
| scene.add(sphere); | |
| rescueMarkers.push(sphere); | |
| rescueAnimObjects.push({ type: 'pulse', mesh: sphere, phase: idx * 0.8 }); | |
| // Animated expanding ring | |
| var ringMat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.5, side: THREE.DoubleSide }); | |
| var ring = new THREE.Mesh(new THREE.TorusGeometry(0.55, 0.05, 8, 24), ringMat); | |
| ring.position.set(l.x, l.y, l.z); | |
| ring.rotation.x = Math.PI / 2; | |
| scene.add(ring); | |
| rescueMarkers.push(ring); | |
| rescueAnimObjects.push({ type: 'ring', mesh: ring, phase: idx * 1.2 }); | |
| // Ground-level colored zone plane (visible from above) | |
| var zoneSize = 2.5; | |
| var zoneMat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.25, side: THREE.DoubleSide }); | |
| var zone = new THREE.Mesh(new THREE.PlaneGeometry(zoneSize, zoneSize), zoneMat); | |
| zone.rotation.x = -Math.PI / 2; | |
| zone.position.set(l.x, 0.05, l.z); | |
| scene.add(zone); | |
| rescueMarkers.push(zone); | |
| // Zone border outline | |
| var borderMat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.6 }); | |
| var bSize = zoneSize / 2; | |
| var bThick = 0.06; | |
| // Four edges of zone square | |
| var edges = [ | |
| { x: l.x, z: l.z - bSize, w: zoneSize, d: bThick }, | |
| { x: l.x, z: l.z + bSize, w: zoneSize, d: bThick }, | |
| { x: l.x - bSize, z: l.z, w: bThick, d: zoneSize }, | |
| { x: l.x + bSize, z: l.z, w: bThick, d: zoneSize } | |
| ]; | |
| edges.forEach(function(e) { | |
| var edgeMesh = new THREE.Mesh( | |
| new THREE.BoxGeometry(e.w, 0.05, e.d), | |
| borderMat | |
| ); | |
| edgeMesh.position.set(e.x, 0.08, e.z); | |
| scene.add(edgeMesh); | |
| rescueMarkers.push(edgeMesh); | |
| }); | |
| }); | |
| // ===== SAFE ZONE (green area away from building) ===== | |
| var W = 10, D = 7; | |
| var safeZoneMat = new THREE.MeshBasicMaterial({ color: 0x40916c, transparent: true, opacity: 0.15, side: THREE.DoubleSide }); | |
| // Safe zone on the approach side (large green area) | |
| var safeZone = new THREE.Mesh(new THREE.PlaneGeometry(12, 8), safeZoneMat); | |
| safeZone.rotation.x = -Math.PI / 2; | |
| safeZone.position.set(0, 0.03, D/2 + 14); | |
| scene.add(safeZone); | |
| rescueMarkers.push(safeZone); | |
| // Safe zone border | |
| var szBorder = new THREE.Mesh( | |
| new THREE.BoxGeometry(12, 0.04, 0.08), | |
| new THREE.MeshBasicMaterial({ color: 0x40916c, transparent: true, opacity: 0.5 }) | |
| ); | |
| szBorder.position.set(0, 0.06, D/2 + 10); | |
| scene.add(szBorder); | |
| rescueMarkers.push(szBorder); | |
| // ===== DANGER ZONE (red area around building) ===== | |
| var dangerZoneMat = new THREE.MeshBasicMaterial({ color: 0xe94560, transparent: true, opacity: 0.1, side: THREE.DoubleSide }); | |
| var dangerZone = new THREE.Mesh(new THREE.PlaneGeometry(W + 5, D + 5), dangerZoneMat); | |
| dangerZone.rotation.x = -Math.PI / 2; | |
| dangerZone.position.set(0, 0.04, 0); | |
| scene.add(dangerZone); | |
| rescueMarkers.push(dangerZone); | |
| // ===== RESCUE APPROACH PATHS ===== | |
| var pathColor = 0x00e5ff; | |
| // Determine least-collapsed side | |
| var sideFallen = { front: 0, back: 0, left: 0, right: 0 }; | |
| buildingParts.forEach(function(p) { | |
| if (p.state !== 'landed') return; | |
| if (p.mesh.position.z > D/2) sideFallen.front++; | |
| else if (p.mesh.position.z < -D/2) sideFallen.back++; | |
| if (p.mesh.position.x < -W/2) sideFallen.left++; | |
| else if (p.mesh.position.x > W/2) sideFallen.right++; | |
| }); | |
| var minSide = 'front', minCount = sideFallen.front; | |
| if (sideFallen.back < minCount) { minSide = 'back'; minCount = sideFallen.back; } | |
| if (sideFallen.left < minCount) { minSide = 'left'; minCount = sideFallen.left; } | |
| if (sideFallen.right < minCount) { minSide = 'right'; minCount = sideFallen.right; } | |
| // Define approach paths | |
| var paths = []; | |
| if (minSide === 'front' || minSide === 'back') { | |
| var zs = minSide === 'front' ? 1 : -1; | |
| paths.push([{x:0, z:zs*18}, {x:0, z:zs*14}, {x:0, z:zs*10}, {x:0, z:zs*6}, {x:0, z:zs*(D/2+1)}]); | |
| paths.push([{x:-5, z:zs*16}, {x:-3.5, z:zs*11}, {x:-W/2+1, z:zs*(D/2+1)}]); | |
| paths.push([{x:5, z:zs*16}, {x:3.5, z:zs*11}, {x:W/2-1, z:zs*(D/2+1)}]); | |
| } else { | |
| var xs = minSide === 'left' ? -1 : 1; | |
| paths.push([{x:xs*18, z:0}, {x:xs*14, z:0}, {x:xs*10, z:0}, {x:xs*6, z:0}, {x:xs*(W/2+1), z:0}]); | |
| paths.push([{x:xs*16, z:-3}, {x:xs*11, z:-2}, {x:xs*(W/2+1), z:-D/4}]); | |
| paths.push([{x:xs*16, z:3}, {x:xs*11, z:2}, {x:xs*(W/2+1), z:D/4}]); | |
| } | |
| // Draw paths with static road + animated flowing arrows | |
| paths.forEach(function(pathPts, pathIdx) { | |
| var isMain = pathIdx === 0; | |
| var thickness = isMain ? 0.15 : 0.1; | |
| var yLevel = 0.12; | |
| // Static path road surface | |
| for (var i = 0; i < pathPts.length - 1; i++) { | |
| var p1 = pathPts[i], p2 = pathPts[i+1]; | |
| var mx = (p1.x + p2.x) / 2; | |
| var mz = (p1.z + p2.z) / 2; | |
| var dx = p2.x - p1.x, dz = p2.z - p1.z; | |
| var len = Math.sqrt(dx*dx + dz*dz); | |
| var angle = Math.atan2(dx, dz); | |
| // Road base | |
| var road = new THREE.Mesh( | |
| new THREE.BoxGeometry(thickness * 3, 0.04, len), | |
| new THREE.MeshBasicMaterial({ color: 0x1a6070, transparent: true, opacity: 0.5 }) | |
| ); | |
| road.position.set(mx, yLevel, mz); | |
| road.rotation.y = angle; | |
| scene.add(road); | |
| rescueMarkers.push(road); | |
| // Edge lines | |
| for (var side = -1; side <= 1; side += 2) { | |
| var edge = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.04, 0.06, len), | |
| new THREE.MeshBasicMaterial({ color: pathColor, transparent: true, opacity: 0.7 }) | |
| ); | |
| edge.position.set(mx + Math.cos(angle + Math.PI/2) * thickness * 1.4 * side, yLevel + 0.02, mz + Math.sin(angle + Math.PI/2) * thickness * 1.4 * side * -1); | |
| edge.rotation.y = angle; | |
| scene.add(edge); | |
| rescueMarkers.push(edge); | |
| } | |
| } | |
| // Animated flowing arrow particles along the path | |
| var numFlowArrows = isMain ? 6 : 4; | |
| for (var a = 0; a < numFlowArrows; a++) { | |
| // Pick a random segment to flow along | |
| var segIdx = a % (pathPts.length - 1); | |
| var p1 = pathPts[segIdx], p2 = pathPts[segIdx + 1]; | |
| var dx = p2.x - p1.x, dz = p2.z - p1.z; | |
| var angle = Math.atan2(dx, dz); | |
| var arrowMat = new THREE.MeshBasicMaterial({ color: pathColor, transparent: true, opacity: 0.8 }); | |
| var arrow = new THREE.Mesh(new THREE.ConeGeometry(0.15, 0.4, 5), arrowMat); | |
| arrow.position.set(p1.x, yLevel + 0.25, p1.z); | |
| arrow.rotation.x = Math.PI / 2; | |
| arrow.rotation.z = -angle; | |
| scene.add(arrow); | |
| rescueMarkers.push(arrow); | |
| rescueAnimObjects.push({ | |
| type: 'flow', | |
| mesh: arrow, | |
| start: { x: p1.x, z: p1.z }, | |
| end: { x: p2.x, z: p2.z }, | |
| speed: 0.4 + Math.random() * 0.2, | |
| offset: a / numFlowArrows | |
| }); | |
| } | |
| // Start point assembly marker | |
| if (pathIdx === 0) { | |
| var startMat = new THREE.MeshBasicMaterial({ color: 0x40916c, transparent: true, opacity: 0.8 }); | |
| var startRing = new THREE.Mesh(new THREE.TorusGeometry(0.7, 0.12, 8, 24), startMat); | |
| startRing.position.set(pathPts[0].x, 0.2, pathPts[0].z); | |
| startRing.rotation.x = Math.PI / 2; | |
| scene.add(startRing); | |
| rescueMarkers.push(startRing); | |
| rescueAnimObjects.push({ type: 'pulse', mesh: startRing, phase: 0 }); | |
| // Cross marker | |
| addBlock(pathPts[0].x, 0.3, pathPts[0].z, 1.0, 0.08, 0.15, 0x40916c, 'marker', -1); | |
| addBlock(pathPts[0].x, 0.3, pathPts[0].z, 0.15, 0.08, 1.0, 0x40916c, 'marker', -1); | |
| } | |
| }); | |
| // Animated danger zone perimeter (blinking red) | |
| var dangerDist = 2.5; | |
| var perim = [ | |
| [-W/2-dangerDist, D/2+dangerDist], | |
| [W/2+dangerDist, D/2+dangerDist], | |
| [W/2+dangerDist, -D/2-dangerDist], | |
| [-W/2-dangerDist, -D/2-dangerDist] | |
| ]; | |
| for (var i = 0; i < 4; i++) { | |
| var p1 = perim[i], p2 = perim[(i+1)%4]; | |
| var mx = (p1[0]+p2[0])/2, mz = (p1[1]+p2[1])/2; | |
| var dx = p2[0]-p1[0], dz = p2[1]-p1[1]; | |
| var len = Math.sqrt(dx*dx+dz*dz); | |
| var angle = Math.atan2(dx, dz); | |
| // Dashed segments for danger border | |
| var numDashes = Math.floor(len / 1.5); | |
| for (var d = 0; d < numDashes; d++) { | |
| var dt = (d + 0.5) / numDashes; | |
| var dashX = p1[0] + dx * dt; | |
| var dashZ = p1[1] + dz * dt; | |
| var dashMat = new THREE.MeshBasicMaterial({ color: 0xe94560, transparent: true, opacity: 0.6 }); | |
| var dash = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.08, len/numDashes * 0.6), dashMat); | |
| dash.position.set(dashX, 0.1, dashZ); | |
| dash.rotation.y = angle; | |
| scene.add(dash); | |
| rescueMarkers.push(dash); | |
| rescueAnimObjects.push({ type: 'blink', mesh: dash, phase: d * 0.5 + i * 2 }); | |
| } | |
| } | |
| } | |
| // ==================== CAPTURE & GALLERY ==================== | |
| function captureImage() { | |
| renderer.render(scene, camera); | |
| var url = renderer.domElement.toDataURL('image/png'); | |
| var totalParts = 0, fallenParts = 0; | |
| buildingParts.forEach(function(p) { if (p.floor >= 0) { totalParts++; if (p.state === 'landed' || p.state === 'falling') fallenParts++; } }); | |
| var actualDamage = totalParts > 0 ? fallenParts / totalParts : 0; | |
| capturedImages.push({ url: url, cfg: JSON.parse(JSON.stringify(config)), damage: actualDamage, time: new Date().toLocaleTimeString() }); | |
| document.getElementById('status').textContent = '📸 Captured (' + capturedImages.length + ' images)'; | |
| var flash = document.createElement('div'); | |
| flash.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;background:white;opacity:0.5;transition:opacity 0.3s;pointer-events:none;'; | |
| document.getElementById('viewport').appendChild(flash); | |
| setTimeout(function() { flash.style.opacity = '0'; }, 50); | |
| setTimeout(function() { flash.remove(); }, 400); | |
| } | |
| function showGallery() { | |
| var g = document.getElementById('gallery'); | |
| var grid = document.getElementById('galleryGrid'); | |
| if (capturedImages.length === 0) { | |
| grid.innerHTML = '<p style="color:#aaa;grid-column:1/-1">No images yet. Run simulation then click Capture.</p>'; | |
| } else { | |
| var h = ''; | |
| for (var i = 0; i < capturedImages.length; i++) { | |
| var img = capturedImages[i]; | |
| h += '<div class="gallery-item"><img src="' + img.url + '"><div class="caption"><b>#' + (i+1) + '</b> ' + img.time + '<br>' + img.cfg.buildingStyle + ' | ' + img.cfg.material + ' | M' + img.cfg.magnitude + '<br>Foundation: ' + img.cfg.foundation + ' | Damage: ' + (img.damage*100).toFixed(0) + '%</div></div>'; | |
| } | |
| grid.innerHTML = h; | |
| } | |
| g.style.display = 'block'; | |
| } | |
| // ==================== RESET & RENDER LOOP ==================== | |
| function resetScene() { | |
| isSimulating = false; | |
| simulationTime = 0; | |
| clearAll(); | |
| // Reset ground position | |
| if (groundMesh) groundMesh.position.set(0, 0, 0); | |
| if (pathMesh) pathMesh.position.set(0, 0.01, 8); | |
| buildHouse(); | |
| document.getElementById('status').textContent = 'Ready - Configure and start simulation'; | |
| document.getElementById('sim-info').textContent = 'Set parameters and press Start.'; | |
| } | |
| var rescueAnimObjects = []; // objects that animate when rescue paths are shown | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| // Animate rescue path elements | |
| if (rescueAnimObjects.length > 0) { | |
| var t = performance.now() * 0.001; | |
| rescueAnimObjects.forEach(function(obj) { | |
| if (obj.type === 'pulse') { | |
| // Pulsing survivor markers | |
| var s = 1.0 + Math.sin(t * 3 + obj.phase) * 0.2; | |
| obj.mesh.scale.set(s, s, s); | |
| obj.mesh.material.opacity = 0.5 + Math.sin(t * 2 + obj.phase) * 0.3; | |
| } else if (obj.type === 'flow') { | |
| // Flowing arrows along path | |
| var flowT = ((t * obj.speed + obj.offset) % 1.0); | |
| var p1 = obj.start; | |
| var p2 = obj.end; | |
| obj.mesh.position.x = p1.x + (p2.x - p1.x) * flowT; | |
| obj.mesh.position.z = p1.z + (p2.z - p1.z) * flowT; | |
| obj.mesh.material.opacity = Math.sin(flowT * Math.PI) * 0.9; | |
| } else if (obj.type === 'blink') { | |
| // Blinking danger perimeter | |
| obj.mesh.material.opacity = 0.3 + Math.sin(t * 4 + obj.phase) * 0.3; | |
| } else if (obj.type === 'ring') { | |
| // Expanding ring at survivor location | |
| var ringScale = 1.0 + ((t * 0.8 + obj.phase) % 2.0) * 0.5; | |
| var ringOpacity = Math.max(0, 0.6 - ((t * 0.8 + obj.phase) % 2.0) * 0.3); | |
| obj.mesh.scale.set(ringScale, ringScale, ringScale); | |
| obj.mesh.material.opacity = ringOpacity; | |
| } | |
| }); | |
| } | |
| renderer.render(scene, camera); | |
| } | |
| // ==================== UI ==================== | |
| function setupUI() { | |
| document.getElementById('magnitude').oninput = function(e) { config.magnitude = parseFloat(e.target.value); document.getElementById('magValue').textContent = config.magnitude.toFixed(1); }; | |
| document.getElementById('distance').oninput = function(e) { config.distance = parseInt(e.target.value); document.getElementById('distValue').textContent = config.distance; }; | |
| document.getElementById('duration').oninput = function(e) { config.duration = parseInt(e.target.value); document.getElementById('durValue').textContent = config.duration; }; | |
| document.getElementById('buildingAge').oninput = function(e) { config.buildingAge = parseInt(e.target.value); document.getElementById('ageValue').textContent = config.buildingAge; }; | |
| document.getElementById('buildingStyle').onchange = function(e) { | |
| config.buildingStyle = e.target.value; | |
| var m = { european: 'unreinforcedMasonry', chinese: 'wood', japanese: 'wood', american: 'wood', middleEast: 'adobe' }; | |
| config.material = m[e.target.value]; | |
| document.getElementById('material').value = config.material; | |
| resetScene(); | |
| }; | |
| document.getElementById('stories').onchange = function(e) { config.stories = parseInt(e.target.value); resetScene(); }; | |
| document.getElementById('material').onchange = function(e) { config.material = e.target.value; resetScene(); }; | |
| document.getElementById('foundation').onchange = function(e) { config.foundation = e.target.value; }; | |
| document.getElementById('startBtn').onclick = function() { resetScene(); setTimeout(startEarthquake, 200); }; | |
| document.getElementById('resetBtn').onclick = resetScene; | |
| document.getElementById('captureBtn').onclick = captureImage; | |
| document.getElementById('analyzeBtn').onclick = analyzeRescuePaths; | |
| document.getElementById('galleryBtn').onclick = showGallery; | |
| document.getElementById('closeGallery').onclick = function() { document.getElementById('gallery').style.display = 'none'; }; | |
| window.onresize = function() { | |
| var vp = document.getElementById('viewport'); | |
| camera.aspect = vp.clientWidth / vp.clientHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(vp.clientWidth, vp.clientHeight); | |
| }; | |
| } | |
| // ==================== INIT ==================== | |
| function init() { | |
| initScene(); | |
| buildHouse(); | |
| setupUI(); | |
| animate(); | |
| } | |
| window.addEventListener('DOMContentLoaded', init); | |