BIIIIG FIXES

This commit is contained in:
2026-01-21 00:57:53 +01:00
parent 08cfcdee19
commit 5d0d285971
3 changed files with 389 additions and 430 deletions

View File

@@ -41,7 +41,7 @@ Example layout for 100 points and 50 triangles:
Total file size: 1864 bytes Total file size: 1864 bytes
Coordinate System: Coordinate System:
Input: Ohio State Plane North (EPSG:3734) in US Survey Feet Input: Ohio State Plane South (EPSG:3735) in US Survey Feet
Output: Web Mercator (EPSG:3857) in meters Output: Web Mercator (EPSG:3857) in meters
This ensures compatibility with MapLibre GL JS and web mapping standards. This ensures compatibility with MapLibre GL JS and web mapping standards.
""" """
@@ -74,9 +74,9 @@ def transform_to_webmercator(x, y, z):
"""Transform Ohio State Plane coordinates to Web Mercator (EPSG:3857).""" """Transform Ohio State Plane coordinates to Web Mercator (EPSG:3857)."""
print("Transforming coordinates to Web Mercator...") print("Transforming coordinates to Web Mercator...")
# Ohio State Plane North (EPSG:3734) in US Survey Feet to Web Mercator (EPSG:3857) in meters # Ohio State Plane South (EPSG:3735) in US Survey Feet to Web Mercator (EPSG:3857) in meters
# Newark is in the North zone # The lidar data uses FIPS 3402 (Ohio South) per metadata
transformer = Transformer.from_crs("EPSG:3734", "EPSG:3857", always_xy=True) transformer = Transformer.from_crs("EPSG:3735", "EPSG:3857", always_xy=True)
# Transform x,y (easting, northing) to Web Mercator meters # Transform x,y (easting, northing) to Web Mercator meters
merc_x, merc_y = transformer.transform(x, y) merc_x, merc_y = transformer.transform(x, y)

View File

