diff --git a/README.md b/README.md index f8028e1..fb0bcab 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,10 @@ Three.js renders: - cluster/point-cloud pairs in distinct colors chosen from a curated palette; - point clouds; - scene bounds and box/cylinder/sphere zones, with live presence labels shown below occupied zones and slider-volume fill on round zones; -- a Pleiades-style orbit camera, 1 × 1 m floor grid and axes. +- a Pleiades-style orbit camera, 1 × 1 m floor grid and axes; +- the Pleiades view cube: clickable Front/Back/Left/Right/Top/Bottom orthographic views plus an Iso shortcut back to perspective, with the cube orientation following manual camera movement. -Mouse navigation follows the Pleiades viewer philosophy: left-drag orbits, right-drag pans parallel to the floor, middle-drag/wheel zooms, and the orbit is constrained above the floor plane. The camera view is persisted after navigation and restored on refresh; setup updates do not overwrite a restored/user-controlled view. **Reset camera** explicitly returns the viewer to automatic framing, so a later full setup can frame its new bounds normally. Without a saved/user-controlled view, a successful connection setup initializes the same centered framing used by **Reset camera**. **Simulate data**, **Reset camera**, or a left-button double-click reframes the displayed setup. The sidebar uses two lightweight levels of disclosure: each main section can be folded to a compact status row, and each section can expose a denser **Advanced** subsection for less-used controls. In **Connection**, the server address, Connect/Simulate controls, and live connection log stay primary while Port, Protocol and Point downsample live under Advanced. **Display** keeps the Scene plus common visibility toggles primary, with **Reset camera** beside the Scene label and Velocity vectors under Advanced. **Live debug data** stays readable as the main content with a lightweight **Clear data** action above the inspector. While the Live debug data section is folded, only its compact summary is refreshed; the detailed debug HTML is not rebuilt until the section is opened again. While interacting with Frame / Objects / Zones / Control disclosures, debug DOM refreshes briefly pause so a live 4 Hz update cannot replace the clicked disclosure between pointer-down and click. The Display panel exposes **All scenes** plus every Scene received in the current World, with independent **Scenes** and **Zones** visibility toggles. On desktop, the translucent panel overlays the 3D view: use the edge arrow to hide/show it, or drag its left edge to resize it. On phone/touch layouts, the same control becomes a small horizontal handle at the viewer/menu boundary; folding the menu expands the viewer to the full viewport while keeping the handle available to reopen it. **Escape** folds the panel. **H** toggles the viewer title/subtitle as a transient display shortcut; unlike the other UI/display preferences, this title visibility is intentionally not persisted. On desktop, after 3 seconds without mouse movement or keyboard input, the handle fades whether the panel is unfolded or folded; activity brings it back quickly. On phone/touch layouts, the handle stays visible so the folded menu can always be reopened. When folded, the sidebar is removed from keyboard navigation; on desktop the hidden QR is inert as well, while on phone/touch layouts the visible QR remains usable. The camera projection follows the panel width so the orbit target remains centered in the unobscured part of the view. +Mouse navigation follows the Pleiades viewer philosophy: left-drag orbits, right-drag pans parallel to the floor, middle-drag/wheel zooms, and the free perspective orbit is constrained above the floor plane. Clicking a view-cube face switches to the matching orthographic view; **Iso** returns to the default perspective view. The selected projection, orthographic zoom, camera position and orbit target are persisted after navigation and restored on refresh; setup updates do not overwrite a restored/user-controlled view. **Reset camera** explicitly returns the viewer to automatic framing, so a later full setup can frame its new bounds normally. Without a saved/user-controlled view, a successful connection setup initializes the same centered framing used by **Reset camera**. **Simulate data**, **Reset camera**, or a left-button double-click reframes the displayed setup. The sidebar uses two lightweight levels of disclosure: each main section can be folded to a compact status row, and each section can expose a denser **Advanced** subsection for less-used controls. In **Connection**, the server address, Connect/Simulate controls, and live connection log stay primary while Port, Protocol and Point downsample live under Advanced. **Display** keeps the Scene plus common visibility toggles primary, with **Reset camera** beside the Scene label and Velocity vectors under Advanced. **Live debug data** stays readable as the main content with a lightweight **Clear data** action above the inspector. While the Live debug data section is folded, only its compact summary is refreshed; the detailed debug HTML is not rebuilt until the section is opened again. While interacting with Frame / Objects / Zones / Control disclosures, debug DOM refreshes briefly pause so a live 4 Hz update cannot replace the clicked disclosure between pointer-down and click. The Display panel exposes **All scenes** plus every Scene received in the current World, with independent **Scenes** and **Zones** visibility toggles. On desktop, the translucent panel overlays the 3D view: use the edge arrow to hide/show it, or drag its left edge to resize it. On phone/touch layouts, the same control becomes a small horizontal handle at the viewer/menu boundary; folding the menu expands the viewer to the full viewport while keeping the handle available to reopen it. **Escape** folds the panel. **H** toggles the viewer title/subtitle as a transient display shortcut; unlike the other UI/display preferences, this title visibility is intentionally not persisted. On desktop, after 3 seconds without mouse movement or keyboard input, the handle fades whether the panel is unfolded or folded; activity brings it back quickly. On phone/touch layouts, the handle stays visible so the folded menu can always be reopened. When folded, the sidebar is removed from keyboard navigation; on desktop the hidden QR is inert as well, while on phone/touch layouts the visible QR remains usable. The camera projection follows the panel width so the orbit target remains centered in the unobscured part of the view. Partial setup updates are merged into the cached hierarchy before rendering, so a Scene update that omits its Zone children does not erase them. Updates that race ahead of the initial full setup are ignored rather than promoted to an incomplete root. When a specific Scene is selected, live setup updates still render from the World root so ancestor transforms remain intact. Zone-event caches are pruned against the latest setup hierarchy so renamed/removed addresses do not accumulate stale visual state. A zone stops rendering 50 ms after its live event heartbeat disappears, so disabled zones vanish quickly while their setup definition remains available. Scene selection and scene-size debug information therefore always use the latest valid merged setup. diff --git a/index.html b/index.html index 8e0b69b..dc65b50 100644 --- a/index.html +++ b/index.html @@ -37,6 +37,28 @@

