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