@@ -1,15 +1,17 @@
<template> <template>
<div id="app"> <div id="app">
<div ref="mapContainer" class="map-container"></div> <div ref="mapContainer" class="map-container"></div>
<canvas ref="threeCanvas" class="three-canvas"></canvas>
<ShadingSandbox <ShadingSandbox
:visible="sandboxVisible" :visible="sandboxVisible"
ref="ShadingSandbox" :offscreen="sandboxOffscreen"
:initial-settings="DEFAULT_RENDER_SETTINGS"
ref="sandboxRef"
@close="sandboxVisible = false" @close="sandboxVisible = false"
@renderComplete="onRenderComplete" @renderComplete="onRenderComplete"
@error="onRenderError" @error="onRenderError"
/> />
<div class="layer-controls"> <div class="layer-controls">
<div class="control-section"> <div class="control-section">
<label>Base Map:</label> <label>Base Map:</label>
@@ -35,366 +37,327 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, onMounted, onUnmounted } from 'vue'; import { ref, onMounted, onUnmounted } from 'vue';
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 * as THREE from 'three';
import ShadingSandbox from './ShadingSandbox.vue'; import ShadingSandbox from './ShadingSandbox.vue';
export default { // Default render settings - used everywhere
name: 'App', const DEFAULT_RENDER_SETTINGS = {
components: { azimuth: 90,
ShadingSandbox altitude: 60,
}, intensity: 1.2,
setup() { heightScale: 3,
const mapContainer = ref(null); terrainColor: 0x9A9996
const threeCanvas = ref(null); };
const ShadingSandbox = ref(null);
let map = null;
let scene = null;
let camera = null;
let renderer = null;
let lidarMeshes = [];
const baseLayer = ref('osm');
const showOctagon = ref(true);
const showLidar = ref(true);
const sandboxVisible = ref(false); // Refs
const currentTileData = ref(null); const mapContainer = ref(null);
const tileCache = ref({}); const sandboxRef = ref(null);
const sandboxVisible = ref(false);
const sandboxOffscreen = ref(false);
const baseLayer = ref('osm');
const showOctagon = ref(true);
const showLidar = ref(true);
const tileCache = ref({});
const currentTileData = ref(null);
// Newark Octagon coordinates (converted from DMS to decimal) // Map instance
const octagonCoords = [ let map = null;
[-82.44133, 40.05431], // 40°03'15.5"N 82°26'28.8"W
[-82.44264, 40.05311], // 40°03'11.2"N 82°26'33.5"W
[-82.44464, 40.05242], // 40°03'08.7"N 82°26'40.7"W
[-82.44631, 40.05342], // 40°03'12.3"N 82°26'46.7"W
[-82.44728, 40.05500], // 40°03'18.0"N 82°26'50.2"W
[-82.44589, 40.05633], // 40°03'22.8"N 82°26'45.2"W
[-82.44389, 40.05697], // 40°03'25.1"N 82°26'38.0"W
[-82.44192, 40.05589], // 40°03'21.2"N 82°26'30.9"W
[-82.44133, 40.05431], // Close the polygon
];
const octagonCenter = [-82.44383, 40.05469]; // Tile names to load
const TILE_NAMES = [
'BS19820747',
'BS19820748',
'BS19830747',
'BS19830748'
];
onMounted(() => { // Newark Octagon coordinates
map = new maplibregl.Map({ const octagonCoords = [
container: mapContainer.value, [-82.44133, 40.05431],
style: { [-82.44264, 40.05311],
version: 8, [-82.44464, 40.05242],
sources: { [-82.44631, 40.05342],
'osm': { [-82.44728, 40.05500],
type: 'raster', [-82.44589, 40.05633],
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], [-82.44389, 40.05697],
tileSize: 256, [-82.44192, 40.05589],
attribution: '© OpenStreetMap contributors' [-82.44133, 40.05431], // Close the polygon
}, ];
'satellite': {
type: 'raster',
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
],
tileSize: 256,
attribution: 'Esri, Maxar, Earthstar Geographics, USDA FSA, USGS, Aerogrid, IGN, IGP, and the GIS User Community'
}
},
layers: [
{
id: 'osm-layer',
type: 'raster',
source: 'osm',
layout: { visibility: 'visible' }
},
{
id: 'satellite-layer',
type: 'raster',
source: 'satellite',
layout: { visibility: 'none' }
}
]
},
center: octagonCenter,
zoom: 15
});
map.on('load', () => { const octagonCenter = [-82.44383, 40.05469];
// Add octagon source and layer
map.addSource('octagon', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [octagonCoords]
}
}
});
map.addLayer({ // Coordinate conversion utilities
id: 'octagon-fill', function webMercatorToLonLat(x, y) {
type: 'fill', const R = 6378137; // Earth's radius in meters
source: 'octagon', const lon = (x / R) * (180 / Math.PI);
paint: { const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * (180 / Math.PI);
'fill-color': '#ff0000', return [lon, lat];
'fill-opacity': 0.2 }
}
});
map.addLayer({ // Parse .mound binary file
id: 'octagon-outline', async function parseMoundFile(url) {
type: 'line', const response = await fetch(url);
source: 'octagon', const buffer = await response.arrayBuffer();
paint: {
'line-color': '#ff0000',
'line-width': 2
}
});
// Initialize Three.js const view = new DataView(buffer);
initThreeJS(); let offset = 0;
loadLidarTiles();
// Update Three.js on map move const magic = String.fromCharCode(
map.on('move', updateThreeCamera); view.getUint8(offset++),
map.on('zoom', updateThreeCamera); view.getUint8(offset++),
}); view.getUint8(offset++),
}); view.getUint8(offset++)
);
const updateBaseLayer = () => { if (magic !== 'LIDR') {
if (!map) return; throw new Error('Invalid .mound file');
}
if (baseLayer.value === 'osm') { const version = view.getUint32(offset, true); offset += 4;
map.setLayoutProperty('osm-layer', 'visibility', 'visible'); const pointCount = view.getUint32(offset, true); offset += 4;
map.setLayoutProperty('satellite-layer', 'visibility', 'none'); const triangleCount = view.getUint32(offset, true); offset += 4;
} else { const minX = view.getFloat32(offset, true); offset += 4;
map.setLayoutProperty('osm-layer', 'visibility', 'none'); const minY = view.getFloat32(offset, true); offset += 4;
map.setLayoutProperty('satellite-layer', 'visibility', 'visible'); 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 bytes
const toggleOctagon = () => { const positions = new Float32Array(buffer, offset, pointCount * 3);
if (!map) return; offset += pointCount * 3 * 4;
const visibility = showOctagon.value ? 'visible' : 'none'; const indices = new Uint32Array(buffer, offset, triangleCount * 3);
map.setLayoutProperty('octagon-fill', 'visibility', visibility);
map.setLayoutProperty('octagon-outline', 'visibility', visibility);
};
const toggleLidar = () => { return {
lidarMeshes.forEach(mesh => { version,
mesh.visible = showLidar.value; pointCount,
}); triangleCount,
if (renderer) renderer.render(scene, camera); bounds: { minX, minY, minZ, maxX, maxY, maxZ },
}; positions,
indices
};
}
// Convert lat/lon to Web Mercator meters (EPSG:3857) // Load and render all lidar tiles
const lonLatToWebMercator = (lon, lat) => { async function loadLidarTiles() {
const x = lon * 20037508.34 / 180; console.log('Loading and rendering tiles...');
let y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (Math.PI / 180);
y = y * 20037508.34 / 180;
return { x, y };
};
const initThreeJS = () => { for (const tileName of TILE_NAMES) {
scene = new THREE.Scene(); try {
console.log(`Loading ${tileName}...`);
const data = await parseMoundFile(`/tiles/${tileName}.mound`);
camera = new THREE.OrthographicCamera( // Cache tile data
-1, 1, 1, -1, 0.1, 10000 tileCache.value[tileName] = data;
);
camera.position.z = 1;
renderer = new THREE.WebGLRenderer({ // Render using ShadingSandbox
canvas: threeCanvas.value, if (sandboxRef.value) {
alpha: true, const result = await sandboxRef.value.renderTileWithSettings(
antialias: true data,
}); DEFAULT_RENDER_SETTINGS,
renderer.setSize(window.innerWidth, window.innerHeight); 512
renderer.setClearColor(0x000000, 0); );
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); if (result.success) {
scene.add(ambientLight); console.log(`Rendered ${tileName} in ${result.renderTime}ms (${result.width}x${result.height})`);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); // Convert bounds to lat/lon
directionalLight.position.set(1, 1, 1); const sw = webMercatorToLonLat(data.bounds.minX, data.bounds.minY);
scene.add(directionalLight); const ne = webMercatorToLonLat(data.bounds.maxX, data.bounds.maxY);
window.addEventListener('resize', handleResize); // Add image source to map
map.addSource(`tile-${tileName}`, {
updateThreeCamera(); type: 'image',
}; url: result.dataURL,
coordinates: [
const handleResize = () => { [sw[0], ne[1]], // top-left
if (!renderer || !camera) return; [ne[0], ne[1]], // top-right
renderer.setSize(window.innerWidth, window.innerHeight); [ne[0], sw[1]], // bottom-right
updateThreeCamera(); [sw[0], sw[1]] // bottom-left
}; ]
const updateThreeCamera = () => {
if (!map || !camera || !renderer) return;
const bounds = map.getBounds();
const ne = bounds.getNorthEast();
const sw = bounds.getSouthWest();
// Convert lat/lon bounds to Web Mercator meters
const neMerc = lonLatToWebMercator(ne.lng, ne.lat);
const swMerc = lonLatToWebMercator(sw.lng, sw.lat);
camera.left = swMerc.x;
camera.right = neMerc.x;
camera.top = neMerc.y;
camera.bottom = swMerc.y;
camera.updateProjectionMatrix();
renderer.render(scene, camera);
};
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 loadLidarTiles = async () => {
const tiles = [
'BS19820747',
'BS19820748',
'BS19830747',
'BS19830748'
];
for (const tileName of tiles) {
try {
console.log(`Loading ${tileName}...`);
const data = await parseMoundFile(`/tiles/${tileName}.mound`);
// Cache the tile data for sandbox use
tileCache.value[tileName] = data;
console.log(`${tileName} bounds:`, data.bounds);
console.log(`First few positions:`, data.positions.slice(0, 15));
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(data.positions, 3));
geometry.setIndex(new THREE.BufferAttribute(data.indices, 1));
geometry.computeVertexNormals();
const material = new THREE.MeshLambertMaterial({
color: 0x8B7355,
side: THREE.DoubleSide,
wireframe: false
}); });
const mesh = new THREE.Mesh(geometry, material); // Add layer
scene.add(mesh); map.addLayer({
lidarMeshes.push(mesh); id: `tile-layer-${tileName}`,
type: 'raster',
source: `tile-${tileName}`,
paint: {
'raster-opacity': 0.8
}
});
console.log(`Loaded ${tileName}: ${data.pointCount} points, ${data.triangleCount} triangles`); console.log(`Added ${tileName} to map at [${sw[0].toFixed(5)}, ${sw[1].toFixed(5)}] - [${ne[0].toFixed(5)}, ${ne[1].toFixed(5)}]`);
console.log(`Mesh position:`, mesh.position); } else {
console.log(`Mesh in scene:`, scene.children.length); console.error(`Failed to render ${tileName}:`, result.error);
} catch (err) {
console.error(`Failed to load ${tileName}:`, err);
} }
} }
} catch (err) {
console.error(`Failed to load ${tileName}:`, err);
}
}
console.log('Map bounds:', map.getBounds().toArray()); console.log('All tiles loaded');
console.log('Camera:', camera); }
updateThreeCamera(); // UI handlers
}; function updateBaseLayer() {
if (!map) return;
const openSandbox = () => { if (baseLayer.value === 'osm') {
// Open sandbox with first available tile map.setLayoutProperty('osm-layer', 'visibility', 'visible');
const firstTile = Object.keys(tileCache.value)[0]; map.setLayoutProperty('satellite-layer', 'visibility', 'none');
if (firstTile) { } else {
currentTileData.value = tileCache.value[firstTile]; map.setLayoutProperty('osm-layer', 'visibility', 'none');
sandboxVisible.value = true; map.setLayoutProperty('satellite-layer', 'visibility', 'visible');
}
}
// Load tile data into renderer after it mounts function toggleOctagon() {
setTimeout(() => { if (!map) return;
if (ShadingSandbox.value) {
ShadingSandbox.value.loadTileData(currentTileData.value); const visibility = showOctagon.value ? 'visible' : 'none';
} map.setLayoutProperty('octagon-fill', 'visibility', visibility);
}, 100); map.setLayoutProperty('octagon-outline', 'visibility', visibility);
}
function toggleLidar() {
if (!map) return;
const visibility = showLidar.value ? 'visible' : 'none';
for (const tileName of TILE_NAMES) {
const layerId = `tile-layer-${tileName}`;
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', visibility);
}
}
}
function openSandbox() {
const firstTile = Object.keys(tileCache.value)[0];
if (firstTile) {
currentTileData.value = tileCache.value[firstTile];
sandboxVisible.value = true;
// Load tile data after sandbox mounts
setTimeout(() => {
if (sandboxRef.value) {
sandboxRef.value.loadTileData(currentTileData.value);
} }
}; }, 100);
}
}
const onRenderComplete = (data) => { function onRenderComplete(data) {
console.log('Render complete:', { console.log('Render complete:', {
size: data.size, size: `${data.width}x${data.height}`,
renderTime: data.renderTime, renderTime: data.renderTime,
settings: data.settings settings: data.settings
}); });
}; }
const onRenderError = (err) => { function onRenderError(err) {
console.error('Renderer error:', err); console.error('Renderer error:', err);
}; }
onUnmounted(() => { // Lifecycle
window.removeEventListener('resize', handleResize); onMounted(() => {
if (renderer) { map = new maplibregl.Map({
renderer.dispose(); container: mapContainer.value,
style: {
version: 8,
sources: {
'osm': {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors'
},
'satellite': {
type: 'raster',
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
],
tileSize: 256,
attribution: 'Esri, Maxar, Earthstar Geographics'
}
},
layers: [
{
id: 'osm-layer',
type: 'raster',
source: 'osm',
layout: { visibility: 'visible' }
},
{
id: 'satellite-layer',
type: 'raster',
source: 'satellite',
layout: { visibility: 'none' }
}
]
},
center: octagonCenter,
zoom: 15
});
map.on('load', async () => {
// Add octagon overlay
map.addSource('octagon', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [octagonCoords]
}
} }
}); });
return { map.addLayer({
mapContainer, id: 'octagon-fill',
threeCanvas, type: 'fill',
ShadingSandbox, source: 'octagon',
baseLayer, paint: {
showOctagon, 'fill-color': '#ff0000',
showLidar, 'fill-opacity': 0.2
updateBaseLayer, }
toggleOctagon, });
toggleLidar,
sandboxVisible, map.addLayer({
currentTileData, id: 'octagon-outline',
openSandbox, type: 'line',
onRenderComplete, source: 'octagon',
onRenderError paint: {
}; 'line-color': '#ff0000',
'line-width': 2
}
});
// Initialize sandbox offscreen for tile rendering
sandboxOffscreen.value = true;
sandboxVisible.value = true;
await new Promise(resolve => setTimeout(resolve, 100));
// Load and render tiles
await loadLidarTiles();
// Hide sandbox
sandboxVisible.value = false;
sandboxOffscreen.value = false;
});
});
onUnmounted(() => {
if (map) {
map.remove();
map = null;
} }
}; });
</script> </script>
<style> <style>
@@ -415,15 +378,6 @@ export default {
height: 100%; height: 100%;
} }
.three-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.layer-controls { .layer-controls {
position: absolute; position: absolute;
bottom: 20px; bottom: 20px;

View File

@@ -1,5 +1,5 @@
<template> <template>
<div v-if="visible" class="modal-container"> <div v-if="visible" :class="['modal-container', { 'offscreen': offscreen }]">
<div class="modal-header"> <div class="modal-header">
<h2>Shading Sandbox</h2> <h2>Shading Sandbox</h2>
<button class="close-btn" @click="close"></button> <button class="close-btn" @click="close"></button>
@@ -143,6 +143,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false default: false
}, },
offscreen: {
type: Boolean,
default: false
},
initialSettings: { initialSettings: {
type: Object, type: Object,
default: () => ({}) default: () => ({})
@@ -249,22 +253,12 @@ const handleResize = () => {
renderer.setSize(width, height); renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio); renderer.setPixelRatio(window.devicePixelRatio);
// Maintain tile aspect ratio in frustum // Use exact normalized terrain bounds (no padding = no black bars)
if (geometryCache && geometryCache.tileAspect) { if (geometryCache) {
const viewSize = 6; camera.left = -geometryCache.normalizedSpanX / 2;
const tileAspect = geometryCache.tileAspect; camera.right = geometryCache.normalizedSpanX / 2;
camera.top = geometryCache.normalizedSpanY / 2;
if (tileAspect > 1) { camera.bottom = -geometryCache.normalizedSpanY / 2;
camera.left = -viewSize * tileAspect;
camera.right = viewSize * tileAspect;
camera.top = viewSize;
camera.bottom = -viewSize;
} else {
camera.left = -viewSize;
camera.right = viewSize;
camera.top = viewSize / tileAspect;
camera.bottom = -viewSize / tileAspect;
}
} else { } else {
// Fallback: square frustum if no tile loaded yet // Fallback: square frustum if no tile loaded yet
const viewSize = 6; const viewSize = 6;
@@ -344,12 +338,20 @@ const loadTileData = (tileData) => {
baseZ[i + 2] = transformedPositions[i + 2]; // Only Z values baseZ[i + 2] = transformedPositions[i + 2]; // Only Z values
} }
// Calculate exact bounds of normalized terrain (no padding = no black bars)
const normalizedSpanX = spanX * normalizeScale;
const normalizedSpanY = spanY * normalizeScale;
geometryCache = { geometryCache = {
geometry, geometry,
baseZ, baseZ,
spanZ, spanZ,
zScale, zScale,
tileAspect tileAspect,
normalizedSpanX,
normalizedSpanY,
// Store original Web Mercator bounds for MapLibre positioning
originalBounds: { ...tileData.bounds }
}; };
// Create material and mesh // Create material and mesh
@@ -362,22 +364,10 @@ const loadTileData = (tileData) => {
scene.add(mesh); scene.add(mesh);
// Configure camera frustum to match tile aspect ratio // Configure camera frustum to match tile aspect ratio
const viewSize = 6; // Base size for the view camera.left = -normalizedSpanX / 2;
camera.right = normalizedSpanX / 2;
// Adjust frustum based on tile aspect ratio camera.top = normalizedSpanY / 2;
if (tileAspect > 1) { camera.bottom = -normalizedSpanY / 2;
// Wider than tall
camera.left = -viewSize * tileAspect;
camera.right = viewSize * tileAspect;
camera.top = viewSize;
camera.bottom = -viewSize;
} else {
// Taller than wide
camera.left = -viewSize;
camera.right = viewSize;
camera.top = viewSize / tileAspect;
camera.bottom = -viewSize / tileAspect;
}
// Adjust near/far to accommodate height exaggeration // Adjust near/far to accommodate height exaggeration
const maxZExtent = (spanZ * zScale / 2) * 20; // Max exaggeration const maxZExtent = (spanZ * zScale / 2) * 20; // Max exaggeration
@@ -469,17 +459,29 @@ const renderTile = async () => {
const originalHeight = renderer.domElement.height; const originalHeight = renderer.domElement.height;
const originalPixelRatio = renderer.getPixelRatio(); const originalPixelRatio = renderer.getPixelRatio();
// Set render size (square) // Calculate render dimensions based on tile aspect ratio
renderer.setSize(size, size); let renderWidth = size;
let renderHeight = size;
if (geometryCache) {
const tileAspect = geometryCache.normalizedSpanX / geometryCache.normalizedSpanY;
if (tileAspect > 1) {
renderHeight = Math.round(size / tileAspect);
} else {
renderWidth = Math.round(size * tileAspect);
}
}
// Set render size
renderer.setSize(renderWidth, renderHeight);
renderer.setPixelRatio(1); // No pixel ratio multiplier for export renderer.setPixelRatio(1); // No pixel ratio multiplier for export
// Update camera aspect for square // Update camera to match exact tile bounds (no black bars)
const aspect = 1; if (geometryCache) {
const viewSize = 6; camera.left = -geometryCache.normalizedSpanX / 2;
camera.left = -viewSize * aspect; camera.right = geometryCache.normalizedSpanX / 2;
camera.right = viewSize * aspect; camera.top = geometryCache.normalizedSpanY / 2;
camera.top = viewSize; camera.bottom = -geometryCache.normalizedSpanY / 2;
camera.bottom = -viewSize; }
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
// Render // Render
@@ -493,13 +495,8 @@ const renderTile = async () => {
renderer.setSize(originalWidth / originalPixelRatio, originalHeight / originalPixelRatio); renderer.setSize(originalWidth / originalPixelRatio, originalHeight / originalPixelRatio);
renderer.setPixelRatio(originalPixelRatio); renderer.setPixelRatio(originalPixelRatio);
// Restore camera aspect // Restore camera
const canvasAspect = originalWidth / originalHeight; handleResize();
camera.left = -viewSize * canvasAspect;
camera.right = viewSize * canvasAspect;
camera.top = viewSize;
camera.bottom = -viewSize;
camera.updateProjectionMatrix();
const endTime = performance.now(); const endTime = performance.now();
const renderTime = Math.round(endTime - startTime); const renderTime = Math.round(endTime - startTime);
@@ -510,7 +507,9 @@ const renderTile = async () => {
dataURL, dataURL,
settings: { ...settings }, settings: { ...settings },
size, size,
renderTime renderTime,
width: renderWidth,
height: renderHeight
}); });
return dataURL; return dataURL;
@@ -556,16 +555,29 @@ const renderTileWithSettings = async (tileData, renderSettings, resolution = 102
const originalHeight = renderer.domElement.height; const originalHeight = renderer.domElement.height;
const originalPixelRatio = renderer.getPixelRatio(); const originalPixelRatio = renderer.getPixelRatio();
// Set render size (square for export) // Calculate render dimensions based on tile aspect ratio
renderer.setSize(resolution, resolution); let renderWidth = resolution;
let renderHeight = resolution;
if (geometryCache) {
const tileAspect = geometryCache.normalizedSpanX / geometryCache.normalizedSpanY;
if (tileAspect > 1) {
renderHeight = Math.round(resolution / tileAspect);
} else {
renderWidth = Math.round(resolution * tileAspect);
}
}
// Set render size
renderer.setSize(renderWidth, renderHeight);
renderer.setPixelRatio(1); renderer.setPixelRatio(1);
// Update camera for square render output // Update camera to match exact tile bounds (no black bars)
const viewSize = 6; if (geometryCache) {
camera.left = -viewSize; camera.left = -geometryCache.normalizedSpanX / 2;
camera.right = viewSize; camera.right = geometryCache.normalizedSpanX / 2;
camera.top = viewSize; camera.top = geometryCache.normalizedSpanY / 2;
camera.bottom = -viewSize; camera.bottom = -geometryCache.normalizedSpanY / 2;
}
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
// Render // Render
@@ -578,27 +590,12 @@ const renderTileWithSettings = async (tileData, renderSettings, resolution = 102
renderer.setSize(originalWidth / originalPixelRatio, originalHeight / originalPixelRatio); renderer.setSize(originalWidth / originalPixelRatio, originalHeight / originalPixelRatio);
renderer.setPixelRatio(originalPixelRatio); renderer.setPixelRatio(originalPixelRatio);
// Restore camera with tile aspect ratio // Restore camera with exact normalized bounds
if (geometryCache && geometryCache.tileAspect) { if (geometryCache) {
const tileAspect = geometryCache.tileAspect; camera.left = -geometryCache.normalizedSpanX / 2;
camera.right = geometryCache.normalizedSpanX / 2;
if (tileAspect > 1) { camera.top = geometryCache.normalizedSpanY / 2;
camera.left = -viewSize * tileAspect; camera.bottom = -geometryCache.normalizedSpanY / 2;
camera.right = viewSize * tileAspect;
camera.top = viewSize;
camera.bottom = -viewSize;
} else {
camera.left = -viewSize;
camera.right = viewSize;
camera.top = viewSize / tileAspect;
camera.bottom = -viewSize / tileAspect;
}
} else {
// Fallback: square frustum
camera.left = -viewSize;
camera.right = viewSize;
camera.top = viewSize;
camera.bottom = -viewSize;
} }
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
@@ -609,7 +606,9 @@ const renderTileWithSettings = async (tileData, renderSettings, resolution = 102
success: true, success: true,
dataURL, dataURL,
settings: { ...settings }, settings: { ...settings },
renderTime renderTime,
width: renderWidth,
height: renderHeight
}; };
} catch (err) { } catch (err) {
@@ -700,7 +699,8 @@ defineExpose({
updateHeightScale(); updateHeightScale();
}, },
getSettings: () => ({ ...settings }), getSettings: () => ({ ...settings }),
isReady: () => tileLoaded.value isReady: () => tileLoaded.value,
getBounds: () => geometryCache?.originalBounds ?? null
}); });
</script> </script>
@@ -720,6 +720,11 @@ defineExpose({
z-index: 1000; z-index: 1000;
} }
.modal-container.offscreen {
left: -10000px;
top: -10000px;
}
.modal-header { .modal-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;