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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user