/* 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/. */ /** * Numeric input panel for the Split tool. Mounted by ToolOverlays * alongside SplitOverlay while `activeTool === 'split'`. Surfaces * a small floating input near the hover preview that lets the user * type a precise distance (metres) or percent (0..100) instead of * relying on cursor positioning. * * Renders only for the single-click element types (wall, beam, * column, member) — slabs use a two-click cut line that doesn't * map to a single scalar. The panel disappears between hovers so * it doesn't compete with the cursor. * * Behaviour: * * - Tab into the panel from anywhere (or Cmd+/ to focus) and * type a number; the live preview updates as you type. * - Enter commits at the typed distance (or, if blank, at the * current cursor distance — same as a click). * - Esc returns focus to the canvas without committing. * - Quick snap buttons: 25% / 50% / 75% jump the distance to * fractions of the element length — common gesture and the * panel stays useful even without numeric input. * * The panel is positioned next to the cursor via cameraCallbacks * .projectToScreen(splitHoverPoint); it tracks the camera the same * way SplitOverlay does, via splitHoverPoint updates. */ import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'; import { useViewerStore } from '@/store'; import { toast } from '@/components/ui/toast'; const ACCENT = '#a855f7'; // purple-500 const PANEL_OFFSET_PX = 32; export function SplitNumericInput() { const activeTool = useViewerStore((s) => s.activeTool); const splitMode = useViewerStore((s) => s.splitMode); const splitHoverPoint = useViewerStore((s) => s.splitHoverPoint); const splitHoverDistance = useViewerStore((s) => s.splitHoverDistance); const splitHoverLength = useViewerStore((s) => s.splitHoverLength); const splitTargetModelId = useViewerStore((s) => s.splitTargetModelId); const splitTargetExpressId = useViewerStore((s) => s.splitTargetExpressId); const projectToScreen = useViewerStore((s) => s.cameraCallbacks.projectToScreen); const splitWallAtDistance = useViewerStore((s) => s.splitWallAtDistance); const splitLinearElementAtDistance = useViewerStore((s) => s.splitLinearElementAtDistance); const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId); const clearSplitHover = useViewerStore((s) => s.clearSplitHover); const [inputValue, setInputValue] = useState(''); const [inputMode, setInputMode] = useState<'metres' | 'percent'>('metres'); const inputRef = useRef(null); // Reset the input whenever the target wall changes — sticky // input across targets would be confusing. useEffect(() => { setInputValue(''); }, [splitTargetExpressId]); // Only render in the single-click element flow. Slab two-click // flow has its own pathway (no scalar input). const eligible = activeTool === 'split' && splitMode === 'aiming' && splitHoverPoint !== null && splitHoverLength !== null && splitHoverLength > 0; if (!eligible || !projectToScreen) return null; const cutScreen = projectToScreen({ x: splitHoverPoint[0], y: splitHoverPoint[1], z: splitHoverPoint[2], }); if (!cutScreen) return null; const commitAt = (distance: number) => { if (splitTargetModelId === null || splitTargetExpressId === null) return; if (!Number.isFinite(distance) || distance <= 0 || distance >= splitHoverLength) { toast.error(`Distance must be between 0 and ${splitHoverLength.toFixed(2)} m`); return; } const wallTry = splitWallAtDistance(splitTargetModelId, splitTargetExpressId, distance); if (wallTry.ok) { clearSplitHover(); setSelectedEntityId(wallTry.right.globalId); const op = wallTry.openings; const opSummary = op.toLeft + op.toRight > 0 ? ` (${op.toLeft + op.toRight} opening${op.toLeft + op.toRight === 1 ? '' : 's'} reassigned)` : ''; toast.success(`Wall split${opSummary} — Ctrl+Z to undo`); return; } const linearTry = splitLinearElementAtDistance(splitTargetModelId, splitTargetExpressId, distance); if (linearTry.ok) { clearSplitHover(); setSelectedEntityId(linearTry.right.globalId); toast.success('Element split — Ctrl+Z to undo'); return; } const reason = linearTry.ok === false ? linearTry.reason : wallTry.reason; toast.error(`Couldn't split: ${reason}`); }; const parsedDistance = (): number => { const raw = parseFloat(inputValue); if (!Number.isFinite(raw)) return splitHoverDistance ?? 0; return inputMode === 'metres' ? raw : (raw / 100) * (splitHoverLength ?? 0); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); commitAt(parsedDistance()); } else if (e.key === 'Escape') { e.preventDefault(); (e.target as HTMLInputElement).blur(); // Move focus back to the viewport canvas so keyboard // shortcuts (K, R, V, …) keep working — without this, the // input field still has focus from the browser's // perspective and global keyboard listeners ignore the // event because it's targeted at the input. const canvas = document.querySelector('[data-viewport="main"]'); canvas?.focus(); } }; const onChange = (e: ChangeEvent) => { setInputValue(e.target.value); }; const snapTo = (fraction: number) => { commitAt(fraction * (splitHoverLength ?? 0)); }; return (
of {splitHoverLength?.toFixed(2)}m
Snap {[0.25, 0.5, 0.75].map((f) => ( ))}
); }