diff --git a/apps/editor/components/editor/floating-action-menu.tsx b/apps/editor/components/editor/floating-action-menu.tsx index 26f0853d..d5667f56 100644 --- a/apps/editor/components/editor/floating-action-menu.tsx +++ b/apps/editor/components/editor/floating-action-menu.tsx @@ -1,6 +1,14 @@ 'use client' -import { type AnyNode, type AnyNodeId, ItemNode, WindowNode, DoorNode, sceneRegistry, useScene } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + DoorNode, + ItemNode, + sceneRegistry, + useScene, + WindowNode, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' @@ -18,7 +26,6 @@ export function FloatingActionMenu() { const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) const setSelection = useViewer((s) => s.setSelection) - const isEditor = useViewer((state) => state.isEditor) const groupRef = useRef(null) @@ -42,61 +49,70 @@ export function FloatingActionMenu() { } }) - const handleMove = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - if (!node) return - sfxEmitter.emit('sfx:item-pick') - if (node.type === 'item' || node.type === 'window' || node.type === 'door') { - setMovingNode(node as any) - } - setSelection({ selectedIds: [] }) - }, [node, setMovingNode, setSelection]) - - const handleDuplicate = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - if (!node || !node.parentId) return - sfxEmitter.emit('sfx:item-pick') - useScene.temporal.getState().pause() - - let duplicateInfo = structuredClone(node) as any - delete duplicateInfo.id - duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true } - - let duplicate: AnyNode | null = null - try { - if (node.type === 'door') { - duplicate = DoorNode.parse(duplicateInfo) - } else if (node.type === 'window') { - duplicate = WindowNode.parse(duplicateInfo) - } else if (node.type === 'item') { - duplicate = ItemNode.parse(duplicateInfo) - } - } catch (error) { - console.error('Failed to parse duplicate', error) - return - } - - if (duplicate) { - if (duplicate.type === 'door' || duplicate.type === 'window') { - useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) - } - if (duplicate.type === 'item' || duplicate.type === 'window' || duplicate.type === 'door') { - setMovingNode(duplicate as any) + const handleMove = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!node) return + sfxEmitter.emit('sfx:item-pick') + if (node.type === 'item' || node.type === 'window' || node.type === 'door') { + setMovingNode(node as any) } setSelection({ selectedIds: [] }) - } - }, [node, setMovingNode, setSelection]) + }, + [node, setMovingNode, setSelection], + ) - const handleDelete = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - if (!selectedId || !node) return - sfxEmitter.emit('sfx:item-delete') - deleteNode(selectedId as AnyNodeId) - if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId) - setSelection({ selectedIds: [] }) - }, [selectedId, node, deleteNode, setSelection]) + const handleDuplicate = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!node || !node.parentId) return + sfxEmitter.emit('sfx:item-pick') + useScene.temporal.getState().pause() - if (!isEditor || !selectedId || !node || !isValidType) return null + let duplicateInfo = structuredClone(node) as any + delete duplicateInfo.id + duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true } + + let duplicate: AnyNode | null = null + try { + if (node.type === 'door') { + duplicate = DoorNode.parse(duplicateInfo) + } else if (node.type === 'window') { + duplicate = WindowNode.parse(duplicateInfo) + } else if (node.type === 'item') { + duplicate = ItemNode.parse(duplicateInfo) + } + } catch (error) { + console.error('Failed to parse duplicate', error) + return + } + + if (duplicate) { + if (duplicate.type === 'door' || duplicate.type === 'window') { + useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) + } + if (duplicate.type === 'item' || duplicate.type === 'window' || duplicate.type === 'door') { + setMovingNode(duplicate as any) + } + setSelection({ selectedIds: [] }) + } + }, + [node, setMovingNode, setSelection], + ) + + const handleDelete = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!selectedId || !node) return + sfxEmitter.emit('sfx:item-delete') + deleteNode(selectedId as AnyNodeId) + if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [selectedId, node, deleteNode, setSelection], + ) + + if (!selectedId || !node || !isValidType) return null return ( @@ -105,10 +121,10 @@ export function FloatingActionMenu() { zIndexRange={[100, 0]} style={{ pointerEvents: 'auto', - touchAction: 'none' + touchAction: 'none', }} > -
e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 161d4ddb..ec095031 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -24,8 +24,10 @@ import { ExportManager } from './export-manager' import { FloatingActionMenu } from './floating-action-menu' import { Grid } from './grid' import { SelectionManager } from './selection-manager' +import { SiteEdgeLabels } from './site-edge-labels' import { ThumbnailGenerator } from './thumbnail-generator' + // Load default scene initially (will be replaced when project loads) useScene.getState().loadScene() initSpatialGridSync() @@ -106,7 +108,7 @@ export default function Editor({ projectId }: EditorProps) { - + @@ -118,6 +120,7 @@ export default function Editor({ projectId }: EditorProps) { +
) diff --git a/apps/editor/components/editor/site-edge-labels.tsx b/apps/editor/components/editor/site-edge-labels.tsx new file mode 100644 index 00000000..1af576ba --- /dev/null +++ b/apps/editor/components/editor/site-edge-labels.tsx @@ -0,0 +1,66 @@ +'use client' + +import { sceneRegistry, useScene } from '@pascal-app/core' +import type { SiteNode } from '@pascal-app/core' +import { Html } from '@react-three/drei' +import { createPortal, useFrame } from '@react-three/fiber' +import { useMemo, useRef, useState } from 'react' +import type { Object3D } from 'three' + +export function SiteEdgeLabels() { + const rootNodeIds = useScene((state) => state.rootNodeIds) + const nodes = useScene((state) => state.nodes) + + const siteNode = rootNodeIds[0] ? (nodes[rootNodeIds[0]] as SiteNode) : null + const siteNodeId = siteNode?.id + + const [siteObj, setSiteObj] = useState(null) + const prevSiteNodeIdRef = useRef(undefined) + + // Poll each frame until the site group is registered. + // Also resets when the site node ID changes (new project loaded). + useFrame(() => { + if (siteNodeId !== prevSiteNodeIdRef.current) { + prevSiteNodeIdRef.current = siteNodeId + setSiteObj(null) + return + } + if (siteObj || !siteNodeId) return + const obj = sceneRegistry.nodes.get(siteNodeId) + if (obj) setSiteObj(obj) + }) + + const edges = useMemo(() => { + const polygon = siteNode?.polygon?.points ?? [] + if (polygon.length < 2) return [] + return polygon.map(([x1, z1], i) => { + const [x2, z2] = polygon[(i + 1) % polygon.length]! + const midX = (x1! + x2) / 2 + const midZ = (z1! + z2) / 2 + const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2) + return { midX, midZ, dist } + }) + }, [siteNode?.polygon?.points]) + + if (!siteObj || edges.length === 0) return null + + return createPortal( + <> + {edges.map((edge, i) => ( + +
+ {edge.dist.toFixed(2)}m +
+ + ))} + , + siteObj, + ) +} diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx index 9b96eb36..bf40beeb 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx @@ -958,7 +958,7 @@ function ZoneItem({ zone, isLast }: { zone: ZoneNode, isLast?: boolean }) {