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]) => (
- {
- event.stopPropagation();
- const state = threeRef.current;
- if (!state) return;
- setCameraToViewDirection(state, viewCubeZoneDirection(face, column, row));
- syncViewCube(state, viewCubeRef.current);
- }}
- />
- ));
-
- const handleViewCubePointerDown = useCallback((event: ReactPointerEvent) => {
- event.stopPropagation();
- if (event.button !== 0 || viewCubeDragRef.current) {
- return;
- }
- suppressViewCubeClickRef.current = false;
- viewCubeDragRef.current = beginViewCubeDrag(event.pointerId, event.clientX, event.clientY);
- }, []);
-
- const handleViewCubePointerMove = useCallback((event: ReactPointerEvent) => {
- const drag = viewCubeDragRef.current;
- if (!drag || drag.pointerId !== event.pointerId) {
- return;
- }
- const move = moveViewCubeDrag(drag, event.clientX, event.clientY);
- if (!move) {
- return;
- }
- if (move.started) {
- // Capture only once the press becomes a drag, so a plain click still
- // lands on the face button underneath.
- capturePointer(event.currentTarget, event.pointerId);
- setViewCubeDragging(true);
- }
+ const zoomCamera = useCallback((scale: number) => {
const state = threeRef.current;
if (!state) {
return;
}
- orbitCameraByDrag(state, move.dx, move.dy);
- syncViewCube(state, viewCubeRef.current);
- }, []);
- const endViewCubeDrag = useCallback((event: ReactPointerEvent) => {
- const drag = viewCubeDragRef.current;
- if (!drag || drag.pointerId !== event.pointerId) {
- return;
- }
- viewCubeDragRef.current = null;
- if (drag.dragging) {
- suppressViewCubeClickRef.current = true;
- setViewCubeDragging(false);
- if (event.currentTarget.hasPointerCapture(event.pointerId)) {
- event.currentTarget.releasePointerCapture(event.pointerId);
- }
- }
+ if (zoomDistanceScale(scale) < 1) movePivotToDepthUnder(state, 0, 0);
+ zoomCameraBy(state, scale);
}, []);
- const handleViewCubeClickCapture = useCallback((event: ReactMouseEvent) => {
- // The click that ends a drag must not also snap to the face it ended on.
- if (suppressViewCubeClickRef.current) {
- suppressViewCubeClickRef.current = false;
- event.preventDefault();
- event.stopPropagation();
- }
- }, []);
-
- const zoomCamera = useCallback((scale: number) => {
- const state = threeRef.current;
- if (!state) {
+ const toggleSecondView = useCallback(() => {
+ if (secondViewWindow) {
+ setSecondViewWindow(null);
return;
}
-
- const scaled = zoomDistanceScale(scale);
- if (scaled < 1) movePivotToDepthUnder(state, 0, 0);
- if (state.camera instanceof THREE.OrthographicCamera) {
- const range = orthographicZoomRange(state.camera);
- state.camera.zoom = clamp(state.camera.zoom / scaled, range.min, range.max);
- } else {
- const offset = state.camera.position.clone().sub(state.controls.target);
- const distance = clamp(offset.length() * scaled, CAMERA_MIN_DISTANCE, CAMERA_MAX_DISTANCE);
- offset.setLength(distance);
- state.camera.position.copy(state.controls.target).add(offset);
- }
- state.camera.updateProjectionMatrix();
- state.controls.update();
- state.needsRender = true;
- }, []);
+ setSecondViewWindow(openSecondViewWindow());
+ }, [secondViewWindow]);
+ const closeSecondView = useCallback(() => setSecondViewWindow(null), []);
+ const getSelectedIds = useCallback(() => selectedIdsRef.current, []);
const toggleProjection = useCallback(() => {
const state = threeRef.current;
@@ -8932,25 +8790,8 @@ export function WorkplaneViewport({
return (
-
-
- setViewCubeFace("bottom")}>{t("view.bottomShort")}{viewCubeZones("bottom")}
- setViewCubeFace("top")}>{t("view.topShort")}{viewCubeZones("top")}
- setViewCubeFace("front")}>{t("view.frontShort")}{viewCubeZones("front")}
- setViewCubeFace("back")}>{t("view.backShort")}{viewCubeZones("back")}
- setViewCubeFace("right")}>{t("view.rightShort")}{viewCubeZones("right")}
- setViewCubeFace("left")}>{t("view.leftShort")}{viewCubeZones("left")}
-
-
+
+ {secondViewWindow ? 0} onClosed={closeSecondView} /> : null}
{cameraControlsCollapsed ? (
@@ -9007,6 +8848,18 @@ export function WorkplaneViewport({
>
+ {/* The second view in a window of its own. Not on a phone or a tablet. */}
+ {secondViewSupported ? (
+
+
+
+ ) : null}
0
- ? offset.clone().normalize()
- : CAMERA_HOME.clone().sub(CAMERA_TARGET).normalize();
- let next: THREE.PerspectiveCamera | THREE.OrthographicCamera;
-
- if (current instanceof THREE.PerspectiveCamera) {
- const visibleHalfHeight = Math.max(
- 0.001,
- offset.length() * Math.tan(THREE.MathUtils.degToRad(current.getEffectiveFOV() / 2)),
- );
- next = new THREE.OrthographicCamera(
- -visibleHalfHeight * aspect,
- visibleHalfHeight * aspect,
- visibleHalfHeight,
- -visibleHalfHeight,
- current.near,
- current.far,
- );
- next.position.copy(current.position);
- } else {
- const visibleHalfHeight = Math.max(0.001, (current.top - current.bottom) / (2 * current.zoom));
- const distance = clamp(
- visibleHalfHeight / Math.tan(THREE.MathUtils.degToRad(CAMERA_FOV / 2)),
- CAMERA_MIN_DISTANCE,
- CAMERA_MAX_DISTANCE,
- );
- next = new THREE.PerspectiveCamera(CAMERA_FOV, aspect, 0.1, 6000);
- next.position.copy(target).addScaledVector(direction, distance);
- }
-
- if (next instanceof THREE.OrthographicCamera) {
- const range = orthographicZoomRange(next);
- state.controls.minZoom = range.min;
- state.controls.maxZoom = range.max;
- }
- next.up.copy(current.up);
- next.layers.mask = current.layers.mask;
- next.lookAt(target);
- next.updateProjectionMatrix();
- fitCameraDepthRange(next, target);
- next.updateMatrixWorld();
- state.camera = next;
- state.controls.object = next;
- state.controls.update();
- state.needsRender = true;
-}
-
-function setCameraToViewFace(state: ThreeState, face: ViewCubeFace) {
- setCameraToViewDirection(state, viewFaceDirection(face));
-}
-
-/** Looks at the orbit target from a unit direction, keeping the distance: a face, an edge or a corner of the view cube. */
-function setCameraToViewDirection(state: ThreeState, direction: THREE.Vector3) {
- const offset = state.camera.position.clone().sub(state.controls.target);
- const distance = clamp(offset.length(), CAMERA_MIN_DISTANCE, CAMERA_MAX_DISTANCE);
-
- state.camera.up.set(0, 1, 0);
- state.camera.position.copy(state.controls.target).add(direction.multiplyScalar(distance));
- state.camera.lookAt(state.controls.target);
- state.camera.updateProjectionMatrix();
- state.controls.update();
- state.needsRender = true;
-}
-
-/** Orbits the camera around the controls target for a drag on the view cube. */
-function orbitCameraByDrag(state: ThreeState, dx: number, dy: number) {
- const offset = orbitOffsetByDrag(state.camera.position.clone().sub(state.controls.target), dx, dy);
-
- state.camera.up.set(0, 1, 0);
- state.camera.position.copy(state.controls.target).add(offset);
- state.camera.lookAt(state.controls.target);
- state.camera.updateProjectionMatrix();
- state.controls.update();
- state.needsRender = true;
-}
-
function constrainCamera(state: ThreeState, workspace: WorkspaceSettings) {
// The pivot stays on the plate, but as far out as the part "Center view" went to, so that part can be centred (#352).
const reach = state.focusReach;
@@ -10184,16 +9912,6 @@ function constrainCamera(state: ThreeState, workspace: WorkspaceSettings) {
}
}
-function syncViewCube(state: ThreeState, cube: HTMLDivElement | null) {
- if (!cube) {
- return;
- }
-
- const { pitch, yaw } = viewCubeAngles(state.camera.position.clone().sub(state.controls.target));
- cube.style.transform = `rotateX(${-pitch}deg) rotateY(${-yaw}deg)`;
-}
-
-
function refreshFrozenObjectMatrix(object: THREE.Object3D) {
object.updateMatrix();
object.updateMatrixWorld(true);
@@ -10737,256 +10455,6 @@ function linesFromPoints(points: number[], material: THREE.LineBasicMaterial) {
return lines;
}
-// The cut a hole makes in a body is drawn from depth textures rather than from a
-// boolean. A hole fragment lies inside a body when it falls between the body's
-// near and its far depth at that pixel, and a body fragment lies inside a hole
-// the same way; four depth textures carry those bounds. Nothing runs on the CPU
-// for it, so the preview keeps up with a shape while it is being dragged, and no
-// result has to be cached or dropped when a gesture starts.
-const CUT_PREVIEW_COLOR = 0x30363a;
-const CUT_PREVIEW_OPACITY = 0.34;
-// Window depth is not linear, so a bias in window units opens a gap where the cut
-// meets the surface that grows as the view is pulled back. The band is widened by it
-// rather than narrowed, so a hole flush with a face stays visible - the cutter pads
-// by 0.05 the same way - and the bias only has to be big enough to break a tie.
-const CUT_PREVIEW_DEPTH_BIAS = 1e-7;
-
-type CutPreviewResources = {
- bodiesNear: THREE.WebGLRenderTarget;
- bodiesFar: THREE.WebGLRenderTarget;
- frontDepth: THREE.MeshBasicMaterial;
- backDepth: THREE.MeshBasicMaterial;
- // The hole shaded where it lies inside a body: what is going to be cut out,
- // drawn on the hole itself. Nothing has to know the shape of either body - only
- // whether a fragment sits between the near and the far depth of the other one.
- ink: THREE.ShaderMaterial;
- width: number;
- height: number;
-};
-
-let cutPreviewResources: CutPreviewResources | null = null;
-
-function cutPreviewInkMaterial() {
- return new THREE.ShaderMaterial({
- transparent: true,
- depthTest: false,
- depthWrite: false,
- // Both sides, so the far wall of a hole that goes right through fills the cut.
- // The stencil keeps one layer per pixel, so the two walls cannot add up to a
- // darker ring.
- side: THREE.DoubleSide,
- stencilWrite: true,
- stencilFunc: THREE.NotEqualStencilFunc,
- stencilRef: 1,
- stencilFail: THREE.KeepStencilOp,
- stencilZFail: THREE.KeepStencilOp,
- stencilZPass: THREE.ReplaceStencilOp,
- uniforms: {
- nearDepth: { value: null },
- farDepth: { value: null },
- resolution: { value: new THREE.Vector2(1, 1) },
- // Written straight into the frame, so the tint keeps the values it was
- // given instead of being converted twice.
- tint: { value: new THREE.Color().setHex(CUT_PREVIEW_COLOR, THREE.LinearSRGBColorSpace) },
- opacity: { value: CUT_PREVIEW_OPACITY },
- bias: { value: CUT_PREVIEW_DEPTH_BIAS },
- },
- vertexShader: [
- "void main() {",
- " gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);",
- "}",
- ].join("\n"),
- fragmentShader: [
- "uniform sampler2D nearDepth;",
- "uniform sampler2D farDepth;",
- "uniform vec2 resolution;",
- "uniform vec3 tint;",
- "uniform float opacity;",
- "uniform float bias;",
- "void main() {",
- " // Both sides are window-space depth, so they compare directly.",
- " float depth = gl_FragCoord.z;",
- " vec2 uv = gl_FragCoord.xy / resolution;",
- " float near = texture2D(nearDepth, uv).r;",
- " float far = texture2D(farDepth, uv).r;",
- " if (depth < near - bias || depth > far + bias) {",
- " discard;",
- " }",
- " gl_FragColor = vec4(tint, opacity);",
- "}",
- ].join("\n"),
- });
-}
-
-function cutPreviewDepthTarget(width: number, height: number) {
- const depthTexture = new THREE.DepthTexture(width, height);
- depthTexture.format = THREE.DepthFormat;
- depthTexture.type = THREE.UnsignedIntType;
- const target = new THREE.WebGLRenderTarget(width, height, {
- minFilter: THREE.NearestFilter,
- magFilter: THREE.NearestFilter,
- depthBuffer: true,
- stencilBuffer: false,
- depthTexture,
- });
- target.texture.colorSpace = THREE.NoColorSpace;
- return target;
-}
-
-function disposeCutPreviewResources() {
- if (!cutPreviewResources) {
- return;
- }
- [cutPreviewResources.bodiesNear, cutPreviewResources.bodiesFar].forEach((target) =>
- target.dispose(),
- );
- [
- cutPreviewResources.frontDepth,
- cutPreviewResources.backDepth,
- cutPreviewResources.ink,
- ].forEach((material) => material.dispose());
- cutPreviewResources = null;
-}
-
-function cutPreviewResourcesFor(width: number, height: number) {
- if (
- cutPreviewResources &&
- cutPreviewResources.width === width &&
- cutPreviewResources.height === height
- ) {
- return cutPreviewResources;
- }
- disposeCutPreviewResources();
- cutPreviewResources = {
- bodiesNear: cutPreviewDepthTarget(width, height),
- bodiesFar: cutPreviewDepthTarget(width, height),
- frontDepth: new THREE.MeshBasicMaterial({
- colorWrite: false,
- depthWrite: true,
- depthTest: true,
- depthFunc: THREE.LessDepth,
- side: THREE.FrontSide,
- }),
- backDepth: new THREE.MeshBasicMaterial({
- colorWrite: false,
- depthWrite: true,
- depthTest: true,
- depthFunc: THREE.LessDepth,
- side: THREE.BackSide,
- }),
- ink: cutPreviewInkMaterial(),
- width,
- height,
- };
- return cutPreviewResources;
-}
-
-function drawCutPreviews(state: ThreeState) {
- const bodies: THREE.Object3D[] = [];
- const holes: THREE.Object3D[] = [];
- state.shapeRecords.forEach((record) => {
- const object = record.object;
- if (!object || record.shape.hidden || !object.visible) {
- return;
- }
- if (record.shape.hole) {
- holes.push(object);
- } else {
- bodies.push(object);
- }
- });
- if (bodies.length === 0 || holes.length === 0) {
- return;
- }
-
- const renderer = state.renderer;
- const scene = state.scene;
- const camera = state.camera;
- const frameTarget = renderer.getRenderTarget();
- const size = frameTarget
- ? new THREE.Vector2(frameTarget.width, frameTarget.height)
- : renderer.getDrawingBufferSize(new THREE.Vector2());
- const resources = cutPreviewResourcesFor(size.x, size.y);
-
- const shapes = [...bodies, ...holes];
- const wasVisible = new Map(shapes.map((object) => [object, object.visible] as const));
- const previousAutoClear = renderer.autoClear;
- const previousOverride = scene.overrideMaterial;
- const previousBackground = scene.background;
- const previousMask = camera.layers.mask;
-
- const renderSet = (visible: THREE.Object3D[]) => {
- const wanted = new Set(visible);
- shapes.forEach((object) => {
- object.visible = wanted.has(object);
- });
- };
-
- try {
- renderer.autoClear = false;
- camera.layers.set(RENDER_LAYER_SHAPES);
- // A scene background is repainted by every render() call, and this renders
- // the scene four more times; left alone it would wipe the frame.
- scene.background = null;
-
- // The section plane clips per material, and these are the preview's own: without
- // it the depth textures keep the geometry that was cut away, and the ink lands in
- // the removed half. Where the plane is gone the depth reads 1.0, which discards
- // the ink there by itself.
- const sectionPlane = state.sectionPlane ?? null;
- for (const material of [resources.frontDepth, resources.backDepth]) {
- if ((material.clippingPlanes?.[0] ?? null) !== sectionPlane) {
- material.clippingPlanes = sectionPlane ? [sectionPlane] : null;
- material.needsUpdate = true;
- }
- }
-
- const depthPass = (
- visible: THREE.Object3D[],
- material: THREE.Material,
- target: THREE.WebGLRenderTarget,
- ) => {
- renderSet(visible);
- renderer.setRenderTarget(target);
- renderer.clear(true, true, false);
- scene.overrideMaterial = material;
- renderer.render(scene, camera);
- };
-
- depthPass(bodies, resources.frontDepth, resources.bodiesNear);
- depthPass(bodies, resources.backDepth, resources.bodiesFar);
-
- // The hole is drawn once, over the frame, and shaded where it lies inside a
- // body. The stencil decides which of its fragments gets a pixel: one layer, so
- // the shading stays even wherever the hole turns back on itself.
- resources.ink.uniforms.nearDepth.value = resources.bodiesNear.depthTexture;
- resources.ink.uniforms.farDepth.value = resources.bodiesFar.depthTexture;
- (resources.ink.uniforms.resolution.value as THREE.Vector2).set(
- resources.width,
- resources.height,
- );
- renderSet(holes);
- renderer.setRenderTarget(frameTarget);
- renderer.clear(false, false, true);
- scene.overrideMaterial = resources.ink;
- renderer.render(scene, camera);
- } finally {
- shapes.forEach((object) => {
- object.visible = wasVisible.get(object) ?? true;
- });
- scene.overrideMaterial = previousOverride;
- scene.background = previousBackground;
- camera.layers.mask = previousMask;
- renderer.autoClear = previousAutoClear;
- renderer.setRenderTarget(frameTarget);
- }
-}
-
-function renderFrame(state: ThreeState) {
- state.renderer.render(state.scene, state.camera);
- drawCutPreviews(state);
-}
-
function updateShapeObjectTransform(object: THREE.Group, shape: WorkplaneShape) {
object.name = shape.name;
object.userData.shapeId = shape.id;
diff --git a/apps/web/src/components/workplane/SecondViewCameraBar.tsx b/apps/web/src/components/workplane/SecondViewCameraBar.tsx
new file mode 100644
index 00000000..811c5000
--- /dev/null
+++ b/apps/web/src/components/workplane/SecondViewCameraBar.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { ChevronLeft, ChevronRight, Crosshair, Cuboid, Home, Minus, Plus } from "lucide-react";
+import { useState } from "react";
+import { GridEyeIcon } from "@/components/GridEyeIcon";
+import { t } from "@/lib/i18n";
+import { useLanguage } from "@/lib/useLanguage";
+
+type SecondViewCameraBarProps = {
+ hasSelection: boolean;
+ orthographic: boolean;
+ workplaneHidden: boolean;
+ onHome: () => void;
+ onFocusSelection: () => void;
+ onZoomIn: () => void;
+ onZoomOut: () => void;
+ onToggleProjection: () => void;
+ onToggleWorkplane: () => void;
+};
+
+/**
+ * The camera bar of the second view's window: the buttons of the 3D view's bar that only move
+ * the camera or change what it shows, and act on this window's view alone.
+ */
+export function SecondViewCameraBar(props: SecondViewCameraBarProps) {
+ useLanguage();
+ const { hasSelection, orthographic, workplaneHidden } = props;
+ // Folded away as in the 3D view, and as there not kept for the next time the window opens.
+ const [collapsed, setCollapsed] = useState(false);
+ if (collapsed) {
+ return (
+
+ setCollapsed(false)}>
+
+
+
+ );
+ }
+ return (
+
+
setCollapsed(true)}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/workplane/SecondViewWindow.tsx b/apps/web/src/components/workplane/SecondViewWindow.tsx
new file mode 100644
index 00000000..c86aa299
--- /dev/null
+++ b/apps/web/src/components/workplane/SecondViewWindow.tsx
@@ -0,0 +1,435 @@
+"use client";
+
+import * as THREE from "three";
+import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
+import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
+import { createPortal } from "react-dom";
+import {
+ fitCameraDepthRange,
+ frameBounds,
+ resetCamera,
+ selectionBounds,
+ setCameraToViewFace,
+ syncViewCube,
+ toggleCameraProjection,
+ updateCameraViewport,
+ zoomCameraBy,
+ type CameraRig,
+} from "@/components/workplane/cameraRig";
+import { saveViewInSlot, showViewInSlot } from "@/components/workplane/cameraViews";
+import { disposeCutPreviewResources, renderFrame } from "@/components/workplane/renderFrame";
+import { SecondViewCameraBar } from "@/components/workplane/SecondViewCameraBar";
+import { ViewCube } from "@/components/workplane/ViewCube";
+import { RENDER_LAYER_HELPERS, type ThreeState } from "@/components/workplane/ViewportOverlays";
+import { leftButtonCameraAction } from "@/lib/cameraMouseButtons";
+import { t } from "@/lib/i18n";
+import { placementOf, readSecondViewPlacement, secondViewSupported, secondViewWindowFeatures, writeSecondViewPlacement } from "@/lib/secondViewPlacement";
+import { orbitViewBy, panViewBy, viewArrowStep } from "@/lib/viewArrowKeys";
+import { savedViewForKey } from "@/lib/designView";
+import { viewFaceForKey } from "@/lib/viewCubeDrag";
+import { ZOOM_STEP_IN, ZOOM_STEP_OUT } from "@/lib/workplaneSettings";
+
+/**
+ * A second view of the design in a window of its own: the same scene, seen through a camera of
+ * its own. The window holds no copy of the design; it draws the 3D view's scene with its own
+ * renderer, so whatever changes there shows here on the next frame. In the window the mouse and
+ * the view keys turn its camera only; the design is edited in the main window.
+ */
+
+const WINDOW_NAME = "layerling-second-view";
+/** The window's own page (public/view.html), so its address bar names it instead of showing about:blank. */
+const VIEW_PAGE = "/view.html";
+
+/** The OrbitControls settings the second view takes over from the 3D view's camera. */
+const SHARED_CONTROL_SETTINGS = [
+ "enableDamping",
+ "dampingFactor",
+ "rotateSpeed",
+ "zoomSpeed",
+ "panSpeed",
+ "screenSpacePanning",
+ "zoomToCursor",
+ "minDistance",
+ "maxDistance",
+ "minPolarAngle",
+ "maxPolarAngle",
+ "minZoom",
+ "maxZoom",
+] as const;
+
+function localStorageOrNull() {
+ try {
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+}
+
+/** Opens the window where it was last; called straight from the click, or the browser blocks it as a popup. */
+export function openSecondViewWindow(): Window | null {
+ // Opened without an address first: a window kept from before (the editor was reloaded) already
+ // on its page is taken as it is, where opening the page again would reload it under the view.
+ // A new one, still empty, goes to the page.
+ const popup = window.open("", WINDOW_NAME, secondViewWindowFeatures(readSecondViewPlacement(localStorageOrNull())));
+ try {
+ if (popup && popup.location.pathname !== VIEW_PAGE) popup.location.replace(VIEW_PAGE);
+ } catch {
+ // The window went to another site, whose address cannot be read: send it back by name.
+ window.open(VIEW_PAGE, WINDOW_NAME);
+ }
+ return popup;
+}
+
+/** Whether the window shows its own page, loaded far enough to be filled. */
+function viewPageReady(popup: Window) {
+ try {
+ return popup.location.pathname === VIEW_PAGE && popup.document.readyState !== "loading";
+ } catch {
+ return false;
+ }
+}
+
+/** Whether to offer the second view at all: not on a phone or a tablet. False until mounted, as the served page. */
+export function useSecondViewSupported() {
+ const [supported, setSupported] = useState(false);
+ useEffect(() => {
+ if (!window.matchMedia) return;
+ const media = window.matchMedia("(pointer: coarse)");
+ // Chrome and Edge say outright whether they run on a phone or a tablet; the others are judged by their name.
+ const userAgentData = (navigator as Navigator & { userAgentData?: { mobile?: boolean } }).userAgentData;
+ const apply = () => setSupported(secondViewSupported({
+ coarsePointer: media.matches,
+ platform: navigator.platform ?? "",
+ maxTouchPoints: navigator.maxTouchPoints ?? 0,
+ userAgent: navigator.userAgent ?? "",
+ mobile: userAgentData?.mobile,
+ }));
+ apply();
+ media.addEventListener("change", apply);
+ return () => media.removeEventListener("change", apply);
+ }, []);
+ return supported;
+}
+
+type SecondViewWindowProps = {
+ popup: Window;
+ /** The 3D view whose scene the window draws. */
+ threeRef: RefObject;
+ /** The design, whose four saved views (7, 8, 9, 0) this window shares with the 3D view. */
+ projectId: string | null | undefined;
+ /** The main window's selection, which F frames here too. */
+ getSelectedIds: () => readonly string[];
+ /** Whether anything is selected there, for the bar's "Center view on selection". */
+ hasSelection: boolean;
+ /** The window was closed by the user, or went away with a reload. */
+ onClosed: () => void;
+};
+
+export function SecondViewWindow({ popup, threeRef, projectId, getSelectedIds, hasSelection, onClosed }: SecondViewWindowProps) {
+ const [container, setContainer] = useState(null);
+ const hostRef = useRef(null);
+ const cubeRef = useRef(null);
+ const rigRef = useRef(null);
+ const onClosedRef = useRef(onClosed);
+ onClosedRef.current = onClosed;
+ const getSelectedIdsRef = useRef(getSelectedIds);
+ getSelectedIdsRef.current = getSelectedIds;
+ const projectIdRef = useRef(projectId);
+ projectIdRef.current = projectId;
+ // A message of this window's own (a view saved, an empty key): the editor's may be hidden behind it.
+ const [notice, setNotice] = useState(null);
+ const noticeTimerRef = useRef(0);
+ const showNotice = useCallback((message: string) => {
+ setNotice(message);
+ window.clearTimeout(noticeTimerRef.current);
+ noticeTimerRef.current = window.setTimeout(() => setNotice(null), 2500);
+ }, []);
+ useEffect(() => () => window.clearTimeout(noticeTimerRef.current), []);
+ const getRig = useCallback(() => rigRef.current, []);
+ const closeTimerRef = useRef(0);
+ const [orthographic, setOrthographic] = useState(false);
+ // Shown or hidden in each window on its own: the plate is the 3D view's, so while this view draws
+ // it takes this window's choice, and the 3D view's own after.
+ const [workplaneHidden, setWorkplaneHidden] = useState(false);
+ const workplaneHiddenRef = useRef(workplaneHidden);
+ workplaneHiddenRef.current = workplaneHidden;
+
+ /** Moves this window's camera, then turns the cube along and asks for a frame. */
+ const moveCamera = useCallback((move: (rig: CameraRig) => void) => {
+ const rig = rigRef.current;
+ if (!rig) return;
+ move(rig);
+ setOrthographic(rig.camera instanceof THREE.OrthographicCamera);
+ syncViewCube(rig, cubeRef.current);
+ rig.needsRender = true;
+ }, []);
+
+ /** Frames the main window's selection; false when nothing is selected there. */
+ const focusSelection = useCallback((rig: CameraRig) => {
+ const state = threeRef.current;
+ const bounds = state ? selectionBounds(state, getSelectedIdsRef.current()) : null;
+ if (bounds) frameBounds(rig, bounds);
+ return Boolean(bounds);
+ }, [threeRef]);
+
+ const toggleWorkplane = useCallback(() => {
+ setWorkplaneHidden((hidden) => !hidden);
+ if (rigRef.current) rigRef.current.needsRender = true;
+ }, []);
+
+ // The window's page: the editor's styles and theme, and a place for the view.
+ useEffect(() => {
+ // React's development mode takes an effect down and sets it up again at once: the window stays.
+ window.clearTimeout(closeTimerRef.current);
+ let takeDown = () => {};
+ const setUp = () => {
+ const doc = popup.document;
+ // The page's own text goes, and so does the view of a window kept from before (the editor was reloaded).
+ doc.head.replaceChildren();
+ doc.body.replaceChildren();
+ // Only now: the title is an element in the head, and clearing the head took it away.
+ doc.title = t("secondView.title");
+ const copyStyle = (node: Node) => {
+ if (!(node instanceof HTMLStyleElement) && !(node instanceof HTMLLinkElement && node.rel === "stylesheet")) return;
+ const copy = node.cloneNode(true) as HTMLElement;
+ // Copied with the full address, as the editor's page resolved it.
+ if (node instanceof HTMLLinkElement) copy.setAttribute("href", node.href);
+ doc.head.appendChild(copy);
+ };
+ document.head.childNodes.forEach(copyStyle);
+ const copyTheme = () => {
+ const source = document.documentElement;
+ const target = doc.documentElement;
+ // An attribute taken away counts too: Medium is the toolbar size without one.
+ for (const { name } of Array.from(target.attributes)) {
+ if (!source.hasAttribute(name)) target.removeAttribute(name);
+ }
+ for (const { name, value } of Array.from(source.attributes)) {
+ target.setAttribute(name, value);
+ }
+ };
+ copyTheme();
+ // Styles that arrive later (a part of the page loaded on demand) and a theme switch follow along.
+ const styleObserver = new MutationObserver((records) => records.forEach((record) => record.addedNodes.forEach(copyStyle)));
+ styleObserver.observe(document.head, { childList: true });
+ const themeObserver = new MutationObserver(copyTheme);
+ themeObserver.observe(document.documentElement, { attributes: true });
+
+ const root = doc.createElement("div");
+ root.className = "second-view-root";
+ doc.body.appendChild(root);
+ setContainer(root);
+
+ // Its place and size are kept for next time: when it is resized, and before it closes.
+ const remember = () => {
+ if (!popup.closed) writeSecondViewPlacement(localStorageOrNull(), placementOf(popup));
+ };
+ const closed = () => {
+ remember();
+ onClosedRef.current();
+ };
+ const closeWithEditor = () => {
+ remember();
+ popup.close();
+ };
+ popup.addEventListener("pagehide", closed);
+ popup.addEventListener("resize", remember);
+ window.addEventListener("pagehide", closeWithEditor);
+ takeDown = () => {
+ styleObserver.disconnect();
+ themeObserver.disconnect();
+ popup.removeEventListener("pagehide", closed);
+ popup.removeEventListener("resize", remember);
+ window.removeEventListener("pagehide", closeWithEditor);
+ };
+ };
+ // A new window is still on its way to its page; the view is built once that has loaded.
+ const waitTimer = window.setInterval(() => {
+ if (popup.closed) {
+ window.clearInterval(waitTimer);
+ onClosedRef.current();
+ } else if (viewPageReady(popup)) {
+ window.clearInterval(waitTimer);
+ setUp();
+ }
+ }, 30);
+ return () => {
+ window.clearInterval(waitTimer);
+ takeDown();
+ setContainer(null);
+ closeTimerRef.current = window.setTimeout(() => {
+ if (!popup.closed) writeSecondViewPlacement(localStorageOrNull(), placementOf(popup));
+ popup.close();
+ });
+ };
+ }, [popup]);
+
+ // The camera, its controls and the frame loop, once the view's place is on the page.
+ useEffect(() => {
+ const host = hostRef.current;
+ const main = threeRef.current;
+ if (!container || !host || !main) return;
+
+ const canvas = popup.document.createElement("canvas");
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: "high-performance", preserveDrawingBuffer: false, stencil: true });
+ renderer.localClippingEnabled = true;
+ renderer.outputColorSpace = main.renderer.outputColorSpace;
+ renderer.shadowMap.type = main.renderer.shadowMap.type;
+ host.appendChild(canvas);
+
+ // It opens looking where the 3D view looks.
+ const camera = main.camera.clone();
+ const controls = new OrbitControls(camera, canvas);
+ const settings = controls as unknown as Record;
+ SHARED_CONTROL_SETTINGS.forEach((name) => {
+ settings[name] = main.controls[name];
+ });
+ controls.mouseButtons = { LEFT: null, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.ROTATE };
+ controls.target.copy(main.controls.target);
+ controls.update();
+ const rig: CameraRig = { renderer, camera, controls, needsRender: true, focusReach: null };
+ rigRef.current = rig;
+ setOrthographic(camera instanceof THREE.OrthographicCamera);
+
+ const requestRender = () => {
+ rig.needsRender = true;
+ };
+ // The same buttons as in the 3D view: right turns, middle pans, Ctrl or Cmd with the left button moves (#654).
+ const configureMouseButtons = (event: PointerEvent) => {
+ controls.mouseButtons.LEFT = leftButtonCameraAction(event);
+ };
+ const resetMouseButtons = () => {
+ controls.mouseButtons.LEFT = null;
+ };
+ const preventContextMenu = (event: MouseEvent) => event.preventDefault();
+ const resize = () => {
+ const width = Math.max(1, host.clientWidth);
+ const height = Math.max(1, host.clientHeight);
+ renderer.setSize(width, height);
+ updateCameraViewport(rig.camera, width, height);
+ rig.needsRender = true;
+ };
+ // The view keys of the 3D view; there is nothing to move here, so the arrows always turn the view.
+ const handleKeyDown = (event: KeyboardEvent) => {
+ const arrow = viewArrowStep(event);
+ if (arrow) {
+ event.preventDefault();
+ moveCamera((view) => {
+ if (arrow.pan) panViewBy(view, ...arrow.pan);
+ else orbitViewBy(view, ...arrow.orbit);
+ });
+ return;
+ }
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
+ // 7, 8, 9 and 0 bring back the design's saved views, Shift with them saves this window's view there.
+ const savedView = savedViewForKey(event.key, event.code, event.shiftKey);
+ if (savedView) {
+ event.preventDefault();
+ if (savedView.save) showNotice(saveViewInSlot(projectIdRef.current, rig, savedView.slot));
+ else moveCamera((view) => {
+ const empty = showViewInSlot(projectIdRef.current, view, savedView.slot);
+ if (empty) showNotice(empty);
+ });
+ return;
+ }
+ const face = viewFaceForKey(event.key, event.code, event.shiftKey);
+ const key = event.key.toLowerCase();
+ let move: ((view: CameraRig) => void) | null = null;
+ if (face) {
+ move = (view) => {
+ setCameraToViewFace(view, face);
+ // With Shift, frame the selection from the new side, as in the 3D view.
+ if (event.shiftKey) focusSelection(view);
+ };
+ } else if (key === "f" && event.shiftKey) move = focusSelection;
+ else if (key === "f") move = (view) => {
+ if (!focusSelection(view)) resetCamera(view);
+ };
+ else if (event.key === "Home") move = resetCamera;
+ else if (key === "o") move = toggleCameraProjection;
+ else if (event.key === "+" || event.key === "=") move = (view) => zoomCameraBy(view, ZOOM_STEP_IN);
+ else if (event.key === "-" || event.key === "_") move = (view) => zoomCameraBy(view, ZOOM_STEP_OUT);
+ if (!move) return;
+ event.preventDefault();
+ moveCamera(move);
+ };
+
+ controls.addEventListener("change", requestRender);
+ canvas.addEventListener("pointerdown", configureMouseButtons, { capture: true });
+ canvas.addEventListener("pointerup", resetMouseButtons);
+ canvas.addEventListener("pointercancel", resetMouseButtons);
+ canvas.addEventListener("contextmenu", preventContextMenu);
+ // A window just opened may not have its size yet: the view follows its box, not the resize event.
+ // The window's own observer, as the box is on its page.
+ const resizeObserver = new (popup as Window & typeof globalThis).ResizeObserver(resize);
+ resizeObserver.observe(host);
+ popup.addEventListener("keydown", handleKeyDown);
+ resize();
+
+ // A frame is drawn when this camera moved, or when the 3D view drew one: that is when the scene changed.
+ let lastMainFrame = -1;
+ let frameId = 0;
+ const animate = () => {
+ frameId = popup.requestAnimationFrame(animate);
+ const state = threeRef.current;
+ if (!state) return;
+ const moved = controls.update();
+ const mainFrame = state.renderer.info.render.frame;
+ if (!moved && !rig.needsRender && mainFrame === lastMainFrame) return;
+ lastMainFrame = mainFrame;
+ rig.needsRender = false;
+ renderer.shadowMap.enabled = state.renderer.shadowMap.enabled;
+ if (renderer.getPixelRatio() !== state.renderer.getPixelRatio()) renderer.setPixelRatio(state.renderer.getPixelRatio());
+ // The handles and guides stand where the 3D view's camera needs them; this view shows the design.
+ rig.camera.layers.mask = state.camera.layers.mask & ~(1 << RENDER_LAYER_HELPERS);
+ fitCameraDepthRange(rig.camera, controls.target);
+ rig.camera.updateMatrixWorld();
+ syncViewCube(rig, cubeRef.current);
+ const plateVisible = state.workplaneLayer.visible;
+ state.workplaneLayer.visible = !workplaneHiddenRef.current;
+ try {
+ renderFrame({ renderer, scene: state.scene, camera: rig.camera, shapeRecords: state.shapeRecords, sectionPlane: state.sectionPlane });
+ } finally {
+ state.workplaneLayer.visible = plateVisible;
+ }
+ };
+ frameId = popup.requestAnimationFrame(animate);
+
+ return () => {
+ popup.cancelAnimationFrame(frameId);
+ controls.removeEventListener("change", requestRender);
+ canvas.removeEventListener("pointerdown", configureMouseButtons, { capture: true });
+ canvas.removeEventListener("pointerup", resetMouseButtons);
+ canvas.removeEventListener("pointercancel", resetMouseButtons);
+ canvas.removeEventListener("contextmenu", preventContextMenu);
+ resizeObserver.disconnect();
+ popup.removeEventListener("keydown", handleKeyDown);
+ controls.dispose();
+ disposeCutPreviewResources(renderer);
+ renderer.dispose();
+ canvas.remove();
+ rigRef.current = null;
+ };
+ }, [container, focusSelection, moveCamera, popup, showNotice, threeRef]);
+
+ if (!container) return null;
+ return createPortal(
+
+
+
+ {notice ? {notice}
: null}
+ moveCamera(resetCamera)}
+ onFocusSelection={() => moveCamera(focusSelection)}
+ onZoomIn={() => moveCamera((rig) => zoomCameraBy(rig, ZOOM_STEP_IN))}
+ onZoomOut={() => moveCamera((rig) => zoomCameraBy(rig, ZOOM_STEP_OUT))}
+ onToggleProjection={() => moveCamera(toggleCameraProjection)}
+ onToggleWorkplane={toggleWorkplane}
+ />
+ ,
+ container,
+ );
+}
diff --git a/apps/web/src/components/workplane/ViewCube.tsx b/apps/web/src/components/workplane/ViewCube.tsx
new file mode 100644
index 00000000..95b2a0bf
--- /dev/null
+++ b/apps/web/src/components/workplane/ViewCube.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import { useCallback, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
+import { orbitCameraByDrag, setCameraToViewDirection, setCameraToViewFace, syncViewCube, type CameraRig } from "@/components/workplane/cameraRig";
+import { t } from "@/lib/i18n";
+import { capturePointer } from "@/lib/pointerCapture";
+import { useLanguage } from "@/lib/useLanguage";
+import {
+ beginViewCubeDrag,
+ moveViewCubeDrag,
+ VIEW_CUBE_ZONE_SIZE,
+ VIEW_CUBE_ZONE_START,
+ VIEW_CUBE_ZONES,
+ viewCubeZoneDirection,
+ type ViewCubeDrag,
+ type ViewCubeFace,
+} from "@/lib/viewCubeDrag";
+
+type ViewCubeProps = {
+ /** The camera the cube turns: the 3D view's, or the second view's in its own window. */
+ getRig: () => CameraRig | null;
+ /** The turning part of the cube; the view's frame loop keeps it facing like the camera. */
+ cubeRef: RefObject;
+};
+
+const FACES: Array<{ face: ViewCubeFace; className: string; key: string }> = [
+ // The top face carries the cube-bottom class and the bottom face cube-top, as the cube's CSS places them.
+ { face: "bottom", className: "cube-top", key: "6" },
+ { face: "top", className: "cube-bottom", key: "5" },
+ { face: "front", className: "cube-front", key: "1" },
+ { face: "back", className: "cube-back", key: "2" },
+ { face: "right", className: "cube-right", key: "4" },
+ { face: "left", className: "cube-left", key: "3" },
+];
+
+/** The view cube: a click on a face, an edge or a corner looks from there, a drag turns the view. */
+export function ViewCube({ getRig, cubeRef }: ViewCubeProps) {
+ useLanguage();
+ const dragRef = useRef(null);
+ const suppressClickRef = useRef(false);
+ const [dragging, setDragging] = useState(false);
+
+ const lookFrom = useCallback((turn: (rig: CameraRig) => void) => {
+ const rig = getRig();
+ if (!rig) return;
+ turn(rig);
+ syncViewCube(rig, cubeRef.current);
+ }, [cubeRef, getRig]);
+
+ // The bands along a face's sides and its corners look from that edge or corner (#202).
+ const zones = (face: ViewCubeFace) => VIEW_CUBE_ZONES.map(([column, row]) => (
+ {
+ event.stopPropagation();
+ lookFrom((rig) => setCameraToViewDirection(rig, viewCubeZoneDirection(face, column, row)));
+ }}
+ />
+ ));
+
+ const handlePointerDown = useCallback((event: ReactPointerEvent) => {
+ event.stopPropagation();
+ if (event.button !== 0 || dragRef.current) {
+ return;
+ }
+ suppressClickRef.current = false;
+ dragRef.current = beginViewCubeDrag(event.pointerId, event.clientX, event.clientY);
+ }, []);
+
+ const handlePointerMove = useCallback((event: ReactPointerEvent) => {
+ const drag = dragRef.current;
+ if (!drag || drag.pointerId !== event.pointerId) {
+ return;
+ }
+ const move = moveViewCubeDrag(drag, event.clientX, event.clientY);
+ if (!move) {
+ return;
+ }
+ if (move.started) {
+ // Capture only once the press becomes a drag, so a plain click still
+ // lands on the face button underneath.
+ capturePointer(event.currentTarget, event.pointerId);
+ setDragging(true);
+ }
+ lookFrom((rig) => orbitCameraByDrag(rig, move.dx, move.dy));
+ }, [lookFrom]);
+
+ const endDrag = useCallback((event: ReactPointerEvent) => {
+ const drag = dragRef.current;
+ if (!drag || drag.pointerId !== event.pointerId) {
+ return;
+ }
+ dragRef.current = null;
+ if (drag.dragging) {
+ suppressClickRef.current = true;
+ setDragging(false);
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+ }
+ }, []);
+
+ const handleClickCapture = useCallback((event: ReactMouseEvent) => {
+ // The click that ends a drag must not also snap to the face it ended on.
+ if (suppressClickRef.current) {
+ suppressClickRef.current = false;
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }, []);
+
+ return (
+
+
+ {FACES.map(({ face, className, key }) => (
+ lookFrom((rig) => setCameraToViewFace(rig, face))}
+ >
+ {t(`view.${face}Short`)}
+ {zones(face)}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/workplane/cameraRig.ts b/apps/web/src/components/workplane/cameraRig.ts
new file mode 100644
index 00000000..b5e34f3a
--- /dev/null
+++ b/apps/web/src/components/workplane/cameraRig.ts
@@ -0,0 +1,265 @@
+import * as THREE from "three";
+import type { ThreeState } from "@/components/workplane/ViewportOverlays";
+import { orthographicFramingZoom, perspectiveFramingDistance } from "@/lib/cameraFraming";
+import { orbitOffsetByDrag, viewCubeAngles, viewFaceDirection, type ViewCubeFace } from "@/lib/viewCubeDrag";
+import { zoomDistanceScale } from "@/lib/workplaneSettings";
+
+/**
+ * The camera of a 3D view and the moves it makes: home, a face of the view cube, a zoom step,
+ * framing the selection, the switch between the normal and the flat view. The 3D view and the
+ * second view's window each have a camera of their own and share these.
+ */
+
+/** How far the camera may back off from what it looks at, in mm. */
+export const CAMERA_MAX_DISTANCE = 9000;
+/** How close the camera may come to what it looks at, in mm. */
+export const CAMERA_MIN_DISTANCE = 3;
+export const CAMERA_FOV = 38;
+export const CAMERA_HOME = new THREE.Vector3(118, 96, 118);
+export const CAMERA_TARGET = new THREE.Vector3(0, 0, 0);
+
+/**
+ * A camera that looks at the scene with its own controls: the 3D view's, or the second view's in
+ * its own window. The helpers below turn either.
+ */
+export type CameraRig = Pick;
+
+function clamp(value: number, min: number, max: number) {
+ return Math.min(max, Math.max(min, value));
+}
+
+/**
+ * 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.
+ */
+export 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) };
+}
+
+/**
+ * Near and far planes that follow the distance: up close a near plane of
+ * 0.1 mm would cut into a 3 mm view, far out a fixed one would leave the
+ * depth buffer too coarse and faces would flicker into each other.
+ *
+ * The flat view draws the same picture wherever the camera stands on its line
+ * of sight, but the camera stays where the standard view (F) or the switch
+ * left it, often closer than the parts in front of the pivot: tilted towards
+ * the front, everything nearer than the camera was cut off. Its depth range
+ * reaches behind the camera as far as in front, so nothing in the picture is.
+ */
+export function fitCameraDepthRange(camera: THREE.Camera, target: THREE.Vector3) {
+ if (camera instanceof THREE.OrthographicCamera) {
+ const far = camera.position.distanceTo(target) + CAMERA_MAX_DISTANCE;
+ if (camera.near === -CAMERA_MAX_DISTANCE && Math.abs(camera.far - far) <= camera.far * 0.05) return;
+ camera.near = -CAMERA_MAX_DISTANCE;
+ camera.far = far;
+ camera.updateProjectionMatrix();
+ return;
+ }
+ if (!(camera instanceof THREE.PerspectiveCamera)) return;
+ const distance = camera.position.distanceTo(target);
+ const near = Math.min(5, Math.max(0.01, distance / 2000));
+ const far = Math.max(6000, distance * 2 + 3000);
+ if (Math.abs(camera.near - near) <= camera.near * 0.05 && Math.abs(camera.far - far) <= camera.far * 0.05) return;
+ camera.near = near;
+ camera.far = far;
+ camera.updateProjectionMatrix();
+}
+
+export function resetCamera(state: CameraRig) {
+ state.focusReach = null;
+ state.camera.up.set(0, 1, 0);
+ state.camera.position.copy(CAMERA_HOME);
+ state.controls.target.copy(CAMERA_TARGET);
+ if (state.camera instanceof THREE.OrthographicCamera) {
+ state.camera.zoom = 1;
+ const distance = CAMERA_HOME.distanceTo(CAMERA_TARGET);
+ const halfHeight = distance * Math.tan(THREE.MathUtils.degToRad(CAMERA_FOV / 2));
+ const canvas = state.renderer.domElement;
+ updateOrthographicFrustum(state.camera, canvas.clientWidth / Math.max(1, canvas.clientHeight), halfHeight);
+ } else {
+ state.camera.zoom = 1;
+ }
+ state.camera.lookAt(CAMERA_TARGET);
+ state.camera.updateProjectionMatrix();
+ state.controls.update();
+}
+
+export function updateCameraViewport(
+ camera: THREE.PerspectiveCamera | THREE.OrthographicCamera,
+ width: number,
+ height: number,
+) {
+ const aspect = width / Math.max(1, height);
+ if (camera instanceof THREE.PerspectiveCamera) {
+ camera.aspect = aspect;
+ } else {
+ const halfHeight = Math.max(0.001, (camera.top - camera.bottom) / 2);
+ updateOrthographicFrustum(camera, aspect, halfHeight);
+ return;
+ }
+ camera.updateProjectionMatrix();
+}
+
+export function updateOrthographicFrustum(camera: THREE.OrthographicCamera, aspect: number, halfHeight: number) {
+ camera.left = -halfHeight * aspect;
+ camera.right = halfHeight * aspect;
+ camera.top = halfHeight;
+ camera.bottom = -halfHeight;
+ camera.updateProjectionMatrix();
+}
+
+export function toggleCameraProjection(state: CameraRig) {
+ const current = state.camera;
+ const target = state.controls.target;
+ const canvas = state.renderer.domElement;
+ const aspect = canvas.clientWidth / Math.max(1, canvas.clientHeight);
+ const offset = current.position.clone().sub(target);
+ const direction = offset.lengthSq() > 0
+ ? offset.clone().normalize()
+ : CAMERA_HOME.clone().sub(CAMERA_TARGET).normalize();
+ let next: THREE.PerspectiveCamera | THREE.OrthographicCamera;
+
+ if (current instanceof THREE.PerspectiveCamera) {
+ const visibleHalfHeight = Math.max(
+ 0.001,
+ offset.length() * Math.tan(THREE.MathUtils.degToRad(current.getEffectiveFOV() / 2)),
+ );
+ next = new THREE.OrthographicCamera(
+ -visibleHalfHeight * aspect,
+ visibleHalfHeight * aspect,
+ visibleHalfHeight,
+ -visibleHalfHeight,
+ current.near,
+ current.far,
+ );
+ next.position.copy(current.position);
+ } else {
+ const visibleHalfHeight = Math.max(0.001, (current.top - current.bottom) / (2 * current.zoom));
+ const distance = clamp(
+ visibleHalfHeight / Math.tan(THREE.MathUtils.degToRad(CAMERA_FOV / 2)),
+ CAMERA_MIN_DISTANCE,
+ CAMERA_MAX_DISTANCE,
+ );
+ next = new THREE.PerspectiveCamera(CAMERA_FOV, aspect, 0.1, 6000);
+ next.position.copy(target).addScaledVector(direction, distance);
+ }
+
+ if (next instanceof THREE.OrthographicCamera) {
+ const range = orthographicZoomRange(next);
+ state.controls.minZoom = range.min;
+ state.controls.maxZoom = range.max;
+ }
+ next.up.copy(current.up);
+ next.layers.mask = current.layers.mask;
+ next.lookAt(target);
+ next.updateProjectionMatrix();
+ fitCameraDepthRange(next, target);
+ next.updateMatrixWorld();
+ state.camera = next;
+ state.controls.object = next;
+ state.controls.update();
+ state.needsRender = true;
+}
+
+export function setCameraToViewFace(state: CameraRig, face: ViewCubeFace) {
+ setCameraToViewDirection(state, viewFaceDirection(face));
+}
+
+/** Looks at the orbit target from a unit direction, keeping the distance: a face, an edge or a corner of the view cube. */
+export function setCameraToViewDirection(state: CameraRig, direction: THREE.Vector3) {
+ const offset = state.camera.position.clone().sub(state.controls.target);
+ const distance = clamp(offset.length(), CAMERA_MIN_DISTANCE, CAMERA_MAX_DISTANCE);
+
+ state.camera.up.set(0, 1, 0);
+ state.camera.position.copy(state.controls.target).add(direction.multiplyScalar(distance));
+ state.camera.lookAt(state.controls.target);
+ state.camera.updateProjectionMatrix();
+ state.controls.update();
+ state.needsRender = true;
+}
+
+/** Orbits the camera around the controls target for a drag on the view cube. */
+export function orbitCameraByDrag(state: CameraRig, dx: number, dy: number) {
+ const offset = orbitOffsetByDrag(state.camera.position.clone().sub(state.controls.target), dx, dy);
+
+ state.camera.up.set(0, 1, 0);
+ state.camera.position.copy(state.controls.target).add(offset);
+ state.camera.lookAt(state.controls.target);
+ state.camera.updateProjectionMatrix();
+ state.controls.update();
+ state.needsRender = true;
+}
+
+export function syncViewCube(state: CameraRig, cube: HTMLDivElement | null) {
+ if (!cube) {
+ return;
+ }
+
+ const { pitch, yaw } = viewCubeAngles(state.camera.position.clone().sub(state.controls.target));
+ cube.style.transform = `rotateX(${-pitch}deg) rotateY(${-yaw}deg)`;
+}
+
+/** A zoom step of the buttons or the + and - keys: the flat view enlarges, the normal view moves closer to its pivot. */
+export function zoomCameraBy(state: CameraRig, scale: number) {
+ const scaled = zoomDistanceScale(scale);
+ if (state.camera instanceof THREE.OrthographicCamera) {
+ const range = orthographicZoomRange(state.camera);
+ state.camera.zoom = clamp(state.camera.zoom / scaled, range.min, range.max);
+ } else {
+ const offset = state.camera.position.clone().sub(state.controls.target);
+ const distance = clamp(offset.length() * scaled, CAMERA_MIN_DISTANCE, CAMERA_MAX_DISTANCE);
+ offset.setLength(distance);
+ state.camera.position.copy(state.controls.target).add(offset);
+ }
+ state.camera.updateProjectionMatrix();
+ state.controls.update();
+ state.needsRender = true;
+}
+
+/** The box around the bodies with these ids, or null when none of them is drawn. */
+export function selectionBounds(state: Pick, ids: readonly string[]) {
+ const bounds = new THREE.Box3();
+ ids.forEach((id) => {
+ const record = state.shapeRecords.get(id);
+ if (record) bounds.expandByObject(record.object);
+ });
+ return bounds.isEmpty() ? null : bounds;
+}
+
+/** Zooms to a box from the side the camera looks from now (F). */
+export function frameBounds(state: CameraRig, bounds: THREE.Box3) {
+ 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();
+ state.needsRender = true;
+}
diff --git a/apps/web/src/components/workplane/cameraViews.ts b/apps/web/src/components/workplane/cameraViews.ts
index 406c65c9..43274491 100644
--- a/apps/web/src/components/workplane/cameraViews.ts
+++ b/apps/web/src/components/workplane/cameraViews.ts
@@ -1,5 +1,6 @@
import * as THREE from "three";
import { useCallback, useEffect, useRef, type RefObject } from "react";
+import { fitCameraDepthRange, syncViewCube, toggleCameraProjection, type CameraRig } from "@/components/workplane/cameraRig";
import type { ThreeState } from "@/components/workplane/ViewportOverlays";
import { readDesignView, readSavedViews, SAVED_VIEW_KEYS, writeDesignView, writeSavedViews, type DesignView } from "@/lib/designView";
import { t } from "@/lib/i18n";
@@ -7,42 +8,9 @@ import { t } from "@/lib/i18n";
/**
* Where the camera of the 3D view looks, as a view that can be kept and brought back: the view a
* design was left in (#220), the one it had before a sketch opened (#150), and the four views saved
- * under the keys 7, 8, 9 and 0.
+ * under the keys 7, 8, 9 and 0. The second view's window saves and brings back those four too.
*/
-/** How far the camera may back off from what it looks at, in mm. */
-export const CAMERA_MAX_DISTANCE = 9000;
-
-/**
- * Near and far planes that follow the distance: up close a near plane of
- * 0.1 mm would cut into a 3 mm view, far out a fixed one would leave the
- * depth buffer too coarse and faces would flicker into each other.
- *
- * The flat view draws the same picture wherever the camera stands on its line
- * of sight, but the camera stays where the standard view (F) or the switch
- * left it, often closer than the parts in front of the pivot: tilted towards
- * the front, everything nearer than the camera was cut off. Its depth range
- * reaches behind the camera as far as in front, so nothing in the picture is.
- */
-export function fitCameraDepthRange(camera: THREE.Camera, target: THREE.Vector3) {
- if (camera instanceof THREE.OrthographicCamera) {
- const far = camera.position.distanceTo(target) + CAMERA_MAX_DISTANCE;
- if (camera.near === -CAMERA_MAX_DISTANCE && Math.abs(camera.far - far) <= camera.far * 0.05) return;
- camera.near = -CAMERA_MAX_DISTANCE;
- camera.far = far;
- camera.updateProjectionMatrix();
- return;
- }
- if (!(camera instanceof THREE.PerspectiveCamera)) return;
- const distance = camera.position.distanceTo(target);
- const near = Math.min(5, Math.max(0.01, distance / 2000));
- const far = Math.max(6000, distance * 2 + 3000);
- if (Math.abs(camera.near - near) <= camera.near * 0.05 && Math.abs(camera.far - far) <= camera.far * 0.05) return;
- camera.near = near;
- camera.far = far;
- camera.updateProjectionMatrix();
-}
-
/** Where the camera looked, per design, while the view was taken down for a sketch (#150). */
const rememberedCameraViews = new Map();
@@ -51,7 +19,7 @@ export function rememberCameraView(projectId: string | null | undefined, state:
rememberedCameraViews.set(projectId ?? "", cameraViewOf(state));
}
-function cameraViewOf(state: ThreeState): DesignView {
+function cameraViewOf(state: CameraRig): DesignView {
return {
orthographic: state.camera instanceof THREE.OrthographicCamera,
position: state.camera.position.toArray() as [number, number, number],
@@ -61,7 +29,7 @@ function cameraViewOf(state: ThreeState): DesignView {
};
}
-function applyCameraView(state: ThreeState, view: DesignView) {
+function applyCameraView(state: CameraRig, view: DesignView) {
state.camera.up.fromArray(view.up);
state.camera.position.fromArray(view.position);
state.controls.target.fromArray(view.target);
@@ -73,6 +41,34 @@ function applyCameraView(state: ThreeState, view: DesignView) {
state.needsRender = true;
}
+/** Turns a camera to a kept view, switching between the flat and the normal view where they differ. */
+function showCameraView(state: CameraRig, view: DesignView) {
+ if ((state.camera instanceof THREE.OrthographicCamera) !== view.orthographic) toggleCameraProjection(state);
+ applyCameraView(state, view);
+}
+
+/**
+ * Shift+7, 8, 9 or 0, in the 3D view or the second view: the view of that window's camera as it is
+ * now - turn, pan, zoom and projection - goes into the design's slot. Returns the message saying so.
+ */
+export function saveViewInSlot(projectId: string | null | undefined, state: CameraRig, slot: number) {
+ const views = readSavedViews(projectId);
+ views[slot] = cameraViewOf(state);
+ writeSavedViews(projectId, views);
+ return t("camera.viewSaved", { key: SAVED_VIEW_KEYS[slot] });
+}
+
+/**
+ * 7, 8, 9 or 0, in either window: that window's camera goes to the view saved there. Returns null when
+ * it did, or, for an empty slot, the message that says how to fill it.
+ */
+export function showViewInSlot(projectId: string | null | undefined, state: CameraRig, slot: number) {
+ const view = readSavedViews(projectId)[slot];
+ if (!view) return t("camera.savedViewEmpty", { key: SAVED_VIEW_KEYS[slot] });
+ showCameraView(state, view);
+ return null;
+}
+
type DesignViewOptions = {
threeRef: RefObject;
projectIdRef: RefObject;
@@ -81,8 +77,6 @@ type DesignViewOptions = {
designViewRestoredRef: RefObject;
orthographicView: boolean;
setOrthographicView: (orthographic: boolean) => void;
- toggleCameraProjection: (state: ThreeState) => void;
- syncViewCube: (state: ThreeState, cube: HTMLDivElement | null) => void;
onNotice?: (message: string) => void;
};
@@ -97,9 +91,8 @@ export function useDesignViews(options: DesignViewOptions) {
const readyRef = useRef(false);
const showView = useCallback((state: ThreeState, view: DesignView) => {
- const { toggleCameraProjection, syncViewCube, viewCubeRef, setOrthographicView } = optionsRef.current;
- if ((state.camera instanceof THREE.OrthographicCamera) !== view.orthographic) toggleCameraProjection(state);
- applyCameraView(state, view);
+ const { viewCubeRef, setOrthographicView } = optionsRef.current;
+ showCameraView(state, view);
syncViewCube(state, viewCubeRef.current);
setOrthographicView(state.camera instanceof THREE.OrthographicCamera);
}, []);
@@ -157,10 +150,7 @@ export function useDesignViews(options: DesignViewOptions) {
const { threeRef, projectIdRef, onNotice } = optionsRef.current;
const state = threeRef.current;
if (!state) return false;
- const views = readSavedViews(projectIdRef.current);
- views[slot] = cameraViewOf(state);
- writeSavedViews(projectIdRef.current, views);
- onNotice?.(t("camera.viewSaved", { key: SAVED_VIEW_KEYS[slot] }));
+ onNotice?.(saveViewInSlot(projectIdRef.current, state, slot));
return true;
}, []);
diff --git a/apps/web/src/components/workplane/renderFrame.ts b/apps/web/src/components/workplane/renderFrame.ts
new file mode 100644
index 00000000..cbfd9687
--- /dev/null
+++ b/apps/web/src/components/workplane/renderFrame.ts
@@ -0,0 +1,257 @@
+import * as THREE from "three";
+import type { ThreeState } from "@/components/workplane/ViewportOverlays";
+import { RENDER_LAYER_SHAPES } from "@/components/workplane/viewportPicking";
+
+/** What drawing one frame needs: the main view's state, or the second view's camera on the same scene. */
+export type FrameState = Pick;
+
+// The cut a hole makes in a body is drawn from depth textures rather than from a
+// boolean. A hole fragment lies inside a body when it falls between the body's
+// near and its far depth at that pixel, and a body fragment lies inside a hole
+// the same way; four depth textures carry those bounds. Nothing runs on the CPU
+// for it, so the preview keeps up with a shape while it is being dragged, and no
+// result has to be cached or dropped when a gesture starts.
+const CUT_PREVIEW_COLOR = 0x30363a;
+const CUT_PREVIEW_OPACITY = 0.34;
+// Window depth is not linear, so a bias in window units opens a gap where the cut
+// meets the surface that grows as the view is pulled back. The band is widened by it
+// rather than narrowed, so a hole flush with a face stays visible - the cutter pads
+// by 0.05 the same way - and the bias only has to be big enough to break a tie.
+const CUT_PREVIEW_DEPTH_BIAS = 1e-7;
+
+type CutPreviewResources = {
+ bodiesNear: THREE.WebGLRenderTarget;
+ bodiesFar: THREE.WebGLRenderTarget;
+ frontDepth: THREE.MeshBasicMaterial;
+ backDepth: THREE.MeshBasicMaterial;
+ // The hole shaded where it lies inside a body: what is going to be cut out,
+ // drawn on the hole itself. Nothing has to know the shape of either body - only
+ // whether a fragment sits between the near and the far depth of the other one.
+ ink: THREE.ShaderMaterial;
+ width: number;
+ height: number;
+};
+
+// Each renderer has its own: the second view's window draws with a WebGL context of its own,
+// and a size shared by both would make each frame throw the other's away.
+const cutPreviewResourcesByRenderer = new WeakMap();
+
+function cutPreviewInkMaterial() {
+ return new THREE.ShaderMaterial({
+ transparent: true,
+ depthTest: false,
+ depthWrite: false,
+ // Both sides, so the far wall of a hole that goes right through fills the cut.
+ // The stencil keeps one layer per pixel, so the two walls cannot add up to a
+ // darker ring.
+ side: THREE.DoubleSide,
+ stencilWrite: true,
+ stencilFunc: THREE.NotEqualStencilFunc,
+ stencilRef: 1,
+ stencilFail: THREE.KeepStencilOp,
+ stencilZFail: THREE.KeepStencilOp,
+ stencilZPass: THREE.ReplaceStencilOp,
+ uniforms: {
+ nearDepth: { value: null },
+ farDepth: { value: null },
+ resolution: { value: new THREE.Vector2(1, 1) },
+ // Written straight into the frame, so the tint keeps the values it was
+ // given instead of being converted twice.
+ tint: { value: new THREE.Color().setHex(CUT_PREVIEW_COLOR, THREE.LinearSRGBColorSpace) },
+ opacity: { value: CUT_PREVIEW_OPACITY },
+ bias: { value: CUT_PREVIEW_DEPTH_BIAS },
+ },
+ vertexShader: [
+ "void main() {",
+ " gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);",
+ "}",
+ ].join("\n"),
+ fragmentShader: [
+ "uniform sampler2D nearDepth;",
+ "uniform sampler2D farDepth;",
+ "uniform vec2 resolution;",
+ "uniform vec3 tint;",
+ "uniform float opacity;",
+ "uniform float bias;",
+ "void main() {",
+ " // Both sides are window-space depth, so they compare directly.",
+ " float depth = gl_FragCoord.z;",
+ " vec2 uv = gl_FragCoord.xy / resolution;",
+ " float near = texture2D(nearDepth, uv).r;",
+ " float far = texture2D(farDepth, uv).r;",
+ " if (depth < near - bias || depth > far + bias) {",
+ " discard;",
+ " }",
+ " gl_FragColor = vec4(tint, opacity);",
+ "}",
+ ].join("\n"),
+ });
+}
+
+function cutPreviewDepthTarget(width: number, height: number) {
+ const depthTexture = new THREE.DepthTexture(width, height);
+ depthTexture.format = THREE.DepthFormat;
+ depthTexture.type = THREE.UnsignedIntType;
+ const target = new THREE.WebGLRenderTarget(width, height, {
+ minFilter: THREE.NearestFilter,
+ magFilter: THREE.NearestFilter,
+ depthBuffer: true,
+ stencilBuffer: false,
+ depthTexture,
+ });
+ target.texture.colorSpace = THREE.NoColorSpace;
+ return target;
+}
+
+export function disposeCutPreviewResources(renderer: THREE.WebGLRenderer) {
+ const resources = cutPreviewResourcesByRenderer.get(renderer);
+ if (!resources) {
+ return;
+ }
+ [resources.bodiesNear, resources.bodiesFar].forEach((target) =>
+ target.dispose(),
+ );
+ [
+ resources.frontDepth,
+ resources.backDepth,
+ resources.ink,
+ ].forEach((material) => material.dispose());
+ cutPreviewResourcesByRenderer.delete(renderer);
+}
+
+function cutPreviewResourcesFor(renderer: THREE.WebGLRenderer, width: number, height: number) {
+ const cached = cutPreviewResourcesByRenderer.get(renderer);
+ if (cached && cached.width === width && cached.height === height) {
+ return cached;
+ }
+ disposeCutPreviewResources(renderer);
+ const resources: CutPreviewResources = {
+ bodiesNear: cutPreviewDepthTarget(width, height),
+ bodiesFar: cutPreviewDepthTarget(width, height),
+ frontDepth: new THREE.MeshBasicMaterial({
+ colorWrite: false,
+ depthWrite: true,
+ depthTest: true,
+ depthFunc: THREE.LessDepth,
+ side: THREE.FrontSide,
+ }),
+ backDepth: new THREE.MeshBasicMaterial({
+ colorWrite: false,
+ depthWrite: true,
+ depthTest: true,
+ depthFunc: THREE.LessDepth,
+ side: THREE.BackSide,
+ }),
+ ink: cutPreviewInkMaterial(),
+ width,
+ height,
+ };
+ cutPreviewResourcesByRenderer.set(renderer, resources);
+ return resources;
+}
+
+function drawCutPreviews(state: FrameState) {
+ const bodies: THREE.Object3D[] = [];
+ const holes: THREE.Object3D[] = [];
+ state.shapeRecords.forEach((record) => {
+ const object = record.object;
+ if (!object || record.shape.hidden || !object.visible) {
+ return;
+ }
+ if (record.shape.hole) {
+ holes.push(object);
+ } else {
+ bodies.push(object);
+ }
+ });
+ if (bodies.length === 0 || holes.length === 0) {
+ return;
+ }
+
+ const renderer = state.renderer;
+ const scene = state.scene;
+ const camera = state.camera;
+ const frameTarget = renderer.getRenderTarget();
+ const size = frameTarget
+ ? new THREE.Vector2(frameTarget.width, frameTarget.height)
+ : renderer.getDrawingBufferSize(new THREE.Vector2());
+ const resources = cutPreviewResourcesFor(renderer, size.x, size.y);
+
+ const shapes = [...bodies, ...holes];
+ const wasVisible = new Map(shapes.map((object) => [object, object.visible] as const));
+ const previousAutoClear = renderer.autoClear;
+ const previousOverride = scene.overrideMaterial;
+ const previousBackground = scene.background;
+ const previousMask = camera.layers.mask;
+
+ const renderSet = (visible: THREE.Object3D[]) => {
+ const wanted = new Set(visible);
+ shapes.forEach((object) => {
+ object.visible = wanted.has(object);
+ });
+ };
+
+ try {
+ renderer.autoClear = false;
+ camera.layers.set(RENDER_LAYER_SHAPES);
+ // A scene background is repainted by every render() call, and this renders
+ // the scene four more times; left alone it would wipe the frame.
+ scene.background = null;
+
+ // The section plane clips per material, and these are the preview's own: without
+ // it the depth textures keep the geometry that was cut away, and the ink lands in
+ // the removed half. Where the plane is gone the depth reads 1.0, which discards
+ // the ink there by itself.
+ const sectionPlane = state.sectionPlane ?? null;
+ for (const material of [resources.frontDepth, resources.backDepth]) {
+ if ((material.clippingPlanes?.[0] ?? null) !== sectionPlane) {
+ material.clippingPlanes = sectionPlane ? [sectionPlane] : null;
+ material.needsUpdate = true;
+ }
+ }
+
+ const depthPass = (
+ visible: THREE.Object3D[],
+ material: THREE.Material,
+ target: THREE.WebGLRenderTarget,
+ ) => {
+ renderSet(visible);
+ renderer.setRenderTarget(target);
+ renderer.clear(true, true, false);
+ scene.overrideMaterial = material;
+ renderer.render(scene, camera);
+ };
+
+ depthPass(bodies, resources.frontDepth, resources.bodiesNear);
+ depthPass(bodies, resources.backDepth, resources.bodiesFar);
+
+ // The hole is drawn once, over the frame, and shaded where it lies inside a
+ // body. The stencil decides which of its fragments gets a pixel: one layer, so
+ // the shading stays even wherever the hole turns back on itself.
+ resources.ink.uniforms.nearDepth.value = resources.bodiesNear.depthTexture;
+ resources.ink.uniforms.farDepth.value = resources.bodiesFar.depthTexture;
+ (resources.ink.uniforms.resolution.value as THREE.Vector2).set(
+ resources.width,
+ resources.height,
+ );
+ renderSet(holes);
+ renderer.setRenderTarget(frameTarget);
+ renderer.clear(false, false, true);
+ scene.overrideMaterial = resources.ink;
+ renderer.render(scene, camera);
+ } finally {
+ shapes.forEach((object) => {
+ object.visible = wasVisible.get(object) ?? true;
+ });
+ scene.overrideMaterial = previousOverride;
+ scene.background = previousBackground;
+ camera.layers.mask = previousMask;
+ renderer.autoClear = previousAutoClear;
+ renderer.setRenderTarget(frameTarget);
+ }
+}
+
+export function renderFrame(state: FrameState) {
+ state.renderer.render(state.scene, state.camera);
+ drawCutPreviews(state);
+}
diff --git a/apps/web/src/lib/messages.ar.ts b/apps/web/src/lib/messages.ar.ts
index 9042d60e..67d86f67 100644
--- a/apps/web/src/lib/messages.ar.ts
+++ b/apps/web/src/lib/messages.ar.ts
@@ -2207,6 +2207,9 @@ export const MESSAGES_AR: Partial> = {
"camera.touchRotateHint": "الإصبعان يكبّران ويحرّكان سطح العمل دائمًا. وأثناء تشغيل هذا الخيار، يدير الإصبع الواحد العرض بدلًا من التحديد.",
"camera.orthographic": "عرض متعامد (Orthographic)",
"camera.perspective": "العودة إلى عرض المنظور (Perspective)",
+ "camera.secondView": "افتح عرضًا ثانيًا",
+ "camera.closeSecondView": "أغلق العرض الثاني",
+ "secondView.title": "عرض Layerling",
"camera.placeWorkplane": "وضع سطح العمل",
"camera.resetWorkplane": "إعادة سطح العمل إلى اللوحة الأساسية",
"camera.hideFaceWorkplane": "إخفاء سطح العمل الموضوع على الوجه",
diff --git a/apps/web/src/lib/messages.cs.ts b/apps/web/src/lib/messages.cs.ts
index 25213326..cb813d36 100644
--- a/apps/web/src/lib/messages.cs.ts
+++ b/apps/web/src/lib/messages.cs.ts
@@ -2211,6 +2211,9 @@ export const MESSAGES_CS: Record = {
"camera.touchRotateHint": "Dva prsty vždy přibližují a posouvají pohled. Při zapnutí jeden prst místo výběru otáčí pohled.",
"camera.orthographic": "Ortografický pohled",
"camera.perspective": "Zpět na perspektivní pohled",
+ "camera.secondView": "Otevřít druhý pohled",
+ "camera.closeSecondView": "Zavřít druhý pohled",
+ "secondView.title": "Pohled Layerling",
"camera.placeWorkplane": "Umístit pracovní rovinu",
"camera.resetWorkplane": "Vrátit pracovní rovinu na podložku",
"camera.hideFaceWorkplane": "Skrýt pracovní rovinu na ploše",
diff --git a/apps/web/src/lib/messages.da.ts b/apps/web/src/lib/messages.da.ts
index 12a4156c..23ce823f 100644
--- a/apps/web/src/lib/messages.da.ts
+++ b/apps/web/src/lib/messages.da.ts
@@ -2119,6 +2119,9 @@ export const MESSAGES_DA: Partial> = {
"camera.touchRotateHint": "To fingre zoomer og flytter altid arbejdsplanet. Mens dette er slået til, drejer én finger visningen i stedet for at vælge.",
"camera.orthographic": "Ortofrafisk visning",
"camera.perspective": "Tilbage til perspektivvisning",
+ "camera.secondView": "Åbn en anden visning",
+ "camera.closeSecondView": "Luk den anden visning",
+ "secondView.title": "Layerling-visning",
"camera.placeWorkplane": "Placér arbejdsplan",
"camera.resetWorkplane": "Arbejdsplan tilbage på grundpladen",
"camera.hideFaceWorkplane": "Skjul flade-arbejdsplan",
diff --git a/apps/web/src/lib/messages.de.ts b/apps/web/src/lib/messages.de.ts
index 53651d1c..44e5ce1e 100644
--- a/apps/web/src/lib/messages.de.ts
+++ b/apps/web/src/lib/messages.de.ts
@@ -2212,6 +2212,9 @@ export const MESSAGES_DE: Record = {
"camera.touchRotateHint": "Zwei Finger zoomen und verschieben die Arbeitsfläche immer. Solange das hier an ist, dreht ein Finger die Ansicht, statt auszuwählen.",
"camera.orthographic": "Orthografische Ansicht",
"camera.perspective": "Zurück zur perspektivischen Ansicht",
+ "camera.secondView": "Zweite Ansicht öffnen",
+ "camera.closeSecondView": "Zweite Ansicht schließen",
+ "secondView.title": "Layerling-Ansicht",
"camera.placeWorkplane": "Arbeitsebene setzen",
"camera.resetWorkplane": "Arbeitsebene zurück auf die Grundplatte",
"camera.hideFaceWorkplane": "Arbeitsebene auf der Fläche ausblenden",
diff --git a/apps/web/src/lib/messages.en.ts b/apps/web/src/lib/messages.en.ts
index 08e64ce3..872a158e 100644
--- a/apps/web/src/lib/messages.en.ts
+++ b/apps/web/src/lib/messages.en.ts
@@ -2213,6 +2213,9 @@ export const MESSAGES_EN = {
"camera.touchRotateHint": "Two fingers always zoom and move the workplane. While this is on, one finger turns the view instead of selecting.",
"camera.orthographic": "Orthographic view",
"camera.perspective": "Back to perspective view",
+ "camera.secondView": "Open a second view",
+ "camera.closeSecondView": "Close the second view",
+ "secondView.title": "Layerling view",
"camera.placeWorkplane": "Place workplane",
"camera.resetWorkplane": "Workplane back to the base plate",
"camera.hideFaceWorkplane": "Hide face workplane",
diff --git a/apps/web/src/lib/messages.es.ts b/apps/web/src/lib/messages.es.ts
index 08cef6be..114342cd 100644
--- a/apps/web/src/lib/messages.es.ts
+++ b/apps/web/src/lib/messages.es.ts
@@ -2215,6 +2215,9 @@ export const MESSAGES_ES: Partial> = {
"camera.touchRotateHint": "Con dos dedos siempre se hace zoom y se mueve el plano de trabajo. Mientras esto esté activado, un dedo gira la vista en lugar de seleccionar.",
"camera.orthographic": "Vista ortográfica",
"camera.perspective": "Volver a la vista en perspectiva",
+ "camera.secondView": "Abrir una segunda vista",
+ "camera.closeSecondView": "Cerrar la segunda vista",
+ "secondView.title": "Vista de Layerling",
"camera.placeWorkplane": "Colocar plano de trabajo",
"camera.resetWorkplane": "Devolver el plano de trabajo a la placa base",
"camera.hideFaceWorkplane": "Ocultar el plano de trabajo de la cara",
diff --git a/apps/web/src/lib/messages.fr.ts b/apps/web/src/lib/messages.fr.ts
index 1b2e8f51..a437cc96 100644
--- a/apps/web/src/lib/messages.fr.ts
+++ b/apps/web/src/lib/messages.fr.ts
@@ -2196,6 +2196,9 @@ export const MESSAGES_FR: Partial> = {
"camera.touchRotateHint": "Deux doigts zooment et déplacent toujours le plan de travail. Lorsque cette option est activée, un doigt fait tourner la vue au lieu de sélectionner.",
"camera.orthographic": "Vue orthographique",
"camera.perspective": "Retour à la vue en perspective",
+ "camera.secondView": "Ouvrir une deuxième vue",
+ "camera.closeSecondView": "Fermer la deuxième vue",
+ "secondView.title": "Vue Layerling",
"camera.placeWorkplane": "Placer le plan de travail",
"camera.resetWorkplane": "Plan de travail retour à la plaque de base",
"camera.hideFaceWorkplane": "Masquer le plan de travail de la face",
diff --git a/apps/web/src/lib/messages.hu.ts b/apps/web/src/lib/messages.hu.ts
index 12e19932..509b1f39 100644
--- a/apps/web/src/lib/messages.hu.ts
+++ b/apps/web/src/lib/messages.hu.ts
@@ -2154,6 +2154,9 @@ export const MESSAGES_HU: Partial> = {
"camera.touchRotateHint": "Két ujjal mindig nagyítasz és mozgatod a munkasíkot. Ha ez be van kapcsolva, egy ujjal a nézetet forgatod kijelölés helyett.",
"camera.orthographic": "Ortografikus nézet",
"camera.perspective": "Vissza a perspektivikus nézethez",
+ "camera.secondView": "Második nézet megnyitása",
+ "camera.closeSecondView": "A második nézet bezárása",
+ "secondView.title": "Layerling nézet",
"camera.placeWorkplane": "Munkasík elhelyezése",
"camera.resetWorkplane": "Munkasík vissza az alaplapra",
"camera.hideFaceWorkplane": "Lapon lévő munkasík elrejtése",
diff --git a/apps/web/src/lib/messages.it.ts b/apps/web/src/lib/messages.it.ts
index 150eab0b..145cfc04 100644
--- a/apps/web/src/lib/messages.it.ts
+++ b/apps/web/src/lib/messages.it.ts
@@ -2115,6 +2115,9 @@ export const MESSAGES_IT: Partial> = {
"camera.touchRotateHint": "Con due dita puoi sempre ingrandire e spostare il piano di lavoro. Quando questa opzione è attiva, un dito ruota la vista invece di selezionare.",
"camera.orthographic": "Vista ortogonale",
"camera.perspective": "Torna alla vista prospettica",
+ "camera.secondView": "Apri una seconda vista",
+ "camera.closeSecondView": "Chiudi la seconda vista",
+ "secondView.title": "Vista Layerling",
"camera.placeWorkplane": "Posiziona il piano di lavoro",
"camera.resetWorkplane": "Riporta il piano di lavoro sulla piastra di base",
"camera.hideFaceWorkplane": "Nascondi il piano di lavoro sulla faccia",
diff --git a/apps/web/src/lib/messages.ja.ts b/apps/web/src/lib/messages.ja.ts
index 75438280..21227449 100644
--- a/apps/web/src/lib/messages.ja.ts
+++ b/apps/web/src/lib/messages.ja.ts
@@ -2144,6 +2144,9 @@ export const MESSAGES_JA: Partial> = {
"camera.touchRotateHint": "2本指は常にズームと作業平面の移動です。これをオンにすると、1本指は選択ではなくビューの回転になります。",
"camera.orthographic": "平行投影表示",
"camera.perspective": "透視投影表示に戻す",
+ "camera.secondView": "2つ目のビューを開く",
+ "camera.closeSecondView": "2つ目のビューを閉じる",
+ "secondView.title": "Layerling ビュー",
"camera.placeWorkplane": "作業平面を配置",
"camera.resetWorkplane": "作業平面をベースプレートに戻す",
"camera.hideFaceWorkplane": "面の作業平面を非表示",
diff --git a/apps/web/src/lib/messages.nl.ts b/apps/web/src/lib/messages.nl.ts
index 39b20d35..2d3506e7 100644
--- a/apps/web/src/lib/messages.nl.ts
+++ b/apps/web/src/lib/messages.nl.ts
@@ -2212,6 +2212,9 @@ export const MESSAGES_NL: Record = {
"camera.touchRotateHint": "Twee vingers zoomen altijd en verplaatsen het werkvlak. Zolang dit aan staat, draait één vinger de weergave in plaats van te selecteren.",
"camera.orthographic": "Orthografische weergave",
"camera.perspective": "Terug naar perspectiefweergave",
+ "camera.secondView": "Tweede weergave openen",
+ "camera.closeSecondView": "Tweede weergave sluiten",
+ "secondView.title": "Layerling-weergave",
"camera.placeWorkplane": "Werkvlak plaatsen",
"camera.resetWorkplane": "Werkvlak terug naar de basisplaat",
"camera.hideFaceWorkplane": "Vlakwerkvlak verbergen",
diff --git a/apps/web/src/lib/messages.pl.ts b/apps/web/src/lib/messages.pl.ts
index 48079679..ae2a2448 100644
--- a/apps/web/src/lib/messages.pl.ts
+++ b/apps/web/src/lib/messages.pl.ts
@@ -2218,6 +2218,9 @@ export const MESSAGES_PL: Partial> = {
"camera.touchRotateHint": "Dwa palce zawsze przybliżają i przesuwają płaszczyznę roboczą. Gdy ta opcja jest włączona, jeden palec obraca widok zamiast zaznaczać.",
"camera.orthographic": "Widok ortogonalny",
"camera.perspective": "Wróć do widoku perspektywicznego",
+ "camera.secondView": "Otwórz drugi widok",
+ "camera.closeSecondView": "Zamknij drugi widok",
+ "secondView.title": "Widok Layerling",
"camera.placeWorkplane": "Umieść płaszczyznę roboczą",
"camera.resetWorkplane": "Płaszczyzna robocza z powrotem na płytę bazową",
"camera.hideFaceWorkplane": "Ukryj płaszczyznę roboczą na ścianie",
diff --git a/apps/web/src/lib/messages.pt-br.ts b/apps/web/src/lib/messages.pt-br.ts
index 4aca2ef0..0be9897a 100644
--- a/apps/web/src/lib/messages.pt-br.ts
+++ b/apps/web/src/lib/messages.pt-br.ts
@@ -745,6 +745,9 @@ export const MESSAGES_PT_BR: Partial> = {
"camera.touchRotateHint": "Dois dedos sempre dão zoom e movem o plano de trabalho. Com isto ligado, um dedo gira a vista em vez de selecionar.",
"camera.orthographic": "Vista ortográfica",
"camera.perspective": "Voltar à vista em perspectiva",
+ "camera.secondView": "Abrir uma segunda vista",
+ "camera.closeSecondView": "Fechar a segunda vista",
+ "secondView.title": "Vista do Layerling",
"camera.placeWorkplane": "Posicionar plano de trabalho",
"camera.resetWorkplane": "Plano de trabalho de volta à base",
"camera.hideFaceWorkplane": "Ocultar plano de trabalho da face",
diff --git a/apps/web/src/lib/messages.ru.ts b/apps/web/src/lib/messages.ru.ts
index 0a348bcb..f3542af2 100644
--- a/apps/web/src/lib/messages.ru.ts
+++ b/apps/web/src/lib/messages.ru.ts
@@ -2203,6 +2203,9 @@ export const MESSAGES_RU: Partial> = {
"camera.touchRotateHint": "Два пальца всегда масштабируют и перемещают вид. Пока это включено, один палец вращает вид вместо выделения.",
"camera.orthographic": "Ортогональный вид",
"camera.perspective": "Вернуться к перспективному виду",
+ "camera.secondView": "Открыть второй вид",
+ "camera.closeSecondView": "Закрыть второй вид",
+ "secondView.title": "Вид Layerling",
"camera.placeWorkplane": "Поставить рабочую плоскость",
"camera.resetWorkplane": "Вернуть рабочую плоскость на основание",
"camera.hideFaceWorkplane": "Скрыть рабочую плоскость на грани",
diff --git a/apps/web/src/lib/messages.sl.ts b/apps/web/src/lib/messages.sl.ts
index 2279ba61..9e0623b0 100644
--- a/apps/web/src/lib/messages.sl.ts
+++ b/apps/web/src/lib/messages.sl.ts
@@ -1649,6 +1649,9 @@ export const MESSAGES_SL: Partial> = {
"camera.touchRotateHint": "Dva prsta vedno približata ali oddaljita ter premakneta delovno ravnino. En prst na praznem prostoru vrti pogled tudi brez tega. Dokler je to vklopljeno, en prst ali leva tipka miške vrti pogled povsod, namesto izbiranja in urejanja.",
"camera.orthographic": "Ortografski pogled",
"camera.perspective": "Nazaj na perspektivni pogled",
+ "camera.secondView": "Odpri drugi pogled",
+ "camera.closeSecondView": "Zapri drugi pogled",
+ "secondView.title": "Pogled Layerling",
"camera.placeWorkplane": "Postavi delovno ravnino",
"camera.resetWorkplane": "Delovna ravnina nazaj na osnovno ploščo",
"camera.hideFaceWorkplane": "Skrij delovno ravnino ploskve",
diff --git a/apps/web/src/lib/messages.tr.ts b/apps/web/src/lib/messages.tr.ts
index 1f5fc625..925c803b 100644
--- a/apps/web/src/lib/messages.tr.ts
+++ b/apps/web/src/lib/messages.tr.ts
@@ -2154,6 +2154,9 @@ export const MESSAGES_TR: Partial> = {
"camera.touchRotateHint": "İki parmak her zaman yakınlaştırır ve çalışma düzlemini kaydırır. Bu açıkken tek parmak seçmek yerine görünümü döndürür.",
"camera.orthographic": "Ortografik görünüm",
"camera.perspective": "Perspektif görünüme dön",
+ "camera.secondView": "İkinci bir görünüm aç",
+ "camera.closeSecondView": "İkinci görünümü kapat",
+ "secondView.title": "Layerling görünümü",
"camera.placeWorkplane": "Çalışma düzlemi yerleştir",
"camera.resetWorkplane": "Çalışma düzlemini taban tablasına döndür",
"camera.hideFaceWorkplane": "Yüzeydeki çalışma düzlemini gizle",
diff --git a/apps/web/src/lib/secondViewPlacement.ts b/apps/web/src/lib/secondViewPlacement.ts
new file mode 100644
index 00000000..fac72258
--- /dev/null
+++ b/apps/web/src/lib/secondViewPlacement.ts
@@ -0,0 +1,65 @@
+/**
+ * Where the second view's window stood and how big it was, so it opens there again. An app
+ * preference of this browser, not part of a design.
+ */
+
+export const SECOND_VIEW_PLACEMENT_KEY = "layerling.secondView.window";
+
+export type SecondViewPlacement = { left: number; top: number; width: number; height: number };
+
+const DEFAULT_SIZE = { width: 960, height: 720 };
+/** Smaller than this the view is of no use; a stored size below it is a mistake. */
+const MIN_SIZE = 200;
+
+export function readSecondViewPlacement(storage: Pick | null): SecondViewPlacement | null {
+ try {
+ const raw = storage?.getItem(SECOND_VIEW_PLACEMENT_KEY);
+ if (!raw) return null;
+ const value = JSON.parse(raw) as Partial;
+ const numbers = [value.left, value.top, value.width, value.height];
+ if (!numbers.every((number) => typeof number === "number" && Number.isFinite(number))) return null;
+ if ((value.width as number) < MIN_SIZE || (value.height as number) < MIN_SIZE) return null;
+ return {
+ left: Math.round(value.left as number),
+ top: Math.round(value.top as number),
+ width: Math.round(value.width as number),
+ height: Math.round(value.height as number),
+ };
+ } catch {
+ return null;
+ }
+}
+
+export function writeSecondViewPlacement(storage: Pick | null, placement: SecondViewPlacement) {
+ if (placement.width < MIN_SIZE || placement.height < MIN_SIZE) return;
+ try {
+ storage?.setItem(SECOND_VIEW_PLACEMENT_KEY, JSON.stringify(placement));
+ } catch {
+ // Storage full or blocked: the window opens in the default place next time.
+ }
+}
+
+/** The features for window.open: the stored place and size, or the default size where the browser puts it. */
+export function secondViewWindowFeatures(placement: SecondViewPlacement | null) {
+ if (!placement) return `popup,width=${DEFAULT_SIZE.width},height=${DEFAULT_SIZE.height}`;
+ return `popup,left=${placement.left},top=${placement.top},width=${placement.width},height=${placement.height}`;
+}
+
+/** A window's place on the screen and the size of its page, as window.open takes them. */
+export function placementOf(win: Pick): SecondViewPlacement {
+ return { left: win.screenX, top: win.screenY, width: win.innerWidth, height: win.innerHeight };
+}
+
+/**
+ * Phones and tablets get no second view: they have no windows side by side, and a popup opens
+ * there as a tab of its own. A finger as the main pointer says so; so does a browser that calls
+ * itself mobile, or names Android, an iPhone, an iPad or an iPod - a tablet with a mouse or a
+ * trackpad on it reports a fine pointer. An iPad that calls itself a Mac gives itself away by its
+ * touch points. A laptop with a touch screen keeps it: its main pointer is the mouse or the trackpad.
+ */
+export function secondViewSupported(env: { coarsePointer: boolean; platform: string; maxTouchPoints: number; userAgent: string; mobile?: boolean }) {
+ if (env.coarsePointer || env.mobile) return false;
+ if (/Android|iPhone|iPad|iPod/i.test(env.userAgent) || /^(iPhone|iPad|iPod)/i.test(env.platform)) return false;
+ if (/^Mac/i.test(env.platform) && env.maxTouchPoints > 1) return false;
+ return true;
+}
diff --git a/apps/web/src/lib/viewArrowKeys.ts b/apps/web/src/lib/viewArrowKeys.ts
index 63821523..5b218d4c 100644
--- a/apps/web/src/lib/viewArrowKeys.ts
+++ b/apps/web/src/lib/viewArrowKeys.ts
@@ -30,20 +30,34 @@ export const VIEW_PAN_COARSE_STEP = 0.5;
* was taken.
*/
export function handleViewArrowKey(event: ArrowKeyEvent, hasSelection: boolean): boolean {
- if (hasSelection || event.altKey) return false;
+ if (hasSelection) return false;
+ const step = viewArrowStep(event);
+ if (!step) return false;
+ if (step.pan) {
+ if (!window.layerlingPanView) return false;
+ window.layerlingPanView(...step.pan);
+ return true;
+ }
+ if (!window.layerlingOrbitView) return false;
+ window.layerlingOrbitView(...step.orbit);
+ return true;
+}
+
+/**
+ * What an arrow key does to the view: a turn (azimuth, polar in degrees) or, with Ctrl or Cmd, a
+ * slide (right, up in parts of the view's height). Null for any other key, or with Alt.
+ */
+export function viewArrowStep(event: ArrowKeyEvent): { orbit: [number, number]; pan?: undefined } | { pan: [number, number]; orbit?: undefined } | null {
+ if (event.altKey) return null;
const x = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
const y = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
- if (!x && !y) return false;
+ if (!x && !y) return null;
if (event.ctrlKey || event.metaKey) {
- if (!window.layerlingPanView) return false;
const step = event.shiftKey ? VIEW_PAN_COARSE_STEP : VIEW_PAN_STEP;
- window.layerlingPanView(x * step, y * step);
- return true;
+ return { pan: [x * step, y * step] };
}
- if (!window.layerlingOrbitView) return false;
const angle = event.shiftKey ? 90 : 15;
- window.layerlingOrbitView(-x * angle, y * angle);
- return true;
+ return { orbit: [-x * angle, y * angle] };
}
export function orbitViewBy({ camera, controls }: ViewCamera, azimuthDegrees: number, polarDegrees: number) {
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 1c4d8b24..88c56cda 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -6,6 +6,8 @@ code still carries it - so a lower number further down is older, not newer.
## Unreleased
+- **A second view in its own window:** A new button in the camera bar (picture-in-picture icon, after the flat-view switch) opens a popup that shows the same design through a camera of its own, so it can be watched from two sides at once. The popup draws the editor's own scene with a renderer of its own, so it holds no copy of the design and every change shows there on the next frame; editing stays in the main window, and the handles and guides are not drawn there. In the popup the mouse, the view cube and the view keys move its camera only: 1-6 (with Shift also framing the selection), F and Shift+F (framing what is selected in the editor), Home, O, + and -, the arrow keys to turn and Ctrl or Cmd with the arrows to slide. The design's four saved views work there too: 7, 8, 9 and 0 bring them back in the popup, and Shift with one of them saves the popup's view in the same slot, so a view saved in either window is there for both; a short message in the popup says the view was saved or the key is empty (`saveViewInSlot` and `showViewInSlot` in `cameraViews.ts`, which the 3D view uses too). Its own camera bar has Home, Center view on selection, zoom in and out, the flat/perspective switch and hide plate, which hides the plate in that window only; it folds away like the main one, follows the toolbar size, and takes two columns only in a low window. The view cube follows the View cube position setting at any width. The popup loads `/view.html` (in `public/`, so it is in the offline copy too), so its address bar names it instead of about:blank, and its title is "Layerling view". Its size and place are kept in this browser (`layerling.secondView.window`); the first time it opens at 960 × 720. It closes with the editor, and a second click on the button closes it. The button is not offered on phones and tablets, a tablet with a mouse or trackpad included: a coarse main pointer, a browser that says it is mobile (`navigator.userAgentData.mobile`) or names Android, iPhone, iPad or iPod, or an iPad that reports itself as a Mac all hide it; a laptop with a touch screen and a Chromebook keep it. The guide (chapter View and workplane, all guide languages) describes it, and the button's texts are in every language. Along the way the camera helpers moved out of `WorkplaneViewport.tsx` into `components/workplane/cameraRig.ts`, the frame and hole preview into `renderFrame.ts` (its buffers now kept per renderer), and the view cube into `ViewCube.tsx`, which both windows use. Unit tests for the window's place and size, where it is offered, the arrow-key steps, the cube's wiring and the saved views both windows share (`sharedSavedViews.test.ts`); tried in Chrome and Edge.
+
## 1.99.0
- **Rounded box radii to 0.01 mm (#705):** `normalizeCornerFillet` and `normalizeTopBottomFillet` rounded to a tenth of a millimetre, so a corner fillet of 0.25 mm came back as 0.3 mm. They round to a hundredth now, and the fields (the shape's properties and the defaults in the workspace settings) step by 0.05. The CAD solid reads the same functions, so it follows. Unit test in `roundedBoxGeometry.test.ts` (0.25 stays 0.25, 7.336 becomes 7.34). Reported by @Algotz on GitHub.
diff --git a/docs/guide/ar/02-view-and-workplane.md b/docs/guide/ar/02-view-and-workplane.md
index 948936fd..2a09021d 100644
--- a/docs/guide/ar/02-view-and-workplane.md
+++ b/docs/guide/ar/02-view-and-workplane.md
@@ -39,6 +39,7 @@ summary: التنقل حول المشهد، النظر مباشرة، ووضع
- {{ui:camera.focusSelection}} يكبر على التحديد ([[F]] أو [[Shift]]+[[F]]).
- {{ui:camera.zoomIn}} و{{ui:camera.zoomOut}} يكبران ويصغران خطوة بخطوة، كما تفعل المفاتيح [[+]] و[[−]].
- {{ui:camera.orthographic}} ينتقل إلى عرض مسطح (إسقاط متعامد) تبقى فيه الحواف المتوازية متوازية. للقياس ومحاذاة الحواف يكون ذلك غالبًا أكثر راحة من العرض المنظوري. [[O]] ينتقل ذهابًا وإيابًا، ونقرة ثانية تعيد العرض المنظوري.
+- {{ui:camera.secondView}} يفتح عرضًا ثانيًا للتصميم في نافذة خاصة به، موصوفة أدناه. نقرة ثانية تغلقها.
- {{ui:camera.hideWorkplane}} (العين فوق الشبكة) يخفي لوحة القاعدة بشبكتها، حتى تتمكن من النظر إلى الجانب السفلي للتصميم من الأسفل. العرض فقط هو ما يتغير: الأشكال الجديدة تظل تهبط على سطح العمل. نقرة ثانية تظهره مجددًا.
- {{ui:camera.placeWorkplane}}، شريط القياس وزاوية النجار أدوات قائمة بذاتها، مشروحة أدناه وفي [القياس والملاحظات](chapter:measuring-and-notes).
@@ -48,6 +49,10 @@ summary: التنقل حول المشهد، النظر مباشرة، ووضع
يمكنك أيضًا الاحتفاظ بأربعة عروض خاصة بك في كل تصميم. يحفظ [[Shift]]+[[7]] أو [[Shift]]+[[8]] أو [[Shift]]+[[9]] أو [[Shift]]+[[0]] العرض كما هو تمامًا - الزاوية والموضع والتكبير، ومسطحًا أو منظوريًا - تحت ذلك المفتاح، ويعيده المفتاح وحده ([[7]] أو [[8]] أو [[9]] أو [[0]]). تؤكد رسالة في الأسفل الحفظ؛ والحفظ مرة أخرى تحت المفتاح نفسه يستبدل العرض. ومثل العرض أعلاه، تبقى العروض المحفوظة مع التصميم في هذا المتصفح وتنتقل في الملف المحفوظ (.lyl)، وهي ليست جزءًا من سجل التراجع.
+### عرض ثانٍ في نافذة خاصة به
+
+{{ui:camera.secondView}} يفتح نافذة تعرض التصميم نفسه عبر كاميرا خاصة بها، فتراه من جهتين في آنٍ واحد - من الأمام مثلًا بينما تعمل من الأعلى. كل تغيير تجريه في المحرر يظهر هناك فورًا؛ أما التصميم نفسه فلا يتغير إلا في النافذة الرئيسية، وتبقى المقابض هناك أيضًا. في النافذة الثانية يدير الفأرة ومكعب العرض عرضها كما هنا، وكذلك المفاتيح: [[1]] إلى [[6]] و[[F]] و[[Home]] و[[O]] و[[+]] و[[−]]، والأسهم للتدوير و[[Ctrl]] مع الأسهم للإزاحة. ويقرّب [[F]] هناك إلى ما هو محدد في المحرر. وتعمل هناك أيضًا العروض الأربعة المحفوظة للتصميم: [[7]] و[[8]] و[[9]] و[[0]] تعيدها، و[[Shift]] مع أحد هذه المفاتيح يحفظ عرض تلك النافذة تحته - للنافذتين معًا. ولشريط الكاميرا الخاص بها {{ui:camera.home}} و{{ui:camera.focusSelection}} و{{ui:camera.zoomIn}} و{{ui:camera.zoomOut}} و{{ui:camera.orthographic}} و{{ui:camera.hideWorkplane}}، وهي تعمل على تلك النافذة وحدها. تُفتح النافذة حيث تركتها وبالحجم نفسه، وتُغلق مع المحرر. لا يظهر الزر على الهواتف والأجهزة اللوحية.
+
## النظر إلى الداخل: عرض المقطع (Looking inside: the section view)
{{ui:camera.sectionView}}، الزر الأخير في الشريط (أداة القطع)، يقطع العرض على طول مستوى حتى تتمكن من التحقق من الجدران والتجاويف والأجزاء التي تتداخل في بعضها البعض. لا يتم قطع أي شيء بالفعل: التصميم وملفاته وكل عملية تصدير تظل كاملة. حيث يمر المستوى عبر جسم مصمت يمتلئ القطع بلون الجسم أغمق قليلًا، ويبقى أي ثقب بداخله فارغًا، فتفرّق بين المصمت والأجوف؛ ويظهر السطح المملوء بعد لحظة من توقف المستوى.
diff --git a/docs/guide/cs/02-view-and-workplane.md b/docs/guide/cs/02-view-and-workplane.md
index 449711d5..2a7c8e8b 100644
--- a/docs/guide/cs/02-view-and-workplane.md
+++ b/docs/guide/cs/02-view-and-workplane.md
@@ -37,6 +37,7 @@ U levého okraje je úzká lišta. Shora obsahuje:
- {{ui:camera.focusSelection}} přiblíží výběr, [[F]] nebo [[Shift]]+[[F]].
- {{ui:camera.zoomIn}} a {{ui:camera.zoomOut}} mění přiblížení po krocích, stejně jako [[+]] a [[−]].
- {{ui:camera.orthographic}} zapne rovnoběžné promítání bez perspektivy. Rovnoběžné hrany zůstávají rovnoběžné, což usnadňuje měření a zarovnávání. Přepíná je i [[O]]; dalším kliknutím se vrátíte.
+- {{ui:camera.secondView}} otevře druhý pohled na projekt ve vlastním okně, popsaném níže. Druhé kliknutí ho zavře.
- {{ui:camera.hideWorkplane}}, oko nad mřížkou, skryje podložku, aby bylo vidět spodní strany modelu. Mění pouze zobrazení; nové tvary stále dopadají na pracovní rovinu. Další kliknutí ji ukáže.
- {{ui:camera.placeWorkplane}}, měření vzdáleností a úhelník jsou samostatné nástroje popsané níže a v kapitole [Měření a poznámky](chapter:measuring-and-notes).
@@ -46,6 +47,10 @@ Projekt se otevírá s naposledy použitým úhlem, přiblížením a polohou po
V každém projektu si také můžete uložit čtyři vlastní pohledy. [[Shift]]+[[7]], [[Shift]]+[[8]], [[Shift]]+[[9]] nebo [[Shift]]+[[0]] uloží pohled přesně tak, jak je - úhel, polohu, přiblížení a kolmé či perspektivní zobrazení - pod tuto klávesu, a samotná klávesa ([[7]], [[8]], [[9]] nebo [[0]]) ho vrátí. Uložení potvrdí zpráva dole; nové uložení pod stejnou klávesu pohled nahradí. Stejně jako pohled výše zůstávají uložené pohledy s projektem v tomto prohlížeči a přenášejí se v uloženém souboru `.lyl`. Nejsou součástí historie kroků zpět.
+### Druhý pohled ve vlastním okně
+
+{{ui:camera.secondView}} otevře okno, které ukazuje tentýž projekt vlastní kamerou, takže ho vidíte ze dvou stran naráz - třeba zepředu, zatímco pracujete shora. Každá změna v editoru se tam hned projeví; projekt se mění jen v hlavním okně a úchyty zůstávají také tam. Ve druhém okně myš a pohledová kostka otáčejí jeho pohledem stejně jako zde, a stejně tak klávesy: [[1]] až [[6]], [[F]], [[Home]], [[O]], [[+]] a [[−]], šipky pro otáčení a [[Ctrl]] se šipkami pro posun. [[F]] tam přiblíží to, co je vybrané v editoru. Fungují tam i čtyři uložené pohledy projektu: [[7]], [[8]], [[9]] a [[0]] je vrátí a [[Shift]] s jednou z těchto kláves pod ni uloží pohled tohoto okna - pro obě okna. Jeho vlastní lišta kamery má {{ui:camera.home}}, {{ui:camera.focusSelection}}, {{ui:camera.zoomIn}}, {{ui:camera.zoomOut}}, {{ui:camera.orthographic}} a {{ui:camera.hideWorkplane}}, které působí jen na toto okno. Okno se otevře tam, kde jste ho nechali, ve stejné velikosti, a zavře se spolu s editorem. Na telefonech a tabletech se tlačítko nezobrazuje.
+
## Pohled dovnitř: řez
{{ui:camera.sectionView}}, poslední tlačítko lišty, zobrazí model proříznutý rovinou. Můžete kontrolovat stěny, dutiny i vzájemné uložení dílů. Skutečnou geometrii nemění: projekt, soubory i exporty zůstávají celé. Tam, kde rovina prochází tělesem, je řez vyplněn o něco tmavší barvou tělesa a otvor v něm zůstane prázdný, takže poznáš plné od dutého; vyplněná plocha se objeví chvíli poté, co se rovina zastaví.
diff --git a/docs/guide/da/02-view-and-workplane.md b/docs/guide/da/02-view-and-workplane.md
index c758878e..c648da4f 100644
--- a/docs/guide/da/02-view-and-workplane.md
+++ b/docs/guide/da/02-view-and-workplane.md
@@ -33,6 +33,7 @@ Ved venstre kant er en smal bjælke. Fra toppen:
- {{ui:camera.focusSelection}} zoomer til markeringen ([[F]] eller [[Shift]]+[[F]]).
- {{ui:camera.zoomIn}} og {{ui:camera.zoomOut}} zoomer skridt for skridt, ligesom tasterne [[+]] og [[−]].
- {{ui:camera.orthographic}} skifter til en flad visning hvor parallelle kanter forbliver parallelle. Til måling og justering af kanter er det ofte mere behageligt end perspektivvisningen. [[O]] skifter frem og tilbage, og endnu et klik vender tilbage.
+- {{ui:camera.secondView}} åbner en anden visning af designet i sit eget vindue, beskrevet nedenfor. Et klik mere lukker det.
- {{ui:camera.hideWorkplane}} (øjet over et gitter) skjuler pladen med sit gitter, så du kan se undersiden af et design nedefra. Kun visningen ændres: nye former lander stadig på arbejdsplanet. Endnu et klik viser den igen.
- {{ui:camera.placeWorkplane}}, målebåndet og vinkelmåleren er deres egne værktøjer, dækket nedenfor og i [Måling og noter](chapter:measuring-and-notes).
@@ -42,6 +43,10 @@ Et design åbner som du forlod det: visningens vinkel, zoom og position gemmes m
Du kan også gemme fire af dine egne visninger i hvert design. [[Shift]]+[[7]], [[Shift]]+[[8]], [[Shift]]+[[9]] eller [[Shift]]+[[0]] gemmer visningen præcis som den er - vinkel, position, zoom, og flad eller perspektiv - under den tast, og tasten alene ([[7]], [[8]], [[9]] eller [[0]]) henter den tilbage. En besked forneden bekræfter at den er gemt; gemmer du igen under samme tast, erstattes visningen. Ligesom visningen ovenfor bliver de gemte visninger hos designet i denne browser og følger med i en gemt fil (.lyl); de er ikke del af fortryd-historikken.
+### En anden visning i sit eget vindue
+
+{{ui:camera.secondView}} åbner et vindue, der viser det samme design gennem sit eget kamera, så du kan se det fra to sider på én gang - forfra, for eksempel, mens du arbejder ovenfra. Hver ændring, du laver i editoren, vises der med det samme; selve designet ændres kun i hovedvinduet, og håndtagene bliver også der. I det andet vindue drejer musen og visningsterningen dets visning som her, og det gør tasterne også: [[1]] til [[6]], [[F]], [[Home]], [[O]], [[+]] og [[−]], piletasterne til at dreje og [[Ctrl]] med pilene til at skubbe. [[F]] zoomer der ind på det, der er valgt i editoren. Designets fire gemte visninger virker der også: [[7]], [[8]], [[9]] og [[0]] henter dem tilbage, og [[Shift]] med en af disse taster gemmer vinduets visning under den - for begge vinduer. Dets egen kamerabjælke har {{ui:camera.home}}, {{ui:camera.focusSelection}}, {{ui:camera.zoomIn}}, {{ui:camera.zoomOut}}, {{ui:camera.orthographic}} og {{ui:camera.hideWorkplane}}, og de virker kun på det vindue. Vinduet åbner, hvor du forlod det, i samme størrelse, og lukker sammen med editoren. På telefoner og tablets vises knappen ikke.
+
## Kigge indeni: snitvisningen
{{ui:camera.sectionView}}, den sidste knap i bjælken (skæreværktøjet), skærer visningen op langs et plan så du kan tjekke vægge, hulrum og dele der passer ind i hinanden. Intet skæres for alvor: designet, dets filer og hver eksport forbliver hele. Hvor planet går gennem et legeme, er snittet fyldt med legemets farve, lidt mørkere, og et hul i det forbliver tomt, så du kan se fast fra hult; den fyldte flade dukker op et øjeblik efter, at planet står stille.
diff --git a/docs/guide/de/02-ansicht-und-arbeitsebene.md b/docs/guide/de/02-ansicht-und-arbeitsebene.md
index 7244a19a..116452de 100644
--- a/docs/guide/de/02-ansicht-und-arbeitsebene.md
+++ b/docs/guide/de/02-ansicht-und-arbeitsebene.md
@@ -39,6 +39,7 @@ Am linken Rand liegt eine schmale Leiste. Von oben nach unten:
- {{ui:camera.focusSelection}} zoomt auf die Auswahl ([[F]] oder [[Umschalt]]+[[F]]).
- {{ui:camera.zoomIn}} und {{ui:camera.zoomOut}} zoomen schrittweise, ebenso die Tasten [[+]] und [[−]].
- {{ui:camera.orthographic}} schaltet auf eine flache Ansicht um, in der parallele Kanten parallel bleiben. Das ist zum Messen und zum Ausrichten von Kanten oft angenehmer als die perspektivische Ansicht. Mit [[O]] wechselst du hin und her, ein zweiter Klick geht zurück.
+- {{ui:camera.secondView}} öffnet eine zweite Ansicht des Entwurfs in einem eigenen Fenster, siehe unten. Ein zweiter Klick schließt es.
- {{ui:camera.hideWorkplane}} (das Auge über dem Gitter) blendet die Platte mit ihrem Gitter aus, damit du die Unterseite eines Entwurfs von unten ansehen kannst. Es ändert sich nur die Ansicht: Neue Formen landen weiter auf der Arbeitsebene. Ein zweiter Klick blendet sie wieder ein.
- {{ui:camera.placeWorkplane}}, das Maßband und das Winkellineal sind eigene Werkzeuge, sie kommen weiter unten und im Kapitel [Messen und Notizen](chapter:messen-und-notizen) vor.
@@ -48,6 +49,10 @@ Ein Entwurf öffnet sich so, wie du ihn verlassen hast: Blickwinkel, Zoom und Au
Du kannst in jedem Entwurf außerdem vier eigene Ansichten festhalten. [[Umschalt]]+[[7]], [[Umschalt]]+[[8]], [[Umschalt]]+[[9]] oder [[Umschalt]]+[[0]] speichert die Ansicht genau so, wie sie ist - Blickwinkel, Ausschnitt, Zoom und flach oder perspektivisch - unter dieser Taste, und die Taste allein ([[7]], [[8]], [[9]] oder [[0]]) holt sie zurück. Eine Meldung unten bestätigt das Speichern; speicherst du erneut unter derselben Taste, ersetzt das die Ansicht. Wie die Ansicht oben bleiben die gespeicherten Ansichten mit dem Entwurf in diesem Browser und reisen in einer gespeicherten Datei (.lyl) mit; zum Rückgängig-Verlauf gehören sie nicht.
+### Eine zweite Ansicht im eigenen Fenster
+
+{{ui:camera.secondView}} öffnet ein Fenster, das denselben Entwurf durch eine eigene Kamera zeigt. So siehst du ihn von zwei Seiten zugleich - etwa von vorn, während du von oben arbeitest. Jede Änderung im Editor erscheint dort sofort; geändert wird der Entwurf nur im Hauptfenster, und auch die Griffe bleiben dort. Im zweiten Fenster drehen die Maus und der Ansichtswürfel dessen Ansicht wie hier, ebenso die Tasten: [[1]] bis [[6]], [[F]], [[Pos1]], [[O]], [[+]] und [[−]], die Pfeiltasten zum Drehen und [[Strg]] mit den Pfeilen zum Verschieben. [[F]] zoomt dort auf das, was im Editor ausgewählt ist. Auch die vier gespeicherten Ansichten des Entwurfs gelten dort: [[7]], [[8]], [[9]] und [[0]] holen sie zurück, und [[Umschalt]] mit einer dieser Tasten speichert die Ansicht dieses Fensters darunter - für beide Fenster. Seine eigene Kameraleiste hat {{ui:camera.home}}, {{ui:camera.focusSelection}}, {{ui:camera.zoomIn}}, {{ui:camera.zoomOut}}, {{ui:camera.orthographic}} und {{ui:camera.hideWorkplane}}, und sie wirken nur auf dieses Fenster. Das Fenster öffnet sich dort, wo du es verlassen hast, in derselben Größe, und schließt sich mit dem Editor. Auf Handys und Tablets wird der Knopf nicht angezeigt.
+
## Ins Innere schauen: die Schnittansicht
{{ui:camera.sectionView}}, der letzte Knopf der Leiste (das Schnittwerkzeug), schneidet die Ansicht entlang einer Ebene auf. So prüfst du Wände, Hohlräume und Teile, die ineinandergreifen. Geschnitten wird nur die Ansicht: Entwurf, Dateien und jeder Export bleiben vollständig. Wo die Ebene durch einen Körper geht, ist der Schnitt in der etwas dunkleren Farbe des Körpers gefüllt, und eine Aussparung darin bleibt leer, so erkennst du Massives und Hohles; die gefüllte Fläche erscheint einen Moment, nachdem die Ebene stillsteht.
diff --git a/docs/guide/en/02-view-and-workplane.md b/docs/guide/en/02-view-and-workplane.md
index fdcad90e..28863bf1 100644
--- a/docs/guide/en/02-view-and-workplane.md
+++ b/docs/guide/en/02-view-and-workplane.md
@@ -39,6 +39,7 @@ At the left edge is a narrow bar. From the top:
- {{ui:camera.focusSelection}} zooms to the selection ([[F]] or [[Shift]]+[[F]]).
- {{ui:camera.zoomIn}} and {{ui:camera.zoomOut}} zoom step by step, as do the keys [[+]] and [[−]].
- {{ui:camera.orthographic}} switches to a flat view in which parallel edges stay parallel. For measuring and lining up edges that is often more comfortable than the perspective view. [[O]] switches back and forth, and a second click returns.
+- {{ui:camera.secondView}} opens a second view of the design in a window of its own, described below. A second click closes it.
- {{ui:camera.hideWorkplane}} (the eye over a grid) hides the plate with its grid, so you can look at the underside of a design from below. Only the view changes: new shapes still land on the workplane. A second click shows it again.
- {{ui:camera.placeWorkplane}}, the tape measure and the framing square are tools of their own, covered below and in [Measuring and notes](chapter:measuring-and-notes).
@@ -48,6 +49,10 @@ A design opens the way you left it: the angle, zoom and position of the view are
You can also keep four views of your own in each design. [[Shift]]+[[7]], [[Shift]]+[[8]], [[Shift]]+[[9]] or [[Shift]]+[[0]] saves the view exactly as it is - angle, position, zoom, and flat or perspective - under that key, and the key alone ([[7]], [[8]], [[9]] or [[0]]) brings it back. A message at the bottom confirms the save; saving again under the same key replaces the view. Like the view above, the saved views stay with the design in this browser and travel in a saved file (.lyl), and they are not part of the undo history.
+### A second view in its own window
+
+{{ui:camera.secondView}} opens a window that shows the same design through a camera of its own, so you can see it from two sides at once - from the front, say, while you work from above. Every change you make in the editor shows there straight away; the design is only changed in the main window, and the handles stay there too. In the second window the mouse and the view cube turn its view as they do here, and so do the keys: [[1]] to [[6]], [[F]], [[Home]], [[O]], [[+]] and [[−]], the arrow keys to turn and [[Ctrl]] with the arrows to slide. [[F]] there zooms to what is selected in the editor. The design's four saved views work there too: [[7]], [[8]], [[9]] and [[0]] bring them back, and [[Shift]] with one of these keys saves that window's view under it - for both windows. Its own camera bar has {{ui:camera.home}}, {{ui:camera.focusSelection}}, {{ui:camera.zoomIn}}, {{ui:camera.zoomOut}}, {{ui:camera.orthographic}} and {{ui:camera.hideWorkplane}}, and they act on that window alone. The window opens where you left it, at the same size, and closes with the editor. On phones and tablets the button is not shown.
+
## Looking inside: the section view
{{ui:camera.sectionView}}, the last button in the bar (the cut tool), cuts the view open along a plane so you can check walls, cavities and parts that fit into each other. Nothing is cut for real: the design, its files and every export stay whole. Where the plane goes through a solid, the cut is filled with the colour of the body a little darker, and a hole inside it stays empty, so you can tell solid from hollow; the filled face appears a moment after the plane stops moving.
diff --git a/docs/guide/es/02-view-and-workplane.md b/docs/guide/es/02-view-and-workplane.md
index 882d6503..47892761 100644
--- a/docs/guide/es/02-view-and-workplane.md
+++ b/docs/guide/es/02-view-and-workplane.md
@@ -39,6 +39,7 @@ En el borde izquierdo hay una barra estrecha. De arriba abajo:
- {{ui:camera.focusSelection}} hace zoom a la selección ([[F]] o [[Mayús]]+[[F]]).
- {{ui:camera.zoomIn}} y {{ui:camera.zoomOut}} hacen zoom paso a paso, igual que las teclas [[+]] y [[−]].
- {{ui:camera.orthographic}} cambia a una vista plana en la que las aristas paralelas siguen siendo paralelas. Para medir y alinear aristas suele ser más cómoda que la vista en perspectiva. [[O]] alterna entre las dos, y un segundo clic vuelve a la de antes.
+- {{ui:camera.secondView}} abre una segunda vista del diseño en una ventana propia, descrita más abajo. Un segundo clic la cierra.
- {{ui:camera.hideWorkplane}} (el ojo sobre una cuadrícula) oculta la placa con su cuadrícula, para que puedas mirar desde abajo la parte inferior de un diseño. Solo cambia la vista: las formas nuevas siguen apareciendo en el plano de trabajo. Un segundo clic vuelve a mostrarla.
- {{ui:camera.placeWorkplane}}, la cinta métrica y la escuadra son herramientas aparte; se explican más abajo y en [Medidas y notas](chapter:measuring-and-notes).
@@ -48,6 +49,10 @@ Un diseño se abre tal como lo dejaste: el ángulo, el zoom y la posición de la
Además, en cada diseño puedes guardar cuatro vistas propias. [[Mayús]]+[[7]], [[Mayús]]+[[8]], [[Mayús]]+[[9]] o [[Mayús]]+[[0]] guarda en esa tecla la vista exactamente como está —ángulo, posición, zoom y si es plana o en perspectiva—, y la tecla sola ([[7]], [[8]], [[9]] o [[0]]) la recupera. Un mensaje abajo confirma que se ha guardado; si vuelves a guardar en la misma tecla, se sustituye la vista. Igual que la vista de antes, las vistas guardadas se quedan con el diseño en este navegador y viajan en un archivo guardado (.lyl), y no forman parte del historial de deshacer.
+### Una segunda vista en su propia ventana
+
+{{ui:camera.secondView}} abre una ventana que muestra el mismo diseño a través de una cámara propia, para que puedas verlo desde dos lados a la vez; por ejemplo, de frente mientras trabajas desde arriba. Cada cambio que haces en el editor aparece allí al instante; el diseño solo se cambia en la ventana principal, y los tiradores también se quedan allí. En la segunda ventana, el ratón y el cubo de vista giran su vista como aquí, y también las teclas: [[1]] a [[6]], [[F]], [[Inicio]], [[O]], [[+]] y [[−]], las flechas para girar y [[Ctrl]] con las flechas para desplazar. [[F]] allí hace zoom sobre lo que está seleccionado en el editor. Las cuatro vistas guardadas del diseño también funcionan allí: [[7]], [[8]], [[9]] y [[0]] las recuperan, y [[Mayús]] con una de esas teclas guarda en ella la vista de esa ventana, para las dos ventanas. Su propia barra de cámara tiene {{ui:camera.home}}, {{ui:camera.focusSelection}}, {{ui:camera.zoomIn}}, {{ui:camera.zoomOut}}, {{ui:camera.orthographic}} y {{ui:camera.hideWorkplane}}, que actúan solo sobre esa ventana. La ventana se abre donde la dejaste, con el mismo tamaño, y se cierra con el editor. En teléfonos y tabletas el botón no aparece.
+
## Mirar por dentro: la vista de sección
{{ui:camera.sectionView}}, el último botón de la barra (la herramienta de corte), abre la vista con un corte a lo largo de un plano para que puedas comprobar paredes, cavidades y piezas que encajan unas en otras. No se corta nada de verdad: el diseño, sus archivos y cualquier exportación quedan enteros. Donde el plano atraviesa un sólido, el corte se rellena con el color del cuerpo algo más oscuro, y un orificio dentro de él queda vacío, así que distingues lo macizo de lo hueco; la cara rellena aparece un momento después de que el plano deja de moverse.
diff --git a/docs/guide/fr/02-view-and-workplane.md b/docs/guide/fr/02-view-and-workplane.md
index 849e43bd..cb733540 100644
--- a/docs/guide/fr/02-view-and-workplane.md
+++ b/docs/guide/fr/02-view-and-workplane.md
@@ -35,6 +35,7 @@ Sur le bord gauche se trouve une barre étroite. Du haut :
- {{ui:camera.focusSelection}} effectue un zoom sur la sélection ([[F]] ou [[Shift]]+[[F]]).
- {{ui:camera.zoomIn}} et {{ui:camera.zoomOut}} zooment pas à pas, tout comme les touches [[+]] et [[−]].
- {{ui:camera.orthographic}} passe à une vue plate dans laquelle les bords parallèles restent parallèles. Pour mesurer et aligner les bords, c'est souvent plus confortable que la vue en perspective. [[O]] bascule d'avant en arrière et un deuxième clic revient.
+- {{ui:camera.secondView}} ouvre une deuxième vue du design dans une fenêtre à part, décrite plus bas. Un second clic la ferme.
- {{ui:camera.hideWorkplane}} (l'œil au-dessus d'une grille) cache la plaque avec sa grille, vous pouvez donc regarder le dessous d'un dessin par le bas. Seule la vue change : de nouvelles formes atterrissent toujours sur le plan de travail. Un deuxième clic l'affiche à nouveau.
- {{ui:camera.placeWorkplane}}, le mètre ruban et l'équerre d'encadrement sont des outils à part entière, abordés ci-dessous et dans [Mesures et notes] (chapter:measuring-and-notes).
@@ -44,6 +45,10 @@ Un design s'ouvre comme vous l'avez laissé : l'angle, le zoom et la position de
Vous pouvez aussi garder quatre vues à vous dans chaque design. [[Shift]]+[[7]], [[Shift]]+[[8]], [[Shift]]+[[9]] ou [[Shift]]+[[0]] enregistre la vue exactement telle qu'elle est - angle, position, zoom, et vue plate ou en perspective - sous cette touche, et la touche seule ([[7]], [[8]], [[9]] ou [[0]]) la ramène. Un message en bas confirme l'enregistrement ; enregistrer à nouveau sous la même touche remplace la vue. Comme la vue ci-dessus, les vues enregistrées restent avec le design dans ce navigateur et voyagent dans un fichier enregistré (.lyl) ; elles ne font pas partie de l'historique d'annulation.
+### Une deuxième vue dans sa propre fenêtre
+
+{{ui:camera.secondView}} ouvre une fenêtre qui montre le même design à travers sa propre caméra, pour le voir de deux côtés à la fois - de face, par exemple, pendant que vous travaillez par le dessus. Chaque modification faite dans l'éditeur y apparaît aussitôt ; le design ne se modifie que dans la fenêtre principale, et les poignées y restent aussi. Dans la deuxième fenêtre, la souris et le cube de vue tournent sa vue comme ici, de même que les touches : [[1]] à [[6]], [[F]], [[Home]], [[O]], [[+]] et [[−]], les flèches pour tourner et [[Ctrl]] avec les flèches pour faire glisser. [[F]] y cadre ce qui est sélectionné dans l'éditeur. Les quatre vues enregistrées du design y fonctionnent aussi : [[7]], [[8]], [[9]] et [[0]] les ramènent, et [[Shift]] avec l'une de ces touches y enregistre la vue de cette fenêtre - pour les deux fenêtres. Sa propre barre de caméra a {{ui:camera.home}}, {{ui:camera.focusSelection}}, {{ui:camera.zoomIn}}, {{ui:camera.zoomOut}}, {{ui:camera.orthographic}} et {{ui:camera.hideWorkplane}}, qui n'agissent que sur cette fenêtre. La fenêtre s'ouvre là où vous l'avez laissée, à la même taille, et se ferme avec l'éditeur. Sur les téléphones et les tablettes, le bouton n'apparaît pas.
+
## Regarder à l'intérieur : la vue en coupe
{{ui:camera.sectionView}}, le dernier bouton de la barre (l'outil de coupe), coupe la vue ouverte le long d'un plan afin que vous puissiez vérifier les murs, les cavités et les pièces qui s'emboîtent les unes dans les autres. Rien n'est coupé pour vrai : le design, ses fichiers et chaque export restent entiers. Là où le plan traverse un corps plein, la coupe est remplie de la couleur du corps, un peu plus foncée, et un évidement à l'intérieur reste vide, de sorte qu'on distingue le plein du creux ; la face remplie apparaît un instant après que le plan s'est arrêté.
diff --git a/docs/guide/hu/02-view-and-workplane.md b/docs/guide/hu/02-view-and-workplane.md
index 8263c712..4fcfb8aa 100644
--- a/docs/guide/hu/02-view-and-workplane.md
+++ b/docs/guide/hu/02-view-and-workplane.md
@@ -37,6 +37,7 @@ A bal szélen egy keskeny sáv van. Felülről lefelé:
- A {{ui:camera.focusSelection}} a kijelölésre nagyít ([[F]] vagy [[Shift]]+[[F]]).
- A {{ui:camera.zoomIn}} és a {{ui:camera.zoomOut}} lépésenként nagyít és kicsinyít, ahogy a [[+]] és a [[−]] billentyű is.
- Az {{ui:camera.orthographic}} lapos nézetre vált, amelyben a párhuzamos élek párhuzamosak maradnak. Méréshez és élek egymáshoz igazításához ez gyakran kényelmesebb, mint a perspektivikus nézet. Az [[O]] oda-vissza vált, és egy második kattintás visszavált.
+- A {{ui:camera.secondView}} a terv második nézetét nyitja meg egy saját ablakban, lásd lent. Egy második kattintás bezárja.
- A {{ui:camera.hideWorkplane}} (a rács fölötti szem) elrejti a tárgyasztalt a rácsával együtt, így alulról is megnézheted egy terv alját. Csak a nézet változik: az új alakzatok továbbra is a munkasíkra kerülnek. Egy második kattintás újra megmutatja.
- A {{ui:camera.placeWorkplane}}, a mérőszalag és a derékszögvonalzó önálló eszközök, ezekről lentebb és a [Mérés és jegyzetek](chapter:measuring-and-notes) fejezetben olvashatsz.
@@ -46,6 +47,10 @@ Egy terv úgy nyílik meg, ahogy hagytad: a nézet szöge, nagyítása és helyz
Minden tervben négy saját nézetet is megőrizhetsz. A [[Shift]]+[[7]], [[Shift]]+[[8]], [[Shift]]+[[9]] vagy [[Shift]]+[[0]] pontosan úgy menti a nézetet, ahogy van - szög, helyzet, nagyítás, valamint sík vagy perspektivikus nézet - az adott billentyűre, a billentyű magában ([[7]], [[8]], [[9]] vagy [[0]]) pedig visszahozza. Egy üzenet alul megerősíti a mentést; ha ugyanarra a billentyűre újra mentesz, az lecseréli a nézetet. A fenti nézethez hasonlóan a mentett nézetek is a tervvel maradnak ebben a böngészőben, és egy mentett fájl (.lyl) is magával viszi őket; nem részei a visszavonási előzményeknek.
+### Második nézet saját ablakban
+
+A {{ui:camera.secondView}} egy ablakot nyit, amely ugyanazt a tervet egy saját kamerán át mutatja, így egyszerre két oldalról láthatod - például elölről, miközben felülről dolgozol. Minden változtatás, amit a szerkesztőben végzel, ott azonnal megjelenik; magát a tervet csak a főablakban lehet módosítani, és a fogantyúk is ott maradnak. A második ablakban az egér és a nézetkocka ugyanúgy forgatja a nézetét, mint itt, és a billentyűk is: [[1]]–[[6]], [[F]], [[Home]], [[O]], [[+]] és [[−]], a nyilak a forgatáshoz, a [[Ctrl]] a nyilakkal az eltoláshoz. Az [[F]] ott arra nagyít, ami a szerkesztőben ki van jelölve. A terv négy mentett nézete is működik ott: a [[7]], [[8]], [[9]] és [[0]] visszahozza őket, a [[Shift]] pedig ezek egyikével az ablak nézetét menti el alá - mindkét ablak számára. Saját kamerasávjában ott van a {{ui:camera.home}}, a {{ui:camera.focusSelection}}, a {{ui:camera.zoomIn}}, a {{ui:camera.zoomOut}}, az {{ui:camera.orthographic}} és a {{ui:camera.hideWorkplane}}, és ezek csak arra az ablakra hatnak. Az ablak ott nyílik meg, ahol hagytad, ugyanakkora méretben, és a szerkesztővel együtt bezárul. Telefonon és táblagépen a gomb nem jelenik meg.
+
## Belelátni: a metszeti nézet
A {{ui:camera.sectionView}}, a sáv utolsó gombja (a vágás eszköz), egy sík mentén felnyitja a nézetet, hogy ellenőrizhesd a falakat, az üregeket és az egymásba illeszkedő részeket. Valójában semmi sem vágódik el: a terv, a fájljai és minden export egészben marad. Ahol a sík egy testen megy át, a metszet a test kissé sötétebb színével van kitöltve, a benne lévő kivágás pedig üres marad, így megkülönböztethető a tömör a üregestől; a kitöltött felület egy pillanattal azután jelenik meg, hogy a sík megáll.
diff --git a/docs/guide/nl/02-weergave-en-werkvlak.md b/docs/guide/nl/02-weergave-en-werkvlak.md
index 203257e1..9b44d122 100644
--- a/docs/guide/nl/02-weergave-en-werkvlak.md
+++ b/docs/guide/nl/02-weergave-en-werkvlak.md
@@ -37,6 +37,7 @@ Aan de linkerrand staat een smalle balk. Van boven naar beneden:
- {{ui:camera.focusSelection}} zoomt in op de selectie ([[F]] of [[Shift]]+[[F]]).
- {{ui:camera.zoomIn}} en {{ui:camera.zoomOut}} zoomen stap voor stap, net als de toetsen [[+]] en [[−]].
- {{ui:camera.orthographic}} schakelt naar een platte weergave waarin evenwijdige randen evenwijdig blijven. Voor meten en randen uitlijnen is dat vaak prettiger dan de perspectiefweergave. [[O]] schakelt heen en weer, en een tweede klik zet het terug.
+- {{ui:camera.secondView}} opent een tweede weergave van het ontwerp in een eigen venster, zie hieronder. Een tweede klik sluit het.
- {{ui:camera.hideWorkplane}} (het oog boven een raster) verbergt de plaat met het raster, zodat je een ontwerp van onderen kunt bekijken. Alleen de weergave verandert: nieuwe vormen komen nog steeds op het werkvlak terecht. Een tweede klik toont de plaat weer.
- {{ui:camera.placeWorkplane}}, het meetlint en de winkelhaak zijn gereedschappen op zich, die hieronder en in [Meten en notities](chapter:meten-en-notities) aan bod komen.
@@ -46,6 +47,10 @@ Een ontwerp opent zoals je het hebt achtergelaten: de hoek, de zoom en de positi
Je kunt in elk ontwerp ook vier eigen weergaven bewaren. [[Shift]]+[[7]], [[Shift]]+[[8]], [[Shift]]+[[9]] of [[Shift]]+[[0]] slaat de weergave precies op zoals ze is - hoek, positie, zoom, en vlak of in perspectief - onder die toets, en de toets alleen ([[7]], [[8]], [[9]] of [[0]]) brengt haar terug. Een melding onderaan bevestigt het opslaan; opnieuw opslaan onder dezelfde toets vervangt de weergave. Net als de weergave hierboven blijven de opgeslagen weergaven in deze browser bij het ontwerp en gaan ze mee in een opgeslagen bestand (.lyl); ze horen niet bij de geschiedenis van ongedaan maken.
+### Een tweede weergave in een eigen venster
+
+{{ui:camera.secondView}} opent een venster dat hetzelfde ontwerp door een eigen camera toont, zodat je het van twee kanten tegelijk ziet - bijvoorbeeld van voren terwijl je van boven werkt. Elke wijziging in de editor verschijnt daar meteen; het ontwerp wordt alleen in het hoofdvenster gewijzigd, en ook de grepen blijven daar. In het tweede venster draaien de muis en de weergavekubus de weergave zoals hier, en ook de toetsen: [[1]] tot [[6]], [[F]], [[Home]], [[O]], [[+]] en [[−]], de pijltjestoetsen om te draaien en [[Ctrl]] met de pijltjes om te schuiven. [[F]] zoomt daar in op wat in de editor geselecteerd is. Ook de vier opgeslagen weergaven van het ontwerp werken daar: [[7]], [[8]], [[9]] en [[0]] halen ze terug, en [[Shift]] met een van die toetsen slaat de weergave van dat venster eronder op - voor beide vensters. De eigen camerabalk heeft {{ui:camera.home}}, {{ui:camera.focusSelection}}, {{ui:camera.zoomIn}}, {{ui:camera.zoomOut}}, {{ui:camera.orthographic}} en {{ui:camera.hideWorkplane}}, en die werken alleen op dat venster. Het venster opent waar je het liet, even groot, en sluit samen met de editor. Op telefoons en tablets wordt de knop niet getoond.
+
## Naar binnen kijken: de doorsnede
{{ui:camera.sectionView}}, de laatste knop in de balk (het snijgereedschap), knipt de weergave open langs een vlak, zodat je wanden, holtes en in elkaar passende onderdelen kunt controleren. Er wordt niets echt doorgesneden: het ontwerp, de bestanden en elke export blijven heel. Waar het vlak door een lichaam gaat, is de snede gevuld met de iets donkerdere kleur van het lichaam, en een uitsparing erin blijft leeg, zo zie je massief en hol; het gevulde vlak verschijnt een moment nadat het vlak stilstaat.
diff --git a/docs/guide/pl/02-view-and-workplane.md b/docs/guide/pl/02-view-and-workplane.md
index 1e2e91c4..e4a04de1 100644
--- a/docs/guide/pl/02-view-and-workplane.md
+++ b/docs/guide/pl/02-view-and-workplane.md
@@ -39,6 +39,7 @@ Przy lewej krawędzi jest wąski pasek. Od góry:
- {{ui:camera.focusSelection}} przybliża do zaznaczenia ([[F]] albo [[Shift]]+[[F]]).
- {{ui:camera.zoomIn}} i {{ui:camera.zoomOut}} przybliżają krok po kroku, podobnie jak klawisze [[+]] i [[−]].
- {{ui:camera.orthographic}} przełącza na płaski widok, w którym równoległe krawędzie zostają równoległe. Do mierzenia i wyrównywania krawędzi bywa wygodniejszy od widoku perspektywicznego. [[O]] przełącza w obie strony, a drugie kliknięcie przywraca poprzedni widok.
+- {{ui:camera.secondView}} otwiera drugi widok projektu we własnym oknie, opisanym niżej. Drugie kliknięcie je zamyka.
- {{ui:camera.hideWorkplane}} (oko nad siatką) ukrywa płytę z siatką, żeby obejrzeć projekt od spodu. Zmienia się tylko widok: nowe kształty dalej trafiają na płaszczyznę roboczą. Drugie kliknięcie pokazuje ją z powrotem.
- {{ui:camera.placeWorkplane}}, narzędzie Zmierz i kątownik to osobne narzędzia, opisane niżej i w rozdziale [Pomiary i notatki](chapter:measuring-and-notes).
@@ -48,6 +49,10 @@ Projekt otwiera się tak, jak go zostawiłeś: kąt, przybliżenie i położenie
W każdym projekcie możesz też zachować cztery własne widoki. [[Shift]]+[[7]], [[Shift]]+[[8]], [[Shift]]+[[9]] lub [[Shift]]+[[0]] zapisuje widok dokładnie taki, jaki jest - kąt, położenie, przybliżenie oraz widok płaski albo perspektywiczny - pod tym klawiszem, a sam klawisz ([[7]], [[8]], [[9]] lub [[0]]) przywraca go. Komunikat na dole potwierdza zapis; ponowny zapis pod tym samym klawiszem zastępuje widok. Tak jak widok opisany wyżej, zapisane widoki zostają z projektem w tej przeglądarce i są zabierane w zapisanym pliku (.lyl). Nie należą do historii cofania.
+### Drugi widok we własnym oknie
+
+{{ui:camera.secondView}} otwiera okno, które pokazuje ten sam projekt przez własną kamerę, więc widzisz go z dwóch stron naraz - na przykład z przodu, gdy pracujesz od góry. Każda zmiana w edytorze od razu się tam pojawia; sam projekt zmienia się tylko w głównym oknie i tam też zostają uchwyty. W drugim oknie mysz i kostka widoku obracają jego widok tak jak tutaj, podobnie klawisze: [[1]] do [[6]], [[F]], [[Home]], [[O]], [[+]] i [[−]], strzałki do obracania i [[Ctrl]] ze strzałkami do przesuwania. [[F]] przybliża tam to, co jest zaznaczone w edytorze. Działają tam też cztery zapisane widoki projektu: [[7]], [[8]], [[9]] i [[0]] je przywracają, a [[Shift]] z jednym z tych klawiszy zapisuje pod nim widok tego okna - dla obu okien. Jego własny pasek kamery ma {{ui:camera.home}}, {{ui:camera.focusSelection}}, {{ui:camera.zoomIn}}, {{ui:camera.zoomOut}}, {{ui:camera.orthographic}} i {{ui:camera.hideWorkplane}}, które działają tylko na to okno. Okno otwiera się tam, gdzie je zostawiłeś, w tym samym rozmiarze, i zamyka się razem z edytorem. Na telefonach i tabletach przycisk się nie pokazuje.
+
## Zajrzyj do środka: widok przekroju
{{ui:camera.sectionView}}, ostatni przycisk na pasku (narzędzie cięcia), rozcina widok wzdłuż płaszczyzny, żeby sprawdzić ścianki, wnęki i części, które w siebie wchodzą. Nic nie jest naprawdę cięte: projekt, jego pliki i każdy eksport zostają całe. Tam, gdzie płaszczyzna przechodzi przez bryłę, przekrój jest wypełniony nieco ciemniejszym kolorem bryły, a otwór w niej zostaje pusty, dzięki czemu widać, co jest pełne, a co puste; wypełniona powierzchnia pojawia się chwilę po zatrzymaniu płaszczyzny.
diff --git a/docs/guide/ru/02-view-and-workplane.md b/docs/guide/ru/02-view-and-workplane.md
index ab6ca1c8..da901e55 100644
--- a/docs/guide/ru/02-view-and-workplane.md
+++ b/docs/guide/ru/02-view-and-workplane.md
@@ -37,6 +37,7 @@ summary: Как перемещаться по сцене, смотреть пр
- {{ui:camera.focusSelection}} приближает к выделению ([[F]] или [[Shift]]+[[F]]).
- {{ui:camera.zoomIn}} и {{ui:camera.zoomOut}} приближают и отдаляют шагами, как и клавиши [[+]] и [[−]].
- {{ui:camera.orthographic}} переключает на плоский вид, в котором параллельные кромки остаются параллельными. Для измерений и выравнивания кромок он часто удобнее перспективного. [[O]] переключает туда и обратно; повторный щелчок по кнопке тоже возвращает.
+- {{ui:camera.secondView}} открывает второй вид проекта в отдельном окне, о нём ниже. Повторный щелчок закрывает его.
- {{ui:camera.hideWorkplane}} (глаз над сеткой) скрывает стол вместе с его сеткой, чтобы посмотреть на нижнюю сторону проекта. Меняется только вид: новые фигуры всё равно встают на рабочую плоскость. Повторный щелчок показывает её снова.
- {{ui:camera.placeWorkplane}}, рулетка и угольник — отдельные инструменты; о них ниже и в разделе Измерения и заметки.
@@ -46,6 +47,10 @@ summary: Как перемещаться по сцене, смотреть пр
В каждом проекте можно также хранить четыре собственных вида. [[Shift]]+[[7]], [[Shift]]+[[8]], [[Shift]]+[[9]] или [[Shift]]+[[0]] сохраняет вид точно таким, какой он есть, — угол, положение, масштаб, плоский или перспективный, — под этой клавишей, а сама клавиша ([[7]], [[8]], [[9]] или [[0]]) возвращает его. Сообщение внизу подтверждает сохранение; повторное сохранение под той же клавишей заменяет вид. Как и вид выше, сохранённые виды остаются с проектом в этом браузере и переносятся в сохранённом файле (.lyl); в историю отмен они не входят.
+### Второй вид в отдельном окне
+
+{{ui:camera.secondView}} открывает окно, которое показывает тот же проект через собственную камеру, так что его видно с двух сторон сразу - например, спереди, пока вы работаете сверху. Каждое изменение в редакторе сразу появляется там; сам проект меняется только в главном окне, и маркеры тоже остаются там. Во втором окне мышь и куб вида поворачивают его вид так же, как здесь, и клавиши тоже: [[1]]–[[6]], [[F]], [[Home]], [[O]], [[+]] и [[−]], стрелки для поворота и [[Ctrl]] со стрелками для сдвига. [[F]] там приближает то, что выделено в редакторе. Там работают и четыре сохранённых вида проекта: [[7]], [[8]], [[9]] и [[0]] возвращают их, а [[Shift]] с одной из этих клавиш сохраняет под ней вид этого окна - для обоих окон. На его собственной панели камеры есть {{ui:camera.home}}, {{ui:camera.focusSelection}}, {{ui:camera.zoomIn}}, {{ui:camera.zoomOut}}, {{ui:camera.orthographic}} и {{ui:camera.hideWorkplane}}, и они действуют только на это окно. Окно открывается там, где вы его оставили, того же размера, и закрывается вместе с редактором. На телефонах и планшетах кнопка не показывается.
+
## Заглянуть внутрь: вид в разрезе
{{ui:camera.sectionView}}, последняя кнопка на панели (инструмент разреза), разрезает вид по плоскости, чтобы проверить стенки, полости и детали, которые входят друг в друга. На самом деле ничего не разрезается: проект, его файлы и любой экспорт остаются целыми. Там, где плоскость проходит через тело, срез заливается чуть более тёмным цветом этого тела, а отверстие внутри остаётся пустым, поэтому видно, где сплошное, а где пустое; залитая поверхность появляется спустя мгновение после остановки плоскости.
diff --git a/docs/guide/sl/02-pogled-in-delovna-ravnina.md b/docs/guide/sl/02-pogled-in-delovna-ravnina.md
index c51b0928..0faecab8 100644
--- a/docs/guide/sl/02-pogled-in-delovna-ravnina.md
+++ b/docs/guide/sl/02-pogled-in-delovna-ravnina.md
@@ -37,6 +37,7 @@ Ob levem robu je ozka vrstica. Od zgoraj navzdol:
- {{ui:camera.focusSelection}} se približa izboru ([[F]] ali [[Shift]]+[[F]]).
- {{ui:camera.zoomIn}} in {{ui:camera.zoomOut}} zumirata po korakih, prav tako tipki [[+]] in [[−]].
- {{ui:camera.orthographic}} preklopi na ploski pogled, v katerem vzporedni robovi ostanejo vzporedni. To je za merjenje in za poravnavanje robov pogosto prijetnejše kot perspektivni pogled. S [[O]] preklapljaš sem in tja, drugi klik gre nazaj.
+- {{ui:camera.secondView}} odpre drugi pogled na načrt v lastnem oknu, opisanem spodaj. Drugi klik ga zapre.
- {{ui:camera.hideWorkplane}} (oko nad mrežo) skrije ploščo z mrežo, da lahko spodnjo stran načrta pogledaš od spodaj. Spremeni se samo pogled: nove oblike še naprej pristanejo na delovni ravnini. Drugi klik jo spet prikaže.
- {{ui:camera.placeWorkplane}}, merilni trak in kotomer so lastna orodja, ki so opisana spodaj in v poglavju [Merjenje in opombe](chapter:merjenje-in-opombe).
@@ -46,6 +47,10 @@ Načrt se odpre tako, kot si ga zapustil: zorni kot, povečava in izrez pogleda
V vsakem načrtu lahko shraniš tudi štiri lastne poglede. [[Shift]]+[[7]], [[Shift]]+[[8]], [[Shift]]+[[9]] ali [[Shift]]+[[0]] shrani pogled natanko takšen, kot je - zorni kot, izrez, povečavo ter raven ali perspektivni pogled - pod to tipko, sama tipka ([[7]], [[8]], [[9]] ali [[0]]) pa ga vrne. Sporočilo spodaj potrdi shranjevanje; ponovno shranjevanje pod isto tipko pogled zamenja. Tako kot zgornji pogled ostanejo shranjeni pogledi z načrtom v tem brskalniku in gredo s seboj v shranjeni datoteki (.lyl); ne spadajo v zgodovino razveljavitev.
+### Drugi pogled v lastnem oknu
+
+{{ui:camera.secondView}} odpre okno, ki kaže isti načrt skozi lastno kamero, tako da ga vidite z dveh strani hkrati - na primer od spredaj, medtem ko delate od zgoraj. Vsaka sprememba v urejevalniku se tam takoj pokaže; načrt se spreminja le v glavnem oknu, in tudi ročaji ostanejo tam. V drugem oknu miška in kocka pogleda vrtita njegov pogled kot tukaj, prav tako tipke: [[1]] do [[6]], [[F]], [[Home]], [[O]], [[+]] in [[−]], puščice za vrtenje in [[Ctrl]] s puščicami za premik. [[F]] tam približa tisto, kar je izbrano v urejevalniku. Tam delujejo tudi štirje shranjeni pogledi načrta: [[7]], [[8]], [[9]] in [[0]] jih vrnejo, [[Shift]] z eno od teh tipk pa pod njo shrani pogled tega okna - za obe okni. Njegova lastna vrstica kamere ima {{ui:camera.home}}, {{ui:camera.focusSelection}}, {{ui:camera.zoomIn}}, {{ui:camera.zoomOut}}, {{ui:camera.orthographic}} in {{ui:camera.hideWorkplane}}, ki delujejo le na to okno. Okno se odpre tam, kjer ste ga pustili, v enaki velikosti, in se zapre skupaj z urejevalnikom. Na telefonih in tablicah gumb ni prikazan.
+
## Pogled v notranjost: prerezni pogled
{{ui:camera.sectionView}}, zadnji gumb vrstice (orodje za prerez), prereže pogled vzdolž ravnine. Tako preveriš stene, votline in dele, ki segajo drug v drugega. Prereže se samo pogled: načrt, datoteke in vsak izvoz ostanejo popolni. Kjer ravnina poteka skozi telo, je rez zapolnjen z nekoliko temnejšo barvo telesa, izdolbina v njem pa ostane prazna, tako da ločiš polno od votlega; zapolnjena ploskev se pokaže trenutek, potem ko se ravnina ustavi.
diff --git a/docs/whatsNew-unreleased.json b/docs/whatsNew-unreleased.json
index fe51488c..29ee4a0b 100644
--- a/docs/whatsNew-unreleased.json
+++ b/docs/whatsNew-unreleased.json
@@ -1 +1,40 @@
-[]
+[
+ {
+ "title": {
+ "de": "Eine zweite Ansicht im eigenen Fenster",
+ "en": "A second view in its own window",
+ "ar": "عرض ثانٍ في نافذة خاصة به",
+ "cs": "Druhý pohled ve vlastním okně",
+ "da": "En anden visning i sit eget vindue",
+ "fr": "Une deuxième vue dans sa propre fenêtre",
+ "hu": "Második nézet saját ablakban",
+ "it": "Una seconda vista nella sua finestra",
+ "ja": "専用ウィンドウで2つ目のビュー",
+ "nl": "Een tweede weergave in een eigen venster",
+ "pl": "Drugi widok we własnym oknie",
+ "pt-br": "Uma segunda vista em sua própria janela",
+ "ru": "Второй вид в отдельном окне",
+ "sl": "Drugi pogled v lastnem oknu",
+ "tr": "Kendi penceresinde ikinci bir görünüm",
+ "es": "Una segunda vista en su propia ventana"
+ },
+ "body": {
+ "de": "Der neue Knopf in der Kameraleiste öffnet ein zweites Fenster, das denselben Entwurf durch eine eigene Kamera zeigt. Es folgt jeder Änderung, hat seinen eigenen Ansichtswürfel und seine eigene Kameraleiste und öffnet sich dort, wo du es verlassen hast.",
+ "en": "The new button in the camera bar opens a second window that shows the same design through a camera of its own. It follows every change, has its own view cube and camera bar, and opens where you left it.",
+ "ar": "يفتح الزر الجديد في شريط الكاميرا نافذة ثانية تعرض التصميم نفسه عبر كاميرا خاصة بها. تتبع كل تغيير، ولها مكعب عرض وشريط كاميرا خاصان بها، وتُفتح حيث تركتها.",
+ "cs": "Nové tlačítko v liště kamery otevře druhé okno, které ukazuje tentýž projekt vlastní kamerou. Sleduje každou změnu, má vlastní pohledovou kostku i lištu kamery a otevře se tam, kde jste ho nechali.",
+ "da": "Den nye knap i kamerabjælken åbner et andet vindue, der viser det samme design gennem sit eget kamera. Det følger hver ændring, har sin egen visningsterning og kamerabjælke og åbner, hvor du forlod det.",
+ "fr": "Le nouveau bouton de la barre de caméra ouvre une deuxième fenêtre qui montre le même design à travers sa propre caméra. Elle suit chaque modification, a son propre cube de vue et sa propre barre de caméra, et s'ouvre là où vous l'avez laissée.",
+ "hu": "A kamerasáv új gombja egy második ablakot nyit, amely ugyanazt a tervet saját kamerán át mutatja. Követ minden változtatást, saját nézetkockája és kamerasávja van, és ott nyílik meg, ahol hagytad.",
+ "it": "Il nuovo pulsante nella barra della fotocamera apre una seconda finestra che mostra lo stesso progetto da una fotocamera propria. Segue ogni modifica, ha il suo cubo di vista e la sua barra della fotocamera, e si apre dove l'hai lasciata.",
+ "ja": "カメラバーの新しいボタンで、同じデザインを専用のカメラで映す2つ目のウィンドウが開きます。すべての変更に追従し、専用のビューキューブとカメラバーを持ち、前回閉じた位置に開きます。",
+ "nl": "De nieuwe knop in de camerabalk opent een tweede venster dat hetzelfde ontwerp door een eigen camera toont. Het volgt elke wijziging, heeft een eigen weergavekubus en camerabalk, en opent waar je het liet.",
+ "pl": "Nowy przycisk na pasku kamery otwiera drugie okno, które pokazuje ten sam projekt przez własną kamerę. Śledzi każdą zmianę, ma własną kostkę widoku i pasek kamery i otwiera się tam, gdzie je zostawiłeś.",
+ "pt-br": "O novo botão na barra da câmera abre uma segunda janela que mostra o mesmo projeto por uma câmera própria. Ela acompanha cada alteração, tem seu próprio cubo de vista e sua própria barra da câmera, e abre onde você a deixou.",
+ "ru": "Новая кнопка на панели камеры открывает второе окно, которое показывает тот же проект через собственную камеру. Оно следит за каждым изменением, у него свой куб вида и своя панель камеры, и оно открывается там, где вы его оставили.",
+ "sl": "Novi gumb v vrstici kamere odpre drugo okno, ki kaže isti načrt skozi lastno kamero. Sledi vsaki spremembi, ima svojo kocko pogleda in vrstico kamere ter se odpre tam, kjer ste ga pustili.",
+ "tr": "Kamera çubuğundaki yeni düğme, aynı tasarımı kendi kamerasından gösteren ikinci bir pencere açar. Her değişikliği izler, kendi görünüm küpü ve kamera çubuğu vardır ve onu bıraktığınız yerde açılır.",
+ "es": "El nuevo botón de la barra de cámara abre una segunda ventana que muestra el mismo diseño a través de una cámara propia. Sigue cada cambio, tiene su propio cubo de vista y su propia barra de cámara, y se abre donde la dejaste."
+ }
+ }
+]
diff --git a/tests/unit/secondViewPlacement.test.ts b/tests/unit/secondViewPlacement.test.ts
new file mode 100644
index 00000000..faf654b7
--- /dev/null
+++ b/tests/unit/secondViewPlacement.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest";
+import {
+ placementOf,
+ readSecondViewPlacement,
+ SECOND_VIEW_PLACEMENT_KEY,
+ secondViewSupported,
+ secondViewWindowFeatures,
+ writeSecondViewPlacement,
+} from "@/lib/secondViewPlacement";
+
+function memoryStorage() {
+ const items = new Map();
+ return {
+ getItem: (key: string) => items.get(key) ?? null,
+ setItem: (key: string, value: string) => void items.set(key, value),
+ items,
+ };
+}
+
+describe("the second view's window placement", () => {
+ it("opens at the default size where the browser puts it, the first time", () => {
+ expect(readSecondViewPlacement(memoryStorage())).toBeNull();
+ expect(secondViewWindowFeatures(null)).toBe("popup,width=960,height=720");
+ });
+
+ it("opens where it was left, at the size it had", () => {
+ const storage = memoryStorage();
+ writeSecondViewPlacement(storage, placementOf({ screenX: 1930, screenY: 40, innerWidth: 1200, innerHeight: 800 }));
+ const placement = readSecondViewPlacement(storage);
+ expect(placement).toEqual({ left: 1930, top: 40, width: 1200, height: 800 });
+ expect(secondViewWindowFeatures(placement)).toBe("popup,left=1930,top=40,width=1200,height=800");
+ });
+
+ it("keeps no size too small to use, and ignores a broken entry", () => {
+ const storage = memoryStorage();
+ writeSecondViewPlacement(storage, { left: 0, top: 0, width: 0, height: 0 });
+ expect(storage.items.has(SECOND_VIEW_PLACEMENT_KEY)).toBe(false);
+ storage.setItem(SECOND_VIEW_PLACEMENT_KEY, "{not json");
+ expect(readSecondViewPlacement(storage)).toBeNull();
+ storage.setItem(SECOND_VIEW_PLACEMENT_KEY, JSON.stringify({ left: 0, top: 0, width: "wide", height: 600 }));
+ expect(readSecondViewPlacement(storage)).toBeNull();
+ });
+
+ it("does not fail without storage", () => {
+ expect(readSecondViewPlacement(null)).toBeNull();
+ expect(() => writeSecondViewPlacement(null, { left: 0, top: 0, width: 800, height: 600 })).not.toThrow();
+ });
+});
+
+describe("where the second view is offered", () => {
+ const UA = {
+ windowsChrome: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
+ macSafari: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15",
+ linuxFirefox: "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0",
+ chromeOs: "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
+ androidPhone: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36",
+ androidTablet: "Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
+ iPhone: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1",
+ iPadMobileSite: "Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1",
+ };
+ const desktop = { coarsePointer: false, maxTouchPoints: 0 };
+
+ it("is offered with a mouse or a trackpad, a touch screen laptop and a Chromebook included", () => {
+ expect(secondViewSupported({ ...desktop, platform: "Win32", userAgent: UA.windowsChrome, mobile: false })).toBe(true);
+ expect(secondViewSupported({ ...desktop, platform: "Win32", userAgent: UA.windowsChrome, maxTouchPoints: 10, mobile: false })).toBe(true);
+ expect(secondViewSupported({ ...desktop, platform: "MacIntel", userAgent: UA.macSafari })).toBe(true);
+ expect(secondViewSupported({ ...desktop, platform: "Linux x86_64", userAgent: UA.linuxFirefox })).toBe(true);
+ expect(secondViewSupported({ ...desktop, platform: "Linux x86_64", userAgent: UA.chromeOs, maxTouchPoints: 10, mobile: false })).toBe(true);
+ });
+
+ it("is not offered on a phone", () => {
+ expect(secondViewSupported({ coarsePointer: true, platform: "Linux armv8l", maxTouchPoints: 5, userAgent: UA.androidPhone, mobile: true })).toBe(false);
+ expect(secondViewSupported({ coarsePointer: true, platform: "iPhone", maxTouchPoints: 5, userAgent: UA.iPhone })).toBe(false);
+ });
+
+ it("is not offered on a tablet, not even with a mouse or a trackpad on it", () => {
+ // Android tablet with a mouse: the pointer reports fine, the name still says Android.
+ expect(secondViewSupported({ ...desktop, platform: "Linux aarch64", maxTouchPoints: 10, userAgent: UA.androidTablet, mobile: false })).toBe(false);
+ // An iPad as it presents itself by default: a Mac with touch points.
+ expect(secondViewSupported({ ...desktop, platform: "MacIntel", maxTouchPoints: 5, userAgent: UA.macSafari })).toBe(false);
+ // An iPad asking for mobile sites, with a trackpad.
+ expect(secondViewSupported({ ...desktop, platform: "iPad", maxTouchPoints: 5, userAgent: UA.iPadMobileSite })).toBe(false);
+ });
+
+ it("is not offered where the browser says it runs on a mobile device, whatever its name", () => {
+ expect(secondViewSupported({ ...desktop, platform: "Linux x86_64", userAgent: UA.linuxFirefox, mobile: true })).toBe(false);
+ });
+});
diff --git a/tests/unit/sharedSavedViews.test.ts b/tests/unit/sharedSavedViews.test.ts
new file mode 100644
index 00000000..2ca1f1af
--- /dev/null
+++ b/tests/unit/sharedSavedViews.test.ts
@@ -0,0 +1,62 @@
+import * as THREE from "three";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { CameraRig } from "@/components/workplane/cameraRig";
+import { saveViewInSlot, showViewInSlot } from "@/components/workplane/cameraViews";
+
+// The 3D view and the second view's window share a design's four saved views (7, 8, 9, 0).
+
+function memoryStorage() {
+ const items = new Map();
+ return {
+ getItem: (key: string) => items.get(key) ?? null,
+ setItem: (key: string, value: string) => void items.set(key, value),
+ removeItem: (key: string) => void items.delete(key),
+ };
+}
+
+/** A camera with what the helpers read of its controls and its canvas. */
+function rig(camera: THREE.PerspectiveCamera | THREE.OrthographicCamera, target: THREE.Vector3): CameraRig {
+ const controls = { target, object: camera, minZoom: 0, maxZoom: Infinity, update: () => true };
+ const renderer = { domElement: { clientWidth: 800, clientHeight: 600 } };
+ return { camera, controls, renderer, needsRender: false, focusReach: null } as unknown as CameraRig;
+}
+
+describe("saved views in either window", () => {
+ beforeEach(() => {
+ vi.stubGlobal("window", { localStorage: memoryStorage() });
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("brings a view saved in one window back in the other, projection and all", () => {
+ const flat = new THREE.OrthographicCamera(-40, 40, 30, -30, 0.1, 6000);
+ flat.position.set(30, 90, 10);
+ flat.zoom = 2;
+ const main = rig(flat, new THREE.Vector3(5, 0, 5));
+ expect(saveViewInSlot("design-1", main, 2)).toContain("9");
+
+ const second = rig(new THREE.PerspectiveCamera(38, 800 / 600, 0.1, 6000), new THREE.Vector3());
+ expect(showViewInSlot("design-1", second, 2)).toBeNull();
+ expect(second.camera).toBeInstanceOf(THREE.OrthographicCamera);
+ expect(second.camera.position.toArray()).toEqual([30, 90, 10]);
+ expect(second.controls.target.toArray()).toEqual([5, 0, 5]);
+ expect(second.camera.zoom).toBe(2);
+ expect(second.needsRender).toBe(true);
+ });
+
+ it("says how to fill a key that holds nothing, and leaves the camera where it is", () => {
+ const camera = new THREE.PerspectiveCamera(38, 800 / 600, 0.1, 6000);
+ camera.position.set(1, 2, 3);
+ const second = rig(camera, new THREE.Vector3());
+ expect(showViewInSlot("design-1", second, 0)).toContain("7");
+ expect(second.camera.position.toArray()).toEqual([1, 2, 3]);
+ });
+
+ it("keeps each design's views apart", () => {
+ const camera = new THREE.PerspectiveCamera(38, 800 / 600, 0.1, 6000);
+ camera.position.set(50, 50, 50);
+ saveViewInSlot("design-1", rig(camera, new THREE.Vector3()), 1);
+ expect(showViewInSlot("design-2", rig(new THREE.PerspectiveCamera(), new THREE.Vector3()), 1)).toContain("8");
+ });
+});
diff --git a/tests/unit/viewArrowKeys.test.ts b/tests/unit/viewArrowKeys.test.ts
index ef63d56e..1401b876 100644
--- a/tests/unit/viewArrowKeys.test.ts
+++ b/tests/unit/viewArrowKeys.test.ts
@@ -1,6 +1,6 @@
import * as THREE from "three";
import { afterEach, describe, expect, it, vi } from "vitest";
-import { handleViewArrowKey, panViewBy, VIEW_PAN_COARSE_STEP, VIEW_PAN_STEP, visibleViewHeight } from "@/lib/viewArrowKeys";
+import { handleViewArrowKey, panViewBy, viewArrowStep, VIEW_PAN_COARSE_STEP, VIEW_PAN_STEP, visibleViewHeight } from "@/lib/viewArrowKeys";
const key = (key: string, mods: Partial<{ shiftKey: boolean; ctrlKey: boolean; metaKey: boolean; altKey: boolean }> = {}) => ({
key, shiftKey: false, ctrlKey: false, metaKey: false, altKey: false, ...mods,
@@ -68,3 +68,21 @@ describe("panViewBy", () => {
expect(visibleViewHeight(viewFrom(camera))).toBeCloseTo(50);
});
});
+
+describe("the arrow key step", () => {
+ const press = (key: string, modifiers: Partial<{ shiftKey: boolean; ctrlKey: boolean; metaKey: boolean; altKey: boolean }> = {}) => ({
+ key, shiftKey: false, ctrlKey: false, metaKey: false, altKey: false, ...modifiers,
+ });
+
+ it("turns with the arrows and slides with Ctrl or Cmd, as the 3D view does with nothing selected", () => {
+ expect(viewArrowStep(press("ArrowRight"))).toEqual({ orbit: [-15, 0] });
+ expect(viewArrowStep(press("ArrowUp", { shiftKey: true }))?.orbit?.[1]).toBe(90);
+ expect(viewArrowStep(press("ArrowLeft", { ctrlKey: true }))).toEqual({ pan: [-VIEW_PAN_STEP, 0] });
+ expect(viewArrowStep(press("ArrowDown", { metaKey: true, shiftKey: true }))).toEqual({ pan: [0, -VIEW_PAN_COARSE_STEP] });
+ });
+
+ it("leaves other keys and Alt alone", () => {
+ expect(viewArrowStep(press("a"))).toBeNull();
+ expect(viewArrowStep(press("ArrowLeft", { altKey: true }))).toBeNull();
+ });
+});
diff --git a/tests/unit/viewCubeDrag.test.ts b/tests/unit/viewCubeDrag.test.ts
index 566a4097..a3d5c74d 100644
--- a/tests/unit/viewCubeDrag.test.ts
+++ b/tests/unit/viewCubeDrag.test.ts
@@ -172,25 +172,32 @@ describe("view cube corners and edges (#202)", () => {
// The faces are buttons inside the draggable cube. A drag must not also click
// the face it ends on, and a plain click must still reach the face.
-describe("view cube wiring in the viewport", () => {
- const source = readFileSync(
- fileURLToPath(new URL("../../apps/web/src/components/WorkplaneViewport.tsx", import.meta.url)),
+describe("view cube wiring", () => {
+ const cube = readFileSync(
+ fileURLToPath(new URL("../../apps/web/src/components/workplane/ViewCube.tsx", import.meta.url)),
"utf8",
);
- const cube = source.slice(source.indexOf('className={`view-cube '), source.indexOf(' {
for (const face of ["top", "bottom", "front", "back", "right", "left"]) {
- expect(cube).toContain(`onClick={() => setViewCubeFace("${face}")}`);
- expect(cube).toContain(`{viewCubeZones("${face}")}`);
+ expect(cube).toContain(`{ face: "${face}", className: `);
}
+ expect(cube).toContain("onClick={() => lookFrom((rig) => setCameraToViewFace(rig, face))}");
+ expect(cube).toContain("{zones(face)}");
});
it("handles the drag on the cube and swallows the click that ends it", () => {
- expect(cube).toContain("onPointerDown={handleViewCubePointerDown}");
- expect(cube).toContain("onPointerMove={handleViewCubePointerMove}");
- expect(cube).toContain("onPointerUp={endViewCubeDrag}");
- expect(cube).toContain("onPointerCancel={endViewCubeDrag}");
- expect(cube).toContain("onClickCapture={handleViewCubeClickCapture}");
+ expect(cube).toContain("onPointerDown={handlePointerDown}");
+ expect(cube).toContain("onPointerMove={handlePointerMove}");
+ expect(cube).toContain("onPointerUp={endDrag}");
+ expect(cube).toContain("onPointerCancel={endDrag}");
+ expect(cube).toContain("onClickCapture={handleClickCapture}");
+ });
+
+ it("is the cube of both the 3D view and the second view", () => {
+ for (const file of ["WorkplaneViewport.tsx", "workplane/SecondViewWindow.tsx"]) {
+ const source = readFileSync(fileURLToPath(new URL(`../../apps/web/src/components/${file}`, import.meta.url)), "utf8");
+ expect(source).toMatch(/
/);
+ }
});
});
diff --git a/tests/unit/viewFaceShortcuts.test.ts b/tests/unit/viewFaceShortcuts.test.ts
index cd734734..81b51322 100644
--- a/tests/unit/viewFaceShortcuts.test.ts
+++ b/tests/unit/viewFaceShortcuts.test.ts
@@ -59,13 +59,14 @@ describe("view face shortcut wiring in the viewport", () => {
});
it("leaves a click on the view cube as a plain view jump", () => {
- const cube = source.slice(source.indexOf('className={`view-cube '), source.indexOf('
{
const focus = source.slice(source.indexOf("const focusSelection = useCallback"), source.indexOf("const resetView = useCallback"));
// It says so (false), so F can show the whole scene instead (#465).
- expect(focus).toContain("if (bounds.isEmpty()) return false;");
+ expect(focus).toContain("if (!bounds) return false;");
});
});