This commit is contained in:
rafaeldpsilva
2025-12-10 14:48:00 +00:00
parent 3c4bead503
commit 19d371aad2
11 changed files with 571 additions and 241 deletions

5
web-app/build_log.txt Normal file
View File

@@ -0,0 +1,5 @@
> web-app@0.0.0 build
> vue-tsc -b && vite build
src/components/AddBuildingModal.vue(40,10): error TS6133: 'ref' is declared but its value is never read.

File diff suppressed because one or more lines are too long

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-BXWyap6F.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BqNgBlW1.css">
<script type="module" crossorigin src="/assets/index-Bso7APm4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9Xb8bfs.css">
</head>
<body>
<div id="app"></div>

View File

@@ -4,8 +4,10 @@ import DigitalTwin from './components/DigitalTwin.vue'
import BuildingInfoCanvas from './components/BuildingInfoCanvas.vue'
import TimeControls from './components/TimeControls.vue'
import SettingsModal from './components/SettingsModal.vue'
import AddBuildingModal from './components/AddBuildingModal.vue'
const isSettingsOpen = ref(false);
const isAddBuildingOpen = ref(false);
</script>
<template>
@@ -17,7 +19,12 @@ const isSettingsOpen = ref(false);
Settings
</button>
<button class="add-building-btn" @click="isAddBuildingOpen = true">
+ Add Building
</button>
<SettingsModal :isOpen="isSettingsOpen" @close="isSettingsOpen = false" />
<AddBuildingModal :isOpen="isAddBuildingOpen" @close="isAddBuildingOpen = false" />
</template>
<style>
@@ -58,4 +65,33 @@ html, body, #app {
transform: translate(4px, 4px);
box-shadow: none;
}
.add-building-btn {
position: absolute;
top: 20px;
right: 160px; /* Positioned to the left of Settings */
z-index: 1000;
padding: 8px 12px;
background: #000;
color: white;
border: 2px solid #00FF00; /* Green accent */
font-family: 'Courier New', monospace;
font-weight: bold;
cursor: pointer;
text-transform: uppercase;
box-shadow: 4px 4px 0 rgba(0,0,0,0.5);
transition: all 0.1s;
}
.add-building-btn:hover {
background: #111;
color: #00FF00;
transform: translate(1px, 1px);
box-shadow: 3px 3px 0 rgba(0,0,0,0.5);
}
.add-building-btn:active {
transform: translate(4px, 4px);
box-shadow: none;
}
</style>

View File

