Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 221 additions & 5 deletions packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<string, unknown>)
: null
const rawBounds =
typeof metadata?.meshLocalBounds === 'object' &&
metadata.meshLocalBounds !== null &&
!Array.isArray(metadata.meshLocalBounds)
? (metadata.meshLocalBounds as Record<string, unknown>)
: 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, AnyNode>): 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.
*/
Expand Down Expand Up @@ -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 }
}

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/systems/stair/stair-system.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>) => 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 ? (
<div
className="absolute z-30"
key={index}
style={{
left: entry.position.x,
top: entry.position.y,
transform: `translate(-50%, calc(-100% - ${offsetY}px))`,
}}
>
<NodeActionMenu
onDelete={entry.onDelete}
onDuplicate={entry.onDuplicate}
onMove={entry.onMove}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
/>
</div>
) : null,
)}
</>
)
})
Loading
Loading