Merge pull request #277 from sudhir9297/feat/2d-editor

Feat/2d editor
This commit is contained in:
Wassim SAMAD
2026-04-28 07:36:05 -04:00
committed by GitHub
39 changed files with 8362 additions and 2152 deletions
@@ -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<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.
*/
@@ -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) {
@@ -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()
}