From b25be8227aad6ae9acf5b28b5df22461c5e7443a Mon Sep 17 00:00:00 2001 From: Pascal Date: Mon, 13 Apr 2026 11:12:24 -0400 Subject: [PATCH] feat(editor): add cut-out button to floating action menu for slabs and ceilings (#224) When selecting a floor slab or ceiling node, the floating UI buttons now include a cut-out icon (carbon:cut-out) that directly creates a hole and enters edit mode. Much more intuitive than navigating to the side panel. Changes: - NodeActionMenu: add onAddHole prop with carbon:cut-out icon - FloatingActionMenu: wire up handleAddHole for slab/ceiling types - Add ceiling to ALLOWED_TYPES so the floating menu appears for ceilings too - Separate HOLE_TYPES from DELETE_ONLY_TYPES for cleaner type gating --- .../editor/floating-action-menu.tsx | 63 ++++++++++++++++--- .../components/editor/node-action-menu.tsx | 14 +++++ 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 34dc070f..62345a2b 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -3,10 +3,12 @@ import { type AnyNode, type AnyNodeId, + type CeilingNode, DoorNode, ItemNode, RoofNode, RoofSegmentNode, + type SlabNode, StairNode, StairSegmentNode, sceneRegistry, @@ -32,17 +34,20 @@ const ALLOWED_TYPES = [ 'stair-segment', 'wall', 'slab', + 'ceiling', ] -const DELETE_ONLY_TYPES = ['wall', 'slab'] +const DELETE_ONLY_TYPES = ['wall'] +const HOLE_TYPES = ['slab', 'ceiling'] export function FloatingActionMenu() { const selectedIds = useViewer((s) => s.selection.selectedIds) const nodes = useScene((s) => s.nodes) + const updateNode = useScene((s) => s.updateNode) const mode = useEditor((s) => s.mode) - const setMode = useEditor((s) => s.setMode) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const setMovingNode = useEditor((s) => s.setMovingNode) const setSelection = useViewer((s) => s.setSelection) + const setEditingHole = useEditor((s) => s.setEditingHole) const groupRef = useRef(null) @@ -61,8 +66,8 @@ export function FloatingActionMenu() { if (!box.isEmpty()) { const center = box.getCenter(new THREE.Vector3()) // Position above the object, with extra offset for walls/slabs to avoid covering measurement labels - const isDeleteOnly = node && DELETE_ONLY_TYPES.includes(node.type) - const yOffset = isDeleteOnly ? 0.8 : 0.3 + const isStructural = node && [...DELETE_ONLY_TYPES, ...HOLE_TYPES].includes(node.type) + const yOffset = isStructural ? 0.8 : 0.3 groupRef.current.position.set(center.x, box.max.y + yOffset, center.z) } } @@ -196,14 +201,45 @@ export function FloatingActionMenu() { [node, setMovingNode, setSelection], ) + const handleAddHole = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!(node && selectedId && (node.type === 'slab' || node.type === 'ceiling'))) return + + const polygon = (node as SlabNode | CeilingNode).polygon + let cx = 0 + let cz = 0 + for (const [x, z] of polygon) { + cx += x + cz += z + } + cx /= polygon.length + cz /= polygon.length + + const holeSize = 0.5 + const newHole: Array<[number, number]> = [ + [cx - holeSize, cz - holeSize], + [cx + holeSize, cz - holeSize], + [cx + holeSize, cz + holeSize], + [cx - holeSize, cz + holeSize], + ] + const currentHoles = (node as SlabNode | CeilingNode).holes || [] + updateNode(selectedId as AnyNodeId, { holes: [...currentHoles, newHole] }) + setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length }) + // Re-assert selection so the node stays selected + setSelection({ selectedIds: [selectedId] }) + }, + [node, selectedId, updateNode, setEditingHole, setSelection], + ) + const handleDelete = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - // Activate delete mode (sledgehammer tool) instead of deleting directly + if (!selectedId) return setSelection({ selectedIds: [] }) - setMode('delete') + useScene.getState().deleteNode(selectedId as AnyNodeId) }, - [setSelection, setMode], + [selectedId, setSelection], ) if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null @@ -219,9 +255,18 @@ export function FloatingActionMenu() { zIndexRange={[100, 0]} > e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} /> diff --git a/packages/editor/src/components/editor/node-action-menu.tsx b/packages/editor/src/components/editor/node-action-menu.tsx index 95696b16..3097d8c5 100644 --- a/packages/editor/src/components/editor/node-action-menu.tsx +++ b/packages/editor/src/components/editor/node-action-menu.tsx @@ -1,9 +1,11 @@ 'use client' +import { Icon } from '@iconify/react' import { Copy, Move, Trash2 } from 'lucide-react' import type { MouseEventHandler, PointerEventHandler } from 'react' type NodeActionMenuProps = { + onAddHole?: MouseEventHandler onDelete?: MouseEventHandler onDuplicate?: MouseEventHandler onMove?: MouseEventHandler @@ -14,6 +16,7 @@ type NodeActionMenuProps = { } export function NodeActionMenu({ + onAddHole, onDelete, onDuplicate, onMove, @@ -52,6 +55,17 @@ export function NodeActionMenu({ )} + {onAddHole && ( + + )} {onDelete && (