Files
MoundHunters/ui/src/App2.vue
2026-01-20 23:03:24 +01:00

398 lines
11 KiB
Vue

<template>
<div id="app">
<canvas ref="canvas" class="canvas"></canvas>
<div class="control-panel">
<div class="control-group">
<label>Light Azimuth (E-W)</label>
<input
v-model.number="controls_state.azimuth"
type="range"
min="0"
max="360"
step="1"
@input="onControlChange"
/>
<span class="value">{{ Math.round(controls_state.azimuth) }}°</span>
</div>
<div class="control-group">
<label>Light Altitude (N-S)</label>
<input
v-model.number="controls_state.altitude"
type="range"
min="0"
max="90"
step="1"
@input="onControlChange"
/>
<span class="value">{{ Math.round(controls_state.altitude) }}°</span>
</div>
<div class="control-group">
<label>Light Intensity</label>
<input
v-model.number="controls_state.intensity"
type="range"
min="0"
max="2"
step="0.1"
@input="onControlChange"
/>
<span class="value">{{ controls_state.intensity.toFixed(1) }}</span>
</div>
<div class="control-group">
<label>Height Exaggeration</label>
<input
v-model.number="controls_state.heightScale"
type="range"
min="0.1"
max="10"
step="0.1"
@input="onControlChange"
/>
<span class="value">{{ controls_state.heightScale.toFixed(1) }}x</span>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
export default {
name: 'App1',
setup() {
const canvas = ref(null);
let scene, camera, renderer, threeControls, mesh, directionalLight;
const controls_state = ref({
azimuth: 45,
altitude: 45,
intensity: 0.8,
heightScale: 1
});
let geometryCache = null;
let centerX, centerY, centerZ, spanZ, normalizeScale;
const parseMoundFile = async (url) => {
const response = await fetch(url);
const buffer = await response.arrayBuffer();
const view = new DataView(buffer);
let offset = 0;
const magic = String.fromCharCode(
view.getUint8(offset++),
view.getUint8(offset++),
view.getUint8(offset++),
view.getUint8(offset++)
);
if (magic !== 'LIDR') {
throw new Error('Invalid .mound file');
}
const version = view.getUint32(offset, true); offset += 4;
const pointCount = view.getUint32(offset, true); offset += 4;
const triangleCount = view.getUint32(offset, true); offset += 4;
const minX = view.getFloat32(offset, true); offset += 4;
const minY = view.getFloat32(offset, true); offset += 4;
const minZ = view.getFloat32(offset, true); offset += 4;
const maxX = view.getFloat32(offset, true); offset += 4;
const maxY = view.getFloat32(offset, true); offset += 4;
const maxZ = view.getFloat32(offset, true); offset += 4;
offset += 24; // Skip reserved
const positions = new Float32Array(buffer, offset, pointCount * 3);
offset += pointCount * 3 * 4;
const indices = new Uint32Array(buffer, offset, triangleCount * 3);
return {
pointCount,
triangleCount,
bounds: { minX, minY, minZ, maxX, maxY, maxZ },
positions,
indices
};
};
const loadLidarTile = async () => {
try {
console.log('Loading BS19820747...');
const data = await parseMoundFile('/tiles/BS19820747.mound');
console.log('Bounds:', data.bounds);
console.log('Points:', data.pointCount);
console.log('Triangles:', data.triangleCount);
const geometry = new THREE.BufferGeometry();
// Calculate center and scale first
centerX = (data.bounds.minX + data.bounds.maxX) / 2;
centerY = (data.bounds.minY + data.bounds.maxY) / 2;
centerZ = (data.bounds.minZ + data.bounds.maxZ) / 2;
const spanX = data.bounds.maxX - data.bounds.minX;
const spanY = data.bounds.maxY - data.bounds.minY;
spanZ = data.bounds.maxZ - data.bounds.minZ;
const maxSpan = Math.max(spanX, spanY);
console.log('Center:', centerX, centerY, centerZ);
console.log('Span X:', spanX, 'Y:', spanY, 'Z:', spanZ);
console.log('Max span:', maxSpan);
// Transform vertices: center and scale
normalizeScale = 10 / maxSpan;
const zScale = normalizeScale * (maxSpan * 0.1) / spanZ;
console.log('Normalize scale:', normalizeScale);
console.log('Z scale:', zScale);
// Create new position array with transformed coordinates
const transformedPositions = new Float32Array(data.positions.length);
for (let i = 0; i < data.positions.length; i += 3) {
transformedPositions[i] = (data.positions[i] - centerX) * normalizeScale; // X
transformedPositions[i + 1] = (data.positions[i + 1] - centerY) * normalizeScale; // Y
transformedPositions[i + 2] = (data.positions[i + 2] - centerZ) * zScale; // Z
}
geometry.setAttribute('position', new THREE.BufferAttribute(transformedPositions, 3));
geometry.setIndex(new THREE.BufferAttribute(data.indices, 1));
geometry.computeVertexNormals();
geometryCache = { geometry, baseZ: new Float32Array(data.positions.map((v, i) => i % 3 === 2 ? (v - centerZ) * zScale : 0)), zScale };
console.log('First few transformed positions:', transformedPositions.slice(0, 15));
const material = new THREE.MeshLambertMaterial({
color: 0x8B7355,
side: THREE.DoubleSide,
wireframe: false
});
mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// Apply initial height scale
updateHeightScale();
console.log('Mesh added to scene at origin');
} catch (err) {
console.error('Failed to load tile:', err);
}
};
const updateHeightScale = () => {
if (!geometryCache) return;
const positions = geometryCache.geometry.attributes.position.array;
const exaggeration = controls_state.value.heightScale;
// Only every 3rd value (Z coordinate)
for (let i = 2; i < positions.length; i += 3) {
positions[i] = geometryCache.baseZ[i] * exaggeration;
}
geometryCache.geometry.attributes.position.needsUpdate = true;
geometryCache.geometry.computeVertexNormals();
};
const updateLightPosition = () => {
if (!directionalLight) return;
const azimuth = (controls_state.value.azimuth * Math.PI) / 180;
const altitude = (controls_state.value.altitude * Math.PI) / 180;
const distance = 20;
const x = Math.cos(azimuth) * Math.cos(altitude) * distance;
const y = Math.sin(azimuth) * Math.cos(altitude) * distance;
const z = Math.sin(altitude) * distance;
directionalLight.position.set(x, y, z);
directionalLight.intensity = controls_state.value.intensity;
};
const onControlChange = () => {
updateLightPosition();
updateHeightScale();
};
onMounted(() => {
// Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x87CEEB);
// Camera
camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.set(0, -15, 10);
camera.lookAt(0, 0, 0);
// Renderer
renderer = new THREE.WebGLRenderer({
canvas: canvas.value,
antialias: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
// OrbitControls for pan, orbit, zoom
threeControls = new OrbitControls(camera, canvas.value);
threeControls.enableDamping = true;
threeControls.dampingFactor = 0.05;
threeControls.target.set(0, 0, 0);
// Lights for the terrain
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
directionalLight = new THREE.DirectionalLight(0xffffff, controls_state.value.intensity);
scene.add(directionalLight);
// Set initial light position
updateLightPosition();
// Axes helper
const axesHelper = new THREE.AxesHelper(15);
scene.add(axesHelper);
// Load lidar
loadLidarTile();
// Animation loop
const animate = () => {
requestAnimationFrame(animate);
threeControls.update();
renderer.render(scene, camera);
};
animate();
// Handle resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
console.log('Three.js initialized');
console.log('Scene:', scene);
console.log('Camera:', camera);
});
return { canvas, controls_state, onControlChange };
}
};
</script>
<style scoped>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
}
.canvas {
display: block;
width: 100%;
height: 100%;
}
.control-panel {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.75);
border-radius: 8px;
padding: 16px;
width: 280px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 100;
}
.control-group {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.control-group:last-child {
margin-bottom: 0;
}
.control-group label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.9;
}
.control-group input[type="range"] {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.control-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #4A9EFF;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: background 0.2s;
}
.control-group input[type="range"]::-webkit-slider-thumb:hover {
background: #5BADFF;
}
.control-group input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
background: #4A9EFF;
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: background 0.2s;
}
.control-group input[type="range"]::-moz-range-thumb:hover {
background: #5BADFF;
}
.value {
font-size: 11px;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
</style>