From f4b7b18072bd6f31519d6f6c39812bbe5d34dc5a Mon Sep 17 00:00:00 2001 From: Gogades Date: Tue, 20 Oct 2026 20:52:58 -0400 Subject: [PATCH 1/4] open an independant read-only view window --- apps/web/src/app/layout.tsx | 1 + apps/web/src/app/secondView.css | 109 ++++ apps/web/src/components/WorkplaneViewport.tsx | 612 ++---------------- .../workplane/SecondViewCameraBar.tsx | 88 +++ .../components/workplane/SecondViewWindow.tsx | 364 +++++++++++ .../web/src/components/workplane/ViewCube.tsx | 149 +++++ .../web/src/components/workplane/cameraRig.ts | 265 ++++++++ .../src/components/workplane/cameraViews.ts | 34 +- .../src/components/workplane/renderFrame.ts | 257 ++++++++ apps/web/src/lib/messages.cs.ts | 3 + apps/web/src/lib/messages.de.ts | 3 + apps/web/src/lib/messages.en.ts | 3 + apps/web/src/lib/messages.fr.ts | 3 + apps/web/src/lib/messages.nl.ts | 3 + apps/web/src/lib/messages.ru.ts | 3 + apps/web/src/lib/messages.sl.ts | 3 + apps/web/src/lib/secondViewPlacement.ts | 62 ++ apps/web/src/lib/viewArrowKeys.ts | 30 +- tests/unit/secondViewPlacement.test.ts | 62 ++ tests/unit/viewArrowKeys.test.ts | 20 +- tests/unit/viewCubeDrag.test.ts | 29 +- tests/unit/viewFaceShortcuts.test.ts | 5 +- 22 files changed, 1481 insertions(+), 627 deletions(-) create mode 100644 apps/web/src/app/secondView.css create mode 100644 apps/web/src/components/workplane/SecondViewCameraBar.tsx create mode 100644 apps/web/src/components/workplane/SecondViewWindow.tsx create mode 100644 apps/web/src/components/workplane/ViewCube.tsx create mode 100644 apps/web/src/components/workplane/cameraRig.ts create mode 100644 apps/web/src/components/workplane/renderFrame.ts create mode 100644 apps/web/src/lib/secondViewPlacement.ts create mode 100644 tests/unit/secondViewPlacement.test.ts diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 43ab8faab..280d5f5c9 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -3,6 +3,7 @@ import "./globals.css"; import "./historyView.css"; import "./myShapes.css"; import "./viewCube.css"; +import "./secondView.css"; import { DiagnoseStrip } from "@/components/DiagnoseStrip"; import { NewVersionNotice } from "@/components/NewVersionNotice"; import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration"; diff --git a/apps/web/src/app/secondView.css b/apps/web/src/app/secondView.css new file mode 100644 index 000000000..7e37795a7 --- /dev/null +++ b/apps/web/src/app/secondView.css @@ -0,0 +1,109 @@ +/* The second view's window (components/workplane/SecondViewWindow.tsx). It + takes the editor's stylesheets, so the view cube looks the same there. */ + +.second-view-root, +.second-view-stage { + position: fixed; + inset: 0; + overflow: hidden; + background: var(--background); +} + +.second-view-canvas { + position: absolute; + inset: 0; +} + +.second-view-canvas canvas { + display: block; +} + +/* The cube's corner follows the setting (viewCube.css) at any width here: the + 3D view keeps it top left in a phone-wide window, but this window is never + on a phone, only made narrow. Nothing stands in the bottom right corner here, + so both bottom corners sit 12 px up. */ +html[data-view-cube-position] .second-view-stage .camera-controls { + top: 22px; +} + +html[data-view-cube-position="top-right"] .second-view-stage .view-cube, +html[data-view-cube-position="bottom-right"] .second-view-stage .view-cube { + right: 10px; + left: auto; +} + +html[data-view-cube-position="bottom-left"] .second-view-stage .view-cube, +html[data-view-cube-position="bottom-right"] .second-view-stage .view-cube { + top: auto; + bottom: 12px; +} + +html[data-view-cube-position="top-right"] .second-view-stage .view-cube { + transform-origin: top right; +} + +html[data-view-cube-position="bottom-left"] .second-view-stage .view-cube { + transform-origin: bottom left; +} + +html[data-view-cube-position="bottom-right"] .second-view-stage .view-cube { + transform-origin: bottom right; +} + +/* A container of its own name: the 3D view's two-column rule (globals.css, + "@container workplane") is measured for its longer bar and does not apply here. */ +.second-view-stage { + container: second-view / size; +} + +/* The bar here has seven buttons. Below 780 px of window it is scaled to 88% + and starts 96 px down (globals.css), so one column needs about 380 px at + Medium, 450 at Large and 320 at Small. Only lower than that does it take + two columns, the zoom pair side by side as in the 3D view. */ +@container second-view (max-height: 400px) { + html:not([data-toolbar-size="small"]) .second-view-stage .camera-controls { + display: grid; + grid-template-columns: repeat(2, auto); + transform: none; + } + + html:not([data-toolbar-size="small"]) .second-view-stage .camera-controls > .camera-zoom-button { + order: 1; + } + + html:not([data-toolbar-size="small"]) .second-view-stage .camera-controls > .workplane-display-control-group { + order: 2; + } +} + +@container second-view (max-height: 470px) { + html[data-toolbar-size="large"] .second-view-stage .camera-controls { + display: grid; + grid-template-columns: repeat(2, auto); + transform: none; + } + + html[data-toolbar-size="large"] .second-view-stage .camera-controls > .camera-zoom-button { + order: 1; + } + + html[data-toolbar-size="large"] .second-view-stage .camera-controls > .workplane-display-control-group { + order: 2; + } +} + +@container second-view (max-height: 340px) { + html[data-toolbar-size="small"] .second-view-stage .camera-controls { + display: grid; + grid-template-columns: repeat(2, auto); + transform: none; + } + + html[data-toolbar-size="small"] .second-view-stage .camera-controls > .camera-zoom-button { + order: 1; + } + + html[data-toolbar-size="small"] .second-view-stage .camera-controls > .workplane-display-control-group { + order: 2; + } +} diff --git a/apps/web/src/components/WorkplaneViewport.tsx b/apps/web/src/components/WorkplaneViewport.tsx index d9ebe9c95..6dc7aadf6 100644 --- a/apps/web/src/components/WorkplaneViewport.tsx +++ b/apps/web/src/components/WorkplaneViewport.tsx @@ -1,8 +1,8 @@ "use client"; import { markEscapeHandled } from "@/lib/escapeHandled"; -import { ArrowDownToLine, Check, ChevronLeft, ChevronRight, Crosshair, Cuboid, Download, Eye, EyeOff, FlipHorizontal, GripVertical, Home, Layers, Minus, MousePointer2, PanelsTopLeft, Plus, Rotate3d, RotateCcw, Ruler, RulerDimensionLine, Slice, Spline, Trash2, X } from "lucide-react"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type Dispatch, type DragEvent, type MouseEvent as ReactMouseEvent, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, type WheelEvent as ReactWheelEvent } from "react"; +import { ArrowDownToLine, Check, ChevronLeft, ChevronRight, Crosshair, Cuboid, Download, Eye, EyeOff, FlipHorizontal, GripVertical, Home, Layers, Minus, MousePointer2, PanelsTopLeft, PictureInPicture2, Plus, Rotate3d, RotateCcw, Ruler, RulerDimensionLine, Slice, Spline, Trash2, X } from "lucide-react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type Dispatch, type DragEvent, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, type WheelEvent as ReactWheelEvent } from "react"; import { objectSnapOffset, shiftSnapBox, type ObjectSnapGuide, type SnapBox } from "@/lib/objectSnap"; import { dragReach } from "@/lib/dragReach"; import { useMovablePanel, type MovablePanelOptions } from "@/lib/useMovablePanel"; @@ -35,22 +35,9 @@ import { t } from "@/lib/i18n"; import type { ModelSplitPlane } from "@/lib/modelSplit"; import { planeCrossingSegments } from "@/lib/splitCutLine"; import { useLanguage } from "@/lib/useLanguage"; -import { orthographicFramingZoom, perspectiveFramingDistance } from "@/lib/cameraFraming"; -import { - beginViewCubeDrag, - moveViewCubeDrag, - orbitOffsetByDrag, - viewCubeAngles, - VIEW_CUBE_ZONE_SIZE, - VIEW_CUBE_ZONE_START, - VIEW_CUBE_ZONES, - viewCubeZoneDirection, - viewDirectionFromName, - viewFaceDirection, - viewFaceForKey, - type ViewCubeDrag, - type ViewCubeFace, -} from "@/lib/viewCubeDrag"; +import { viewDirectionFromName, viewFaceForKey, type ViewCubeFace } from "@/lib/viewCubeDrag"; +import { ViewCube } from "@/components/workplane/ViewCube"; +import { openSecondViewWindow, SecondViewWindow, useSecondViewSupported } from "@/components/workplane/SecondViewWindow"; import { createGearGeometry } from "@/lib/gearGeometry"; import { createStarGeometry } from "@/lib/starGeometry"; import { createHeartGeometry } from "@/lib/heartGeometry"; @@ -77,7 +64,9 @@ import { createLatestRunner, type LatestRunner } from "@/lib/latestRunner"; import { manifoldRevision, onManifoldReady } from "@/lib/manifoldHandle"; import { customFontRevision, onCustomFontsChanged } from "@/lib/textFonts"; import { readSavedViews, SAVED_VIEW_KEYS, savedViewForKey } from "@/lib/designView"; -import { CAMERA_MAX_DISTANCE, fitCameraDepthRange, rememberCameraView, useDesignViews } from "@/components/workplane/cameraViews"; +import { rememberCameraView, useDesignViews } from "@/components/workplane/cameraViews"; +import { CAMERA_FOV, CAMERA_MAX_DISTANCE, CAMERA_MIN_DISTANCE, CAMERA_TARGET, fitCameraDepthRange, frameBounds, resetCamera, selectionBounds, setCameraToViewDirection, setCameraToViewFace, syncViewCube, toggleCameraProjection, updateCameraViewport, zoomCameraBy } from "@/components/workplane/cameraRig"; +import { disposeCutPreviewResources, renderFrame } from "@/components/workplane/renderFrame"; import { DIMENSION_FORMULA_FIELDS, formulaMatchesMillimeters, formulaToRemember, withFieldFormula } from "@/lib/fieldFormulas"; import { displayToMillimeters, formatLengthMm, lengthDisplayUnit, millimetersToDisplay, parseLengthMm, parseMeasurementInput, resolveLengthMm, setLengthUnit } from "@/lib/measurementUnits"; import { computeCornerRulerRelativeCoordinates, cornerRulerAxisModes, cornerRulerFrameForWorkplane, cornerRulerShiftVector, pointAlongRuler, rotateCornerRulerFrame, rulerDimensionMatch, rulerTicks, toggledCornerRulerAxis, toggledCornerRulerMode, type CornerRulerAxis, type RulerDimensionField } from "@/lib/rulerDimensions"; @@ -180,9 +169,6 @@ const START_IN_PERSPECTIVE_STORAGE_KEY = "layerling.editor.startInPerspective"; const CORNER_RULER_DEFAULT_ARM_X = 100; const CORNER_RULER_DEFAULT_ARM_Z = 80; const DEFAULT_WORKSPACE = DEFAULT_WORKPLANE_WORKSPACE; -const CAMERA_FOV = 38; -/** How close the camera may come to what it looks at, in mm; how far it may back off is in cameraViews. */ -const CAMERA_MIN_DISTANCE = 3; /** * The normal view zooms towards its pivot and stops at the closest distance @@ -226,20 +212,6 @@ function movePivotToDepthUnder(state: ThreeState, ndcX: number, ndcY: number) { state.controls.target.copy(target); } -/** - * The flat (orthographic) view's zoom range, so that it frames exactly what - * the normal view frames between its closest and farthest distance: its zoom - * divides the frame it was made with, the normal view's frame grows with the - * distance. Before, the flat view had a fixed 0.02 to 100 and went much closer. - */ -function orthographicZoomRange(camera: THREE.OrthographicCamera) { - const frameHalfHeight = Math.max(0.001, (camera.top - camera.bottom) / 2); - const tanHalf = Math.tan(THREE.MathUtils.degToRad(CAMERA_FOV / 2)); - return { min: frameHalfHeight / (CAMERA_MAX_DISTANCE * tanHalf), max: frameHalfHeight / (CAMERA_MIN_DISTANCE * tanHalf) }; -} - -const CAMERA_HOME = new THREE.Vector3(118, 96, 118); -const CAMERA_TARGET = new THREE.Vector3(0, 0, 0); const MIN_SHAPE_SIZE = 0.01; // A height is no longer held to the 220 mm of the old plate: the plate sets how far the sliders reach, the guard is for a typo (#425). /** A finger held this long (ms) without moving more than this (px) opens the context menu (#337). */ @@ -3464,6 +3436,9 @@ export function WorkplaneViewport({ const [cameraControlsCollapsed, setCameraControlsCollapsed] = useState(false); const language = useLanguage(); const [orthographicView, setOrthographicView] = useState(false); + /** The window of the second view, while it is open. */ + const [secondViewWindow, setSecondViewWindow] = useState(null); + const secondViewSupported = useSecondViewSupported(); // Hides the whole plate - grid, labels and any face workplane - for a clear // look at the underside. Only the view changes: shapes still land on it. const [workplaneLayerHidden, setWorkplaneLayerHidden] = useState(false); @@ -3612,11 +3587,7 @@ export function WorkplaneViewport({ const lastWorkspaceSettingsSyncRef = useRef(""); const pendingWorkspaceHydrationFingerprintRef = useRef(null); const viewCubeRef = useRef(null); - // Dragging the view cube orbits the camera; a press that never passes the - // drag threshold stays a click so the faces keep snapping to their views. - const viewCubeDragRef = useRef(null); - const suppressViewCubeClickRef = useRef(false); - const [viewCubeDragging, setViewCubeDragging] = useState(false); + const getViewCubeRig = useCallback(() => threeRef.current, []); // Der Umschalter fuer das Drehen mit einem Finger steht nur dort, wo er // gebraucht wird. Anfangs falsch, damit das ausgelieferte HTML passt. const [touchDevice, setTouchDevice] = useState(false); @@ -4760,7 +4731,7 @@ export function WorkplaneViewport({ state.sectionPlaneHelper = null; } clearSectionCaps(state); - disposeCutPreviewResources(); + disposeCutPreviewResources(state.renderer); state.renderer.dispose(); host.replaceChildren(); if (window.layerlingCaptureCanvas) { @@ -8264,43 +8235,10 @@ export function WorkplaneViewport({ if (!state) return false; // The real selection, not renderSelectionIds: the workplane and edge tools hide // the outline while they are open, but the objects are still what to look at. - const bounds = new THREE.Box3(); - selectedIdsRef.current.forEach((id) => { - const record = state.shapeRecords.get(id); - if (record) bounds.expandByObject(record.object); - }); - if (bounds.isEmpty()) return false; - - const center = bounds.getCenter(new THREE.Vector3()); - const radius = Math.max(bounds.getSize(new THREE.Vector3()).length() / 2, 0.5); - const canvas = state.renderer.domElement; - const aspect = canvas.clientWidth / Math.max(1, canvas.clientHeight); - const offset = state.camera.position.clone().sub(state.controls.target); - const direction = offset.lengthSq() > 0 - ? offset.clone().normalize() - : CAMERA_HOME.clone().sub(CAMERA_TARGET).normalize(); - - if (state.camera instanceof THREE.OrthographicCamera) { - const halfHeight = Math.max(0.001, (state.camera.top - state.camera.bottom) / 2); - const zoom = orthographicFramingZoom(radius, halfHeight, aspect); - const range = orthographicZoomRange(state.camera); - if (zoom) state.camera.zoom = clamp(zoom, range.min, range.max); - state.camera.position.copy(center).add(direction.multiplyScalar(clamp(offset.length(), CAMERA_MIN_DISTANCE, CAMERA_MAX_DISTANCE))); - } else { - const distance = clamp(perspectiveFramingDistance(radius, CAMERA_FOV, aspect), CAMERA_MIN_DISTANCE, CAMERA_MAX_DISTANCE); - state.camera.position.copy(center).add(direction.multiplyScalar(distance)); - } - - // constrainCamera keeps the orbit target on the plate on every frame, as far out as this - // part: one parked beyond the plate edge is centred too, as in Tinkercad (#352). The house - // button takes the allowance back. - state.focusReach = bounds.clone(); - state.controls.target.copy(center); - state.camera.lookAt(center); - state.camera.updateProjectionMatrix(); - state.controls.update(); + const bounds = selectionBounds(state, selectedIdsRef.current); + if (!bounds) return false; + frameBounds(state, bounds); syncViewCube(state, viewCubeRef.current); - state.needsRender = true; return true; }, []); @@ -8321,105 +8259,25 @@ export function WorkplaneViewport({ syncViewCube(state, viewCubeRef.current); }, []); - // The bands along a face's sides and its corners look from that edge or corner (#202). - const viewCubeZones = (face: ViewCubeFace) => VIEW_CUBE_ZONES.map(([column, row]) => ( -