/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
/**
* Section plane visual indicator/gizmo.
*
* In addition to the cardinal-axis corner badge (existing), this also
* renders the 3D drag gizmo for face-picked custom planes (issue #243):
* a violet dot at the live plane anchor (`pickedAt` projected onto the
* current plane via `customPlaneCenter`) plus an arrow along the picked
* normal that the user can click + drag to slide the cut plane
* perpendicular to its surface. Anchoring to the projected center —
* instead of `pickedAt` directly — keeps the gizmo glued to the plane
* as `distance` changes; using `pickedAt` directly would freeze the
* gizmo at the original face-pick location while the geometry clip
* slides to the new distance. The drag math projects the cursor delta
* onto the screen-projected normal and converts pixels-per-meter via
* the camera's point-projection of `center + normal * 1m`.
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import { AXIS_INFO } from './sectionConstants';
import { useViewerStore, customPlaneCenter } from '@/store';
import { getGlobalRenderer } from '@/hooks/useBCF';
interface SectionPlaneVisualizationProps {
axis: 'down' | 'front' | 'side';
enabled: boolean;
}
// Section plane visual indicator component
export function SectionPlaneVisualization({ axis, enabled }: SectionPlaneVisualizationProps) {
// Get the axis color
const axisColors = {
down: '#03A9F4', // Light blue for horizontal cuts
front: '#4CAF50', // Green for front cuts
side: '#FF9800', // Orange for side cuts
};
// Custom plane (face-pick) — paints violet to match the renderer's
// gizmo quad so the user reads "this is a non-cardinal cut".
const CUSTOM_COLOR = '#9C6BDE';
const customPlane = useViewerStore((s) => s.sectionPlane.custom);
const setSectionCustomDistance = useViewerStore((s) => s.setSectionCustomDistance);
const setPreviewStride = useViewerStore((s) => s.setPointCloudPreviewStride);
const pointCloudAssetCount = useViewerStore((s) => s.pointCloudAssetCount);
// Live face-pick hover preview (issue #243 follow-up). Only set
// while pick mode is armed AND the cursor has dwelled ~200ms over a
// surface. Drives the violet quad + arrow that telegraph "this is
// where I'll cut if you click here" before the user commits.
const sectionPickPreview = useViewerStore((s) => s.sectionPickPreview);
const isCustom = customPlane !== undefined;
const color = isCustom ? CUSTOM_COLOR : axisColors[axis];
return (
);
}
/**
* Translucent violet quad + tiny normal arrow painted on the surface
* the user is hovering while section pick mode is armed (issue #243
* follow-up). Purely a hint — does not commit a section plane;
* `selectionHandlers.ts` does that on click.
*
* Rendered as an SVG overlay to match `CustomPlaneDragGizmo` (no new
* GPU pipeline, follows the camera "for free" via per-frame
* projection). The quad's footprint follows `tangent`/`bitangent` of
* the hit normal so it looks like a flat square laid on the surface
* regardless of camera angle, and its on-screen radius is clamped to
* `[24px, 80px]` so it stays readable from any zoom.
*
* Pointer-events are forced off so the overlay never intercepts the
* click that would commit the actual cut — the SVG container above
* already disables them, but child elements with `pointerEvents:
* 'auto'` (e.g. the drag gizmo's circle) co-exist in the same tree.
*/
function SectionPickPreviewOverlay(props: {
color: string;
preview: NonNullable['sectionPickPreview']>;
}) {
const { color, preview } = props;
// Project the four quad corners + the arrow tip every animation
// frame so the overlay tracks camera orbit/pan without any extra
// store subscription. Cheap (5 mat-mul per frame).
const [proj, setProj] = useState<{
quad: Array<{ x: number; y: number }>;
foot: { x: number; y: number };
tip: { x: number; y: number };
} | null>(null);
useEffect(() => {
let raf = 0;
const project = () => {
const renderer = getGlobalRenderer();
const camera = renderer?.getCamera();
const canvas = renderer?.getCanvas();
if (camera && canvas) {
const w = canvas.clientWidth, h = canvas.clientHeight;
const [px, py, pz] = preview.point;
const [nx, ny, nz] = preview.normal;
// Build an orthonormal in-plane basis from the normal. This
// duplicates `planeBasis()` from the renderer package — done
// inline to keep the overlay self-contained and avoid pulling
// a renderer dep into the React layer just for two cross
// products. The choice of seed (Z vs X) avoids a degenerate
// cross when the normal is near ±Y.
const seedX = Math.abs(ny) > 0.9 ? 1 : 0;
const seedY = Math.abs(ny) > 0.9 ? 0 : 0;
const seedZ = Math.abs(ny) > 0.9 ? 0 : 1;
// tangent = normalize(cross(normal, seed))
let tx = ny * seedZ - nz * seedY;
let ty = nz * seedX - nx * seedZ;
let tz = nx * seedY - ny * seedX;
const tLen = Math.hypot(tx, ty, tz) || 1;
tx /= tLen; ty /= tLen; tz /= tLen;
// bitangent = cross(normal, tangent)
const bx = ny * tz - nz * ty;
const by = nz * tx - nx * tz;
const bz = nx * ty - ny * tx;
// Quad half-extent: 0.5m world to start; we'll clamp the
// visible size in screen pixels below by interpolating along
// the projected diagonal if the apparent size lands outside
// [24, 80]px.
const halfWorld = 0.5;
const corner = (s: number, t: number) => {
const wx = px + tx * s + bx * t;
const wy = py + ty * s + by * t;
const wz = pz + tz * s + bz * t;
return camera.projectToScreen({ x: wx, y: wy, z: wz }, w, h);
};
const c0 = corner(-halfWorld, -halfWorld);
const c1 = corner( halfWorld, -halfWorld);
const c2 = corner( halfWorld, halfWorld);
const c3 = corner(-halfWorld, halfWorld);
const foot = camera.projectToScreen({ x: px, y: py, z: pz }, w, h);
// Arrow tip 0.4m along the normal — half a typical wall
// thickness, enough for the arrowhead to read at default
// zoom without dwarfing small objects.
const tip = camera.projectToScreen(
{ x: px + nx * 0.4, y: py + ny * 0.4, z: pz + nz * 0.4 },
w, h,
);
if (c0 && c1 && c2 && c3 && foot && tip) {
// On-screen size clamp: rescale the four corners about the
// foot so the apparent diagonal falls in [24px, 80px]. This
// keeps the preview readable at extreme zooms (a 1m quad
// can otherwise shrink to 2px from far away or fill the
// canvas up close).
const dx = c2.x - c0.x;
const dy = c2.y - c0.y;
const diag = Math.hypot(dx, dy) || 1;
const minPx = 50; // ~50px diagonal — visible but not
// overpowering
const maxPx = 140;
const scale = diag < minPx ? minPx / diag
: diag > maxPx ? maxPx / diag
: 1;
const rescale = (c: { x: number; y: number }) => ({
x: foot.x + (c.x - foot.x) * scale,
y: foot.y + (c.y - foot.y) * scale,
});
setProj({
quad: [rescale(c0), rescale(c1), rescale(c2), rescale(c3)],
foot,
tip,
});
}
}
raf = requestAnimationFrame(project);
};
project();
return () => cancelAnimationFrame(raf);
}, [preview.point, preview.normal, preview.faceKey]);
if (!proj) return null;
const { quad, foot, tip } = proj;
// Arrow pixel length capped at 36px so it stays a small "telltale"
// rather than visually competing with the quad. Direction comes
// from the projected normal so it tracks camera orientation.
const adx = tip.x - foot.x, ady = tip.y - foot.y;
const aLen = Math.hypot(adx, ady) || 1;
const ARROW_PX = Math.min(36, aLen);
const tipX = foot.x + (adx / aLen) * ARROW_PX;
const tipY = foot.y + (ady / aLen) * ARROW_PX;
return (
{/* Translucent violet quad — the "you'll cut here" hint. */}
`${p.x},${p.y}`).join(' ')}
fill={color}
fillOpacity="0.28"
stroke={color}
strokeWidth="1.5"
strokeOpacity="0.7"
/>
{/* Tiny normal arrow — shaft. */}
{/* Arrowhead — small triangle perpendicular to the shaft. */}
{
const ux = adx / aLen, uy = ady / aLen;
const nxp = -uy, nyp = ux;
const baseX = tipX - ux * 6;
const baseY = tipY - uy * 6;
const ax = baseX + nxp * 4, ay = baseY + nyp * 4;
const bx = baseX - nxp * 4, by = baseY - nyp * 4;
return `${tipX},${tipY} ${ax},${ay} ${bx},${by}`;
})()}
fill={color} opacity="0.95"
/>
);
}
/**
* Click+drag arrow that translates the custom section plane along its
* picked normal. Uses screen-space projection of `center` (= pickedAt
* projected onto the live plane) and `center + normal` to convert
* cursor pixels into world units — resolution-independent and works
* for any tilt.
*
* Re-projects the anchor every animation frame while dragging so the
* gizmo stays glued to the live plane even if the camera moves
* (orbit / pan are still allowed underneath this overlay because we
* only call `setPointerCapture` on the handle's ).
*/
function CustomPlaneDragGizmo(props: {
color: string;
customPlane: NonNullable['sectionPlane']['custom']>;
setDistance: (d: number) => void;
onDragStart: () => void;
onDragEnd: () => void;
}) {
const { color, customPlane, setDistance, onDragStart, onDragEnd } = props;
const [proj, setProj] = useState<{ p0: { x: number; y: number }; p1: { x: number; y: number } } | null>(null);
const dragStateRef = useRef<{
active: boolean;
startDistance: number;
startCursor: { x: number; y: number };
screenNormal: { x: number; y: number };
pixelsPerMeter: number;
} | null>(null);
// Project the gizmo's two anchor points (foot + tip-of-arrow) every
// animation frame so it follows the camera. Cheap: two
// matrix-multiplies per frame.
//
// The foot anchor is `pickedAt` projected onto the LIVE plane (not
// `pickedAt` itself). As the user drags the gizmo only `distance`
// changes; pickedAt sits off the moving plane, so anchoring the
// gizmo to it would leave the arrow stranded at the original pick
// location while the cut slides along the normal. Using the
// projected center keeps the gizmo glued to the actual cut plane.
useEffect(() => {
let raf = 0;
const project = () => {
const renderer = getGlobalRenderer();
const camera = renderer?.getCamera();
const canvas = renderer?.getCanvas();
if (camera && canvas) {
const center = customPlaneCenter(customPlane);
const tipWorld = {
x: center[0] + customPlane.normal[0],
y: center[1] + customPlane.normal[1],
z: center[2] + customPlane.normal[2],
};
const footWorld = {
x: center[0],
y: center[1],
z: center[2],
};
const w = canvas.clientWidth, h = canvas.clientHeight;
const p0 = camera.projectToScreen(footWorld, w, h);
const p1 = camera.projectToScreen(tipWorld, w, h);
if (p0 && p1) {
setProj({ p0, p1 });
}
}
raf = requestAnimationFrame(project);
};
project();
return () => cancelAnimationFrame(raf);
}, [customPlane.pickedAt, customPlane.normal, customPlane.distance]);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
if (!proj) return;
e.stopPropagation();
e.preventDefault();
(e.target as Element).setPointerCapture(e.pointerId);
const dx = proj.p1.x - proj.p0.x;
const dy = proj.p1.y - proj.p0.y;
const ppm = Math.hypot(dx, dy);
if (ppm < 1e-3) return; // edge-on view — drag would be unstable
dragStateRef.current = {
active: true,
startDistance: customPlane.distance,
startCursor: { x: e.clientX, y: e.clientY },
screenNormal: { x: dx / ppm, y: dy / ppm },
pixelsPerMeter: ppm,
};
onDragStart();
}, [proj, customPlane.distance, onDragStart]);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
const s = dragStateRef.current;
if (!s?.active) return;
e.stopPropagation();
const cdx = e.clientX - s.startCursor.x;
const cdy = e.clientY - s.startCursor.y;
// Project cursor delta onto the screen-projected normal, then
// convert pixels → meters via `pixelsPerMeter`.
const screenDelta = cdx * s.screenNormal.x + cdy * s.screenNormal.y;
const meters = screenDelta / s.pixelsPerMeter;
setDistance(s.startDistance + meters);
}, [setDistance]);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
if (dragStateRef.current?.active) {
dragStateRef.current.active = false;
try {
(e.target as Element).releasePointerCapture(e.pointerId);
} catch (_err) {
/* cleanup — safe to ignore: pointer already released by browser */
}
onDragEnd();
}
}, [onDragEnd]);
if (!proj) return null;
// Arrow goes 60px past `p0` along the projected normal direction so
// it stays a consistent visual size regardless of camera distance —
// we'd otherwise get a tiny arrow when the camera is far away.
const dx = proj.p1.x - proj.p0.x;
const dy = proj.p1.y - proj.p0.y;
const len = Math.hypot(dx, dy) || 1;
const ARROW_PX = 60;
const tipX = proj.p0.x + (dx / len) * ARROW_PX;
const tipY = proj.p0.y + (dy / len) * ARROW_PX;
return (
{/* Tip arrowhead — small triangle perpendicular to the line. */}
{
const nx = -dy / len, ny = dx / len; // perpendicular to direction
const baseX = tipX - (dx / len) * 8;
const baseY = tipY - (dy / len) * 8;
const ax = baseX + nx * 5, ay = baseY + ny * 5;
const bx = baseX - nx * 5, by = baseY - ny * 5;
return `${tipX},${tipY} ${ax},${ay} ${bx},${by}`;
})()}
fill={color} opacity="0.9"
/>
{/* Foot dot — the actual click+drag target. Larger hit area than
visual radius for finger-friendly UX. */}
Drag to slide the cut along its normal
);
}