Augmenta × Three.js

+
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+
{ + if (!(event.target instanceof Element)) return; + const button = event.target.closest('[data-view]'); + if (!button || !root.contains(button)) return; + const view = button.dataset.view; + if (!VIEW_IDS.has(view)) return; + viewer.setView(view); + }); + + viewer.setViewStateChangeHandler(render); + return { refresh: render }; +} diff --git a/src/viewer.js b/src/viewer.js index d52b3cc..ba01437 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -7,10 +7,16 @@ import { collectZoneAddresses } from './zone-state.js'; const FLOOR_Y = 0; const PANEL_INSET_ANIMATION_DURATION_MS = 220; +const CAMERA_VIEW_TRANSITION_MS = 550; const MIN_GEOMETRY_SIZE = 0.001; const MIN_ARROW_LENGTH_M = 0.001; const MIN_VISIBLE_SPEED_MPS = 0.001; const CAMERA_FLOOR_CLEARANCE_M = 0.02; +const PERSPECTIVE_MIN_POLAR_ANGLE = THREE.MathUtils.degToRad(2); +const PERSPECTIVE_MAX_POLAR_ANGLE = THREE.MathUtils.degToRad(88.5); +const ORTHOGRAPHIC_MIN_POLAR_ANGLE = 0.001; +const ORTHOGRAPHIC_MAX_POLAR_ANGLE = Math.PI - 0.001; +const VIEW_IDS = new Set(['home', 'front', 'back', 'right', 'left', 'top', 'bottom']); const GHOST_COLOR = new THREE.Color(0x8a909b); const LOOK_AT_MARKER_OPACITY = 0.58; const LOOK_AT_MARKER_GHOST_OPACITY = 0.24; @@ -36,7 +42,9 @@ export function createViewer(host) { scene.background = new THREE.Color(0x0c0f14); scene.fog = new THREE.FogExp2(0x0c0f14, 0.014); - const camera = new THREE.PerspectiveCamera(48, 1, 0.02, 500); + const perspectiveCamera = new THREE.PerspectiveCamera(48, 1, 0.02, 500); + const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.02, 500); + let camera = perspectiveCamera; const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; @@ -87,15 +95,26 @@ export function createViewer(host) { const velocityDirection = new THREE.Vector3(); const homePosition = new THREE.Vector3(0, 2.5, 7.5); const homeTarget = new THREE.Vector3(0, 1.2, 0); + const cameraOffset = new THREE.Vector3(); + const cameraSpherical = new THREE.Spherical(); + let homeOrthoHalfWidth = 4; + let orthoHalfWidth = 4; let rightInset = 0; let insetAnimationFrame; + let cameraTransitionFrame; let cameraChangeHandler; + let viewStateChangeHandler; let cameraUserControlled = false; + let activeView = 'home'; function getCameraView() { return { position: camera.position.toArray(), - target: controls.target.toArray() + target: controls.target.toArray(), + mode: camera.isOrthographicCamera ? 'orthographic' : 'perspective', + zoom: camera.zoom, + orthoHalfWidth, + activeView }; } @@ -104,10 +123,27 @@ export function createViewer(host) { const target = validVector3(view?.target); if (!position || !target) return false; + cancelCameraTransition(); + if (Number.isFinite(view?.orthoHalfWidth) && view.orthoHalfWidth > 0) { + orthoHalfWidth = view.orthoHalfWidth; + } + if (Number.isFinite(view?.zoom) && view.zoom > 0) { + orthographicCamera.zoom = view.zoom; + } + + activeView = VIEW_IDS.has(view?.activeView) + ? view.activeView + : view?.mode === 'orthographic' + ? activeView + : 'home'; + + switchCamera(view?.mode === 'orthographic' ? 'orthographic' : 'perspective'); camera.position.fromArray(position); controls.target.fromArray(target); cameraUserControlled = true; + updateCameraProjection(); controls.update(); + notifyViewState(); return true; } @@ -115,36 +151,182 @@ export function createViewer(host) { cameraChangeHandler = typeof handler === 'function' ? handler : undefined; } + function setViewStateChangeHandler(handler) { + viewStateChangeHandler = typeof handler === 'function' ? handler : undefined; + notifyViewState(); + } + function isCameraUserControlled() { return cameraUserControlled; } + function currentViewState() { + cameraOffset.copy(camera.position).sub(controls.target); + if (cameraOffset.lengthSq() < 1e-8) { + return { + activeView, + mode: camera.isOrthographicCamera ? 'orthographic' : 'perspective', + cubeTransform: 'rotateX(0deg) rotateY(0deg) rotateZ(0deg)' + }; + } + + cameraSpherical.setFromVector3(cameraOffset); + const x = Math.round((THREE.MathUtils.radToDeg(cameraSpherical.phi) - 90) * 10) / 10; + const yaw = normalizeDegrees(THREE.MathUtils.radToDeg(cameraSpherical.theta) - 180); + const y = Math.round(yaw * 10) / 10; + return { + activeView, + mode: camera.isOrthographicCamera ? 'orthographic' : 'perspective', + cubeTransform: `rotateX(${x}deg) rotateY(${y}deg) rotateZ(0deg)` + }; + } + + function notifyViewState() { + viewStateChangeHandler?.(currentViewState()); + } + + function cancelCameraTransition() { + if (!cameraTransitionFrame) return; + cancelAnimationFrame(cameraTransitionFrame); + cameraTransitionFrame = undefined; + } + + function configureProjectionControls() { + controls.minPolarAngle = camera.isOrthographicCamera + ? ORTHOGRAPHIC_MIN_POLAR_ANGLE + : PERSPECTIVE_MIN_POLAR_ANGLE; + controls.maxPolarAngle = camera.isOrthographicCamera + ? ORTHOGRAPHIC_MAX_POLAR_ANGLE + : PERSPECTIVE_MAX_POLAR_ANGLE; + } + + function switchCamera(mode) { + const nextCamera = mode === 'orthographic' ? orthographicCamera : perspectiveCamera; + if (camera === nextCamera) { + configureProjectionControls(); + updateCameraProjection(); + return; + } + + nextCamera.position.copy(camera.position); + nextCamera.quaternion.copy(camera.quaternion); + nextCamera.up.copy(camera.up); + camera = nextCamera; + controls.object = camera; + configureProjectionControls(); + updateCameraProjection(); + controls.update(); + } + + function animateCameraTo(position, target, mode, duration = CAMERA_VIEW_TRANSITION_MS) { + cancelCameraTransition(); + + const startPosition = camera.position.clone(); + const startTarget = controls.target.clone(); + switchCamera(mode); + camera.position.copy(startPosition); + controls.target.copy(startTarget); + + cameraUserControlled = true; + if (duration <= 0) { + camera.position.copy(position); + controls.target.copy(target); + controls.update(); + notifyViewState(); + return; + } + + const startedAt = performance.now(); + const tick = (now) => { + const t = Math.min((now - startedAt) / duration, 1); + const eased = 1 - Math.pow(1 - t, 5); + camera.position.lerpVectors(startPosition, position, eased); + controls.target.lerpVectors(startTarget, target, eased); + controls.update(); + + if (t < 1) { + cameraTransitionFrame = requestAnimationFrame(tick); + } else { + cameraTransitionFrame = undefined; + camera.position.copy(position); + controls.target.copy(target); + controls.update(); + notifyViewState(); + } + }; + + cameraTransitionFrame = requestAnimationFrame(tick); + } + + function setView(view, duration = CAMERA_VIEW_TRANSITION_MS) { + if (!VIEW_IDS.has(view)) return false; + + activeView = view; + if (view === 'home') { + animateCameraTo(homePosition.clone(), homeTarget.clone(), 'perspective', duration); + return true; + } + + const directions = { + front: new THREE.Vector3(0, 0, -1), + back: new THREE.Vector3(0, 0, 1), + right: new THREE.Vector3(1, 0, 0), + left: new THREE.Vector3(-1, 0, 0), + top: new THREE.Vector3(0, 1, 0), + bottom: new THREE.Vector3(0, -1, 0) + }; + const direction = directions[view]; + const distance = Math.max(homePosition.distanceTo(homeTarget), 2); + const target = homeTarget.clone(); + const position = target.clone().addScaledVector(direction, distance); + + orthographicCamera.zoom = 1; + orthoHalfWidth = homeOrthoHalfWidth; + animateCameraTo(position, target, 'orthographic', duration); + return true; + } + controls.addEventListener('start', () => { + cancelCameraTransition(); cameraUserControlled = true; }); controls.addEventListener('change', () => { + notifyViewState(); cameraChangeHandler?.(getCameraView()); }); function resetCamera() { + cancelCameraTransition(); cameraUserControlled = false; + activeView = 'home'; + switchCamera('perspective'); camera.position.copy(homePosition); controls.target.copy(homeTarget); controls.update(); + notifyViewState(); } function updateCameraProjection() { const width = Math.max(host.clientWidth, 1); const height = Math.max(host.clientHeight, 1); + const hasInset = rightInset > 0 && width > rightInset + 80; + const virtualWidth = hasInset ? width + rightInset : width; + const aspect = Math.max(virtualWidth / height, 0.1); + camera.clearViewOffset(); + if (camera.isPerspectiveCamera) { + camera.aspect = aspect; + } else { + const halfHeight = orthoHalfWidth / aspect; + camera.left = -orthoHalfWidth; + camera.right = orthoHalfWidth; + camera.top = halfHeight; + camera.bottom = -halfHeight; + } + camera.updateProjectionMatrix(); - if (rightInset > 0 && width > rightInset + 80) { - const virtualWidth = width + rightInset; - camera.aspect = Math.max(virtualWidth / height, 0.1); + if (hasInset) { camera.setViewOffset(virtualWidth, height, rightInset, 0, width, height); - } else { - camera.aspect = Math.max(width / height, 0.1); - camera.updateProjectionMatrix(); } } @@ -510,8 +692,9 @@ export function createViewer(host) { const size = bounds.getSize(new THREE.Vector3()); const center = bounds.getCenter(new THREE.Vector3()); const span = Math.max(size.x, size.y, size.z, 1); - const fov = THREE.MathUtils.degToRad(camera.fov); + const fov = THREE.MathUtils.degToRad(perspectiveCamera.fov); const distance = Math.max(span / (2 * Math.tan(fov / 2)) * 1.35, 2); + homeOrthoHalfWidth = Math.max(span * 0.82 + 1.8, 1); homeTarget.copy(center); // A centered, slightly elevated front view similar to Pleiades' default, @@ -566,6 +749,8 @@ export function createViewer(host) { zoneRenderer.resetViews(); homePosition.set(0, 2.5, 7.5); homeTarget.set(0, 1.2, 0); + homeOrthoHalfWidth = 4; + orthoHalfWidth = 4; } function disposeView(view) { @@ -595,9 +780,12 @@ export function createViewer(host) { controls.update(); zoneRenderer.animate(performance.now()); - // Orbiting stays above the world floor. With screenSpacePanning=false, - // right-drag panning also remains parallel to the floor plane. - if (camera.position.y < FLOOR_Y + CAMERA_FLOOR_CLEARANCE_M) { + // Perspective orbiting stays above the world floor. Orthographic face + // views intentionally include top and bottom, so they are not clamped. + if ( + camera.isPerspectiveCamera + && camera.position.y < FLOOR_Y + CAMERA_FLOOR_CLEARANCE_M + ) { camera.position.y = FLOOR_Y + CAMERA_FLOOR_CLEARANCE_M; } @@ -615,10 +803,16 @@ export function createViewer(host) { setCameraChangeHandler, setCameraView, setRightInset, + setView, + setViewStateChangeHandler, setVisibility }; } +function normalizeDegrees(value) { + return ((((value + 180) % 360) + 360) % 360) - 180; +} + function validVector3(value) { return Array.isArray(value) && value.length === 3 @@ -659,8 +853,8 @@ function configureControls(controls) { controls.minDistance = 0.05; controls.maxDistance = 500; controls.zoomToCursor = false; - controls.minPolarAngle = THREE.MathUtils.degToRad(2); - controls.maxPolarAngle = THREE.MathUtils.degToRad(88.5); + controls.minPolarAngle = PERSPECTIVE_MIN_POLAR_ANGLE; + controls.maxPolarAngle = PERSPECTIVE_MAX_POLAR_ANGLE; controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE; controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY; controls.mouseButtons.RIGHT = THREE.MOUSE.PAN;