Earthquake / app.js
AK51's picture
Upload 2 files
bfd81e8 verified
Raw
History Blame Contribute Delete
80.1 kB
// 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);