From 33815e0855da3a3e931f5fa07922f2386d806051 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 17 Jul 2026 17:26:11 +0900 Subject: [PATCH] 260717_3 --- B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 11 ++- B04_wf1_Surface/B04_wf1_Surface_Engine.py | 23 +++-- B04_wf1_Surface/B04_wf1_Surface_Router.py | 19 +++- B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts | 42 +++++++++ .../B04_wf1_Surface_UI_MapViewer.ts | 36 ++++++-- B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 5 +- .../B04_wf1_Surface_UI_TerrainViewer.ts | 89 +++++++------------ B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts | 84 ++++++++--------- graphify-out/manifest.json | 20 ++--- 9 files changed, 189 insertions(+), 140 deletions(-) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index c3b942ab..2a69aec3 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -65,12 +65,21 @@ export interface SurfaceInputFileListResponse { files: SurfaceInputFileSummary[]; } +export interface SurfaceBounds { + x_min: number; + x_max: number; + y_min: number; + y_max: number; + z_min: number; + z_max: number; +} + export interface SurfacePointCloudSampleResponse { status: string; project_id: string; point_count: number; sampled_count: number; - bounds: Record; + bounds: SurfaceBounds; points: [number, number, number][]; rgb?: [number, number, number][]; } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine.py b/B04_wf1_Surface/B04_wf1_Surface_Engine.py index accc2faf..f366f189 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine.py @@ -19,6 +19,7 @@ from config.config_system import build_surface_model_config # 진행 콜백 시그니처: (진행률 0~100, 현재 단계 키, 메시지) ProgressCallback = Callable[[int, str, str], None] GROUND_POINT_SAMPLE_LIMIT = 500_000 +GROUND_POINT_CACHE_VERSION = 2 def _relative_to_project(project_root: Path, path: Path) -> str: @@ -37,19 +38,27 @@ def cache_ground_points( if mask is None: mask = build_ground_masks(structured, [filter_key])[filter_key] ground_indexes = np.flatnonzero(mask) - ground_point_count = int(len(ground_indexes)) + ground_points = xyz[ground_indexes] + ground_point_count = int(len(ground_points)) + if ground_point_count: + data_bounds = np.column_stack((ground_points.min(axis=0), ground_points.max(axis=0))) + else: + data_bounds = np.zeros((3, 2), dtype=np.float64) if ground_point_count > GROUND_POINT_SAMPLE_LIMIT: rng = np.random.default_rng(20260717) - ground_indexes = rng.choice(ground_indexes, GROUND_POINT_SAMPLE_LIMIT, replace=False) - points = xyz[ground_indexes] - if len(points): - bounds = np.column_stack((points.min(axis=0), points.max(axis=0))) + sample_indexes = rng.choice( + ground_point_count, GROUND_POINT_SAMPLE_LIMIT, replace=False + ) + ground_indexes = ground_indexes[sample_indexes] + points = ground_points[sample_indexes] else: - bounds = np.zeros((3, 2), dtype=np.float64) + points = ground_points arrays = { "xyz": points, - "bounds": np.asarray(bounds, dtype=np.float64), + "bounds": np.asarray(structured["bounds"], dtype=np.float64), + "data_bounds": np.asarray(data_bounds, dtype=np.float64), + "cache_version": np.asarray(GROUND_POINT_CACHE_VERSION, dtype=np.int16), "point_count": np.asarray(ground_point_count, dtype=np.int64), "sampled_count": np.asarray(len(points), dtype=np.int64), } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index 84ca04a6..86581145 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -10,11 +10,15 @@ from uuid import UUID import aiomysql import numpy as np -from fastapi import APIRouter, HTTPException, Response +from fastapi import APIRouter from fastapi.responses import FileResponse, JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from B04_wf1_Surface.B04_wf1_Surface_Engine import cache_ground_points, run_surface_analysis +from B04_wf1_Surface.B04_wf1_Surface_Engine import ( + GROUND_POINT_CACHE_VERSION, + cache_ground_points, + run_surface_analysis, +) from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( CONTOUR_EXTRACTOR_VERSION, extract_contours, @@ -22,10 +26,10 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( from B04_wf1_Surface.B04_wf1_Surface_Repository import ( clear_confirmed_surface_models, confirm_surface_model, + get_input_file, list_project_point_cloud_inputs, list_surface_models, save_surface_analysis_to_db, - update_project_status, ) from B04_wf1_Surface.B04_wf1_Surface_Schema import ( SurfaceAnalyzeRequest, @@ -285,7 +289,14 @@ async def get_surface_point_cloud( content={"status": "error", "message": "지원하지 않는 지면 필터입니다."}, ) source_path = structured_path.parent / f"ground_points_{filter}.npz" - if not source_path.is_file(): + cache_is_current = False + if source_path.is_file(): + with np.load(source_path) as cached: + cache_is_current = ( + "cache_version" in cached + and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION + ) + if not cache_is_current: source_path = await asyncio.to_thread(cache_ground_points, structured_path, filter) with np.load(source_path) as structured: diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts new file mode 100644 index 00000000..fb41ae3d --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts @@ -0,0 +1,42 @@ +import type { SurfaceBounds } from "./B04_wf1_Surface_Api_Fetch"; + +export const SURFACE_CAMERA_FOV = 50; + +export interface SurfaceCameraState { + direction: [number, number, number]; + distanceMeters: number; + targetMeters: [number, number, number]; +} + +export function getReferenceCenter(bounds: SurfaceBounds): [number, number, number] { + return [ + (bounds.x_min + bounds.x_max) / 2, + (bounds.y_min + bounds.y_max) / 2, + (bounds.z_min + bounds.z_max) / 2, + ]; +} + +export function getTopFitDistance(bounds: SurfaceBounds, aspect: number): number { + const width = Math.max(bounds.x_max - bounds.x_min, 1); + const depth = Math.max(bounds.y_max - bounds.y_min, 1); + const verticalFov = (SURFACE_CAMERA_FOV * Math.PI) / 180; + const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * Math.max(aspect, 0.1)); + const verticalDistance = depth / (2 * Math.tan(verticalFov / 2)); + const horizontalDistance = width / (2 * Math.tan(horizontalFov / 2)); + return Math.max(verticalDistance, horizontalDistance, 1) * 1.12; +} + +export function targetPlaneMetersPerPixel(distanceMeters: number, viewportHeight: number): number { + const verticalFov = (SURFACE_CAMERA_FOV * Math.PI) / 180; + return ( + (2 * Math.tan(verticalFov / 2) * Math.max(distanceMeters, 0.001)) / Math.max(viewportHeight, 1) + ); +} + +export function niceScaleDistance(roughMeters: number): number { + const exponent = Math.floor(Math.log10(Math.max(roughMeters, 0.001))); + const base = 10 ** exponent; + const normalized = roughMeters / base; + const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; + return step * base; +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index f935fcda..a4533751 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -3,12 +3,14 @@ import { fetchGisGeoJson, fetchVWorldMeta, getVWorldMapUrl, + type SurfaceBounds, type VWorldMeta, } from "./B04_wf1_Surface_Api_Fetch"; +import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera"; export interface SurfaceMapViewer { root: HTMLElement; - render: (projectId: string) => void; + render: (projectId: string, referenceBounds?: SurfaceBounds) => void; dispose: () => void; } @@ -36,11 +38,6 @@ function makeOption(value: string, label: string): HTMLOptionElement { return option; } -function prettyScaleDistance(roughMeters: number): number { - const candidates = [2, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000]; - return candidates.find((value) => value >= roughMeters) ?? candidates[candidates.length - 1]; -} - export function createSurfaceMapViewer(): SurfaceMapViewer { const root = document.createElement("section"); root.className = "b04-map"; @@ -105,6 +102,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { root.append(header, viewport); let currentProjectId: string | null = null; + let referenceBounds: SurfaceBounds | null = null; let meta: VWorldMeta | null = null; let geoJson: GeoJsonCollection | null = null; let scale = 1; @@ -117,10 +115,29 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; } + function fitReferenceBounds(): void { + if (!meta || !referenceBounds) return; + const rect = viewport.getBoundingClientRect(); + const width = Math.max(rect.width, 1); + const height = Math.max(rect.height, 1); + const mapRect = getMapRect(width, height); + const referenceWidth = Math.max(referenceBounds.x_max - referenceBounds.x_min, 1); + const referenceHeight = Math.max(referenceBounds.y_max - referenceBounds.y_min, 1); + scale = + Math.min(meta.width_meters / referenceWidth, meta.height_meters / referenceHeight) * 0.9; + const centerX = (referenceBounds.x_min + referenceBounds.x_max) / 2; + const centerY = (referenceBounds.y_min + referenceBounds.y_max) / 2; + const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width; + const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height; + offsetX = -(baseX - width / 2) * scale; + offsetY = -(baseY - height / 2) * scale; + } + function resetView(): void { scale = 1; offsetX = 0; offsetY = 0; + fitReferenceBounds(); updateImageTransform(); drawVectorLayer(); } @@ -212,7 +229,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { return; } const metersPerPixel = meta.width_meters / getMapRect(width, height).width / scale; - const meters = prettyScaleDistance(100 * metersPerPixel); + const meters = niceScaleDistance(100 * metersPerPixel); const pixels = meters / metersPerPixel; scaleBar.hidden = false; scaleBar.style.width = `${pixels}px`; @@ -274,7 +291,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { status.textContent = geoJson?.features ? L("B04_Surface_Map_Features").replace("{count}", geoJson.features.length.toLocaleString()) : ""; - drawVectorLayer(); + resetView(); } catch (error) { if (sequence !== loadSequence) return; status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed"); @@ -314,8 +331,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { return { root, - render(projectId) { + render(projectId, nextReferenceBounds) { currentProjectId = projectId; + referenceBounds = nextReferenceBounds ?? null; void loadLayers(); }, dispose() { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index d7b5e744..c451082f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -283,6 +283,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { showLoadingOverlay(); try { pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); + terrainViewer.setReferenceBounds(pointCloud.bounds); viewer.render(pointCloud); renderInputInfo(); } catch (error) { @@ -304,13 +305,15 @@ export async function renderB04Surface(root: HTMLElement): Promise { models = modelResponse.models; renderInputFiles(inputs.files); renderStatus(status); - mapViewer.render(projectId); try { pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); + terrainViewer.setReferenceBounds(pointCloud.bounds); viewer.render(pointCloud); + mapViewer.render(projectId, pointCloud.bounds); } catch { pointCloud = null; viewer.render(null); + mapViewer.render(projectId); } renderInputInfo(); updateSelectedModel(); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index 8aa9ec63..1d318118 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -3,13 +3,20 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; -import type { SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch"; -import type { SurfaceCameraState } from "./B04_wf1_Surface_UI_Viewer"; +import type { SurfaceBounds, SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch"; +import { + getTopFitDistance, + niceScaleDistance, + SURFACE_CAMERA_FOV, + targetPlaneMetersPerPixel, + type SurfaceCameraState, +} from "./B04_wf1_Surface_UI_Camera"; export interface SurfaceTerrainViewer { root: HTMLElement; optionsGroup: HTMLElement; render: (projectId: string, models: readonly SurfaceModelSummary[]) => void; + setReferenceBounds: (bounds: SurfaceBounds) => void; setSelection: (sourceFilter: string, method: string) => void; applyCameraState: (state: SurfaceCameraState) => void; onCameraChange: (listener: (state: SurfaceCameraState) => void) => void; @@ -115,27 +122,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Scale bar overlay const scaleBar = document.createElement("div"); - scaleBar.style.position = "absolute"; - scaleBar.style.bottom = "16px"; - scaleBar.style.left = "16px"; - scaleBar.style.background = "rgba(255, 255, 255, 0.9)"; - scaleBar.style.border = "1.5px solid #1e293b"; - scaleBar.style.borderTop = "none"; - scaleBar.style.height = "8px"; - scaleBar.style.width = "100px"; - scaleBar.style.zIndex = "10"; - scaleBar.style.display = "none"; - scaleBar.style.flexDirection = "column"; - scaleBar.style.alignItems = "center"; - scaleBar.style.justifyContent = "flex-end"; + scaleBar.className = "b04-surface__scale"; + scaleBar.hidden = true; const scaleLabel = document.createElement("span"); - scaleLabel.style.fontSize = "10px"; - scaleLabel.style.fontWeight = "bold"; - scaleLabel.style.color = "#1e293b"; - scaleLabel.style.position = "absolute"; - scaleLabel.style.bottom = "10px"; - scaleLabel.style.whiteSpace = "nowrap"; scaleBar.append(scaleLabel); viewerArea.append(scaleBar); @@ -182,7 +172,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Three.js context variables let currentProjectId = ""; let currentModelsList: readonly SurfaceModelSummary[] = []; - const sceneCenter = new THREE.Vector3(); + let referenceBounds: SurfaceBounds | null = null; let cameraListener: ((state: SurfaceCameraState) => void) | null = null; let suppressCameraEvent = false; let smoothPreferred = true; @@ -190,7 +180,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf5f7f9); - const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 100000); + const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.01, 100000); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); @@ -256,12 +246,13 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { }; const fitCamera = (object: THREE.Object3D) => { - const { center, span } = getFitParams(object); - sceneCenter.copy(center); - controls.target.copy(center); - camera.position.set(center.x, center.y + span * 1.2, center.z + 0.001); - camera.near = Math.max(span / 10000, 0.01); - camera.far = span * 100; + const { span } = getFitParams(object); + const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1); + const distance = referenceBounds ? getTopFitDistance(referenceBounds, aspect) : span * 1.2; + controls.target.set(0, 0, 0); + camera.position.set(0, distance, 0.001); + camera.near = Math.max(distance / 10000, 0.01); + camera.far = distance * 100; camera.updateProjectionMatrix(); controls.update(); }; @@ -270,18 +261,17 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { if (suppressCameraEvent || !cameraListener) return; const offset = camera.position.clone().sub(controls.target); const distance = Math.max(offset.length(), 0.001); - const targetOffset = controls.target.clone().sub(sceneCenter); offset.normalize(); cameraListener({ direction: [offset.x, offset.y, offset.z], distanceMeters: distance, - targetMeters: [targetOffset.x, targetOffset.y, targetOffset.z], + targetMeters: [controls.target.x, controls.target.y, controls.target.z], }); } function applyCameraState(state: SurfaceCameraState): void { suppressCameraEvent = true; - controls.target.set(...state.targetMeters).add(sceneCenter); + controls.target.set(...state.targetMeters); camera.position .set(...state.direction) .multiplyScalar(Math.max(state.distanceMeters, 0.001)) @@ -305,7 +295,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { clearMesh(); clearContours(); - scaleBar.style.display = "none"; + scaleBar.hidden = true; statusSpan.textContent = "모델 조회 중..."; // 1. Find matching model in list @@ -513,38 +503,16 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Render scale bar dynamically if (terrainMesh && terrainMesh.visible) { - scaleBar.style.display = "flex"; + scaleBar.hidden = false; const dist = camera.position.distanceTo(controls.target); - const metersPerPixel = - (2 * Math.tan((camera.fov * Math.PI) / 360) * dist) / viewerArea.clientWidth; + const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight); const roughMeters = 100 * metersPerPixel; - const prettyMeters = - roughMeters < 5 - ? 2 - : roughMeters < 15 - ? 10 - : roughMeters < 35 - ? 20 - : roughMeters < 75 - ? 50 - : roughMeters < 150 - ? 100 - : roughMeters < 350 - ? 200 - : roughMeters < 750 - ? 500 - : roughMeters < 1500 - ? 1000 - : roughMeters < 3500 - ? 2000 - : roughMeters < 7500 - ? 5000 - : 10000; + const prettyMeters = niceScaleDistance(roughMeters); scaleBar.style.width = `${prettyMeters / metersPerPixel}px`; scaleLabel.textContent = prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`; } else { - scaleBar.style.display = "none"; + scaleBar.hidden = true; } // Update labels position @@ -610,6 +578,9 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { currentModelsList = models; updateSelectedModel(); }, + setReferenceBounds(bounds) { + referenceBounds = bounds; + }, setSelection(sourceFilter, method) { activeFilter = sourceFilter; activeMethod = method; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts index 0fbf3823..0699b744 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts @@ -1,13 +1,17 @@ import { RENDER_OPTIONS } from "@config/config_frontend"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; -import type { SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch"; +import type { SurfaceBounds, SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch"; +import { + getReferenceCenter, + getTopFitDistance, + niceScaleDistance, + SURFACE_CAMERA_FOV, + targetPlaneMetersPerPixel, + type SurfaceCameraState, +} from "./B04_wf1_Surface_UI_Camera"; -export interface SurfaceCameraState { - direction: [number, number, number]; - distanceMeters: number; - targetMeters: [number, number, number]; -} +export type { SurfaceCameraState } from "./B04_wf1_Surface_UI_Camera"; export interface SurfacePointCloudViewer { root: HTMLElement; @@ -30,11 +34,6 @@ function makeButton(label: string): HTMLButtonElement { return button; } -function prettyScaleDistance(roughMeters: number): number { - const values = [2, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000]; - return values.find((value) => value >= roughMeters) ?? values[values.length - 1]; -} - export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { const root = document.createElement("div"); root.className = "point-viewer"; @@ -114,7 +113,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio)); const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf5f7f9); - const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 22000); + const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.1, 22000); const orbit = new OrbitControls(camera, canvas); orbit.enableDamping = true; orbit.dampingFactor = 0.08; @@ -129,7 +128,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { let disposed = false; let cameraListener: ((state: SurfaceCameraState) => void) | null = null; let suppressCameraEvent = false; - let sceneScale = 1; + let referenceBounds: SurfaceBounds | null = null; function resize(): void { const rect = viewerArea.getBoundingClientRect(); @@ -151,14 +150,22 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { } function setCameraView(view: CameraView): void { - const positions: Record = { + const directions: Record = { iso: [120, 95, 135], - top: [0, 190, 0.001], + top: [0, 1, 0.00001], front: [0, 60, 240], side: [240, 60, 0], }; + const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1); + const distance = referenceBounds ? getTopFitDistance(referenceBounds, aspect) : 190; orbit.target.set(0, 0, 0); - camera.position.set(...positions[view]); + camera.position + .set(...directions[view]) + .normalize() + .multiplyScalar(distance); + camera.near = Math.max(distance / 10000, 0.01); + camera.far = distance * 100; + camera.updateProjectionMatrix(); camera.lookAt(orbit.target); orbit.update(); } @@ -170,21 +177,17 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { offset.normalize(); cameraListener({ direction: [offset.x, offset.y, offset.z], - distanceMeters: distance / sceneScale, - targetMeters: [ - orbit.target.x / sceneScale, - orbit.target.y / sceneScale, - orbit.target.z / sceneScale, - ], + distanceMeters: distance, + targetMeters: [orbit.target.x, orbit.target.y, orbit.target.z], }); } function applyCameraState(state: SurfaceCameraState): void { suppressCameraEvent = true; - orbit.target.set(...state.targetMeters).multiplyScalar(sceneScale); + orbit.target.set(...state.targetMeters); camera.position .set(...state.direction) - .multiplyScalar(Math.max(state.distanceMeters * sceneScale, 0.001)) + .multiplyScalar(Math.max(state.distanceMeters, 0.001)) .add(orbit.target); camera.lookAt(orbit.target); orbit.update(); @@ -194,18 +197,11 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { function renderPointCloud(data: SurfacePointCloudSampleResponse): void { clearPoints(); const bounds = data.bounds; - const xMin = bounds.x_min ?? 0; - const xMax = bounds.x_max ?? 0; - const yMin = bounds.y_min ?? 0; - const yMax = bounds.y_max ?? 0; const zMin = bounds.z_min ?? 0; const zMax = bounds.z_max ?? 0; - const xMid = (xMin + xMax) / 2; - const yMid = (yMin + yMax) / 2; - const zMid = (zMin + zMax) / 2; + const [xMid, yMid, zMid] = getReferenceCenter(bounds); const zSpan = Math.max(zMax - zMin, 1e-9); - const scale = 180 / Math.max(xMax - xMin, yMax - yMin, zSpan, 1e-9); - sceneScale = scale; + referenceBounds = bounds; const density = Number(densityInput.value); const step = Math.max(1, Math.ceil(10 / density)); const count = Math.ceil(data.points.length / step); @@ -215,9 +211,9 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { let outputIndex = 0; for (let index = 0; index < data.points.length; index += step) { const [x, y, z] = data.points[index]; - positions[outputIndex * 3] = (x - xMid) * scale; - positions[outputIndex * 3 + 1] = (z - zMid) * scale; - positions[outputIndex * 3 + 2] = -(y - yMid) * scale; + positions[outputIndex * 3] = x - xMid; + positions[outputIndex * 3 + 1] = z - zMid; + positions[outputIndex * 3 + 2] = -(y - yMid); const rgb = hasRgb ? data.rgb?.[index] : undefined; if (rgb) { const divisor = Math.max(...rgb) > 255 ? 65535 : 255; @@ -251,21 +247,11 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { scaleBar.hidden = true; return; } - const bounds = currentData.bounds; - const span = Math.max( - bounds.x_max - bounds.x_min, - bounds.y_max - bounds.y_min, - bounds.z_max - bounds.z_min, - 1e-9, - ); - const internalScale = 180 / span; - const width = viewerArea.clientWidth || 1; const distance = camera.position.distanceTo(orbit.target); - const sceneUnitsPerPixel = (2 * Math.tan((camera.fov * Math.PI) / 360) * distance) / width; - const metersPerPixel = sceneUnitsPerPixel / internalScale; - const meters = prettyScaleDistance(100 * metersPerPixel); + const metersPerPixel = targetPlaneMetersPerPixel(distance, viewerArea.clientHeight); + const meters = niceScaleDistance(100 * metersPerPixel); scaleBar.hidden = false; - scaleBar.style.width = `${(meters * internalScale) / sceneUnitsPerPixel}px`; + scaleBar.style.width = `${meters / metersPerPixel}px`; scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`; } diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json index 7104598b..fed8cc39 100644 --- a/graphify-out/manifest.json +++ b/graphify-out/manifest.json @@ -90,8 +90,8 @@ "semantic_hash": "" }, "docs/wiki/concepts/storage_paths.md": { - "mtime": 1783850555.0, - "ast_hash": "2cf2c535406b15d5f2d7d5cde19fd4d2", + "mtime": 1784276227.6785357, + "ast_hash": "a0fe28424a580b6dce5ddbcb66f886e5", "semantic_hash": "" }, "docs/wiki/concepts/ui_templates.md": { @@ -110,8 +110,8 @@ "semantic_hash": "" }, "docs/wiki/log.md": { - "mtime": 1784273614.860682, - "ast_hash": "509c18e4f8dc914ee6fc8d0abed6abfe", + "mtime": 1784276174.9796534, + "ast_hash": "a9bc801087f68a8cf53b4ccd025587ea", "semantic_hash": "" }, "docs/wiki/pages/A01_Home/A01_components.md": { @@ -255,13 +255,13 @@ "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_api.md": { - "mtime": 1784267047.0904605, - "ast_hash": "5558d1f973ce53a60c263b62f71b9b8d", + "mtime": 1784276164.8003862, + "ast_hash": "1ca8565f179fb4643bd4997cb6f1bc46", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_backend.md": { - "mtime": 1784267712.4147556, - "ast_hash": "0de152bccf2967c5989e2ed9f0253781", + "mtime": 1784276156.6492395, + "ast_hash": "f3f818ea467831d6f60136ece8e3bb9b", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_db.md": { @@ -275,8 +275,8 @@ "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_frontend.md": { - "mtime": 1784273544.11365, - "ast_hash": "c7505151247ab901363aaf50e942013c", + "mtime": 1784276170.2440593, + "ast_hash": "514a6d8a8e8a3b0093bbbad7df764651", "semantic_hash": "" }, "docs/wiki/pages/B05_wf2_Route/B05_api.md": {