diff --git a/README.de.md b/README.de.md index bdfc4981..882f98e3 100644 --- a/README.de.md +++ b/README.de.md @@ -117,6 +117,7 @@ Ein vollständiges CAD-Paket ist es nicht: Es gibt keine parametrische Zeitleist - **Vier gespeicherte Ansichten je Entwurf** – **Umschalt+7**, **8**, **9** oder **0** speichert die Ansicht genau so, wie sie ist (Blickwinkel, Ausschnitt, Zoom), und die Taste allein holt sie zurück. Jeder Entwurf hat seine eigenen, und eine gespeicherte `.lyl`-Datei nimmt sie mit. - **Der Ansichtswürfel in jeder Ecke** – Einstellungen > Darstellung > **Position des Ansichtswürfels** stellt den Würfel oben links, oben rechts, unten links oder unten rechts hin; in jeder Ecke außer oben links rückt die Kamerasteuerung an seinen Platz nach oben. - **Von unten schauen** – das Auge über dem Gitter in der Kameraleiste blendet die Platte aus, damit die Unterseite eines Entwurfs frei zu sehen ist. +- **Eine zweite Ansicht im eigenen Fenster** – ein Knopf in der Kameraleiste öffnet ein zweites Fenster, das denselben Entwurf durch eine eigene Kamera zeigt, mit eigenem Ansichtswürfel und eigener Kameraleiste, und folgt jeder Änderung. Es öffnet sich dort, wo du es zuletzt gelassen hast. - **Fenster nach Wunsch** – Objektliste, Einstellungen, Schnittansicht, Maßband und die Werkzeugfenster für Kanten, Aushöhlen und Muster lassen sich an ihrer Titelleiste frei verschieben und öffnen sich dort wieder; ein Doppelklick dockt sie an. - **Rechtsklick-Menü** – ein kurzer Rechtsklick auf einen Körper bringt die häufigsten Befehle: duplizieren, Aussparung oder Körper, gruppieren, Fase, Rundung und Aushöhlen für einen Körper, spiegeln und um Prozent skalieren, ausblenden, sperren, auf die Arbeitsebene setzen, löschen. Ziehen mit der rechten Taste dreht weiter die Ansicht. - **Schnittansicht** – die Ansicht entlang einer Ebene quer zu X, Y oder Z aufschneiden, um Wände, Hohlräume und ineinandergreifende Teile zu sehen, mit grobem und feinem Regler. Geschnitten wird nur die Ansicht: Entwurf und jeder Export bleiben ganz. Den Schnitt selbst speichert „Schnitt als SVG“ im Maßstab 1:1, etwa für einen Laser oder eine Schablone, und „Messen“ liest Wandstärken und Spalte direkt auf dem Schnitt ab, im rechten Winkel zur Wand eingerastet. diff --git a/README.md b/README.md index ae08435c..b2c9ca8e 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ It is not a full CAD package: there is no parametric timeline and no assemblies. - **Four saved views per design** – **Shift+7**, **8**, **9** or **0** saves the view exactly as it is (angle, pan, zoom), and the key alone brings it back. Each design keeps its own, and a saved `.lyl` file carries them along. - **The view cube in any corner** – Settings > Appearance > **View cube position** puts the cube in the top left, top right, bottom left or bottom right; in any corner but the top left, the camera controls move up into its place. - **Look from below** – the eye over a grid in the camera bar hides the plate, so the underside of a design can be seen without it in the way. +- **A second view in its own window** – a button in the camera bar opens a second window that shows the same design through a camera of its own, with its own view cube and camera bar, and follows every change. It opens where you last left it. - **Panels where you want them** – the object list, the settings, the section view, the tape measure and the tool panels for edges, hollowing and patterns move by their title bar and open there again; a double-click docks them. - **Right-click menu** – a short right click on a body brings up the most used commands: duplicate, hole or solid, group, chamfer, fillet and hollow for one body, mirror and scale by percent, hide, lock, drop to the workplane, delete. Dragging with the right button still turns the view. - **Section view** – cut the view open along a plane across X, Y or Z to look at walls, cavities and parts that fit into each other, with a coarse and a fine slider. Only the view is cut: the design and every export stay whole. "Section as SVG" saves the cut itself at 1:1, for a laser or a template, and "Measure" reads wall thickness and gaps right on the cut, snapping square to the wall. diff --git a/README.nl.md b/README.nl.md index 8331e139..3c7d444e 100644 --- a/README.nl.md +++ b/README.nl.md @@ -114,6 +114,7 @@ Het is geen volledig CAD-pakket: er is geen parametrische tijdlijn en er zijn ge - **Vier opgeslagen weergaven per ontwerp** – **Shift+7**, **8**, **9** of **0** slaat de weergave precies op zoals ze is (hoek, positie, zoom), en de toets alleen brengt haar terug. Elk ontwerp heeft zijn eigen, en een opgeslagen `.lyl`-bestand neemt ze mee. - **De richtingskubus in elke hoek** – Instellingen > Weergave > **Positie van de richtingskubus** zet de kubus linksboven, rechtsboven, linksonder of rechtsonder; in elke hoek behalve linksboven schuift de camerabediening omhoog naar zijn plek. - **Van onderen kijken** – het oog boven een raster in de camerabalk verbergt het printbed, zodat je de onderkant van een ontwerp kunt zien zonder dat het in de weg zit. +- **Een tweede weergave in een eigen venster** – een knop in de camerabalk opent een tweede venster dat hetzelfde ontwerp door een eigen camera toont, met een eigen weergavekubus en camerabalk, en volgt elke wijziging. Het opent waar je het het laatst liet. - **Panelen waar jij ze wilt** – de objectenlijst, de instellingen, de doorsnede, het meetlint en de gereedschapspanelen voor randen, uithollen en patronen verplaats je aan hun titelbalk en openen daar weer; dubbelklikken dockt ze. - **Rechtsklikmenu** – een korte rechtsklik op een lichaam toont de meestgebruikte commando's: dupliceren, gat of solid, groeperen, afschuining, afronding en uithollen voor één lichaam, spiegelen en schalen in procenten, verbergen, vergrendelen, op het werkvlak laten vallen, verwijderen. Slepen met de rechtermuisknop draait nog steeds de weergave. - **Doorsnede** – knip de weergave open langs een vlak dwars op X, Y of Z om wanden, holtes en in elkaar passende onderdelen te bekijken, met een grove en een fijne schuif. Alleen de weergave wordt doorgesneden: het ontwerp en elke export blijven heel. "Doorsnede als SVG" slaat de snede zelf op schaal 1:1 op, voor een laser of een mal, en "Meten" leest wanddikte en spleten direct op de snede af, haaks op de wand gesnapt. diff --git a/apps/web/public/view.html b/apps/web/public/view.html new file mode 100644 index 00000000..777fa144 --- /dev/null +++ b/apps/web/public/view.html @@ -0,0 +1,18 @@ + + + + + +Layerling view + + + + +

This window shows a second view of a design. Open it with the second view button in the Layerling editor.

+ + diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 43ab8faa..280d5f5c 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 00000000..f804b6cd --- /dev/null +++ b/apps/web/src/app/secondView.css @@ -0,0 +1,116 @@ +/* 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; + } +} + +/* The status message (globals.css .editor-status), placed for this window: + it has no toolbar above it, and no settings panel on the right. */ +.second-view-stage .second-view-notice { + top: 22px; + max-width: calc(100vw - 130px); +} diff --git a/apps/web/src/components/WorkplaneViewport.tsx b/apps/web/src/components/WorkplaneViewport.tsx index d9ebe9c9..08798018 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) { @@ -4827,7 +4798,7 @@ export function WorkplaneViewport({ }, [workspaceSettingsKey]); const { saveView, showSavedView } = useDesignViews({ - threeRef, projectIdRef, viewCubeRef, designViewRestoredRef, orthographicView, setOrthographicView, toggleCameraProjection, syncViewCube, onNotice, + threeRef, projectIdRef, viewCubeRef, designViewRestoredRef, orthographicView, setOrthographicView, onNotice, }); // The same keys for MCP (layerling_saved_view), through the functions the keys use. @@ -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]) => ( -