'use client' import { type AnyNode, emitter, type GuideNode, loadAssetUrl, saveAsset, type ScanNode, useScene, } from '@pascal-app/core' import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { getGuideImageName } from '../../../lib/local-guide-image' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' type ReferenceNode = ScanNode | GuideNode function getScaleStatus(guide: GuideNode, scaleReferenceVisible: boolean) { const reference = guide.scaleReference if (!reference) { return 'Uncalibrated' } return `${scaleReferenceVisible ? 'Scaled' : 'Scaled (hidden)'} · ${reference.label}` } export function ReferencePanel() { const selectedReferenceId = useEditor((s) => s.selectedReferenceId) const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId) const guideUi = useEditor((s) => (selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined)) const setGuideLocked = useEditor((s) => s.setGuideLocked) const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible) const clearGuideUi = useEditor((s) => s.clearGuideUi) const updateNode = useScene((s) => s.updateNode) const deleteNode = useScene((s) => s.deleteNode) const replaceInputRef = useRef(null) const [isReplacing, setIsReplacing] = useState(false) const [replaceError, setReplaceError] = useState(null) const [isAssetMissing, setIsAssetMissing] = useState(false) const node = useScene((s) => selectedReferenceId ? (s.nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined) : undefined, ) const handleUpdate = useCallback( (updates: Partial) => { if (!selectedReferenceId) return updateNode(selectedReferenceId as AnyNode['id'], updates) }, [selectedReferenceId, updateNode], ) const handleClose = useCallback(() => { setSelectedReferenceId(null) }, [setSelectedReferenceId]) const handleReplaceFile = useCallback( async (file: File) => { if (!(selectedReferenceId && node?.type === 'guide')) { return } if (!file.type.startsWith('image/')) { setReplaceError('Choose a PNG, JPEG, or WebP image.') return } setIsReplacing(true) setReplaceError(null) try { const assetUrl = await saveAsset(file) updateNode(selectedReferenceId as AnyNode['id'], { name: getGuideImageName(file.name), url: assetUrl, scaleReference: null, } as Partial) setGuideScaleReferenceVisible(selectedReferenceId, true) } catch { setReplaceError('Could not replace that image.') } finally { setIsReplacing(false) } }, [node?.type, selectedReferenceId, setGuideScaleReferenceVisible, updateNode], ) const handleDeleteGuide = useCallback(() => { if (!(selectedReferenceId && node?.type === 'guide')) { return } deleteNode(selectedReferenceId as AnyNode['id']) emitter.emit('guide:deleted', { guideId: selectedReferenceId as GuideNode['id'] }) clearGuideUi(selectedReferenceId) setSelectedReferenceId(null) }, [clearGuideUi, deleteNode, node?.type, selectedReferenceId, setSelectedReferenceId]) const handleStartScale = useCallback(() => { if (node?.type !== 'guide') { return } emitter.emit('guide:set-reference-scale', { guideId: node.id }) }, [node]) const handleCancelScale = useCallback(() => { emitter.emit('guide:cancel-reference-scale') }, []) useEffect(() => { if (node?.type !== 'guide' || !node.url.startsWith('asset://')) { setIsAssetMissing(false) return } let cancelled = false loadAssetUrl(node.url).then((resolvedUrl) => { if (!cancelled) { setIsAssetMissing(!resolvedUrl) } }) return () => { cancelled = true } }, [node]) if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null const isScan = node.type === 'scan' const guideLocked = !isScan && guideUi?.locked === true const scaleReferenceVisible = !isScan && guideUi?.scaleReferenceVisible !== false const scaleStatus = !isScan ? getScaleStatus(node, scaleReferenceVisible) : null return ( {!isScan && ( <> { const file = event.currentTarget.files?.[0] event.currentTarget.value = '' if (file) { void handleReplaceFile(file) } }} ref={replaceInputRef} type="file" /> } label={isReplacing ? 'Replacing...' : 'Replace'} onClick={() => replaceInputRef.current?.click()} disabled={isReplacing} /> } label="Delete" onClick={handleDeleteGuide} className="text-destructive hover:bg-destructive/10" /> ) : ( ) } label={node.visible === false ? 'Show' : 'Hide'} onClick={() => handleUpdate({ visible: node.visible === false })} /> ) : ( ) } label={guideLocked ? 'Unlock' : 'Lock'} onClick={() => setGuideLocked(node.id, !guideLocked)} /> {replaceError && (
{replaceError}
)} {isAssetMissing && (
Overlay image unavailable. Replace the image to restore it.
)}
{scaleStatus}
{ if (!node.scaleReference) return setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible) }} /> handleUpdate({ scaleReference: null } as Partial)} />
} label="Center" onClick={() => handleUpdate({ position: [0, node.position[1], 0], } as Partial) } /> } label="Reset Rotation" onClick={() => handleUpdate({ rotation: [node.rotation[0], 0, node.rotation[2]], } as Partial) } /> } label="Reset Image Scale" onClick={() => handleUpdate({ scale: 1 } as Partial)} /> )} Xpos } max={50} min={-50} onChange={(value) => { const pos = [...node.position] as [number, number, number] pos[0] = value handleUpdate({ position: pos }) }} precision={2} step={0.1} unit="m" value={Math.round(node.position[0] * 100) / 100} /> Ypos } max={50} min={-50} onChange={(value) => { const pos = [...node.position] as [number, number, number] pos[1] = value handleUpdate({ position: pos }) }} precision={2} step={0.1} unit="m" value={Math.round(node.position[1] * 100) / 100} /> Zpos } max={50} min={-50} onChange={(value) => { const pos = [...node.position] as [number, number, number] pos[2] = value handleUpdate({ position: pos }) }} precision={2} step={0.1} unit="m" value={Math.round(node.position[2] * 100) / 100} /> Yrot } max={180} min={-180} onChange={(degrees) => { const radians = (degrees * Math.PI) / 180 handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]], }) }} precision={0} step={1} unit="°" value={Math.round((node.rotation[1] * 180) / Math.PI)} />
handleUpdate({ rotation: [node.rotation[0], node.rotation[1] - Math.PI / 4, node.rotation[2]], }) } /> handleUpdate({ rotation: [node.rotation[0], node.rotation[1] + Math.PI / 4, node.rotation[2]], }) } />
XYZscale } max={10} min={0.01} onChange={(value) => { if (value > 0) { handleUpdate({ scale: value }) } }} precision={2} step={0.1} value={Math.round(node.scale * 100) / 100} /> handleUpdate({ opacity: v })} precision={0} step={1} unit="%" value={node.opacity} />
) }