This commit is contained in:
rafaeldpsilva
2025-12-10 13:58:24 +00:00
parent adbbf6bf50
commit 0152886cc2
13 changed files with 4310 additions and 4028 deletions

View File

@@ -0,0 +1 @@
:root{font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400}body{margin:0;display:flex;place-items:center;min-width:320px;min-height:100vh}.canvas-container[data-v-0c7f17d8]{width:100%;height:100vh;display:block}.building-panel[data-v-7c86670f]{position:absolute;width:300px;background:#141419f2;border:3px solid #000;border-radius:0;box-shadow:6px 6px #0006;padding:0;overflow:hidden;font-family:Courier New,monospace;z-index:1000;pointer-events:auto;color:#fff;transform:translate(-50%,-110%);transition:left .05s linear,top .05s linear}.building-panel.flipped[data-v-7c86670f]{transform:translate(-50%,10%)}.header[data-v-7c86670f]{background:gold;color:#000;padding:12px;display:flex;justify-content:space-between;align-items:center;border-bottom:3px solid #000;text-transform:uppercase}.header h3[data-v-7c86670f]{margin:0;font-size:1rem;font-weight:900}.close-btn[data-v-7c86670f]{background:#000;border:2px solid black;color:#fff;font-size:1.2rem;cursor:pointer;line-height:1;padding:2px 8px;font-weight:700}.close-btn[data-v-7c86670f]:hover{background:#f44}.content[data-v-7c86670f]{padding:16px}.info-row[data-v-7c86670f]{display:flex;justify-content:space-between;margin-bottom:8px;border-bottom:2px dashed #444;padding-bottom:4px}.label[data-v-7c86670f]{color:#aaa;font-weight:700;text-transform:uppercase;font-size:.8rem}.value[data-v-7c86670f]{font-weight:600;color:#fff;font-family:monospace}.section-title[data-v-7c86670f]{margin-top:16px;margin-bottom:8px;font-size:.8rem;text-transform:uppercase;background:#333;color:#fff;padding:4px;text-align:center;font-weight:700;border:2px solid #000}.iot-text[data-v-7c86670f]{font-size:.85rem;color:#ccc;background:#111;padding:8px;border:2px solid #333;margin:0}.actions[data-v-7c86670f]{margin-top:20px;display:flex;gap:8px}.action-btn[data-v-7c86670f]{flex:1;padding:8px;border:3px solid #000;border-radius:0;background:#fc0;color:#000;font-weight:900;cursor:pointer;text-transform:uppercase;box-shadow:3px 3px #000;transition:all .1s}.action-btn[data-v-7c86670f]:hover{transform:translate(2px,2px);box-shadow:1px 1px #000}.action-btn[data-v-7c86670f]:active{transform:translate(3px,3px);box-shadow:0 0 #000}.dashboard-bar[data-v-99b9fc73]{position:absolute;bottom:0;left:0;width:100%;height:80px;background:#111;border-top:4px solid #000;display:flex;justify-content:space-between;align-items:center;padding:0 40px;box-sizing:border-box;color:#fff;font-family:Courier New,monospace;z-index:900;box-shadow:0 -4px 20px #00000080}.stat-group[data-v-99b9fc73]{display:flex;gap:30px}.stat-item[data-v-99b9fc73]{display:flex;flex-direction:column}.label[data-v-99b9fc73]{font-size:.75rem;color:#888;font-weight:700}.value[data-v-99b9fc73]{font-size:1.4rem;font-weight:700}.value.green[data-v-99b9fc73]{color:#0f0;text-shadow:0 0 10px rgba(0,255,0,.3)}.value.red[data-v-99b9fc73]{color:#f33;text-shadow:0 0 10px rgba(255,0,0,.3)}.time-display[data-v-99b9fc73]{font-size:1.8rem;font-weight:900;color:gold;background:#000;padding:5px 20px;border:2px solid #333;letter-spacing:2px}.controls[data-v-99b9fc73]{display:flex;gap:20px;align-items:center}.speed-controls[data-v-99b9fc73]{display:flex;gap:5px;background:#222;padding:4px;border:1px solid #444}button[data-v-99b9fc73]{background:transparent;border:2px solid transparent;color:#888;padding:6px 12px;cursor:pointer;font-family:Courier New,monospace;font-weight:700;text-transform:uppercase;transition:all .1s}button[data-v-99b9fc73]:hover{color:#fff;background:#333}button.active[data-v-99b9fc73]{background:gold;color:#000;box-shadow:0 0 10px gold}.play-btn[data-v-99b9fc73]{border:2px solid #444;padding:10px 24px;font-size:1.1rem;background:#222;color:#fff}.play-btn[data-v-99b9fc73]:hover{border-color:#fff}.play-btn.active[data-v-99b9fc73]{background:#f33;color:#fff;box-shadow:none;border-color:red}html,body,#app{margin:0;padding:0;width:100%;height:100%;overflow:hidden}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
:root{font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400}body{margin:0;display:flex;place-items:center;min-width:320px;min-height:100vh}.canvas-container[data-v-3dc03414]{width:100%;height:100vh;display:block}.building-panel[data-v-2da88f20]{position:absolute;transform:translate(-50%,-110%);width:300px;background:#fffffff2;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border-radius:12px;box-shadow:0 4px 20px #00000026;padding:0;overflow:hidden;font-family:Inter,sans-serif;animation:popIn-2da88f20 .2s cubic-bezier(.175,.885,.32,1.275);z-index:1000;pointer-events:auto}@keyframes popIn-2da88f20{0%{opacity:0;transform:translate(-50%,-100%) scale(.9)}to{opacity:1;transform:translate(-50%,-110%) scale(1)}}.header[data-v-2da88f20]{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;padding:16px;display:flex;justify-content:space-between;align-items:center}.header h3[data-v-2da88f20]{margin:0;font-size:1.1rem;font-weight:600}.close-btn[data-v-2da88f20]{background:none;border:none;color:#fff;font-size:1.5rem;cursor:pointer;line-height:1;padding:0 5px}.content[data-v-2da88f20]{padding:20px}.info-row[data-v-2da88f20]{display:flex;justify-content:space-between;margin-bottom:12px;border-bottom:1px solid #eee;padding-bottom:8px}.label[data-v-2da88f20]{color:#666;font-weight:500}.value[data-v-2da88f20]{font-weight:600;color:#333}.section-title[data-v-2da88f20]{margin-top:20px;margin-bottom:10px;font-size:.9rem;text-transform:uppercase;letter-spacing:1px;color:#888;font-weight:700}.iot-text[data-v-2da88f20]{font-size:.9rem;color:#555;background:#f5f5f7;padding:10px;border-radius:6px;margin:0}.actions[data-v-2da88f20]{margin-top:20px;display:flex;gap:10px}.action-btn[data-v-2da88f20]{flex:1;padding:10px;border:none;border-radius:6px;background:#f0f0f0;color:#333;font-weight:600;cursor:pointer;transition:all .2s}.action-btn[data-v-2da88f20]:hover{background:#e0e0e0;transform:translateY(-1px)}.time-controls[data-v-fa1ceac6]{position:absolute;bottom:20px;left:50%;transform:translate(-50%);background:#141419d9;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);padding:12px 24px;border-radius:30px;display:flex;flex-direction:column;align-items:center;gap:10px;color:#fff;box-shadow:0 4px 15px #0000004d}.time-display[data-v-fa1ceac6]{font-family:Monaco,Consolas,monospace;font-size:1.1rem;font-weight:600;color:#4facfe}.controls[data-v-fa1ceac6]{display:flex;gap:15px;align-items:center}button[data-v-fa1ceac6]{background:transparent;border:1px solid rgba(255,255,255,.2);color:#fff;padding:6px 12px;border-radius:15px;cursor:pointer;font-size:.9rem;transition:all .2s}button[data-v-fa1ceac6]:hover{background:#ffffff1a}button.active[data-v-fa1ceac6]{background:#4facfe;color:#fff;border-color:#4facfe}.speed-controls[data-v-fa1ceac6]{display:flex;gap:5px}.speed-controls button[data-v-fa1ceac6]{padding:4px 8px;font-size:.8rem}html,body,#app{margin:0;padding:0;width:100%;height:100%;overflow:hidden}

3858
web-app/dist/assets/index-CwCwvZ0Q.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Community Digital Twin</title>
<script type="module" crossorigin src="/assets/index-BDdF_E2G.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CSR7bLu2.css">
<script type="module" crossorigin src="/assets/index-CwCwvZ0Q.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-1evUyPMc.css">
</head>
<body>
<div id="app"></div>

View File

@@ -43,7 +43,6 @@ import { useSimulation } from '../composables/useSimulation';
import { computed, ref, onMounted, onUnmounted } from 'vue';
const { selectedBuilding } = useSimulation();
const panelRef = ref<HTMLElement | null>(null);
const windowWidth = ref(window.innerWidth);
const windowHeight = ref(window.innerHeight);
@@ -104,20 +103,20 @@ const panelStyle = computed(() => {
.building-panel {
position: absolute;
width: 300px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
background: rgba(20, 20, 25, 0.95); /* Dark HUD background */
border: 3px solid #000; /* Bold Outline */
border-radius: 0; /* Sharp corners */
box-shadow: 6px 6px 0px rgba(0,0,0,0.4); /* Hard drop shadow */
padding: 0;
overflow: hidden;
font-family: 'Inter', sans-serif;
animation: popIn 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
font-family: 'Courier New', monospace; /* Tech/Game font */
z-index: 1000;
pointer-events: auto;
color: white;
/* Default: Above the point */
transform: translate(-50%, -110%);
transition: left 0.1s ease-out, top 0.1s ease-out, transform 0.2s;
transition: left 0.05s linear, top 0.05s linear; /* Snappier movement */
}
/* Flip to below the point if active */
@@ -125,97 +124,111 @@ const panelStyle = computed(() => {
transform: translate(-50%, 10%); /* 10% gap below point */
}
@keyframes popIn {
from { opacity: 0; transform: scale(0.9); } /* simplified generic pop */
to { opacity: 1; transform: scale(1); } /* overridden by class transforms */
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 16px;
background: #FFD700; /* Vibrant Gold */
color: black;
padding: 12px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 3px solid #000;
text-transform: uppercase;
}
.header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
font-size: 1.0rem;
font-weight: 900;
}
.close-btn {
background: none;
border: none;
background: black;
border: 2px solid black;
color: white;
font-size: 1.5rem;
font-size: 1.2rem;
cursor: pointer;
line-height: 1;
padding: 0 5px;
padding: 2px 8px;
font-weight: bold;
}
.close-btn:hover {
background: #ff4444;
}
.content {
padding: 20px;
padding: 16px;
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
border-bottom: 1px solid #eee;
padding-bottom: 8px;
margin-bottom: 8px;
border-bottom: 2px dashed #444;
padding-bottom: 4px;
}
.label {
color: #666;
font-weight: 500;
color: #aaa;
font-weight: 700;
text-transform: uppercase;
font-size: 0.8rem;
}
.value {
font-weight: 600;
color: #333;
color: #fff;
font-family: monospace;
}
.section-title {
margin-top: 20px;
margin-bottom: 10px;
font-size: 0.9rem;
margin-top: 16px;
margin-bottom: 8px;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 1px;
color: #888;
font-weight: 700;
background: #333;
color: #fff;
padding: 4px;
text-align: center;
font-weight: bold;
border: 2px solid #000;
}
.iot-text {
font-size: 0.9rem;
color: #555;
background: #f5f5f7;
padding: 10px;
border-radius: 6px;
font-size: 0.85rem;
color: #ccc;
background: #111;
padding: 8px;
border: 2px solid #333;
margin: 0;
}
.actions {
margin-top: 20px;
display: flex;
gap: 10px;
gap: 8px;
}
.action-btn {
flex: 1;
padding: 10px;
border: none;
border-radius: 6px;
background: #f0f0f0;
color: #333;
font-weight: 600;
padding: 8px;
border: 3px solid #000;
border-radius: 0;
background: #ffcc00;
color: black;
font-weight: 900;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
box-shadow: 3px 3px 0px #000;
transition: all 0.1s;
}
.action-btn:hover {
background: #e0e0e0;
transform: translateY(-1px);
transform: translate(2px, 2px); /* Click effect */
box-shadow: 1px 1px 0px #000;
}
.action-btn:active {
transform: translate(3px, 3px);
box-shadow: 0px 0px 0px #000;
}
</style>

View File

@@ -7,85 +7,162 @@ const { state, togglePlay, setSpeed } = useSimulation();
const formattedTime = computed(() => {
const hours = Math.floor(state.value.currentTime);
const minutes = Math.floor((state.value.currentTime - hours) * 60);
return `Day ${state.value.day}, ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
return `Day ${state.value.day} - ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
});
const netEnergy = computed(() => {
return (state.value.totalGeneration || 0) - (state.value.totalConsumption || 0);
});
</script>
<template>
<div class="time-controls">
<div class="time-display">{{ formattedTime }}</div>
<div class="controls">
<button @click="togglePlay" :class="{ active: state.isPlaying }">
{{ state.isPlaying ? 'Pause' : 'Play' }}
</button>
<div class="speed-controls">
<button @click="setSpeed(1)" :class="{ active: state.speed === 1 }">1x</button>
<button @click="setSpeed(5)" :class="{ active: state.speed === 5 }">5x</button>
<button @click="setSpeed(20)" :class="{ active: state.speed === 20 }">20x</button>
<div class="dashboard-bar">
<!-- Left: Energy Stats -->
<div class="stat-group">
<div class="stat-item">
<span class="label">CONSUMPTION</span>
<span class="value red">{{ Math.round(state.totalConsumption || 0) }} kW</span>
</div>
<div class="stat-item">
<span class="label">GENERATION</span>
<span class="value green">{{ Math.round(state.totalGeneration || 0) }} kW</span>
</div>
<div class="stat-item">
<span class="label">NET GRID</span>
<span class="value" :class="netEnergy >= 0 ? 'green' : 'red'">
{{ netEnergy > 0 ? '+' : ''}}{{ Math.round(netEnergy) }} kW
</span>
</div>
</div>
<!-- Center: Time -->
<div class="time-display">
{{ formattedTime }}
</div>
<!-- Right: Controls -->
<div class="controls">
<div class="speed-controls">
<button @click="setSpeed(1)" :class="{ active: state.speed === 1 }">1x</button>
<button @click="setSpeed(200)" :class="{ active: state.speed === 200 }">200x</button>
</div>
<button class="play-btn" @click="togglePlay" :class="{ active: state.isPlaying }">
{{ state.isPlaying ? 'PAUSE' : 'PLAY' }}
</button>
</div>
</div>
</template>
<style scoped>
.time-controls {
.dashboard-bar {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(20, 20, 25, 0.85);
backdrop-filter: blur(8px);
padding: 12px 24px;
border-radius: 30px;
bottom: 0px;
left: 0;
width: 100%;
height: 80px;
background: #111;
border-top: 4px solid #000;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 0 40px;
box-sizing: border-box;
color: white;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
font-family: 'Courier New', monospace;
z-index: 900;
box-shadow: 0 -4px 20px rgba(0,0,0,0.5);
}
.stat-group {
display: flex;
gap: 30px;
}
.stat-item {
display: flex;
flex-direction: column;
}
.label {
font-size: 0.75rem;
color: #888;
font-weight: 700;
}
.value {
font-size: 1.4rem;
font-weight: 700;
}
.value.green { color: #00ff00; text-shadow: 0 0 10px rgba(0,255,0,0.3); }
.value.red { color: #ff3333; text-shadow: 0 0 10px rgba(255,0,0,0.3); }
.time-display {
font-family: 'Monaco', 'Consolas', monospace;
font-size: 1.1rem;
font-weight: 600;
color: #4facfe;
font-size: 1.8rem;
font-weight: 900;
color: #FFD700; /* Gold */
background: #000;
padding: 5px 20px;
border: 2px solid #333;
letter-spacing: 2px;
}
.controls {
display: flex;
gap: 15px;
gap: 20px;
align-items: center;
}
button {
background: transparent;
border: 1px solid rgba(255,255,255,0.2);
color: white;
padding: 6px 12px;
border-radius: 15px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
button:hover {
background: rgba(255,255,255,0.1);
}
button.active {
background: #4facfe;
color: white;
border-color: #4facfe;
}
.speed-controls {
display: flex;
gap: 5px;
background: #222;
padding: 4px;
border: 1px solid #444;
}
.speed-controls button {
padding: 4px 8px;
font-size: 0.8rem;
button {
background: transparent;
border: 2px solid transparent;
color: #888;
padding: 6px 12px;
cursor: pointer;
font-family: 'Courier New', monospace;
font-weight: bold;
text-transform: uppercase;
transition: all 0.1s;
}
button:hover {
color: white;
background: #333;
}
button.active {
background: #FFD700;
color: black;
box-shadow: 0 0 10px #FFD700;
}
.play-btn {
border: 2px solid #444;
padding: 10px 24px;
font-size: 1.1rem;
background: #222;
color: white;
}
.play-btn:hover {
border-color: #fff;
}
.play-btn.active {
background: #ff3333; /* Stop/Pause color */
color: white;
box-shadow: none;
border-color: #ff0000;
}
</style>

View File

@@ -16,11 +16,12 @@ export function useCameraControls(camera: THREE.Camera, renderer: THREE.WebGLRen
const initControls = () => {
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.enableDamping = false;
controls.dampingFactor = 0.05;
controls.enableRotate = false;
controls.enablePan = true;
controls.enableZoom = false;
controls.screenSpacePanning = false; // Fix: Pan on XZ plane only
// Strictly match legacy controls.js
controls.mouseButtons = {
LEFT: THREE.MOUSE.PAN
@@ -38,19 +39,22 @@ export function useCameraControls(camera: THREE.Camera, renderer: THREE.WebGLRen
renderer.domElement.addEventListener('wheel', (event: WheelEvent) => {
event.preventDefault();
if (!moveAnimating) {
if (event.deltaY < 0 && camera.position.y <= 35) {
const zoomAmount = 5;
if (event.deltaY < 0 && camera.position.y <= 120) { // Increased Zoom Out limit
// Zoom Out (Up)
moveStart = camera.position.clone();
moveEnd = camera.position.clone().add(new THREE.Vector3(2, 10, 2));
moveEnd = camera.position.clone().add(new THREE.Vector3(0, zoomAmount, 0));
contStart = controls.target.clone();
contEnd = controls.target.clone().add(new THREE.Vector3(2, 0, 2));
contEnd = controls.target.clone(); // Target stays same
moveProgress = 0;
moveStartTime = performance.now() / 1000;
moveAnimating = true;
} else if (event.deltaY > 0 && camera.position.y >= 30) {
} else if (event.deltaY > 0 && camera.position.y >= 10) {
// Zoom In (Down)
moveStart = camera.position.clone();
moveEnd = camera.position.clone().add(new THREE.Vector3(-2, -10, -2));
moveEnd = camera.position.clone().add(new THREE.Vector3(0, -zoomAmount, 0));
contStart = controls.target.clone();
contEnd = controls.target.clone().add(new THREE.Vector3(-2, 0, -2));
contEnd = controls.target.clone();
moveProgress = 0;
moveStartTime = performance.now() / 1000;
moveAnimating = true;
@@ -78,7 +82,6 @@ export function useCameraControls(camera: THREE.Camera, renderer: THREE.WebGLRen
};
return {
controls, // exposed if needed
initControls,
updateControls,
getControls: () => controls

View File

@@ -4,32 +4,49 @@ export function useCityObjects() {
const buildings: THREE.Mesh[] = [];
const interactableObjects: THREE.Object3D[] = [];
// --- Assets & Materials ---
const MATERIALS = {
road: new THREE.MeshLambertMaterial({ color: 0x333333 }),
roadLine: new THREE.MeshBasicMaterial({ color: 0xffffff }),
sidewalk: new THREE.MeshLambertMaterial({ color: 0xcccccc }),
grass: new THREE.MeshLambertMaterial({ color: 0x7cfc00 }),
treeTrunk: new THREE.MeshLambertMaterial({ color: 0x8b4513 }),
treeLeaves: new THREE.MeshLambertMaterial({ color: 0x228b22 }),
};
const buildingTypes: Record<string, any> = {
house: {
geometry: new THREE.BoxGeometry(4, 6, 4),
material: new THREE.MeshLambertMaterial({ color: 0x87CEEB }),
geometry: new THREE.BoxGeometry(6, 6, 6),
material: new THREE.MeshLambertMaterial({ color: 0xffaa00 }), // Vibrant Orange
description: "Residential House"
},
factory: {
geometry: (() => {
const shape = new THREE.Shape().moveTo(0, 0).lineTo(0, 6).lineTo(8, 6).lineTo(8, 8).lineTo(10, 8).lineTo(10, 0).lineTo(0, 0);
return new THREE.ExtrudeGeometry(shape, { depth: 12, bevelEnabled: false });
})(),
material: new THREE.MeshLambertMaterial({ color: 0xB22222 }),
geometry: new THREE.BoxGeometry(8, 6, 10),
material: new THREE.MeshLambertMaterial({ color: 0xe74c3c }), // Red
description: "Manufacturing Plant"
},
office: {
geometry: new THREE.BoxGeometry(6, 10, 6),
material: new THREE.MeshLambertMaterial({ color: 0xFFFFFF }),
geometry: new THREE.BoxGeometry(8, 14, 8),
material: new THREE.MeshLambertMaterial({ color: 0x3498db }), // Blue
description: "Office Building"
},
shop: {
geometry: new THREE.BoxGeometry(8, 4, 6),
material: new THREE.MeshLambertMaterial({ color: 0xd8d400 }),
geometry: new THREE.BoxGeometry(8, 5, 8),
material: new THREE.MeshLambertMaterial({ color: 0xf1c40f }), // Yellow
description: "Retail Shop"
}
};
// --- City Generation Config ---
const BLOCK_SIZE = 30; // Size of one grid cell (including road half-width)
const ROAD_WIDTH = 8;
const SIDEWALK_WIDTH = 1.5;
const GRID_SIZE = 6; // 6x6 grid
// --- Instanced Mesh Config ---
const MAX_TREES = 200; // Safe upper bound for 6x6 grid with max 3 trees per plot
const GRID_AREA = GRID_SIZE * GRID_SIZE; // 36
const createGenericBuilding = (scene: THREE.Scene, buildingData: any, position: THREE.Vector3, id: string, csvPath: string, rotation: number = 0) => {
const mesh = new THREE.Mesh(buildingData.geometry, buildingData.material);
mesh.position.copy(position);
@@ -51,46 +68,156 @@ export function useCityObjects() {
scene.add(mesh);
};
const createBuilding = (scene: THREE.Scene, type: string, position: THREE.Vector3, id: string, csvPath: string, rotation?: number) => {
const createBuilding = (scene: THREE.Scene, type: string, position: THREE.Vector3, id: string, csvPath: string, rotation: number = 0) => {
const buildingData = buildingTypes[type];
if (!buildingData) {
console.warn(`Building type ${type} not found`);
return;
}
createGenericBuilding(scene, buildingData, position, id, csvPath, rotation);
// Adjust position to sit on sidewalk/grass (y=0.5)
const pos = position.clone();
pos.y = 0.5;
createGenericBuilding(scene, buildingData, pos, id, csvPath, rotation);
};
const initGround = (scene: THREE.Scene) => {
const groundGeometry = new THREE.PlaneGeometry(150, 150);
const groundMaterial = new THREE.MeshLambertMaterial({ color: 0x91ca49, side: THREE.DoubleSide });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Base Plane (Asphalt)
const baseGeo = new THREE.PlaneGeometry(300, 300);
const base = new THREE.Mesh(baseGeo, MATERIALS.road);
base.rotation.x = -Math.PI / 2;
base.position.y = -0.1;
base.receiveShadow = true;
scene.add(base);
const plots: THREE.Vector3[] = [];
const offset = ((GRID_SIZE - 1) * BLOCK_SIZE) / 2;
// --- Instanced Meshes Setup ---
// 1. Sidewalks
const sidewalkGeo = new THREE.BoxGeometry(BLOCK_SIZE - ROAD_WIDTH, 0.4, BLOCK_SIZE - ROAD_WIDTH);
const sidewalkMesh = new THREE.InstancedMesh(sidewalkGeo, MATERIALS.sidewalk, GRID_AREA);
sidewalkMesh.receiveShadow = true;
// 2. Grass
const grassSize = (BLOCK_SIZE - ROAD_WIDTH) - (SIDEWALK_WIDTH * 2);
const grassGeo = new THREE.BoxGeometry(grassSize, 0.5, grassSize);
const grassMesh = new THREE.InstancedMesh(grassGeo, MATERIALS.grass, GRID_AREA);
grassMesh.receiveShadow = true;
const dummy = new THREE.Object3D();
let instanceIdx = 0;
for (let ix = 0; ix < GRID_SIZE; ix++) {
for (let iz = 0; iz < GRID_SIZE; iz++) {
const x = (ix * BLOCK_SIZE) - offset;
const z = (iz * BLOCK_SIZE) - offset;
// Position Helper
dummy.position.set(x, 0.2, z);
dummy.rotation.set(0, 0, 0);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
sidewalkMesh.setMatrixAt(instanceIdx, dummy.matrix);
dummy.position.set(x, 0.25, z);
dummy.updateMatrix();
grassMesh.setMatrixAt(instanceIdx, dummy.matrix);
plots.push(new THREE.Vector3(x, 0, z));
instanceIdx++;
}
}
scene.add(sidewalkMesh);
scene.add(grassMesh);
// Grid Lines
const gridHelper = new THREE.GridHelper(GRID_SIZE * BLOCK_SIZE, GRID_SIZE, 0xffffff, 0xffffff);
gridHelper.position.y = 0.05;
// scene.add(gridHelper); // Optional, kept off for clean look
return plots;
};
const initBuildings = (scene: THREE.Scene) => {
createBuilding(scene, 'house', new THREE.Vector3(-9, 0, 24), 'H01', 'data/H01.csv');
createBuilding(scene, 'factory', new THREE.Vector3(48, 0, -49), 'H02', 'data/H02.csv');
createBuilding(scene, 'office', new THREE.Vector3(-38, 0, -12), 'H03', 'data/H03.csv');
createBuilding(scene, 'shop', new THREE.Vector3(-5, 0, -5), 'H04', 'data/H04.csv', Math.PI / 2);
createBuilding(scene, 'house', new THREE.Vector3(-5, 0, 40), 'H05', 'data/H05.csv');
createBuilding(scene, 'house', new THREE.Vector3(6, 0, 24), 'H06', 'data/H06.csv');
createBuilding(scene, 'house', new THREE.Vector3(-37, 0, 30), 'H07', 'data/H07.csv');
createBuilding(scene, 'house', new THREE.Vector3(-34, 0, 9), 'H08', 'data/H08.csv', -Math.PI / 2);
createBuilding(scene, 'house', new THREE.Vector3(19, 0, -20), 'H09', 'data/H09.csv', Math.PI / 2);
createBuilding(scene, 'house', new THREE.Vector3(36, 0, 6), 'H10', 'data/H10.csv');
createBuilding(scene, 'house', new THREE.Vector3(34, 0, -19), 'H11', 'data/H11.csv', Math.PI);
createBuilding(scene, 'house', new THREE.Vector3(-21, 0, -18), 'H12', 'data/H12.csv');
createBuilding(scene, 'house', new THREE.Vector3(-5, 0, -20), 'H13', 'data/H13.csv');
createBuilding(scene, 'house', new THREE.Vector3(-7, 0, -48), 'H14', 'data/H14.csv');
const initBuildings = (scene: THREE.Scene, plots: THREE.Vector3[]) => {
const buildingDefs = [
{ id: 'H01', type: 'house', csv: 'data/H01.csv' },
{ id: 'H02', type: 'factory', csv: 'data/H02.csv' },
{ id: 'H03', type: 'office', csv: 'data/H03.csv' },
{ id: 'H04', type: 'shop', csv: 'data/H04.csv' },
{ id: 'H05', type: 'house', csv: 'data/H05.csv' },
{ id: 'H06', type: 'house', csv: 'data/H06.csv' },
{ id: 'H07', type: 'house', csv: 'data/H07.csv' },
{ id: 'H08', type: 'shop', csv: 'data/H08.csv' },
{ id: 'H09', type: 'shop', csv: 'data/H09.csv' },
{ id: 'H10', type: 'house', csv: 'data/H10.csv' },
{ id: 'H11', type: 'house', csv: 'data/H11.csv' },
{ id: 'H12', type: 'house', csv: 'data/H12.csv' },
{ id: 'H13', type: 'house', csv: 'data/H13.csv' },
{ id: 'H14', type: 'house', csv: 'data/H14.csv' },
];
// --- Tree Instancing Setup ---
const trunkGeo = new THREE.CylinderGeometry(0.5, 0.5, 2, 6);
const leavesGeo = new THREE.ConeGeometry(2, 4, 8);
const trunkMesh = new THREE.InstancedMesh(trunkGeo, MATERIALS.treeTrunk, MAX_TREES);
const leavesMesh = new THREE.InstancedMesh(leavesGeo, MATERIALS.treeLeaves, MAX_TREES);
trunkMesh.castShadow = true; trunkMesh.receiveShadow = true;
leavesMesh.castShadow = true; leavesMesh.receiveShadow = true;
let treeCount = 0;
const dummy = new THREE.Object3D();
const addTreeInstance = (pos: THREE.Vector3) => {
if (treeCount >= MAX_TREES) return;
// Trunk
dummy.position.copy(pos).add(new THREE.Vector3(0, 1, 0));
dummy.rotation.set(0, 0, 0);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
trunkMesh.setMatrixAt(treeCount, dummy.matrix);
// Leaves
dummy.position.copy(pos).add(new THREE.Vector3(0, 4, 0));
dummy.updateMatrix();
leavesMesh.setMatrixAt(treeCount, dummy.matrix);
treeCount++;
};
let buildingIndex = 0;
for (let i = 0; i < plots.length; i++) {
const plot = plots[i];
if (buildingIndex < buildingDefs.length) {
const def = buildingDefs[buildingIndex];
createBuilding(scene, def.type, plot, def.id, def.csv, 0);
buildingIndex++;
} else {
// Empty plot filler: Trees
addTreeInstance(plot.clone().add(new THREE.Vector3(-5, 0.5, -5)));
if (Math.random() > 0.5) {
addTreeInstance(plot.clone().add(new THREE.Vector3(6, 0.5, 4)));
}
}
}
// Finalize Instanced Meshes
trunkMesh.count = treeCount;
leavesMesh.count = treeCount;
scene.add(trunkMesh);
scene.add(leavesMesh);
};
return {
interactableObjects,
initCity: (scene: THREE.Scene) => {
initGround(scene);
initBuildings(scene);
const plots = initGround(scene);
initBuildings(scene, plots);
}
};
}

View File

@@ -95,7 +95,11 @@ export function useInteraction(
if (controls) controls.enabled = !event.value;
});
scene.add(transformControls as unknown as THREE.Object3D);
try {
scene.add(transformControls.getHelper());
} catch (e) {
console.error("Failed to add TransformControls helper to scene:", e);
}
window.addEventListener('keydown', (event) => {
if (event.key === 'm') {
@@ -109,9 +113,26 @@ export function useInteraction(
});
renderer.domElement.addEventListener('pointerdown', onPointerDown);
renderer.domElement.addEventListener('pointerup', onPointerUp);
};
let startX = 0;
let startY = 0;
const onPointerDown = (event: MouseEvent) => {
startX = event.clientX;
startY = event.clientY;
};
const onPointerUp = (event: MouseEvent) => {
const diffX = Math.abs(event.clientX - startX);
const diffY = Math.abs(event.clientY - startY);
// Click Threshold (pixels)
if (diffX > 5 || diffY > 5) {
return; // Dragged, ignore
}
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
@@ -128,8 +149,6 @@ export function useInteraction(
} else {
transformControls.detach();
}
// Fall through to allow selection info/ring even in move mode,
// matching legacy behavior where listeners likely co-existed.
}
// Selection Mode
@@ -139,7 +158,7 @@ export function useInteraction(
const data = group.userData;
// Screen coords
// Screen coords for Info Window
const vector = group.position.clone();
vector.project(camera);
const x = (vector.x * .5 + .5) * rect.width;
@@ -170,6 +189,7 @@ export function useInteraction(
}
updateSelectionRing(group);
} else {
// Only Deselect if we clicked on NOTHING, and we weren't dragging.
selectedBuilding.value = null;
updateSelectionRing(null as any);
}

View File

@@ -21,7 +21,7 @@ export function useSunSystem(scene: THREE.Scene, state: Ref<{ currentTime: numbe
};
const initSun = () => {
ambientLight = new THREE.AmbientLight(0x90bbbd, 0.9);
ambientLight = new THREE.AmbientLight(0x90bbbd, 1.2); // Boosted Ambient
scene.add(ambientLight);
directionalLight = new THREE.DirectionalLight(0xffffff);
@@ -52,7 +52,7 @@ export function useSunSystem(scene: THREE.Scene, state: Ref<{ currentTime: numbe
sun: COLORS.daySun,
ambient: COLORS.dayAmbient,
bg: COLORS.day,
intensity: 1.0,
intensity: 1.5, // Boosted Sun Intensity
shadow: true
};
}
@@ -68,7 +68,7 @@ export function useSunSystem(scene: THREE.Scene, state: Ref<{ currentTime: numbe
sun: COLORS.tempSun,
ambient: COLORS.tempAmbient,
bg: COLORS.tempBg, // We might want a more "orange" sky here, but lerp is safe
intensity: 0.5 + (0.5 * t), // 0.5 to 1.0
intensity: 0.5 + (1.0 * t), // 0.5 to 1.5
shadow: true
};
}
@@ -102,14 +102,7 @@ export function useSunSystem(scene: THREE.Scene, state: Ref<{ currentTime: numbe
const updateSun = () => {
if (!directionalLight || !ambientLight) return;
sunPosition();
// Get elevation from the already calculated position
// We can re-calculate or just extract it.
// We have `sunPosition` function but it updates the light directly.
// Let's rely on state.
// Actually, we need the elevation.
// Ideally `sunPosition` returns it.
// Calculate Position
const latitude = 41.17873;
const longitude = -8.60835;
const currentHour = state.value.currentTime;
@@ -117,8 +110,25 @@ export function useSunSystem(scene: THREE.Scene, state: Ref<{ currentTime: numbe
now.setHours(Math.floor(currentHour));
now.setMinutes(Math.floor((currentHour % 1) * 60));
now.setSeconds(0);
const sunPos = SunCalc.getPosition(now, latitude, longitude);
// Position the light
const sunRadius = 100;
const azimuth = sunPos.azimuth;
const elevation = sunPos.altitude;
const sunX = sunRadius * Math.cos(elevation) * Math.cos(azimuth);
const sunY = sunRadius * Math.sin(elevation);
const sunZ = sunRadius * Math.cos(elevation) * Math.sin(azimuth);
if (directionalLight) {
directionalLight.position.set(sunX, sunY, sunZ);
directionalLight.target.position.set(0, 0, 0);
directionalLight.target.updateMatrixWorld();
}
// Update Colors based on elevation
const colors = getSunColors(sunPos.altitude);
directionalLight.color.copy(colors.sun);

View File

@@ -7,8 +7,12 @@ interface SimulationState {
speed: number;
day: number;
totalSeconds: number;
totalConsumption: number;
totalGeneration: number;
}
interface BuildingData {
id: string;
type: string;
@@ -32,9 +36,11 @@ const dayOfYear = Math.floor(diff / oneDay);
const state = ref<SimulationState>({
currentTime: now.getHours() + now.getMinutes() / 60,
isPlaying: false,
speed: 1, // 1 real sec = 1 game min (default)
speed: 1,
day: dayOfYear,
totalSeconds: 0 // Will be calculated
totalSeconds: 0,
totalConsumption: 0,
totalGeneration: 0
});
const selectedBuilding = ref<BuildingData | null>(null);
@@ -48,9 +54,8 @@ export const useSimulation = () => {
state.value.speed = speed;
};
const loadBuildingData = (buildingId: string, csvPath: string) => {
console.log(`Loading data for ${buildingId} from ${csvPath}`);
// In a real app we might cache this
const loadBuildingData = (_buildingId: string, csvPath: string) => {
// console.log(`Loading data for ${_buildingId} from ${csvPath}`);
return new Promise((resolve, reject) => {
Papa.parse(csvPath, {
download: true,
@@ -68,12 +73,39 @@ export const useSimulation = () => {
const updateTime = (delta: number) => {
if (!state.value.isPlaying) return;
// Simple time progression mock
state.value.currentTime += delta * state.value.speed;
if (state.value.currentTime >= 24) {
state.value.currentTime = 0;
state.value.day++;
}
// --- Simulated Community Energy Logic ---
const time = state.value.currentTime;
// Consumption Curve: Base load + Evening Peak (18-22)
// Base Community Load ~4000kW
let load = 4000;
// Peak
if (time > 17 && time < 23) {
load += 2000 * Math.sin(((time - 17) / 6) * Math.PI);
}
// Morning spike
if (time > 6 && time < 9) {
load += 1000 * Math.sin(((time - 6) / 3) * Math.PI);
}
// Generation Curve: Based on simplified Sun Elevation
// Simple Parabola from 6am to 6pm
let gen = 0;
if (time > 6 && time < 18) {
// Peak at 12
const sunFactor = Math.sin(((time - 6) / 12) * Math.PI);
gen = 3500 * sunFactor; // Max 3500kW generation
}
state.value.totalConsumption = Math.floor(load);
state.value.totalGeneration = Math.floor(gen);
};
return {

View File

@@ -1 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/composables/useSimulation.ts","./src/composables/three/useCameraControls.ts","./src/composables/three/useCityObjects.ts","./src/composables/three/useInteraction.ts","./src/composables/three/useSunSystem.ts","./src/composables/three/useThreeScene.ts","./src/App.vue","./src/components/BuildingInfoCanvas.vue","./src/components/DigitalTwin.vue","./src/components/TimeControls.vue"],"errors":true,"version":"5.6.3"}
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/composables/useSimulation.ts","./src/composables/three/useCameraControls.ts","./src/composables/three/useCityObjects.ts","./src/composables/three/useInteraction.ts","./src/composables/three/useSunSystem.ts","./src/composables/three/useThreeScene.ts","./src/App.vue","./src/components/BuildingInfoCanvas.vue","./src/components/DigitalTwin.vue","./src/components/TimeControls.vue"],"version":"5.6.3"}