remove community
This commit is contained in:
@@ -39,7 +39,7 @@
|
||||
"zustand": "^5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "*",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/react": "^19.2.2",
|
||||
"typescript": "5.9.3",
|
||||
"@types/three": "^0.183.0"
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import type { ThreeEvent } from '@react-three/fiber'
|
||||
import mitt from 'mitt'
|
||||
import type { BuildingNode, CeilingNode, DoorNode, ItemNode, LevelNode, RoofNode, SiteNode, SlabNode, WallNode, WindowNode, ZoneNode } from '../schema'
|
||||
import type {
|
||||
BuildingNode,
|
||||
CeilingNode,
|
||||
DoorNode,
|
||||
ItemNode,
|
||||
LevelNode,
|
||||
RoofNode,
|
||||
SiteNode,
|
||||
SlabNode,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
ZoneNode,
|
||||
} from '../schema'
|
||||
import type { AnyNode } from '../schema/types'
|
||||
|
||||
// Base event interfaces
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import type * as THREE from "three";
|
||||
import { useLayoutEffect } from 'react'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
export const sceneRegistry = {
|
||||
// Master lookup: ID -> Object3D
|
||||
@@ -22,7 +22,7 @@ export const sceneRegistry = {
|
||||
window: new Set<string>(),
|
||||
door: new Set<string>(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useRegistry(
|
||||
id: string,
|
||||
@@ -30,19 +30,19 @@ export function useRegistry(
|
||||
ref: React.RefObject<THREE.Object3D>,
|
||||
) {
|
||||
useLayoutEffect(() => {
|
||||
const obj = ref.current;
|
||||
if (!obj) return;
|
||||
const obj = ref.current
|
||||
if (!obj) return
|
||||
|
||||
// 1. Add to master map
|
||||
sceneRegistry.nodes.set(id, obj);
|
||||
sceneRegistry.nodes.set(id, obj)
|
||||
|
||||
// 2. Add to type-specific set
|
||||
sceneRegistry.byType[type].add(id);
|
||||
sceneRegistry.byType[type].add(id)
|
||||
|
||||
// 4. Cleanup when component unmounts
|
||||
return () => {
|
||||
sceneRegistry.nodes.delete(id);
|
||||
sceneRegistry.byType[type].delete(id);
|
||||
};
|
||||
}, [id, type, ref]);
|
||||
sceneRegistry.nodes.delete(id)
|
||||
sceneRegistry.byType[type].delete(id)
|
||||
}
|
||||
}, [id, type, ref])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getScaledDimensions } from '../../schema'
|
||||
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
||||
import { getScaledDimensions } from '../../schema'
|
||||
import { SpatialGrid } from './spatial-grid'
|
||||
import { WallSpatialGrid } from './wall-spatial-grid'
|
||||
|
||||
@@ -14,10 +14,12 @@ export function pointInPolygon(px: number, pz: number, polygon: Array<[number, n
|
||||
let inside = false
|
||||
const n = polygon.length
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const xi = polygon[i]![0], zi = polygon[i]![1]
|
||||
const xj = polygon[j]![0], zj = polygon[j]![1]
|
||||
const xi = polygon[i]![0],
|
||||
zi = polygon[i]![1]
|
||||
const xj = polygon[j]![0],
|
||||
zj = polygon[j]![1]
|
||||
|
||||
if ((zi > pz) !== (zj > pz) && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {
|
||||
if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
@@ -53,8 +55,14 @@ function getItemFootprint(
|
||||
* Test if two line segments (a1->a2) and (b1->b2) intersect.
|
||||
*/
|
||||
function segmentsIntersect(
|
||||
ax1: number, az1: number, ax2: number, az2: number,
|
||||
bx1: number, bz1: number, bx2: number, bz2: number,
|
||||
ax1: number,
|
||||
az1: number,
|
||||
ax2: number,
|
||||
az2: number,
|
||||
bx1: number,
|
||||
bz1: number,
|
||||
bx2: number,
|
||||
bz2: number,
|
||||
): boolean {
|
||||
const cross = (ox: number, oz: number, ax: number, az: number, bx: number, bz: number) =>
|
||||
(ax - ox) * (bz - oz) - (az - oz) * (bx - ox)
|
||||
@@ -64,15 +72,16 @@ function segmentsIntersect(
|
||||
const d3 = cross(ax1, az1, ax2, az2, bx1, bz1)
|
||||
const d4 = cross(ax1, az1, ax2, az2, bx2, bz2)
|
||||
|
||||
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
|
||||
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
|
||||
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Collinear touching cases
|
||||
const onSeg = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) =>
|
||||
Math.min(px, qx) <= rx && rx <= Math.max(px, qx) &&
|
||||
Math.min(pz, qz) <= rz && rz <= Math.max(pz, qz)
|
||||
Math.min(px, qx) <= rx &&
|
||||
rx <= Math.max(px, qx) &&
|
||||
Math.min(pz, qz) <= rz &&
|
||||
rz <= Math.max(pz, qz)
|
||||
|
||||
if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true
|
||||
if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) return true
|
||||
@@ -86,16 +95,27 @@ function segmentsIntersect(
|
||||
* Test if a line segment intersects any edge of a polygon.
|
||||
*/
|
||||
function segmentIntersectsPolygon(
|
||||
sx1: number, sz1: number, sx2: number, sz2: number,
|
||||
sx1: number,
|
||||
sz1: number,
|
||||
sx2: number,
|
||||
sz2: number,
|
||||
polygon: Array<[number, number]>,
|
||||
): boolean {
|
||||
const n = polygon.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
if (segmentsIntersect(
|
||||
sx1, sz1, sx2, sz2,
|
||||
polygon[i]![0], polygon[i]![1], polygon[j]![0], polygon[j]![1],
|
||||
)) {
|
||||
if (
|
||||
segmentsIntersect(
|
||||
sx1,
|
||||
sz1,
|
||||
sx2,
|
||||
sz2,
|
||||
polygon[i]![0],
|
||||
polygon[i]![1],
|
||||
polygon[j]![0],
|
||||
polygon[j]![1],
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -129,10 +149,16 @@ export function itemOverlapsPolygon(
|
||||
// Check if any item edge intersects any polygon edge
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const j = (i + 1) % 4
|
||||
if (segmentIntersectsPolygon(
|
||||
corners[i]![0], corners[i]![1], corners[j]![0], corners[j]![1],
|
||||
polygon,
|
||||
)) return true
|
||||
if (
|
||||
segmentIntersectsPolygon(
|
||||
corners[i]![0],
|
||||
corners[i]![1],
|
||||
corners[j]![0],
|
||||
corners[j]![1],
|
||||
polygon,
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -144,8 +170,14 @@ export function itemOverlapsPolygon(
|
||||
* This prevents walls that just touch one point from being detected.
|
||||
*/
|
||||
function segmentsCollinearAndOverlap(
|
||||
ax1: number, az1: number, ax2: number, az2: number,
|
||||
bx1: number, bz1: number, bx2: number, bz2: number,
|
||||
ax1: number,
|
||||
az1: number,
|
||||
ax2: number,
|
||||
az2: number,
|
||||
bx1: number,
|
||||
bz1: number,
|
||||
bx2: number,
|
||||
bz2: number,
|
||||
): boolean {
|
||||
const EPSILON = 1e-6
|
||||
|
||||
@@ -159,8 +191,10 @@ function segmentsCollinearAndOverlap(
|
||||
|
||||
// Check if a point is on segment b
|
||||
const onSegment = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) =>
|
||||
Math.min(px, qx) - EPSILON <= rx && rx <= Math.max(px, qx) + EPSILON &&
|
||||
Math.min(pz, qz) - EPSILON <= rz && rz <= Math.max(pz, qz) + EPSILON
|
||||
Math.min(px, qx) - EPSILON <= rx &&
|
||||
rx <= Math.max(px, qx) + EPSILON &&
|
||||
Math.min(pz, qz) - EPSILON <= rz &&
|
||||
rz <= Math.max(pz, qz) + EPSILON
|
||||
|
||||
// BOTH endpoints of wall (a) must be on edge (b) for substantial overlap
|
||||
const a1OnB = onSegment(bx1, bz1, bx2, bz2, ax1, az1)
|
||||
@@ -314,7 +348,7 @@ export class SpatialGridManager {
|
||||
// position[1] is the bottom of the item
|
||||
this.getWallGrid(levelId).insert({
|
||||
itemId: item.id,
|
||||
wallId: wallId,
|
||||
wallId,
|
||||
tStart: t - halfW,
|
||||
tEnd: t + halfW,
|
||||
yStart: item.position[1],
|
||||
@@ -328,7 +362,12 @@ export class SpatialGridManager {
|
||||
// Ceiling item - use parentId as the ceiling ID
|
||||
const ceilingId = item.parentId
|
||||
if (ceilingId && this.ceilings.has(ceilingId)) {
|
||||
this.getCeilingGrid(ceilingId).insert(item.id, item.position, getScaledDimensions(item), item.rotation)
|
||||
this.getCeilingGrid(ceilingId).insert(
|
||||
item.id,
|
||||
item.position,
|
||||
getScaledDimensions(item),
|
||||
item.rotation,
|
||||
)
|
||||
this.itemCeilingMap.set(item.id, ceilingId)
|
||||
}
|
||||
} else if (!item.asset.attachTo) {
|
||||
@@ -367,7 +406,7 @@ export class SpatialGridManager {
|
||||
// position[1] is the bottom of the item
|
||||
this.getWallGrid(levelId).insert({
|
||||
itemId: item.id,
|
||||
wallId: wallId,
|
||||
wallId,
|
||||
tStart: t - halfW,
|
||||
tEnd: t + halfW,
|
||||
yStart: item.position[1],
|
||||
@@ -387,7 +426,12 @@ export class SpatialGridManager {
|
||||
// Insert into new ceiling grid
|
||||
const ceilingId = item.parentId
|
||||
if (ceilingId && this.ceilings.has(ceilingId)) {
|
||||
this.getCeilingGrid(ceilingId).insert(item.id, item.position, getScaledDimensions(item), item.rotation)
|
||||
this.getCeilingGrid(ceilingId).insert(
|
||||
item.id,
|
||||
item.position,
|
||||
getScaledDimensions(item),
|
||||
item.rotation,
|
||||
)
|
||||
this.itemCeilingMap.set(item.id, ceilingId)
|
||||
}
|
||||
} else if (!item.asset.attachTo) {
|
||||
@@ -530,9 +574,12 @@ export class SpatialGridManager {
|
||||
const slabMap = this.slabsByLevel.get(levelId)
|
||||
if (!slabMap) return 0
|
||||
|
||||
let maxElevation = -Infinity
|
||||
let maxElevation = Number.NEGATIVE_INFINITY
|
||||
for (const slab of slabMap.values()) {
|
||||
if (slab.polygon.length >= 3 && itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) {
|
||||
if (
|
||||
slab.polygon.length >= 3 &&
|
||||
itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)
|
||||
) {
|
||||
// Check if item is entirely within a hole (if so, ignore this slab)
|
||||
// We consider it entirely in a hole if the item center is in the hole
|
||||
let inHole = false
|
||||
@@ -553,7 +600,7 @@ export class SpatialGridManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxElevation === -Infinity ? 0 : maxElevation
|
||||
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -561,15 +608,11 @@ export class SpatialGridManager {
|
||||
* Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments).
|
||||
* Returns the highest slab elevation found, or 0 if none.
|
||||
*/
|
||||
getSlabElevationForWall(
|
||||
levelId: string,
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
): number {
|
||||
getSlabElevationForWall(levelId: string, start: [number, number], end: [number, number]): number {
|
||||
const slabMap = this.slabsByLevel.get(levelId)
|
||||
if (!slabMap) return 0
|
||||
|
||||
let maxElevation = -Infinity
|
||||
let maxElevation = Number.NEGATIVE_INFINITY
|
||||
for (const slab of slabMap.values()) {
|
||||
if (slab.polygon.length < 3) continue
|
||||
if (!wallOverlapsPolygon(start, end, slab.polygon)) continue
|
||||
@@ -609,7 +652,7 @@ export class SpatialGridManager {
|
||||
if (elevation > maxElevation) maxElevation = elevation
|
||||
}
|
||||
}
|
||||
return maxElevation === -Infinity ? 0 : maxElevation
|
||||
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { getScaledDimensions, type AnyNode, type AnyNodeId, type ItemNode, type SlabNode, type WallNode } from '../../schema'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
type SlabNode,
|
||||
type WallNode,
|
||||
} from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { itemOverlapsPolygon, spatialGridManager, wallOverlapsPolygon } from './spatial-grid-manager'
|
||||
import {
|
||||
itemOverlapsPolygon,
|
||||
spatialGridManager,
|
||||
wallOverlapsPolygon,
|
||||
} from './spatial-grid-manager'
|
||||
|
||||
export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): string {
|
||||
// If the node itself is a level
|
||||
@@ -13,10 +24,10 @@ export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): s
|
||||
while (current) {
|
||||
if (current.type === 'level') return current.id
|
||||
// Find parent (you might need to add parentId to your schema or derive it)
|
||||
if (!current.parentId) {
|
||||
current = undefined
|
||||
} else {
|
||||
if (current.parentId) {
|
||||
current = nodes[current.parentId]
|
||||
} else {
|
||||
current = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,9 +82,11 @@ export function initSpatialGridSync() {
|
||||
|
||||
if (node.type === 'item' && prev.type === 'item') {
|
||||
if (
|
||||
!arraysEqual(node.position, prev.position) ||
|
||||
!arraysEqual(node.rotation, prev.rotation) ||
|
||||
!arraysEqual(node.scale, prev.scale) ||
|
||||
!(
|
||||
arraysEqual(node.position, prev.position) &&
|
||||
arraysEqual(node.rotation, prev.rotation) &&
|
||||
arraysEqual(node.scale, prev.scale)
|
||||
) ||
|
||||
node.parentId !== prev.parentId ||
|
||||
node.side !== prev.side
|
||||
) {
|
||||
@@ -85,7 +98,11 @@ export function initSpatialGridSync() {
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'slab' && prev.type === 'slab') {
|
||||
if (node.polygon !== prev.polygon || node.elevation !== prev.elevation || node.holes !== prev.holes) {
|
||||
if (
|
||||
node.polygon !== prev.polygon ||
|
||||
node.elevation !== prev.elevation ||
|
||||
node.holes !== prev.holes
|
||||
) {
|
||||
const levelId = resolveLevelId(node, state.nodes)
|
||||
spatialGridManager.handleNodeUpdated(node, levelId)
|
||||
|
||||
@@ -119,7 +136,15 @@ function markNodesOverlappingSlab(
|
||||
// Only floor items are affected by slabs
|
||||
if (item.asset.attachTo) continue
|
||||
if (resolveLevelId(node, nodes) !== slabLevelId) continue
|
||||
if (itemOverlapsPolygon(item.position, getScaledDimensions(item), item.rotation, slab.polygon, 0.01)) {
|
||||
if (
|
||||
itemOverlapsPolygon(
|
||||
item.position,
|
||||
getScaledDimensions(item),
|
||||
item.rotation,
|
||||
slab.polygon,
|
||||
0.01,
|
||||
)
|
||||
) {
|
||||
markDirty(node.id)
|
||||
}
|
||||
} else if (node.type === 'wall') {
|
||||
|
||||
@@ -49,7 +49,13 @@ export function useSpatialQuery() {
|
||||
rotation: [number, number, number],
|
||||
ignoreIds?: string[],
|
||||
) => {
|
||||
return spatialGridManager.canPlaceOnCeiling(ceilingId, position, dimensions, rotation, ignoreIds)
|
||||
return spatialGridManager.canPlaceOnCeiling(
|
||||
ceilingId,
|
||||
position,
|
||||
dimensions,
|
||||
rotation,
|
||||
ignoreIds,
|
||||
)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
@@ -140,7 +140,7 @@ export class WallSpatialGrid {
|
||||
|
||||
// Both are 'wall-side' - only conflict if they're on the same side
|
||||
// If either side is undefined, be conservative and assume conflict
|
||||
if (!newSide || !existing.side) {
|
||||
if (!(newSide && existing.side)) {
|
||||
return true
|
||||
}
|
||||
return newSide === existing.side
|
||||
|
||||
@@ -41,7 +41,11 @@ export {
|
||||
} from './lib/space-detection'
|
||||
// Schema
|
||||
export * from './schema'
|
||||
export { useInteractive, type ControlValue, type ItemInteractiveState } from './store/use-interactive'
|
||||
export {
|
||||
type ControlValue,
|
||||
type ItemInteractiveState,
|
||||
useInteractive,
|
||||
} from './store/use-interactive'
|
||||
export { default as useScene } from './store/use-scene'
|
||||
// Systems
|
||||
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
||||
|
||||
@@ -199,7 +199,7 @@ type WallSideUpdate = {
|
||||
export function detectSpacesForLevel(
|
||||
levelId: string,
|
||||
walls: WallNode[],
|
||||
gridResolution: number = 0.5, // Match spatial grid cell size
|
||||
gridResolution = 0.5, // Match spatial grid cell size
|
||||
): {
|
||||
wallUpdates: WallSideUpdate[]
|
||||
spaces: Space[]
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
// Base
|
||||
export { BaseNode, generateId, Material, nodeType, objectId } from './base'
|
||||
// Collections
|
||||
export { generateCollectionId, type Collection, type CollectionId } from './collections'
|
||||
// Camera
|
||||
export { CameraSchema } from './camera'
|
||||
export type { AnimationEffect, Asset, AssetInput, Control, Effect, Interactive, LightEffect, SliderControl, TemperatureControl, ToggleControl } from './nodes/item'
|
||||
// Collections
|
||||
export { type Collection, type CollectionId, generateCollectionId } from './collections'
|
||||
export { BuildingNode } from './nodes/building'
|
||||
export { CeilingNode } from './nodes/ceiling'
|
||||
export { DoorNode, DoorSegment } from './nodes/door'
|
||||
export { GuideNode } from './nodes/guide'
|
||||
export type {
|
||||
AnimationEffect,
|
||||
Asset,
|
||||
AssetInput,
|
||||
Control,
|
||||
Effect,
|
||||
Interactive,
|
||||
LightEffect,
|
||||
SliderControl,
|
||||
TemperatureControl,
|
||||
ToggleControl,
|
||||
} from './nodes/item'
|
||||
export { getScaledDimensions, ItemNode } from './nodes/item'
|
||||
export { LevelNode } from './nodes/level'
|
||||
export { RoofNode } from './nodes/roof'
|
||||
export { ScanNode } from './nodes/scan'
|
||||
// Nodes
|
||||
export { SiteNode } from './nodes/site'
|
||||
export { SlabNode } from './nodes/slab'
|
||||
export { WallNode } from './nodes/wall'
|
||||
export { BuildingNode } from './nodes/building'
|
||||
export { CeilingNode } from './nodes/ceiling'
|
||||
|
||||
export { ZoneNode } from './nodes/zone'
|
||||
export { RoofNode } from './nodes/roof'
|
||||
export { ScanNode } from './nodes/scan'
|
||||
export { GuideNode } from './nodes/guide'
|
||||
export type { AnyNodeId, AnyNodeType } from './types'
|
||||
export { DoorNode, DoorSegment } from './nodes/door'
|
||||
export { WindowNode } from './nodes/window'
|
||||
export { ZoneNode } from './nodes/zone'
|
||||
export type { AnyNodeId, AnyNodeType } from './types'
|
||||
// Union types
|
||||
export { AnyNode } from './types'
|
||||
|
||||
@@ -11,7 +11,7 @@ export const DoorSegment = z.object({
|
||||
dividerThickness: z.number().default(0.03),
|
||||
|
||||
// panel-specific
|
||||
panelDepth: z.number().default(0.01), // + raised, - recessed
|
||||
panelDepth: z.number().default(0.01), // + raised, - recessed
|
||||
panelInset: z.number().default(0.04),
|
||||
})
|
||||
|
||||
@@ -42,8 +42,22 @@ export const DoorNode = BaseNode.extend({
|
||||
|
||||
// Leaf segments — stacked top to bottom, each with its own column split
|
||||
segments: z.array(DoorSegment).default([
|
||||
{ type: 'panel', heightRatio: 0.4, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
|
||||
{ type: 'panel', heightRatio: 0.6, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
|
||||
{
|
||||
type: 'panel',
|
||||
heightRatio: 0.4,
|
||||
columnRatios: [1],
|
||||
dividerThickness: 0.03,
|
||||
panelDepth: 0.01,
|
||||
panelInset: 0.04,
|
||||
},
|
||||
{
|
||||
type: 'panel',
|
||||
heightRatio: 0.6,
|
||||
columnRatios: [1],
|
||||
dividerThickness: 0.03,
|
||||
panelDepth: 0.01,
|
||||
panelInset: 0.04,
|
||||
},
|
||||
]),
|
||||
|
||||
// Handle
|
||||
@@ -58,7 +72,6 @@ export const DoorNode = BaseNode.extend({
|
||||
doorCloser: z.boolean().default(false),
|
||||
panicBar: z.boolean().default(false),
|
||||
panicBarHeight: z.number().default(1.0),
|
||||
|
||||
}).describe(dedent`Door node - a parametric door placed on a wall
|
||||
- position: center of the door in wall-local coordinate system (Y = height/2, always at floor)
|
||||
- segments: rows stacked top to bottom, each defining its own columnRatios
|
||||
|
||||
@@ -56,10 +56,7 @@ const lightEffectSchema = z.object({
|
||||
offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
})
|
||||
|
||||
const effectSchema = z.discriminatedUnion('kind', [
|
||||
animationEffectSchema,
|
||||
lightEffectSchema,
|
||||
])
|
||||
const effectSchema = z.discriminatedUnion('kind', [animationEffectSchema, lightEffectSchema])
|
||||
|
||||
// --- Interactive descriptor ---
|
||||
|
||||
|
||||
@@ -12,7 +12,19 @@ import { ZoneNode } from './zone'
|
||||
export const LevelNode = BaseNode.extend({
|
||||
id: objectId('level'),
|
||||
type: nodeType('level'),
|
||||
children: z.array(z.union([WallNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, CeilingNode.shape.id, RoofNode.shape.id, ScanNode.shape.id, GuideNode.shape.id])).default([]),
|
||||
children: z
|
||||
.array(
|
||||
z.union([
|
||||
WallNode.shape.id,
|
||||
ZoneNode.shape.id,
|
||||
SlabNode.shape.id,
|
||||
CeilingNode.shape.id,
|
||||
RoofNode.shape.id,
|
||||
ScanNode.shape.id,
|
||||
GuideNode.shape.id,
|
||||
]),
|
||||
)
|
||||
.default([]),
|
||||
// Specific props
|
||||
level: z.number().default(0),
|
||||
}).describe(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import z from 'zod'
|
||||
import { BuildingNode } from './nodes/building'
|
||||
import { CeilingNode } from './nodes/ceiling'
|
||||
import { DoorNode } from './nodes/door'
|
||||
import { GuideNode } from './nodes/guide'
|
||||
import { ItemNode } from './nodes/item'
|
||||
import { LevelNode } from './nodes/level'
|
||||
@@ -8,7 +9,6 @@ import { RoofNode } from './nodes/roof'
|
||||
import { ScanNode } from './nodes/scan'
|
||||
import { SiteNode } from './nodes/site'
|
||||
import { SlabNode } from './nodes/slab'
|
||||
import { DoorNode } from './nodes/door'
|
||||
import { WallNode } from './nodes/wall'
|
||||
import { WindowNode } from './nodes/window'
|
||||
import { ZoneNode } from './nodes/zone'
|
||||
|
||||
@@ -164,7 +164,6 @@ export const deleteNodesAction = (
|
||||
return { nodes: nextNodes, rootNodeIds: nextRootIds, collections: nextCollections }
|
||||
})
|
||||
|
||||
|
||||
// Trigger a full scene re-validation after deleting node (as deleting a slab can cause widespread changes to level elevations)
|
||||
const currentNodes = get().nodes
|
||||
Object.values(currentNodes).forEach((node) => {
|
||||
|
||||
@@ -172,7 +172,8 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
for (const nodeId of nodeIds) {
|
||||
const node = nextNodes[nodeId]
|
||||
if (!node) continue
|
||||
const existing = ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
||||
const existing =
|
||||
('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
||||
nextNodes[nodeId] = { ...node, collectionIds: [...existing, id] } as AnyNode
|
||||
}
|
||||
return { collections: nextCollections, nodes: nextNodes }
|
||||
@@ -189,7 +190,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
const nextNodes = { ...state.nodes }
|
||||
for (const nodeId of col?.nodeIds ?? []) {
|
||||
const node = nextNodes[nodeId]
|
||||
if (!node || !('collectionIds' in node)) continue
|
||||
if (!(node && 'collectionIds' in node)) continue
|
||||
nextNodes[nodeId] = {
|
||||
...node,
|
||||
collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id),
|
||||
@@ -217,7 +218,8 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
}
|
||||
const node = state.nodes[nodeId]
|
||||
if (!node) return { collections: nextCollections }
|
||||
const existing = ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
||||
const existing =
|
||||
('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
||||
const nextNodes = {
|
||||
...state.nodes,
|
||||
[nodeId]: { ...node, collectionIds: [...existing, id] } as AnyNode,
|
||||
@@ -235,7 +237,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
[id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) },
|
||||
}
|
||||
const node = state.nodes[nodeId]
|
||||
if (!node || !('collectionIds' in node)) return { collections: nextCollections }
|
||||
if (!(node && 'collectionIds' in node)) return { collections: nextCollections }
|
||||
const nextNodes = {
|
||||
...state.nodes,
|
||||
[nodeId]: {
|
||||
@@ -279,7 +281,10 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
if (persisted.nodes) {
|
||||
for (const [id, node] of Object.entries(persisted.nodes)) {
|
||||
if (node.type === 'item' && !('scale' in node)) {
|
||||
persisted.nodes[id as AnyNodeId] = { ...(node as object), scale: [1, 1, 1] } as AnyNode
|
||||
persisted.nodes[id as AnyNodeId] = {
|
||||
...(node as object),
|
||||
scale: [1, 1, 1],
|
||||
} as AnyNode
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,8 +313,8 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
|
||||
// Collect existing root nodes (should be BuildingNode or ItemNode)
|
||||
const existingRoots = (state.rootNodeIds || [])
|
||||
.map(id => state.nodes[id])
|
||||
.filter(node => node?.type === 'building' || node?.type === 'item')
|
||||
.map((id) => state.nodes[id])
|
||||
.filter((node) => node?.type === 'building' || node?.type === 'item')
|
||||
|
||||
// Create a new SiteNode with existing roots as children
|
||||
const site = SiteNode.parse({
|
||||
|
||||
@@ -58,8 +58,12 @@ export const DoorSystem = () => {
|
||||
function addBox(
|
||||
parent: THREE.Object3D,
|
||||
material: THREE.Material,
|
||||
w: number, h: number, d: number,
|
||||
x: number, y: number, z: number,
|
||||
w: number,
|
||||
h: number,
|
||||
d: number,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
) {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
||||
m.position.set(x, y, z)
|
||||
@@ -84,29 +88,77 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
}
|
||||
|
||||
const {
|
||||
width, height, frameThickness, frameDepth, threshold, thresholdHeight,
|
||||
segments, handle, handleHeight, handleSide,
|
||||
doorCloser, panicBar, panicBarHeight, contentPadding, hingesSide,
|
||||
width,
|
||||
height,
|
||||
frameThickness,
|
||||
frameDepth,
|
||||
threshold,
|
||||
thresholdHeight,
|
||||
segments,
|
||||
handle,
|
||||
handleHeight,
|
||||
handleSide,
|
||||
doorCloser,
|
||||
panicBar,
|
||||
panicBarHeight,
|
||||
contentPadding,
|
||||
hingesSide,
|
||||
} = node
|
||||
|
||||
// Leaf occupies the full opening (no bottom frame bar — door opens to floor)
|
||||
const leafW = width - 2 * frameThickness
|
||||
const leafH = height - frameThickness // only top frame
|
||||
const leafH = height - frameThickness // only top frame
|
||||
const leafDepth = 0.04
|
||||
// Leaf center is shifted down from door center by half the top frame
|
||||
const leafCenterY = -frameThickness / 2
|
||||
|
||||
// ── Frame members ──
|
||||
// Left post — full height
|
||||
addBox(mesh, baseMaterial, frameThickness, height, frameDepth, -width / 2 + frameThickness / 2, 0, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
frameThickness,
|
||||
height,
|
||||
frameDepth,
|
||||
-width / 2 + frameThickness / 2,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
// Right post — full height
|
||||
addBox(mesh, baseMaterial, frameThickness, height, frameDepth, width / 2 - frameThickness / 2, 0, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
frameThickness,
|
||||
height,
|
||||
frameDepth,
|
||||
width / 2 - frameThickness / 2,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
// Head (top bar) — full width
|
||||
addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
width,
|
||||
frameThickness,
|
||||
frameDepth,
|
||||
0,
|
||||
height / 2 - frameThickness / 2,
|
||||
0,
|
||||
)
|
||||
|
||||
// ── Threshold (inside the frame) ──
|
||||
if (threshold) {
|
||||
addBox(mesh, baseMaterial, leafW, thresholdHeight, frameDepth, 0, -height / 2 + thresholdHeight / 2, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
leafW,
|
||||
thresholdHeight,
|
||||
frameDepth,
|
||||
0,
|
||||
-height / 2 + thresholdHeight / 2,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Leaf — contentPadding border strips (no full backing; glass areas are open) ──
|
||||
@@ -142,7 +194,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
const numCols = seg.columnRatios.length
|
||||
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
||||
const usableW = contentW - (numCols - 1) * seg.dividerThickness
|
||||
const colWidths = seg.columnRatios.map(r => (r / colSum) * usableW)
|
||||
const colWidths = seg.columnRatios.map((r) => (r / colSum) * usableW)
|
||||
|
||||
// Column x-centers (relative to mesh center)
|
||||
const colXCenters: number[] = []
|
||||
@@ -157,7 +209,16 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
cx = -contentW / 2
|
||||
for (let c = 0; c < numCols - 1; c++) {
|
||||
cx += colWidths[c]!
|
||||
addBox(mesh, baseMaterial, seg.dividerThickness, segH, leafDepth + 0.001, cx + seg.dividerThickness / 2, segCenterY, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
seg.dividerThickness,
|
||||
segH,
|
||||
leafDepth + 0.001,
|
||||
cx + seg.dividerThickness / 2,
|
||||
segCenterY,
|
||||
0,
|
||||
)
|
||||
cx += seg.dividerThickness
|
||||
}
|
||||
|
||||
@@ -198,9 +259,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
const faceZ = leafDepth / 2
|
||||
|
||||
// X position: handleSide refers to which side the grip is on
|
||||
const handleX = handleSide === 'right'
|
||||
? leafW / 2 - 0.045
|
||||
: -leafW / 2 + 0.045
|
||||
const handleX = handleSide === 'right' ? leafW / 2 - 0.045 : -leafW / 2 + 0.045
|
||||
|
||||
// Backplate
|
||||
addBox(mesh, baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005)
|
||||
@@ -214,7 +273,16 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
// Body
|
||||
addBox(mesh, baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03)
|
||||
// Arm (simplified as thin bar to frame side)
|
||||
addBox(mesh, baseMaterial, 0.14, 0.015, 0.015, leafW / 4, closerY + 0.025, leafDepth / 2 + 0.015)
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
0.14,
|
||||
0.015,
|
||||
0.015,
|
||||
leafW / 4,
|
||||
closerY + 0.025,
|
||||
leafDepth / 2 + 0.015,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Panic bar ──
|
||||
@@ -225,9 +293,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
|
||||
// ── Hinges (3 knuckle-style hinges on the hinge side) ──
|
||||
{
|
||||
const hingeX = hingesSide === 'right'
|
||||
? leafW / 2 - 0.012
|
||||
: -leafW / 2 + 0.012
|
||||
const hingeX = hingesSide === 'right' ? leafW / 2 - 0.012 : -leafW / 2 + 0.012
|
||||
const hingeZ = 0 // centered in leaf depth
|
||||
const hingeH = 0.1
|
||||
const hingeW = 0.024
|
||||
|
||||
@@ -74,8 +74,8 @@ function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<
|
||||
offEdges.push([polygon[i]![0], polygon[i]![1], dx, dz])
|
||||
continue
|
||||
}
|
||||
const nx = (s * dz / len) * amount
|
||||
const nz = (s * -dx / len) * amount
|
||||
const nx = ((s * dz) / len) * amount
|
||||
const nz = ((s * -dx) / len) * amount
|
||||
offEdges.push([polygon[i]![0] + nx, polygon[i]![1] + nz, dx, dz])
|
||||
}
|
||||
|
||||
|
||||
@@ -321,10 +321,10 @@ function collectCutoutBrushes(
|
||||
|
||||
// Calculate bounds in wall-local space
|
||||
const v3 = new THREE.Vector3()
|
||||
let minX = Infinity,
|
||||
maxX = -Infinity
|
||||
let minY = Infinity,
|
||||
maxY = -Infinity
|
||||
let minX = Number.POSITIVE_INFINITY,
|
||||
maxX = Number.NEGATIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY,
|
||||
maxY = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
v3.fromBufferAttribute(positions, i)
|
||||
|
||||
@@ -58,8 +58,12 @@ export const WindowSystem = () => {
|
||||
function addBox(
|
||||
parent: THREE.Object3D,
|
||||
material: THREE.Material,
|
||||
w: number, h: number, d: number,
|
||||
x: number, y: number, z: number,
|
||||
w: number,
|
||||
h: number,
|
||||
d: number,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
) {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
||||
m.position.set(x, y, z)
|
||||
@@ -84,9 +88,17 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
}
|
||||
|
||||
const {
|
||||
width, height, frameDepth, frameThickness,
|
||||
columnRatios, rowRatios, columnDividerThickness, rowDividerThickness,
|
||||
sill, sillDepth, sillThickness,
|
||||
width,
|
||||
height,
|
||||
frameDepth,
|
||||
frameThickness,
|
||||
columnRatios,
|
||||
rowRatios,
|
||||
columnDividerThickness,
|
||||
rowDividerThickness,
|
||||
sill,
|
||||
sillDepth,
|
||||
sillThickness,
|
||||
} = node
|
||||
|
||||
const innerW = width - 2 * frameThickness
|
||||
@@ -94,11 +106,47 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
|
||||
// ── Frame members ──
|
||||
// Top / bottom — full width
|
||||
addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0)
|
||||
addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
frameMaterial,
|
||||
width,
|
||||
frameThickness,
|
||||
frameDepth,
|
||||
0,
|
||||
height / 2 - frameThickness / 2,
|
||||
0,
|
||||
)
|
||||
addBox(
|
||||
mesh,
|
||||
frameMaterial,
|
||||
width,
|
||||
frameThickness,
|
||||
frameDepth,
|
||||
0,
|
||||
-height / 2 + frameThickness / 2,
|
||||
0,
|
||||
)
|
||||
// Left / right — inner height to avoid corner overlap
|
||||
addBox(mesh, frameMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0)
|
||||
addBox(mesh, frameMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
frameMaterial,
|
||||
frameThickness,
|
||||
innerH,
|
||||
frameDepth,
|
||||
-width / 2 + frameThickness / 2,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
addBox(
|
||||
mesh,
|
||||
frameMaterial,
|
||||
frameThickness,
|
||||
innerH,
|
||||
frameDepth,
|
||||
width / 2 - frameThickness / 2,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
// ── Pane grid ──
|
||||
const numCols = columnRatios.length
|
||||
@@ -109,8 +157,8 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
|
||||
const colSum = columnRatios.reduce((a, b) => a + b, 0)
|
||||
const rowSum = rowRatios.reduce((a, b) => a + b, 0)
|
||||
const colWidths = columnRatios.map(r => (r / colSum) * usableW)
|
||||
const rowHeights = rowRatios.map(r => (r / rowSum) * usableH)
|
||||
const colWidths = columnRatios.map((r) => (r / colSum) * usableW)
|
||||
const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH)
|
||||
|
||||
// Compute column x-centers starting from left edge of inner area
|
||||
const colXCenters: number[] = []
|
||||
@@ -134,7 +182,16 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
cx = -innerW / 2
|
||||
for (let c = 0; c < numCols - 1; c++) {
|
||||
cx += colWidths[c]!
|
||||
addBox(mesh, frameMaterial, columnDividerThickness, innerH, frameDepth, cx + columnDividerThickness / 2, 0, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
frameMaterial,
|
||||
columnDividerThickness,
|
||||
innerH,
|
||||
frameDepth,
|
||||
cx + columnDividerThickness / 2,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
cx += columnDividerThickness
|
||||
}
|
||||
|
||||
@@ -144,7 +201,16 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
cy -= rowHeights[r]!
|
||||
const divY = cy - rowDividerThickness / 2
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
addBox(mesh, frameMaterial, colWidths[c]!, rowDividerThickness, frameDepth, colXCenters[c]!, divY, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
frameMaterial,
|
||||
colWidths[c]!,
|
||||
rowDividerThickness,
|
||||
frameDepth,
|
||||
colXCenters[c]!,
|
||||
divY,
|
||||
0,
|
||||
)
|
||||
}
|
||||
cy -= rowDividerThickness
|
||||
}
|
||||
@@ -153,7 +219,16 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
const glassDepth = Math.max(0.004, frameDepth * 0.08)
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
for (let r = 0; r < numRows; r++) {
|
||||
addBox(mesh, glassMaterial, colWidths[c]!, rowHeights[r]!, glassDepth, colXCenters[c]!, rowYCenters[r]!, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
glassMaterial,
|
||||
colWidths[c]!,
|
||||
rowHeights[r]!,
|
||||
glassDepth,
|
||||
colXCenters[c]!,
|
||||
rowYCenters[r]!,
|
||||
0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +237,16 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
const sillW = width + sillDepth * 0.4 // slightly wider than frame
|
||||
// Protrudes from the front face of the frame (+Z)
|
||||
const sillZ = frameDepth / 2 + sillDepth / 2
|
||||
addBox(mesh, frameMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ)
|
||||
addBox(
|
||||
mesh,
|
||||
frameMaterial,
|
||||
sillW,
|
||||
sillThickness,
|
||||
sillDepth,
|
||||
0,
|
||||
-height / 2 - sillThickness / 2,
|
||||
sillZ,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Cutout (for wall CSG) — always full window dimensions, 1m deep ──
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
*/
|
||||
export const isObject = (val: unknown): val is Record<string, any> => {
|
||||
return val !== null && typeof val === 'object' && !Array.isArray(val)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/react-library.json",
|
||||
"extends": "@pascal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": false,
|
||||
"composite": true,
|
||||
"incremental": true
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user