remove community

This commit is contained in:
wass08
2026-03-13 09:07:46 +01:00
parent 3eeba322a8
commit f2501be2bf
693 changed files with 8264 additions and 35562 deletions
+1 -1
View File
@@ -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"
+13 -1
View File
@@ -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
+5 -1
View File
@@ -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'
+1 -1
View File
@@ -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[]
+22 -12
View File
@@ -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'
+17 -4
View File
@@ -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
+1 -4
View File
@@ -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 ---
+13 -1
View File
@@ -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 -1
View File
@@ -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) => {
+12 -7
View File
@@ -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({
+85 -19
View File
@@ -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 ──
+1 -1
View File
@@ -4,4 +4,4 @@
*/
export const isObject = (val: unknown): val is Record<string, any> => {
return val !== null && typeof val === 'object' && !Array.isArray(val)
}
}
+2 -1
View File
@@ -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
},