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
This commit is contained in:
Pascal
2026-04-13 11:12:24 -04:00
committed by GitHub
parent 384eab819c
commit b25be8227a
2 changed files with 68 additions and 9 deletions
@@ -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<THREE.Group>(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]}
>
<NodeActionMenu
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
onDelete={handleDelete}
onDuplicate={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleDuplicate : undefined}
onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
onDuplicate={
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
? handleDuplicate
: undefined
}
onMove={
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
? handleMove
: undefined
}
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
/>
@@ -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<HTMLButtonElement>
onDelete?: MouseEventHandler<HTMLButtonElement>
onDuplicate?: MouseEventHandler<HTMLButtonElement>
onMove?: MouseEventHandler<HTMLButtonElement>
@@ -14,6 +16,7 @@ type NodeActionMenuProps = {
}
export function NodeActionMenu({
onAddHole,
onDelete,
onDuplicate,
onMove,
@@ -52,6 +55,17 @@ export function NodeActionMenu({
<Copy className="h-4 w-4" />
</button>
)}
{onAddHole && (
<button
aria-label="Cut Out"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onAddHole}
title="Cut Out"
type="button"
>
<Icon height={16} icon="carbon:cut-out" width={16} />
</button>
)}
{onDelete && (
<button
aria-label="Delete"