diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index b9ba288b7..4b8ea127d 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,5 +1,6 @@ import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions } from '../../schema' +import useScene from '../../store/use-scene' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' @@ -51,6 +52,121 @@ function getItemFootprint( ] } +type ItemLocalBounds = { + min: [number, number, number] + max: [number, number, number] +} + +type ItemParentAabb = { + minX: number + maxX: number + minY: number + maxY: number + minZ: number + maxZ: number +} + +function getFallbackItemLocalBounds(item: ItemNode): ItemLocalBounds { + const [width, height, depth] = getScaledDimensions(item) + const minZ = item.asset.attachTo === 'wall-side' ? -depth : -depth / 2 + const maxZ = item.asset.attachTo === 'wall-side' ? 0 : depth / 2 + return { + min: [-width / 2, 0, minZ], + max: [width / 2, height, maxZ], + } +} + +function getItemLocalBounds(item: ItemNode): ItemLocalBounds { + const metadata = + typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata) + ? (item.metadata as Record) + : null + const rawBounds = + typeof metadata?.meshLocalBounds === 'object' && + metadata.meshLocalBounds !== null && + !Array.isArray(metadata.meshLocalBounds) + ? (metadata.meshLocalBounds as Record) + : null + const min = rawBounds?.min + const max = rawBounds?.max + + if ( + Array.isArray(min) && + min.length >= 3 && + Array.isArray(max) && + max.length >= 3 && + typeof min[0] === 'number' && + typeof min[1] === 'number' && + typeof min[2] === 'number' && + typeof max[0] === 'number' && + typeof max[1] === 'number' && + typeof max[2] === 'number' + ) { + return { + min: [min[0], min[1], min[2]], + max: [max[0], max[1], max[2]], + } + } + + return getFallbackItemLocalBounds(item) +} + +function getItemParentAabb(item: ItemNode): ItemParentAabb { + const bounds = getItemLocalBounds(item) + const corners: Array<[number, number, number]> = [ + [bounds.min[0], bounds.min[1], bounds.min[2]], + [bounds.min[0], bounds.min[1], bounds.max[2]], + [bounds.min[0], bounds.max[1], bounds.min[2]], + [bounds.min[0], bounds.max[1], bounds.max[2]], + [bounds.max[0], bounds.min[1], bounds.min[2]], + [bounds.max[0], bounds.min[1], bounds.max[2]], + [bounds.max[0], bounds.max[1], bounds.min[2]], + [bounds.max[0], bounds.max[1], bounds.max[2]], + ] + const yRot = item.rotation[1] ?? 0 + const cos = Math.cos(yRot) + const sin = Math.sin(yRot) + + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + + for (const [cx, cy, cz] of corners) { + const rotatedX = cx * cos + cz * sin + const rotatedZ = -cx * sin + cz * cos + const worldX = rotatedX + item.position[0] + const worldY = cy + item.position[1] + const worldZ = rotatedZ + item.position[2] + minX = Math.min(minX, worldX) + minY = Math.min(minY, worldY) + minZ = Math.min(minZ, worldZ) + maxX = Math.max(maxX, worldX) + maxY = Math.max(maxY, worldY) + maxZ = Math.max(maxZ, worldZ) + } + + return { minX, maxX, minY, maxY, minZ, maxZ } +} + +function intervalsOverlap(minA: number, maxA: number, minB: number, maxB: number, epsilon = 1e-4) { + return minA < maxB - epsilon && maxA > minB + epsilon +} + +function resolveNodeLevelId(node: AnyNode, nodes: Record): string { + if (node.type === 'level') return node.id + + let current: AnyNode | undefined = node + while (current) { + if (current.type === 'level') return current.id + current = current.parentId ? nodes[current.parentId] : undefined + } + + return 'default' +} + /** * Test if two line segments (a1->a2) and (b1->b2) intersect. */ @@ -481,8 +597,39 @@ export class SpatialGridManager { rotation: [number, number, number], ignoreIds?: string[], ) { - const grid = this.getFloorGrid(levelId) - return grid.canPlace(position, dimensions, rotation, ignoreIds) + const nodes = useScene.getState().nodes + const ignoreSet = new Set(ignoreIds ?? []) + const [width, , depth] = dimensions + const yRot = rotation[1] + const cos = Math.abs(Math.cos(yRot)) + const sin = Math.abs(Math.sin(yRot)) + const rotatedW = width * cos + depth * sin + const rotatedD = width * sin + depth * cos + const draftBounds = { + minX: position[0] - rotatedW / 2, + maxX: position[0] + rotatedW / 2, + minZ: position[2] - rotatedD / 2, + maxZ: position[2] + rotatedD / 2, + } + + const conflicts: string[] = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'item') continue + const item = node as ItemNode + if (item.asset.attachTo) continue + if (ignoreSet.has(item.id)) continue + if (resolveNodeLevelId(item, nodes) !== levelId) continue + + const bounds = getItemParentAabb(item) + if ( + intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && + intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ) + ) { + conflicts.push(item.id) + } + } + + return { valid: conflicts.length === 0, conflictIds: conflicts } } /** @@ -514,7 +661,7 @@ export class SpatialGridManager { // Convert local X position to parametric t (0-1) const tCenter = localX / wallLength const [itemWidth, itemHeight] = dimensions - return this.getWallGrid(levelId).canPlaceOnWall( + const baseResult = this.getWallGrid(levelId).canPlaceOnWall( wallId, wallLength, wallHeight, @@ -526,6 +673,44 @@ export class SpatialGridManager { side, ignoreIds, ) + + if (!baseResult.valid) return baseResult + + const nodes = useScene.getState().nodes + const ignoreSet = new Set(ignoreIds ?? []) + const draftBounds = { + minX: localX - itemWidth / 2, + maxX: localX + itemWidth / 2, + minY: baseResult.adjustedY, + maxY: baseResult.adjustedY + itemHeight, + } + + const conflicts: string[] = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'item') continue + const item = node as ItemNode + if (!(item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side')) continue + if (ignoreSet.has(item.id)) continue + if (item.parentId !== wallId) continue + + if (attachType === 'wall-side' && item.asset.attachTo === 'wall-side' && side && item.side) { + if (side !== item.side) continue + } + + const bounds = getItemParentAabb(item) + if ( + intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && + intervalsOverlap(draftBounds.minY, draftBounds.maxY, bounds.minY, bounds.maxY) + ) { + conflicts.push(item.id) + } + } + + return { + ...baseResult, + valid: conflicts.length === 0, + conflictIds: conflicts, + } } getWallForItem(levelId: string, itemId: string): string | undefined { @@ -692,8 +877,39 @@ export class SpatialGridManager { } } - // Check for overlaps with other ceiling items - return this.getCeilingGrid(ceilingId).canPlace(position, dimensions, rotation, ignoreIds) + const nodes = useScene.getState().nodes + const ignoreSet = new Set(ignoreIds ?? []) + const [width, , depth] = dimensions + const yRot = rotation[1] + const cos = Math.abs(Math.cos(yRot)) + const sin = Math.abs(Math.sin(yRot)) + const rotatedW = width * cos + depth * sin + const rotatedD = width * sin + depth * cos + const draftBounds = { + minX: position[0] - rotatedW / 2, + maxX: position[0] + rotatedW / 2, + minZ: position[2] - rotatedD / 2, + maxZ: position[2] + rotatedD / 2, + } + + const conflicts: string[] = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'item') continue + const item = node as ItemNode + if (item.asset.attachTo !== 'ceiling') continue + if (ignoreSet.has(item.id)) continue + if (item.parentId !== ceilingId) continue + + const bounds = getItemParentAabb(item) + if ( + intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && + intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ) + ) { + conflicts.push(item.id) + } + } + + return { valid: conflicts.length === 0, conflictIds: conflicts } } clearLevel(levelId: string) { diff --git a/packages/core/src/systems/stair/stair-system.tsx b/packages/core/src/systems/stair/stair-system.tsx index 3d0964e6f..77e400296 100644 --- a/packages/core/src/systems/stair/stair-system.tsx +++ b/packages/core/src/systems/stair/stair-system.tsx @@ -216,7 +216,7 @@ function generateStairSegmentGeometry( extrudedGeometry.applyMatrix4(matrix) extrudedGeometry.computeVertexNormals() - const geometry = extrudedGeometry.toNonIndexed() ?? extrudedGeometry + const geometry = extrudedGeometry.index ? extrudedGeometry.toNonIndexed() : extrudedGeometry if (geometry !== extrudedGeometry) { extrudedGeometry.dispose() } diff --git a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx new file mode 100644 index 000000000..1fdc5c816 --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx @@ -0,0 +1,90 @@ +'use client' + +import { memo, type MouseEvent as ReactMouseEvent } from 'react' +import useEditor from '../../store/use-editor' +import { NodeActionMenu } from '../editor/node-action-menu' + +type SvgPoint = { + x: number + y: number +} + +export type FloorplanActionMenuHandler = (event: ReactMouseEvent) => void + +export type FloorplanActionMenuEntry = { + position: SvgPoint | null + onDelete: FloorplanActionMenuHandler + onMove: FloorplanActionMenuHandler + onDuplicate?: FloorplanActionMenuHandler +} + +type FloorplanActionMenuLayerProps = { + item: FloorplanActionMenuEntry + wall: FloorplanActionMenuEntry + fence: FloorplanActionMenuEntry + slab: FloorplanActionMenuEntry + ceiling: FloorplanActionMenuEntry + opening: FloorplanActionMenuEntry + stair: FloorplanActionMenuEntry + roof: FloorplanActionMenuEntry + offsetY?: number +} + +export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ + item, + wall, + fence, + slab, + ceiling, + opening, + stair, + roof, + offsetY = 10, +}: FloorplanActionMenuLayerProps) { + const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) + const movingNode = useEditor((state) => state.movingNode) + const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) + const curvingWall = useEditor((state) => state.curvingWall) + const curvingFence = useEditor((state) => state.curvingFence) + + if (!isFloorplanHovered || movingNode || movingFenceEndpoint || curvingWall || curvingFence) { + return null + } + + const entries: FloorplanActionMenuEntry[] = [ + item, + wall, + fence, + slab, + ceiling, + opening, + stair, + roof, + ] + + return ( + <> + {entries.map((entry, index) => + entry.position ? ( +
+ event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + /> +
+ ) : null, + )} + + ) +}) diff --git a/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx new file mode 100644 index 000000000..152eca79f --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx @@ -0,0 +1,160 @@ +'use client' + +import { Icon } from '@iconify/react' +import { memo, useMemo } from 'react' +import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor' +import { furnishTools } from '../ui/action-menu/furnish-tools' +import { tools as structureTools } from '../ui/action-menu/structure-tools' + +type SvgPoint = { + x: number + y: number +} + +type FloorplanCursorIndicator = + | { + kind: 'asset' + iconSrc: string + } + | { + kind: 'icon' + icon: string + } + +type FloorplanCursorIndicatorOverlayProps = { + cursorPosition: SvgPoint | null + cursorAnchorPosition: SvgPoint | null + floorplanSelectionTool: FloorplanSelectionTool + movingOpeningType: 'door' | 'window' | null + isPanning: boolean + cursorColor: string + indicatorLineHeight?: number + indicatorBadgeOffsetX?: number + indicatorBadgeOffsetY?: number +} + +export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndicatorOverlay({ + cursorPosition, + cursorAnchorPosition, + floorplanSelectionTool, + movingOpeningType, + isPanning, + cursorColor, + indicatorLineHeight = 18, + indicatorBadgeOffsetX = 14, + indicatorBadgeOffsetY = 14, +}: FloorplanCursorIndicatorOverlayProps) { + const mode = useEditor((state) => state.mode) + const tool = useEditor((state) => state.tool) + const structureLayer = useEditor((state) => state.structureLayer) + const catalogCategory = useEditor((state) => state.catalogCategory) + + const activeFloorplanToolConfig = useMemo(() => { + if (movingOpeningType) { + return structureTools.find((entry) => entry.id === movingOpeningType) ?? null + } + + if (mode !== 'build' || !tool) { + return null + } + + if (tool === 'item' && catalogCategory) { + return furnishTools.find((entry) => entry.catalogCategory === catalogCategory) ?? null + } + + return structureTools.find((entry) => entry.id === tool) ?? null + }, [catalogCategory, mode, movingOpeningType, tool]) + + const indicator = useMemo(() => { + if (activeFloorplanToolConfig) { + return { kind: 'asset', iconSrc: activeFloorplanToolConfig.iconSrc } + } + + if (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') { + return { kind: 'icon', icon: 'mdi:select-drag' } + } + + if (mode === 'delete') { + return { kind: 'icon', icon: 'mdi:trash-can-outline' } + } + + return null + }, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer]) + + const position = mode === 'delete' ? cursorPosition : cursorAnchorPosition + + if (!(indicator && position) || isPanning) { + return null + } + + return ( +