@@ -0,0 +1,212 @@
<template>
<div v-if="isOpen" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3>Add New Building</h3>
<button class="close-btn" @click="close">×</button>
</div>
<div class="modal-content">
<div class="setting-group">
<label>Building ID</label>
<input v-model="form.id" type="text" class="input-field" placeholder="e.g. H15" />
</div>
<div class="setting-group">
<label>Type</label>
<select v-model="form.type" class="input-field select-field">
<option value="house">House</option>
<option value="factory">Factory</option>
<option value="office">Office</option>
<option value="shop">Shop</option>
</select>
</div>
<div class="setting-group">
<label>Data Source (CSV Path)</label>
<input v-model="form.csvPath" type="text" class="input-field" placeholder="e.g. data/H01.csv" />
</div>
</div>
<div class="modal-actions">
<button class="btn secondary" @click="close">Cancel</button>
<button class="btn primary" @click="confirm">Add Building</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { watch, reactive } from 'vue';
import { useSimulation } from '../composables/useSimulation';
const props = defineProps<{
isOpen: boolean;
}>();
const emit = defineEmits(['close']);
const { addBuilding, state } = useSimulation();
const form = reactive({
id: '',
type: 'house',
csvPath: 'data/H01.csv'
});
// Auto-generate ID when opened
watch(() => props.isOpen, (newVal) => {
if (newVal) {
const nextNum = state.value.buildings.length + 1;
form.id = `H${nextNum.toString().padStart(2, '0')}`;
form.type = 'house';
form.csvPath = 'data/H01.csv';
}
});
const close = () => {
emit('close');
};
const confirm = () => {
addBuilding({ ...form });
close();
};
</script>
<style scoped>
/* Reuse styles from SettingsModal to maintain consistency */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
}
.modal {
background: #1a1a1a;
border: 3px solid #000;
width: 400px;
max-width: 90%;
color: white;
font-family: 'Courier New', monospace;
box-shadow: 10px 10px 0px rgba(0,0,0,0.5);
}
.modal-header {
background: #FFD700;
color: black;
padding: 12px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 3px solid #000;
}
.modal-header h3 {
margin: 0;
text-transform: uppercase;
font-weight: 900;
font-size: 1.1rem;
}
.close-btn {
background: black;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
padding: 0 8px;
font-weight: bold;
}
.close-btn:hover {
color: #ff4444;
}
.modal-content {
padding: 20px;
}
.setting-group {
margin-bottom: 20px;
}
.setting-group label {
display: block;
margin-bottom: 8px;
font-weight: bold;
text-transform: uppercase;
color: #ffd700;
}
.input-field {
width: 100%;
padding: 8px;
background: #000;
border: 2px solid #333;
color: white;
font-family: monospace;
font-size: 0.9rem;
box-sizing: border-box;
}
.input-field:focus {
outline: none;
border-color: #FFD700;
}
.select-field {
cursor: pointer;
}
.modal-actions {
padding: 16px;
display: flex;
justify-content: flex-end;
gap: 12px;
border-top: 2px solid #333;
background: #222;
}
.btn {
padding: 8px 16px;
font-family: 'Courier New', monospace;
text-transform: uppercase;
font-weight: bold;
cursor: pointer;
border: 2px solid #000;
box-shadow: 2px 2px 0px #000;
transition: all 0.1s;
}
.btn:active {
transform: translate(2px, 2px);
box-shadow: none;
}
.btn.primary {
background: #FFD700;
color: black;
}
.btn.primary:hover {
background: #ffea70;
}
.btn.secondary {
background: #444;
color: white;
}
.btn.secondary:hover {
background: #555;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { onMounted, watch } from 'vue';
import { useSimulation } from '../composables/useSimulation';
import { useThreeScene } from '../composables/three/useThreeScene';
import { useSunSystem } from '../composables/three/useSunSystem';
@@ -24,8 +24,16 @@ const init = () => {
const renderer = getRenderer();
// 1. City Objects
const { initCity, interactableObjects } = useCityObjects();
initCity(scene);
const { initCity, interactableObjects, clearCity } = useCityObjects();
// @ts-ignore - 'buildings' added to state dynamically
initCity(scene, state.value.buildings);
// Watch for new buildings
watch(() => state.value.buildings.length, () => {
clearCity(scene);
// @ts-ignore
initCity(scene, state.value.buildings);
});
// 2. Sun System
const { initSun, updateSun } = useSunSystem(scene, state);

View File

@@ -3,6 +3,7 @@ import * as THREE from 'three';
export function useCityObjects() {
const buildings: THREE.Mesh[] = [];
const interactableObjects: THREE.Object3D[] = [];
const sceneObjects: THREE.Object3D[] = [];
// --- Assets & Materials ---
const MATERIALS = {
@@ -65,6 +66,7 @@ export function useCityObjects() {
};
interactableObjects.push(mesh);
buildings.push(mesh);
sceneObjects.push(mesh); // Track
scene.add(mesh);
};
@@ -82,29 +84,32 @@ export function useCityObjects() {
};
const initGround = (scene: THREE.Scene) => {
// Base Plane (Asphalt)
// ... (Base creation)
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);
sceneObjects.push(base); // Track
const plots: THREE.Vector3[] = [];
const offset = ((GRID_SIZE - 1) * BLOCK_SIZE) / 2;
// --- Instanced Meshes Setup ---
// 1. Sidewalks
// ... (Instanced Meshes)
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;
sceneObjects.push(sidewalkMesh); // Track
// 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;
sceneObjects.push(grassMesh); // Track
// ... (Loop to fill instances and plots - remains same)
const dummy = new THREE.Object3D();
let instanceIdx = 0;
@@ -132,33 +137,11 @@ export function useCityObjects() {
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, 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 initBuildings = (scene: THREE.Scene, plots: THREE.Vector3[], buildingDefs: any[]) => {
// ... (Trees setup)
const trunkGeo = new THREE.CylinderGeometry(0.5, 0.5, 2, 6);
const leavesGeo = new THREE.ConeGeometry(2, 4, 8);
@@ -166,21 +149,21 @@ export function useCityObjects() {
const leavesMesh = new THREE.InstancedMesh(leavesGeo, MATERIALS.treeLeaves, MAX_TREES);
trunkMesh.castShadow = true; trunkMesh.receiveShadow = true;
leavesMesh.castShadow = true; leavesMesh.receiveShadow = true;
sceneObjects.push(trunkMesh); // Track
sceneObjects.push(leavesMesh); // Track
let treeCount = 0;
const dummy = new THREE.Object3D();
const addTreeInstance = (pos: THREE.Vector3) => {
// ... (remains same)
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);
@@ -195,7 +178,8 @@ export function useCityObjects() {
if (buildingIndex < buildingDefs.length) {
const def = buildingDefs[buildingIndex];
createBuilding(scene, def.type, plot, def.id, def.csv, 0);
// Support both 'csv' and 'csvPath' keys for compatibility if needed, but new state uses csvPath
createBuilding(scene, def.type, plot, def.id, def.csvPath || def.csv, 0);
buildingIndex++;
} else {
// Empty plot filler: Trees
@@ -206,18 +190,28 @@ export function useCityObjects() {
}
}
// Finalize Instanced Meshes
trunkMesh.count = treeCount;
leavesMesh.count = treeCount;
scene.add(trunkMesh);
scene.add(leavesMesh);
};
const clearCity = (scene: THREE.Scene) => {
sceneObjects.forEach(obj => {
scene.remove(obj);
// Optional: Dispose geometries/materials if strictly needed
});
sceneObjects.length = 0;
buildings.length = 0;
interactableObjects.length = 0;
};
return {
interactableObjects,
initCity: (scene: THREE.Scene) => {
initCity: (scene: THREE.Scene, buildingDefs: any[]) => {
const plots = initGround(scene);
initBuildings(scene, plots);
}
initBuildings(scene, plots, buildingDefs);
},
clearCity
};
}

View File

@@ -57,6 +57,41 @@ export function useInteraction(
scene.add(selectionRing);
};
let selectedMesh: THREE.Mesh | null = null;
let originalMaterial: THREE.Material | THREE.Material[] | null = null;
const clearSelectionHighlight = () => {
if (selectedMesh && originalMaterial) {
// Dispose the cloned material to avoid memory leaks
if (Array.isArray(selectedMesh.material)) {
selectedMesh.material.forEach(m => m.dispose());
} else {
selectedMesh.material.dispose();
}
// Restore original
selectedMesh.material = originalMaterial;
}
selectedMesh = null;
originalMaterial = null;
};
const applySelectionHighlight = (mesh: THREE.Mesh) => {
clearSelectionHighlight(); // Clear previous
selectedMesh = mesh;
if (Array.isArray(mesh.material)) {
originalMaterial = mesh.material;
// Clone isn't straightforward for array, skip for now or we could map clone
} else {
originalMaterial = mesh.material;
const newMat = mesh.material.clone();
if ('emissive' in newMat) {
(newMat as THREE.MeshStandardMaterial).emissive.setHex(0x555555);
}
mesh.material = newMat;
}
};
const updateSelectionRing = (target: THREE.Object3D) => {
if (!target) {
selectionRing.visible = false;
@@ -71,11 +106,11 @@ export function useInteraction(
const size = new THREE.Vector3();
box.getSize(size);
const padding = 4; // Extra space around building
selectionRing.scale.set(size.x + padding, size.z + padding, 1); // z is y in local space of plane
const padding = 2; // Reduced padding for tighter fit
selectionRing.scale.set(size.x + padding, size.z + padding, 1);
selectionRing.position.copy(target.position);
selectionRing.position.y = 0.2; // Just above ground
selectionRing.position.y = 0.6; // Raised above grass (0.5)
};
const initInteraction = () => {
@@ -158,6 +193,11 @@ export function useInteraction(
const data = group.userData;
// Highlight Mesh
if (group instanceof THREE.Mesh) {
applySelectionHighlight(group);
}
// Screen coords for Info Window
const vector = group.position.clone();
vector.project(camera);
@@ -191,6 +231,7 @@ export function useInteraction(
} else {
// Only Deselect if we clicked on NOTHING, and we weren't dragging.
selectedBuilding.value = null;
clearSelectionHighlight();
updateSelectionRing(null as any);
}
};

View File

@@ -14,8 +14,16 @@ interface SimulationState {
simulationMode: 'simulated' | 'live';
maxZoom: number;
zoomSensitivity: number;
buildings: BuildingDefinition[];
}
export interface BuildingDefinition {
id: string;
type: string;
csvPath: string;
}
// ...
interface BuildingData {
id: string;
@@ -30,6 +38,11 @@ interface BuildingData {
uiY?: number;
}
// (State initialization is already correct with buildings..., just ensure the types match)
// ...
// Initialize with current real-world time
const now = new Date();
const startOfYear = new Date(now.getFullYear(), 0, 0);
@@ -50,7 +63,23 @@ const state = ref<SimulationState>({
brightness: 1.0,
simulationMode: 'simulated',
maxZoom: 120,
zoomSensitivity: 5
zoomSensitivity: 5,
buildings: [
{ id: 'H01', type: 'house', csvPath: 'data/H01.csv' },
{ id: 'H02', type: 'factory', csvPath: 'data/H02.csv' },
{ id: 'H03', type: 'office', csvPath: 'data/H03.csv' },
{ id: 'H04', type: 'shop', csvPath: 'data/H04.csv' },
{ id: 'H05', type: 'house', csvPath: 'data/H05.csv' },
{ id: 'H06', type: 'house', csvPath: 'data/H06.csv' },
{ id: 'H07', type: 'house', csvPath: 'data/H07.csv' },
{ id: 'H08', type: 'shop', csvPath: 'data/H08.csv' },
{ id: 'H09', type: 'shop', csvPath: 'data/H09.csv' },
{ id: 'H10', type: 'house', csvPath: 'data/H10.csv' },
{ id: 'H11', type: 'house', csvPath: 'data/H11.csv' },
{ id: 'H12', type: 'house', csvPath: 'data/H12.csv' },
{ id: 'H13', type: 'house', csvPath: 'data/H13.csv' },
{ id: 'H14', type: 'house', csvPath: 'data/H14.csv' },
]
});
const selectedBuilding = ref<BuildingData | null>(null);
@@ -157,6 +186,10 @@ export const useSimulation = () => {
state.value.totalGeneration = Math.floor(gen);
};
const addBuilding = (def: BuildingDefinition) => {
state.value.buildings.push(def);
};
return {
state,
selectedBuilding,
@@ -168,6 +201,7 @@ export const useSimulation = () => {
setBrightness,
setSimulationMode,
setMaxZoom,
setZoomSensitivity
setZoomSensitivity,
addBuilding
};
};

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/SettingsModal.vue","./src/components/TimeControls.vue"],"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/AddBuildingModal.vue","./src/components/BuildingInfoCanvas.vue","./src/components/DigitalTwin.vue","./src/components/SettingsModal.vue","./src/components/TimeControls.vue"],"version":"5.6.3"}