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 { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type CeilingNode,
DoorNode, DoorNode,
ItemNode, ItemNode,
RoofNode, RoofNode,
RoofSegmentNode, RoofSegmentNode,
type SlabNode,
StairNode, StairNode,
StairSegmentNode, StairSegmentNode,
sceneRegistry, sceneRegistry,
@@ -32,17 +34,20 @@ const ALLOWED_TYPES = [
'stair-segment', 'stair-segment',
'wall', 'wall',
'slab', 'slab',
'ceiling',
] ]
const DELETE_ONLY_TYPES = ['wall', 'slab'] const DELETE_ONLY_TYPES = ['wall']
const HOLE_TYPES = ['slab', 'ceiling']
export function FloatingActionMenu() { export function FloatingActionMenu() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
const nodes = useScene((s) => s.nodes) const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const setMode = useEditor((s) => s.setMode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const setEditingHole = useEditor((s) => s.setEditingHole)
const groupRef = useRef<THREE.Group>(null) const groupRef = useRef<THREE.Group>(null)
@@ -61,8 +66,8 @@ export function FloatingActionMenu() {
if (!box.isEmpty()) { if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3()) const center = box.getCenter(new THREE.Vector3())
// Position above the object, with extra offset for walls/slabs to avoid covering measurement labels // 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 isStructural = node && [...DELETE_ONLY_TYPES, ...HOLE_TYPES].includes(node.type)
const yOffset = isDeleteOnly ? 0.8 : 0.3 const yOffset = isStructural ? 0.8 : 0.3
groupRef.current.position.set(center.x, box.max.y + yOffset, center.z) groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
} }
} }
@@ -196,14 +201,45 @@ export function FloatingActionMenu() {
[node, setMovingNode, setSelection], [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( const handleDelete = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
// Activate delete mode (sledgehammer tool) instead of deleting directly if (!selectedId) return
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
setMode('delete') useScene.getState().deleteNode(selectedId as AnyNodeId)
}, },
[setSelection, setMode], [selectedId, setSelection],
) )
if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null
@@ -219,9 +255,18 @@ export function FloatingActionMenu() {
zIndexRange={[100, 0]} zIndexRange={[100, 0]}
> >
<NodeActionMenu <NodeActionMenu
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
onDelete={handleDelete} onDelete={handleDelete}
onDuplicate={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleDuplicate : undefined} onDuplicate={
onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined} 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()} onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()}
/> />
@@ -1,9 +1,11 @@
'use client' 'use client'
import { Icon } from '@iconify/react'
import { Copy, Move, Trash2 } from 'lucide-react' import { Copy, Move, Trash2 } from 'lucide-react'
import type { MouseEventHandler, PointerEventHandler } from 'react' import type { MouseEventHandler, PointerEventHandler } from 'react'
type NodeActionMenuProps = { type NodeActionMenuProps = {
onAddHole?: MouseEventHandler<HTMLButtonElement>
onDelete?: MouseEventHandler<HTMLButtonElement> onDelete?: MouseEventHandler<HTMLButtonElement>
onDuplicate?: MouseEventHandler<HTMLButtonElement> onDuplicate?: MouseEventHandler<HTMLButtonElement>
onMove?: MouseEventHandler<HTMLButtonElement> onMove?: MouseEventHandler<HTMLButtonElement>
@@ -14,6 +16,7 @@ type NodeActionMenuProps = {
} }
export function NodeActionMenu({ export function NodeActionMenu({
onAddHole,
onDelete, onDelete,
onDuplicate, onDuplicate,
onMove, onMove,
@@ -52,6 +55,17 @@ export function NodeActionMenu({
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</button> </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 && ( {onDelete && (
<button <button
aria-label="Delete" aria-label="Delete"