add tile-pre-rendering and fetching

This commit is contained in:
2026-01-22 23:11:38 +01:00
parent e3f7e1bd70
commit 73625bf6a5
4 changed files with 329 additions and 91 deletions

1
ui/public/png Symbolic link
View File

@@ -0,0 +1 @@
/home/mark/projects/moundhunters/data/PNG

View File

@@ -102,10 +102,13 @@
> >
<div class="context-menu-header"> <div class="context-menu-header">
{{ formatCoordinate(contextMenu.lngLat.lng, 'lng') }}, {{ formatCoordinate(contextMenu.lngLat.lat, 'lat') }} {{ formatCoordinate(contextMenu.lngLat.lng, 'lng') }}, {{ formatCoordinate(contextMenu.lngLat.lat, 'lat') }}
<span v-if="contextMenu.tileName" class="tile-name">{{ contextMenu.tileName }}</span>
</div> </div>
<button @click="dropPin" class="context-menu-item">📍 Drop Pin</button> <button @click="dropPin" class="context-menu-item">📍 Drop Pin</button>
<button @click="startMeasure" class="context-menu-item">📏 Measure from here</button> <button @click="startMeasure" class="context-menu-item">📏 Measure from here</button>
<button v-if="!contextMenu.hasTile" @click="requestTile" class="context-menu-item">📥 Request Tile</button> <button v-if="!contextMenu.hasTile" @click="requestTile" class="context-menu-item">📥 Request Tile</button>
<button v-if="contextMenu.hasTile && !contextMenu.hasMound" @click="loadTileFromContextMenu" class="context-menu-item">📦 Load Tile Data</button>
<button v-if="contextMenu.hasMound" @click="openSandboxFromContextMenu" class="context-menu-item">🔬 Open in Shading Sandbox</button>
</div> </div>
<div <div
@@ -138,6 +141,11 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue'; import { ref, onMounted, onUnmounted } from 'vue';
// Devmode rendering
//import { batchRenderTiles } from './batch-renderer.js';
//<button @click="batchRenderTiles(sandboxRef, tileCache, TEST_TILES)">SPAM</button>
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import ShadingSandbox from './ShadingSandbox.vue'; import ShadingSandbox from './ShadingSandbox.vue';
@@ -164,7 +172,8 @@ const showLidar = ref(true);
const showGeometry = ref(true); const showGeometry = ref(true);
const imperialUnits = ref(false); const imperialUnits = ref(false);
const lidarOpacity = ref(80); const lidarOpacity = ref(80);
const tileCache = ref({}); const tileCache = ref({}); // Cached .mound data (for shading sandbox)
const loadedPngTiles = ref(new Set()); // Track which PNG tiles are displayed on map
const currentTileData = ref(null); const currentTileData = ref(null);
// Geometry state // Geometry state
@@ -175,7 +184,15 @@ const nextPinNumber = ref(1);
const nextFeatureId = ref(1); const nextFeatureId = ref(1);
// UI state // UI state
const contextMenu = ref({ visible: false, x: 0, y: 0, lngLat: null, hasTile: false }); const contextMenu = ref({
visible: false,
x: 0,
y: 0,
lngLat: null,
hasTile: false, // Has PNG tile displayed
hasMound: false, // Has .mound data cached
tileName: null // The tile name at this location
});
const popup = ref({ visible: false, x: 0, y: 0, type: null, feature: null, lngLat: null }); const popup = ref({ visible: false, x: 0, y: 0, type: null, feature: null, lngLat: null });
// Map instance // Map instance
@@ -476,6 +493,8 @@ const KNOWN_SITES = [
} }
]; ];
const TEST_TILES = KNOWN_SITES.flatMap(i=>i.tiles);
// Export for use in application // Export for use in application
if (typeof module !== 'undefined' && module.exports) { if (typeof module !== 'undefined' && module.exports) {
module.exports = { KNOWN_SITES, BIBLIOGRAPHY }; module.exports = { KNOWN_SITES, BIBLIOGRAPHY };
@@ -532,16 +551,16 @@ function calculateBearing(lng1, lat1, lng2, lat2) {
return (θ * 180 / Math.PI + 360) % 360; // Bearing in degrees return (θ * 180 / Math.PI + 360) % 360; // Bearing in degrees
} }
// Check if a point has a loaded lidar tile // Check if a point has a loaded lidar tile, return tile info
function hasLidarAtPoint(lng, lat) { function getTileInfoAtPoint(lng, lat) {
if (!map) return false; if (!map) return { hasTile: false, hasMound: false, tileName: null };
// Check all loaded tile layers // Check all loaded PNG tile layers
for (const tileName of TILE_NAMES) { for (const tileName of loadedPngTiles.value) {
const layerId = `tile-layer-${tileName}`; const layerId = `png-tile-layer-${tileName}`;
if (!map.getLayer(layerId)) continue; if (!map.getLayer(layerId)) continue;
const source = map.getSource(`tile-${tileName}`); const source = map.getSource(`png-tile-${tileName}`);
if (!source) continue; if (!source) continue;
// Get the bounds from the image source coordinates // Get the bounds from the image source coordinates
@@ -557,11 +576,20 @@ function hasLidarAtPoint(lng, lat) {
// Check if point is within bounds // Check if point is within bounds
if (lng >= minLng && lng <= maxLng && lat >= minLat && lat <= maxLat) { if (lng >= minLng && lng <= maxLng && lat >= minLat && lat <= maxLat) {
return true; return {
hasTile: true,
hasMound: !!tileCache.value[tileName],
tileName: tileName
};
} }
} }
return false; return { hasTile: false, hasMound: false, tileName: null };
}
// Legacy wrapper for compatibility
function hasLidarAtPoint(lng, lat) {
return getTileInfoAtPoint(lng, lat).hasTile;
} }
// Extend a line from point1 through point2 to map bounds // Extend a line from point1 through point2 to map bounds
@@ -677,66 +705,88 @@ async function parseMoundFile(url) {
}; };
} }
// Load and render all lidar tiles // Load PNG tiles (pre-rendered hillshade images)
async function loadLidarTiles() { async function loadPngTiles() {
console.log('Loading and rendering tiles...'); console.log('Loading PNG tiles...');
for (const tileName of TILE_NAMES) { for (const tileName of TILE_NAMES) {
try { try {
console.log(`Loading ${tileName}...`); // Fetch metadata JSON for bounds
const data = await parseMoundFile(`/tiles/${tileName}.mound`); const metaResponse = await fetch(`/png/${tileName}.json`);
if (!metaResponse.ok) {
// Cache tile data console.warn(`No metadata for ${tileName}, skipping`);
tileCache.value[tileName] = data; continue;
// Render using ShadingSandbox
if (sandboxRef.value) {
const result = await sandboxRef.value.renderTileWithSettings(
data,
DEFAULT_RENDER_SETTINGS,
512
);
if (result.success) {
console.log(`Rendered ${tileName} in ${result.renderTime}ms (${result.width}x${result.height})`);
// Convert bounds to lat/lon
const sw = webMercatorToLonLat(data.bounds.minX, data.bounds.minY);
const ne = webMercatorToLonLat(data.bounds.maxX, data.bounds.maxY);
// Add image source to map
map.addSource(`tile-${tileName}`, {
type: 'image',
url: result.dataURL,
coordinates: [
[sw[0], ne[1]], // top-left
[ne[0], ne[1]], // top-right
[ne[0], sw[1]], // bottom-right
[sw[0], sw[1]] // bottom-left
]
});
// Add layer
map.addLayer({
id: `tile-layer-${tileName}`,
type: 'raster',
source: `tile-${tileName}`,
paint: {
'raster-opacity': 0.8
}
}, 'lidar-datum'); // Insert before datum layer
console.log(`Added ${tileName} to map at [${sw[0].toFixed(5)}, ${sw[1].toFixed(5)}] - [${ne[0].toFixed(5)}, ${ne[1].toFixed(5)}]`);
} else {
console.error(`Failed to render ${tileName}:`, result.error);
}
} }
const meta = await metaResponse.json();
// Check PNG exists
const pngUrl = `/png/${tileName}.png`;
const pngResponse = await fetch(pngUrl, { method: 'HEAD' });
if (!pngResponse.ok) {
console.warn(`No PNG for ${tileName}, skipping`);
continue;
}
// Convert bounds to lat/lon (assuming meta has bounds in web mercator)
const sw = webMercatorToLonLat(meta.bounds.minX, meta.bounds.minY);
const ne = webMercatorToLonLat(meta.bounds.maxX, meta.bounds.maxY);
// Add image source to map
map.addSource(`png-tile-${tileName}`, {
type: 'image',
url: pngUrl,
coordinates: [
[sw[0], ne[1]], // top-left
[ne[0], ne[1]], // top-right
[ne[0], sw[1]], // bottom-right
[sw[0], sw[1]] // bottom-left
]
});
// Add layer
map.addLayer({
id: `png-tile-layer-${tileName}`,
type: 'raster',
source: `png-tile-${tileName}`,
paint: {
'raster-opacity': lidarOpacity.value / 100
}
}, 'lidar-datum'); // Insert before datum layer
loadedPngTiles.value.add(tileName);
console.log(`Loaded PNG tile ${tileName} at [${sw[0].toFixed(5)}, ${sw[1].toFixed(5)}] - [${ne[0].toFixed(5)}, ${ne[1].toFixed(5)}]`);
} catch (err) { } catch (err) {
console.error(`Failed to load ${tileName}:`, err); console.error(`Failed to load PNG tile ${tileName}:`, err);
} }
} }
console.log('All tiles loaded'); console.log(`Loaded ${loadedPngTiles.value.size} PNG tiles`);
}
// Load .mound data for a specific tile (for shading sandbox)
async function loadMoundData(tileName) {
if (tileCache.value[tileName]) {
console.log(`Mound data for ${tileName} already cached`);
return tileCache.value[tileName];
}
console.log(`Loading mound data for ${tileName}...`);
try {
const data = await parseMoundFile(`/mound/${tileName}.mound`);
tileCache.value[tileName] = data;
console.log(`Cached mound data for ${tileName}`);
return data;
} catch (err) {
console.error(`Failed to load mound data for ${tileName}:`, err);
throw err;
}
}
// Legacy function - now loads PNG tiles
async function loadLidarTiles() {
await loadPngTiles();
} }
// UI handlers // UI handlers
@@ -818,8 +868,8 @@ function toggleLidar() {
if (!map) return; if (!map) return;
const visibility = showLidar.value ? 'visible' : 'none'; const visibility = showLidar.value ? 'visible' : 'none';
for (const tileName of TILE_NAMES) { for (const tileName of loadedPngTiles.value) {
const layerId = `tile-layer-${tileName}`; const layerId = `png-tile-layer-${tileName}`;
if (map.getLayer(layerId)) { if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', visibility); map.setLayoutProperty(layerId, 'visibility', visibility);
} }
@@ -830,8 +880,8 @@ function updateLidarOpacity() {
if (!map) return; if (!map) return;
const opacity = lidarOpacity.value / 100; const opacity = lidarOpacity.value / 100;
for (const tileName of TILE_NAMES) { for (const tileName of loadedPngTiles.value) {
const layerId = `tile-layer-${tileName}`; const layerId = `png-tile-layer-${tileName}`;
if (map.getLayer(layerId)) { if (map.getLayer(layerId)) {
map.setPaintProperty(layerId, 'raster-opacity', opacity); map.setPaintProperty(layerId, 'raster-opacity', opacity);
} }
@@ -839,18 +889,37 @@ function updateLidarOpacity() {
} }
function openSandbox() { function openSandbox() {
if (Object.keys(tileCache.value).length > 0) { sandboxVisible.value = true;
sandboxVisible.value = true;
// Load tile data after sandbox mounts // If we have cached tile data, load it after sandbox mounts
setTimeout(() => { setTimeout(() => {
if (sandboxRef.value) { if (sandboxRef.value && Object.keys(tileCache.value).length > 0) {
const firstTile = Object.keys(tileCache.value)[0]; const firstTile = Object.keys(tileCache.value)[0];
sandboxRef.value.loadTileData(tileCache.value[firstTile]); sandboxRef.value.loadTileData(tileCache.value[firstTile]);
sandboxRef.value.setAvailableTiles(tileCache.value); }
} }, 100);
}, 100); }
// Open sandbox with a specific tile
async function openSandboxWithTile(tileName) {
// Load mound data if not cached
if (!tileCache.value[tileName]) {
try {
await loadMoundData(tileName);
} catch (err) {
console.error('Failed to load tile for sandbox:', err);
return;
}
} }
sandboxVisible.value = true;
setTimeout(() => {
if (sandboxRef.value) {
sandboxRef.value.loadTileData(tileCache.value[tileName]);
sandboxRef.value.setAvailableTiles(tileCache.value);
}
}, 100);
} }
function onRenderComplete(data) { function onRenderComplete(data) {
@@ -1004,6 +1073,38 @@ async function requestTile() {
// TODO: Handle response and update UI // TODO: Handle response and update UI
} }
// Load mound data for interactive shading
async function loadTileFromContextMenu() {
const tileName = contextMenu.value.tileName;
contextMenu.value.visible = false;
if (!tileName) {
console.error('No tile name available');
return;
}
try {
await loadMoundData(tileName);
console.log(`Mound data loaded for ${tileName}, ready for shading sandbox`);
} catch (err) {
console.error('Failed to load mound data:', err);
// TODO: Show error toast
}
}
// Open shading sandbox for the tile at context menu location
async function openSandboxFromContextMenu() {
const tileName = contextMenu.value.tileName;
contextMenu.value.visible = false;
if (!tileName) {
console.error('No tile name available');
return;
}
await openSandboxWithTile(tileName);
}
function updateGeometryLayer() { function updateGeometryLayer() {
if (!map || !map.getSource('geometry')) return; if (!map || !map.getSource('geometry')) return;
@@ -1074,13 +1175,15 @@ onMounted(() => {
// Context menu handler // Context menu handler
map.on('contextmenu', (e) => { map.on('contextmenu', (e) => {
e.preventDefault(); e.preventDefault();
const hasTile = hasLidarAtPoint(e.lngLat.lng, e.lngLat.lat); const tileInfo = getTileInfoAtPoint(e.lngLat.lng, e.lngLat.lat);
contextMenu.value = { contextMenu.value = {
visible: true, visible: true,
x: e.point.x, x: e.point.x,
y: e.point.y, y: e.point.y,
lngLat: e.lngLat, lngLat: e.lngLat,
hasTile: hasTile hasTile: tileInfo.hasTile,
hasMound: tileInfo.hasMound,
tileName: tileInfo.tileName
}; };
}); });
@@ -1333,17 +1436,8 @@ onMounted(() => {
} }
}); });
// Initialize sandbox offscreen for tile rendering // Load PNG tiles (no sandbox needed for initial display)
sandboxOffscreen.value = true; await loadPngTiles();
sandboxVisible.value = true;
await new Promise(resolve => setTimeout(resolve, 100));
// Load and render tiles
await loadLidarTiles();
// Hide sandbox
sandboxVisible.value = false;
sandboxOffscreen.value = false;
}); });
}); });
@@ -1575,6 +1669,13 @@ onUnmounted(() => {
color: #666; color: #666;
} }
.context-menu-header .tile-name {
display: block;
margin-top: 4px;
font-size: 11px;
color: #999;
}
.context-menu-item { .context-menu-item {
display: block; display: block;
width: 100%; width: 100%;

136
ui/src/batch-renderer.js Normal file
View File

@@ -0,0 +1,136 @@
/**
* Batch Renderer for Hopewell Lidar Tiles
*
* Usage in dev console with loaded app:
* import { batchRenderTiles } from './batch-renderer.js';
* const app = document.querySelector('#app').__vue_app__;
* const sandboxRef = app._instance.refs.sandboxRef;
* const tileCache = app._instance.data.tileCache;
*
* await batchRenderTiles(sandboxRef, tileCache, tileNames);
*/
/**
* Download a data URL as a file
*/
function downloadDataURL(dataURL, filename) {
const link = document.createElement('a');
link.href = dataURL;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* Download text content as a file
*/
function downloadText(text, filename) {
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Batch render tiles using already-loaded app
*
* @param {Object} sandboxRef - Vue ref to ShadingSandbox component
* @param {Object} tileCache - Cache of loaded tile data
* @param {string[]} tileNames - Array of tile names to render
* @param {Object} options - Options
* @param {Object} options.renderSettings - Override render settings
* @param {number} options.renderQuality - Render quality (default: 1024)
*
* @returns {Promise<Object[]>} Array of results
*/
export async function batchRenderTiles(sandboxRef, tileCache, tileNames, options = {}) {
const {
renderSettings = null, // Use sandbox defaults if null
renderQuality = 1024
} = options;
console.log(`[BatchRenderer] Starting batch render of ${tileNames.length} tiles`);
console.log(`[BatchRenderer] Quality: ${renderQuality}px`);
const results = [];
const startTime = Date.now();
for (let i = 0; i < tileNames.length; i++) {
const tileName = tileNames[i];
const current = i + 1;
const total = tileNames.length;
console.log(`[BatchRenderer] [${current}/${total}] Processing ${tileName}...`);
try {
const tileData = tileCache[tileName];
if (!tileData) {
throw new Error(`Tile ${tileName} not found in cache`);
}
// Render
console.log(`[BatchRenderer] Rendering...`);
const renderResult = await sandboxRef.renderTileWithSettings(
tileData,
renderSettings || sandboxRef.getSettings(),
renderQuality
);
if (!renderResult.success) {
throw new Error(renderResult.error || 'Render failed');
}
console.log(`[BatchRenderer] Rendered in ${renderResult.renderTime}ms`);
// Generate metadata
const metadata = {
tileName,
bounds: tileData.bounds,
renderSettings: renderSettings || sandboxRef.getSettings(),
renderQuality,
pointCount: tileData.pointCount,
triangleCount: tileData.triangleCount,
renderedAt: new Date().toISOString()
};
// Download
downloadDataURL(renderResult.dataURL, `${tileName}.png`);
downloadText(JSON.stringify(metadata, null, 2), `${tileName}.json`);
results.push({
tileName,
metadata,
renderTime: renderResult.renderTime,
success: true
});
console.log(`[BatchRenderer] ✓ Complete`);
// Small delay
await new Promise(resolve => setTimeout(resolve, 100));
} catch (err) {
console.error(`[BatchRenderer] ✗ Failed: ${err.message}`);
results.push({
tileName,
success: false,
error: err.message
});
}
}
const totalTime = Date.now() - startTime;
const successCount = results.filter(r => r.success).length;
const failCount = results.filter(r => !r.success).length;
console.log(`[BatchRenderer] Batch complete in ${(totalTime / 1000).toFixed(1)}s`);
console.log(`[BatchRenderer] Success: ${successCount}, Failed: ${failCount}`);
return results;
}