/* 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 controls panel */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { X, Slice, ChevronDown, FileImage, FlipHorizontal2, MousePointerClick, RotateCcw, GripVertical } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useViewerStore, loadLastSectionMode } from '@/store'; import { useDraggablePanel } from '@/hooks/useDraggablePanel'; import { AXIS_INFO } from './sectionConstants'; import { SectionPlaneVisualization } from './SectionVisualization'; import { SectionCapControls } from './SectionCapControls'; export function SectionOverlay() { const sectionPlane = useViewerStore((s) => s.sectionPlane); const setSectionPlaneAxis = useViewerStore((s) => s.setSectionPlaneAxis); const setSectionPlanePosition = useViewerStore((s) => s.setSectionPlanePosition); const toggleSectionPlane = useViewerStore((s) => s.toggleSectionPlane); const flipSectionPlane = useViewerStore((s) => s.flipSectionPlane); // Face-pick + custom plane actions (issue #243). const sectionPickMode = useViewerStore((s) => s.sectionPickMode); const setSectionPickMode = useViewerStore((s) => s.setSectionPickMode); const setSectionCustomDistance = useViewerStore((s) => s.setSectionCustomDistance); const setPreviewStride = useViewerStore((s) => s.setPointCloudPreviewStride); const pointCloudAssetCount = useViewerStore((s) => s.pointCloudAssetCount); const setActiveTool = useViewerStore((s) => s.setActiveTool); const setDrawingPanelVisible = useViewerStore((s) => s.setDrawing2DPanelVisible); const drawingPanelVisible = useViewerStore((s) => s.drawing2DPanelVisible); const clearDrawing = useViewerStore((s) => s.clearDrawing2D); const [isPanelCollapsed, setIsPanelCollapsed] = useState(true); const isCustom = sectionPlane.custom !== undefined; const handleClose = useCallback(() => { setActiveTool('select'); }, [setActiveTool]); const handleAxisChange = useCallback((axis: 'down' | 'front' | 'side') => { setSectionPlaneAxis(axis); }, [setSectionPlaneAxis]); // Toggle the "next click picks a face" arming. The actual click is // intercepted in `selectionHandlers.ts`, which calls // `setSectionPlaneFromFace` and clears pick mode for us. (issue #243) const handleTogglePickMode = useCallback(() => { setSectionPickMode(!sectionPickMode); }, [sectionPickMode, setSectionPickMode]); // "Reset to axis" in custom mode — clearing the custom field via // setSectionPlaneAxis re-uses the existing cardinal pathway. We pick // the nearest cardinal that's already in `axis` (kept in sync at pick // time) so the user lands on the closest preset they had before. const handleResetToAxis = useCallback(() => { setSectionPlaneAxis(sectionPlane.axis); }, [sectionPlane.axis, setSectionPlaneAxis]); const handleCustomDistanceChange = useCallback((e: React.ChangeEvent) => { const v = Number(e.target.value); if (Number.isFinite(v)) setSectionCustomDistance(v); }, [setSectionCustomDistance]); const handlePositionChange = useCallback((e: React.ChangeEvent) => { const value = Number(e.target.value); if (!Number.isNaN(value)) { setSectionPlanePosition(value); } }, [setSectionPlanePosition]); // Section-plane drag preview: while the user is actively dragging // the position slider, render the splat shader at 1/4 density so // huge scans (>10M points) keep up. Restored on release. const handleSliderDragStart = useCallback(() => { if (pointCloudAssetCount > 0) setPreviewStride(4); }, [setPreviewStride, pointCloudAssetCount]); const handleSliderDragEnd = useCallback(() => { setPreviewStride(1); }, [setPreviewStride]); // Reset stride if the panel disappears mid-drag (e.g. user closes // the section tool without releasing the slider). Without this the // store can stay stuck at 4 and keep scans thinned indefinitely. useEffect(() => { return () => setPreviewStride(1); }, [setPreviewStride]); // Restore the user's last-used section mode when the panel mounts // (issue #243 follow-up). Two modes round-trip via localStorage: // // • 'pick' — face-pick is the default for first-time users and // anyone whose last action was a face pick. The 200ms // debounce stops the click that opened the tool from // bleeding through to the canvas pick handler and // accidentally sectioning the floor on the same frame // the panel mounts. // • 'cardinal' — restore axis + position + flipped so the cut // appears exactly where the user left it. Section is // enabled by these setters so the cut is immediately // visible — matches the user's mental model of // "opening the panel where I left it". // // Cleanup disarms pick mode on unmount so leaving the tool doesn't // leave pick mode armed for the next tool. useEffect(() => { const mode = loadLastSectionMode(); let armTimer: ReturnType | null = null; if (mode.kind === 'cardinal') { // Read current flipped via getState() so we don't pull the live // store value into the dep array (which would re-run the effect // every flip and clobber the restore on each interaction). const currentFlipped = useViewerStore.getState().sectionPlane.flipped; setSectionPlaneAxis(mode.axis); setSectionPlanePosition(mode.position); if (currentFlipped !== mode.flipped) flipSectionPlane(); } else { armTimer = setTimeout(() => setSectionPickMode(true), 200); } return () => { if (armTimer !== null) clearTimeout(armTimer); setSectionPickMode(false); }; // The setters are stable refs from zustand; flipSectionPlane reads // current state via getState() so it's intentionally NOT in the dep // array (would cause the restore to re-run on every flip). // eslint-disable-next-line react-hooks/exhaustive-deps }, [setSectionPickMode, setSectionPlaneAxis, setSectionPlanePosition, flipSectionPlane]); const togglePanel = useCallback(() => { setIsPanelCollapsed(prev => !prev); }, []); const handleView2D = useCallback(() => { // Clear existing drawing to force regeneration with current settings clearDrawing(); setDrawingPanelVisible(true); }, [clearDrawing, setDrawingPanelVisible]); const panelRef = useRef(null); const drag = useDraggablePanel(panelRef); return ( <> {/* Compact Section Tool Panel - matches Measure tool style */}
{/* Header doubles as a drag handle — buttons/inputs are ignored by the hook so they keep working (issue #1107). */}
{/* Only show 2D button when panel is closed */} {!drawingPanelVisible && ( )}
{/* Expandable content */} {!isPanelCollapsed && (
{/* Direction Selection. "Pick face" is the primary affordance — face-pick auto-arms on tool open (issue #243 follow-up) and matches Bonsai/Revit point-and-cut UX. Cardinal presets are demoted to a secondary row below for power users who want an axis-aligned cut without picking a surface. */}
or pick an axis
{(['down', 'front', 'side'] as const).map((axis) => ( ))}
{isCustom && (
n=({sectionPlane.custom!.normal.map((v) => v.toFixed(2)).join(', ')})
)}
{/* Position. In cardinal mode this is a 0..100% slider along the axis. In custom mode (issue #243) the numeric input becomes a precise signed distance in world units along the picked normal; the slider still works (it shifts the plane by a small amount along the normal — see sectionSlice). */}
{isCustom ? 'Distance (m)' : 'Position'}
{isCustom ? ( ) : ( )}
{/* Cap surface controls (hatch, colour, spacing) */} {/* Show 2D panel button - only when panel is closed */} {!drawingPanelVisible && (
)}
)}
{/* Instruction hint - brutalist style matching Measure tool */}
{sectionPickMode ? 'Hover a surface to preview, click to cut' : sectionPlane.enabled ? isCustom ? `Custom cut at d=${sectionPlane.custom!.distance.toFixed(2)}m${sectionPlane.flipped ? ' (flipped)' : ''}` : `Cut ${AXIS_INFO[sectionPlane.axis].label.toLowerCase()} at ${sectionPlane.position.toFixed(1)}%${sectionPlane.flipped ? ' (flipped)' : ''}` : 'Clip off — drag slider to cut'}
{/* Enable toggle — when OFF the model is not clipped even though the plane visual is shown. Label is explicit so users don't mistake "Preview" for "nothing will happen". */}
{/* Section plane visualization overlay */} ); }