Merge pull request #286 from sudhir9297/feat/improvement-and-fixes

Feat/improvement and fixes
This commit is contained in:
Wassim SAMAD
2026-05-04 09:30:03 -04:00
committed by GitHub
54 changed files with 6288 additions and 883 deletions
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Office chair floor plan">
<g fill="#ffffff" stroke="#3f3f3f" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" transform="rotate(180 128 128)">
<path d="M78 72c0-9 100-9 100 0v11c-27-7-73-7-100 0z"/>
<path d="M89 88c22-9 56-9 78 0"/>
<path d="M78 106c10-16 90-16 100 0l7 62c-4 19-15 31-57 31s-53-12-57-31z"/>
<path d="M54 110c-9 0-15 7-15 17v38c0 10 6 17 15 17h7l6-72z"/>
<path d="M202 110c9 0 15 7 15 17v38c0 10-6 17-15 17h-7l-6-72z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 572 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 256" role="img" aria-label="Sofa floor plan">
<g fill="#ffffff" stroke="#3f3f3f" stroke-linecap="round" stroke-linejoin="round" stroke-width="3">
<path d="M70 45h372c9 0 16 7 16 16v140c0 16-13 29-29 29H83c-16 0-29-13-29-29V61c0-9 7-16 16-16z"/>
<path d="M80 45v141"/>
<path d="M432 45v141"/>
<path d="M256 45v141"/>
<path d="M80 186h352"/>
<rect x="99" y="126" width="141" height="62" rx="8"/>
<rect x="272" y="126" width="141" height="62" rx="8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 550 B

+13 -2
View File
@@ -56,8 +56,6 @@
"idb-keyval": "^6.2.2",
"mitt": "^3.0.1",
"nanoid": "^5.1.6",
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8",
"zod": "^4.3.5",
"zundo": "^2.3.0",
"zustand": "^5",
@@ -79,6 +77,9 @@
"name": "@pascal-app/editor",
"version": "0.6.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@iconify/react": "^6.0.2",
"@number-flow/react": "^0.5.14",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -193,6 +194,8 @@
"version": "0.6.0",
"dependencies": {
"polygon-clipping": "^0.15.7",
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8",
"zustand": "^5",
},
"devDependencies": {
@@ -278,6 +281,14 @@
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
-2
View File
@@ -67,8 +67,6 @@
"idb-keyval": "^6.2.2",
"mitt": "^3.0.1",
"nanoid": "^5.1.6",
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8",
"zod": "^4.3.5",
"zundo": "^2.3.0",
"zustand": "^5"
+8
View File
@@ -6,6 +6,7 @@ import type {
CeilingNode,
DoorNode,
FenceNode,
GuideNode,
ItemNode,
LevelNode,
RoofNode,
@@ -130,6 +131,12 @@ type ToolEvents = {
'tool:cancel': undefined
}
type GuideEvents = {
'guide:set-reference-scale': { guideId: GuideNode['id'] }
'guide:cancel-reference-scale': undefined
'guide:deleted': { guideId: GuideNode['id'] }
}
type PresetEvents = {
'preset:generate-thumbnail': { presetId: string; nodeId: string }
'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string }
@@ -170,6 +177,7 @@ type EditorEvents = GridEvents &
NodeEvents<'door', DoorEvent> &
CameraControlEvents &
ToolEvents &
GuideEvents &
PresetEvents &
ThumbnailEvents &
SnapshotEvents &
+9 -16
View File
@@ -33,6 +33,7 @@ export {
} from './hooks/spatial-grid/spatial-grid-sync'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
export { loadAssetUrl, saveAsset } from './lib/asset-storage'
export { getRenderableSlabPolygon } from './lib/slab-polygon'
export {
detectSpacesForLevel,
initSpaceDetectionSync,
@@ -47,32 +48,25 @@ export {
LIBRARY_MATERIAL_REF_PREFIX,
MATERIAL_CATALOG,
MATERIAL_CATEGORIES,
type MaterialCategory,
type MaterialCatalogItem,
type MaterialCategory,
toLibraryMaterialRef,
} from './material-library'
export { baseMaterial, glassMaterial } from './materials'
export * from './schema'
export {
type ControlValue,
type ItemInteractiveState,
useInteractive,
} from './store/use-interactive'
export {
getSceneHistoryPauseDepth,
pauseSceneHistory,
resetSceneHistoryPauseDepth,
resumeSceneHistory,
} from './store/history-control'
export {
type ControlValue,
type ItemInteractiveState,
useInteractive,
} from './store/use-interactive'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { clearSceneHistory, default as useScene } from './store/use-scene'
export { CeilingSystem } from './systems/ceiling/ceiling-system'
export { DoorSystem } from './systems/door/door-system'
export { FenceSystem } from './systems/fence/fence-system'
export { ItemSystem } from './systems/item/item-system'
export { RoofSystem } from './systems/roof/roof-system'
export { SlabSystem } from './systems/slab/slab-system'
export { StairSystem } from './systems/stair/stair-system'
export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
export {
getClampedWallCurveOffset,
getMaxWallCurveOffset,
@@ -94,14 +88,13 @@ export {
} from './systems/wall/wall-footprint'
export {
calculateLevelMiters,
getAdjacentWallIds,
getWallMiterBoundaryPoints,
type Point2D,
pointToKey,
type WallMiterBoundaryPoints,
type WallMiterData,
} from './systems/wall/wall-mitering'
export { WallSystem } from './systems/wall/wall-system'
export { WindowSystem } from './systems/window/window-system'
export type { SceneGraph } from './utils/clone-scene-graph'
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
export { isObject } from './utils/types'
+67
View File
@@ -0,0 +1,67 @@
import type { SlabNode } from '../schema'
import { insetPolygonFromCentroid, simplifyClosedPolygon } from './polygon-geometry'
/** Half of default wall thickness — used to extend slab geometry under walls */
const SLAB_OUTSET = 0.05
const AUTO_SLAB_INSET = 0.02
const AUTO_SLAB_SIMPLIFY_TOLERANCE = 0.08
export function getRenderableSlabPolygon(slabNode: SlabNode): Array<[number, number]> {
return slabNode.autoFromWalls
? simplifyClosedPolygon(
insetPolygonFromCentroid(slabNode.polygon, AUTO_SLAB_INSET),
AUTO_SLAB_SIMPLIFY_TOLERANCE,
)
: outsetPolygon(slabNode.polygon, SLAB_OUTSET)
}
/**
* Expand a polygon outward by a uniform distance.
* Offsets each edge outward then intersects consecutive offset edges.
*/
function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<[number, number]> {
const n = polygon.length
if (n < 3) return polygon
// Determine winding via signed area
let area2 = 0
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
area2 += polygon[i]![0] * polygon[j]![1] - polygon[j]![0] * polygon[i]![1]
}
const s = area2 >= 0 ? 1 : -1
// Offset each edge outward by amount
const offEdges: Array<[number, number, number, number]> = []
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const dx = polygon[j]![0] - polygon[i]![0]
const dz = polygon[j]![1] - polygon[i]![1]
const len = Math.sqrt(dx * dx + dz * dz)
if (len < 1e-9) {
offEdges.push([polygon[i]![0], polygon[i]![1], dx, dz])
continue
}
const nx = ((s * dz) / len) * amount
const nz = ((s * -dx) / len) * amount
offEdges.push([polygon[i]![0] + nx, polygon[i]![1] + nz, dx, dz])
}
// Intersect consecutive offset edges to get new vertices
const result: Array<[number, number]> = []
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const [ax, az, adx, adz] = offEdges[i]!
const [bx, bz, bdx, bdz] = offEdges[j]!
const denom = adx * bdz - adz * bdx
if (Math.abs(denom) < 1e-9) {
// Parallel edges — use offset endpoint
result.push([ax + adx, az + adz])
} else {
const t = ((bx - ax) * bdz - (bz - az) * bdx) / denom
result.push([ax + t * adx, az + t * adz])
}
}
return result
}
-24
View File
@@ -1,24 +0,0 @@
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
/**
* Shared base material for structural elements: walls, frames, slabs, roof.
*/
export const baseMaterial = new MeshStandardNodeMaterial({
color: '#f2f0ed',
roughness: 0.5,
metalness: 0,
})
/**
* Shared glass material for windows, glazed door panels, and glass items.
*/
export const glassMaterial = new MeshStandardNodeMaterial({
name: 'glass',
color: 'lightblue',
roughness: 0.05,
metalness: 0.1,
transparent: true,
opacity: 0.35,
side: DoubleSide,
depthWrite: false,
})
+3 -3
View File
@@ -28,7 +28,7 @@ export { BuildingNode } from './nodes/building'
export { CeilingNode } from './nodes/ceiling'
export { DoorNode, DoorSegment } from './nodes/door'
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
export { GuideNode } from './nodes/guide'
export { GuideNode, GuideScaleReference } from './nodes/guide'
export type {
AnimationEffect,
Asset,
@@ -43,13 +43,14 @@ export type {
} from './nodes/item'
export { getScaledDimensions, ItemNode } from './nodes/item'
export { LevelNode } from './nodes/level'
export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
export { RoofSegmentNode, RoofType } from './nodes/roof-segment'
export { ScanNode } from './nodes/scan'
// Nodes
export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab'
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
export { SpawnNode } from './nodes/spawn'
export {
getEffectiveStairSurfaceMaterial,
@@ -59,7 +60,6 @@ export {
StairTopLandingMode,
StairType,
} from './nodes/stair'
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall'
+16 -1
View File
@@ -32,6 +32,15 @@ export const DoorNode = BaseNode.extend({
width: z.number().default(0.9),
height: z.number().default(2.1),
// Opening mode
openingKind: z.enum(['door', 'opening']).default('door'),
openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'),
openingRadiusMode: z.enum(['all', 'individual']).default('all'),
openingTopRadii: z.tuple([z.number(), z.number()]).default([0.15, 0.15]),
cornerRadius: z.number().min(0).default(0.15),
archHeight: z.number().min(0).default(0.45),
openingRevealRadius: z.number().min(0).default(0.025),
// Frame
frameThickness: z.number().default(0.05),
frameDepth: z.number().default(0.07),
@@ -41,6 +50,11 @@ export const DoorNode = BaseNode.extend({
// Swing
hingesSide: z.enum(['left', 'right']).default('left'),
swingDirection: z.enum(['inward', 'outward']).default('inward'),
swingAngle: z
.number()
.min(0)
.max(Math.PI / 2)
.default(0),
// Leaf segments — stacked top to bottom, each with its own column split
segments: z.array(DoorSegment).default([
@@ -76,9 +90,10 @@ export const DoorNode = BaseNode.extend({
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)
- openingKind/openingShape: hinged door or frameless wall opening shape
- segments: rows stacked top to bottom, each defining its own columnRatios
- type 'empty' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed
- hingesSide/swingDirection: which way the door opens
- hingesSide/swingDirection/swingAngle: which way the door opens and how far it is currently open
- doorCloser/panicBar: commercial and emergency hardware options
`)
+11
View File
@@ -2,6 +2,15 @@ import { z } from 'zod'
import { AssetUrl } from '../asset-url'
import { BaseNode, nodeType, objectId } from '../base'
export const GuideScaleReference = z.object({
start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]),
realLengthMeters: z.number().positive(),
measuredLengthUnits: z.number().positive(),
metersPerUnit: z.number().positive(),
label: z.string(),
})
export const GuideNode = BaseNode.extend({
id: objectId('guide'),
type: nodeType('guide'),
@@ -10,6 +19,8 @@ export const GuideNode = BaseNode.extend({
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
scale: z.number().default(1),
opacity: z.number().min(0).max(100).default(50),
scaleReference: GuideScaleReference.nullable().default(null),
})
export type GuideScaleReference = z.infer<typeof GuideScaleReference>
export type GuideNode = z.infer<typeof GuideNode>
+11
View File
@@ -19,6 +19,17 @@ export const WindowNode = BaseNode.extend({
width: z.number().default(1.5),
height: z.number().default(1.5),
// Opening mode - when set to "opening", the window is only a shaped cutout
openingKind: z.enum(['window', 'opening']).default('window'),
openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'),
openingRadiusMode: z.enum(['all', 'individual']).default('all'),
openingCornerRadii: z
.tuple([z.number(), z.number(), z.number(), z.number()])
.default([0.15, 0.15, 0.15, 0.15]),
cornerRadius: z.number().default(0.15),
archHeight: z.number().default(0.35),
openingRevealRadius: z.number().default(0.025),
// Frame
frameThickness: z.number().default(0.05),
frameDepth: z.number().default(0.07),
+3
View File
@@ -21,6 +21,9 @@
"three": "^0.184"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@iconify/react": "^6.0.2",
"@number-flow/react": "^0.5.14",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -15,6 +15,7 @@ export type FloorplanActionMenuEntry = {
position: SvgPoint | null
onDelete: FloorplanActionMenuHandler
onMove: FloorplanActionMenuHandler
onAddHole?: FloorplanActionMenuHandler
onDuplicate?: FloorplanActionMenuHandler
}
@@ -76,6 +77,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
}}
>
<NodeActionMenu
onAddHole={entry.onAddHole}
onDelete={entry.onDelete}
onDuplicate={entry.onDuplicate}
onMove={entry.onMove}
@@ -18,7 +18,10 @@ type FloorplanDraftLayerProps = {
draftAnchorPoints: Array<{ x: number; y: number; isPrimary: boolean }>
draftFill: string
draftStroke: string
polygonDraftStroke?: string
polygonDraftStrokeWidth?: string
anchorFill: string
unitsPerPixel: number
}
export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
@@ -30,8 +33,15 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
draftAnchorPoints,
draftFill,
draftStroke,
polygonDraftStroke,
polygonDraftStrokeWidth = '0.08',
anchorFill,
unitsPerPixel,
}: FloorplanDraftLayerProps) {
const primaryAnchorRadius = 6 * unitsPerPixel
const secondaryAnchorRadius = 5 * unitsPerPixel
const activePolygonDraftStroke = polygonDraftStroke ?? draftStroke
return (
<>
{draftPolygonPoints && (
@@ -69,21 +79,21 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
<polyline
fill="none"
points={polygonDraftPolylinePoints}
stroke={draftStroke}
stroke={activePolygonDraftStroke}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="0.08"
strokeWidth={polygonDraftStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
)}
{polygonDraftClosingSegment && (
<line
stroke={draftStroke}
stroke={activePolygonDraftStroke}
strokeDasharray="0.16 0.1"
strokeLinecap="round"
strokeOpacity={0.75}
strokeWidth="0.05"
strokeWidth={polygonDraftStrokeWidth}
vectorEffect="non-scaling-stroke"
x1={polygonDraftClosingSegment.x1}
x2={polygonDraftClosingSegment.x2}
@@ -100,7 +110,7 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
fillOpacity={0.95}
key={`polygon-draft-${index}`}
pointerEvents="none"
r={point.isPrimary ? 0.12 : 0.1}
r={point.isPrimary ? primaryAnchorRadius : secondaryAnchorRadius}
vectorEffect="non-scaling-stroke"
/>
))}
@@ -1,7 +1,7 @@
'use client'
import { memo } from 'react'
import type { Point2D, RoofNode, RoofSegmentNode } from '@pascal-app/core'
import { memo } from 'react'
import { toSvgX, toSvgY } from '../svg-paths'
type FloorplanLineSegment = {
@@ -20,14 +20,27 @@ type FloorplanRoofEntry = {
segments: FloorplanRoofSegmentEntry[]
}
type FloorplanRoofPalette = {
roofFill: string
roofActiveFill: string
roofSelectedFill: string
roofStroke: string
roofActiveStroke: string
roofSelectedStroke: string
roofRidgeStroke: string
roofSelectedRidgeStroke: string
}
type FloorplanRoofLayerProps = {
highlightedIdSet: ReadonlySet<string>
palette: FloorplanRoofPalette
roofEntries: FloorplanRoofEntry[]
selectedIdSet: ReadonlySet<string>
}
export const FloorplanRoofLayer = memo(function FloorplanRoofLayer({
highlightedIdSet,
palette,
roofEntries,
selectedIdSet,
}: FloorplanRoofLayerProps) {
@@ -59,18 +72,18 @@ export const FloorplanRoofLayer = memo(function FloorplanRoofLayer({
<polygon
fill={
isSegmentActive
? 'rgba(14, 165, 233, 0.2)'
? palette.roofSelectedFill
: isRoofActive
? 'rgba(14, 165, 233, 0.14)'
: 'rgba(14, 165, 233, 0.08)'
? palette.roofActiveFill
: palette.roofFill
}
points={points}
stroke={
isSegmentActive
? '#0369a1'
? palette.roofSelectedStroke
: isRoofActive
? '#0ea5e9'
: 'rgba(14, 165, 233, 0.65)'
? palette.roofActiveStroke
: palette.roofStroke
}
strokeWidth={isSegmentActive ? '2.25' : isRoofActive ? '1.75' : '1.1'}
vectorEffect="non-scaling-stroke"
@@ -78,7 +91,9 @@ export const FloorplanRoofLayer = memo(function FloorplanRoofLayer({
{ridgeLine ? (
<line
fill="none"
stroke={isSegmentActive ? '#0f172a' : 'rgba(3, 105, 161, 0.75)'}
stroke={
isSegmentActive ? palette.roofSelectedRidgeStroke : palette.roofRidgeStroke
}
strokeWidth={isSegmentActive ? '2' : '1.4'}
vectorEffect="non-scaling-stroke"
x1={toSvgX(ridgeLine.start.x)}
@@ -1,7 +1,11 @@
'use client'
import type { Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
import { memo, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from 'react'
import {
memo,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
} from 'react'
import {
buildSvgAnnularSectorPath,
buildSvgArcPath,
@@ -38,6 +42,12 @@ type FloorplanStairEntry = {
type FloorplanPalette = {
deleteFill: string
deleteStroke: string
stairFill: string
stairSelectedFill: string
stairStroke: string
stairAccent: string
stairTread: string
stairSelectedTread: string
}
type FloorplanStairLayerProps = {
@@ -64,8 +74,7 @@ function clamp(value: number, min: number, max: number) {
function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
const stairType = stair.stairType ?? 'straight'
const baseSweepAngle =
stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
const baseSweepAngle = stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
@@ -74,6 +83,25 @@ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
return baseSweepAngle
}
function getFloorplanStairStepCount(stair: StairNode, minimum: number) {
return Math.max(minimum, Math.round(stair.stepCount ?? 10))
}
function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
if (stair.stairType !== 'spiral' || (stair.topLandingMode ?? 'none') !== 'integrated') {
return 0
}
const innerRadius = Math.max(0.05, stair.innerRadius ?? 0.9)
const width = Math.max(stair.width ?? 1, 0.4)
const landingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max(width * 0.9, 0.8))
return (
Math.min(Math.PI * 0.75, landingDepth / Math.max(innerRadius + width / 2, 0.1)) *
Math.sign(sweepAngle || 1)
)
}
export const FloorplanStairLayer = memo(function FloorplanStairLayer({
canFocusStairs,
canSelectStairs,
@@ -108,8 +136,10 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
stairSelected || stairHighlighted || segmentSelected || segmentHighlighted
const stairType = stair.stairType ?? 'straight'
const normalizedSweepAngle = getNormalizedFloorplanStairSweepAngle(stair)
const sectorStartAngle = stair.rotation - normalizedSweepAngle / 2
const sectorStartAngle = -stair.rotation - normalizedSweepAngle / 2
const sectorEndAngle = sectorStartAngle + normalizedSweepAngle
const spiralLandingSweep = getFloorplanSpiralLandingSweep(stair, normalizedSweepAngle)
const visualSectorEndAngle = sectorEndAngle + spiralLandingSweep
const stairCenter = {
x: stair.position[0],
y: stair.position[2],
@@ -124,37 +154,37 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
? palette.deleteStroke
: isSelectionActive
? '#2563eb'
: 'rgba(31, 41, 55, 0.9)'
: palette.stairStroke
const curvedAccent = isDeleteHovered
? palette.deleteStroke
: isSelectionActive
? '#1d4ed8'
: 'rgba(23, 23, 23, 0.96)'
: palette.stairAccent
const curvedFill = isDeleteHovered
? palette.deleteFill
: isSelectionActive
? 'rgba(59, 130, 246, 0.16)'
: '#ffffff'
? palette.stairSelectedFill
: palette.stairFill
const straightAccent = isDeleteHovered
? palette.deleteStroke
: isSelectionActive
? '#1d4ed8'
: 'rgba(23, 23, 23, 0.96)'
: palette.stairAccent
const straightStroke = isDeleteHovered
? palette.deleteStroke
: isSelectionActive
? '#1d4ed8'
: 'rgba(23, 23, 23, 0.88)'
: palette.stairStroke
const straightTread = isDeleteHovered
? palette.deleteStroke
: isSelectionActive
? 'rgba(37, 99, 235, 0.78)'
: 'rgba(38, 38, 38, 0.62)'
? palette.stairSelectedTread
: palette.stairTread
const straightFill = isDeleteHovered
? palette.deleteFill
: isSelectionActive
? 'rgba(59, 130, 246, 0.08)'
: 'rgba(255, 255, 255, 0.02)'
? palette.stairSelectedFill
: palette.stairFill
const curvedOuterLineWidth = isSelectionActive ? '2' : '1.4'
const curvedInnerLineWidth = isSelectionActive ? '1.7' : '1.2'
const stairSymbol =
@@ -166,13 +196,18 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
innerRadius,
outerRadius,
sectorStartAngle,
sectorEndAngle,
visualSectorEndAngle,
)}
fill={curvedFill}
pointerEvents="none"
/>
<path
d={buildSvgArcPath(stairCenter, outerRadius, sectorStartAngle, sectorEndAngle)}
d={buildSvgArcPath(
stairCenter,
outerRadius,
sectorStartAngle,
visualSectorEndAngle,
)}
fill="none"
pointerEvents="none"
stroke={curvedStroke}
@@ -180,15 +215,20 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
vectorEffect="non-scaling-stroke"
/>
<path
d={buildSvgArcPath(stairCenter, innerRadius, sectorStartAngle, sectorEndAngle)}
d={buildSvgArcPath(
stairCenter,
innerRadius,
sectorStartAngle,
visualSectorEndAngle,
)}
fill="none"
pointerEvents="none"
stroke={curvedStroke}
strokeWidth={curvedInnerLineWidth}
vectorEffect="non-scaling-stroke"
/>
{Array.from({ length: Math.max(6, stair.stepCount) }, (_, index) => {
const stepCount = Math.max(6, stair.stepCount)
{Array.from({ length: getFloorplanStairStepCount(stair, 6) + 1 }, (_, index) => {
const stepCount = getFloorplanStairStepCount(stair, 6)
const stepSweep = normalizedSweepAngle / stepCount
const angle = sectorStartAngle + stepSweep * index
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
@@ -199,9 +239,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
<line
key={`${stair.id}:spiral-step:${index}`}
pointerEvents="none"
stroke={index === stepCount - 1 ? curvedAccent : curvedStroke}
stroke={index === stepCount ? curvedAccent : curvedStroke}
strokeDasharray={index >= dashedFromIndex ? '0.1 0.08' : undefined}
strokeWidth={index === stepCount - 1 ? '1.8' : '1.15'}
strokeWidth={index === stepCount ? '1.8' : '1.15'}
vectorEffect="non-scaling-stroke"
x1={toSvgX(innerPoint.x)}
x2={toSvgX(outerPoint.x)}
@@ -213,7 +253,7 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
<circle
cx={toSvgX(stairCenter.x)}
cy={toSvgY(stairCenter.y)}
fill="#ffffff"
fill={curvedFill}
pointerEvents="none"
r={Math.max(innerRadius * 0.18, 0.06)}
stroke={curvedAccent}
@@ -221,7 +261,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
vectorEffect="non-scaling-stroke"
/>
{(() => {
const directionAngle = sectorStartAngle + normalizedSweepAngle * 0.86
const directionAngle =
visualSectorEndAngle -
(normalizedSweepAngle / getFloorplanStairStepCount(stair, 6)) * 0.8
const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, directionAngle)
const tangentAngle =
directionAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2)
@@ -269,8 +311,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
strokeWidth={curvedInnerLineWidth}
vectorEffect="non-scaling-stroke"
/>
{Array.from({ length: Math.max(4, stair.stepCount) + 1 }, (_, index) => {
const stepCount = Math.max(4, stair.stepCount)
{Array.from({ length: getFloorplanStairStepCount(stair, 4) + 1 }, (_, index) => {
const stepCount = getFloorplanStairStepCount(stair, 4)
const stepSweep = normalizedSweepAngle / stepCount
const angle = sectorStartAngle + stepSweep * index
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
@@ -294,8 +336,10 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
d={buildSvgArcPath(
stairCenter,
centerlineRadius,
sectorStartAngle + (normalizedSweepAngle / Math.max(4, stair.stepCount)) * 0.55,
sectorEndAngle - (normalizedSweepAngle / Math.max(4, stair.stepCount)) * 0.55,
sectorStartAngle +
(normalizedSweepAngle / getFloorplanStairStepCount(stair, 4)) * 0.55,
sectorEndAngle -
(normalizedSweepAngle / getFloorplanStairStepCount(stair, 4)) * 0.55,
)}
fill="none"
pointerEvents="none"
@@ -305,7 +349,7 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
vectorEffect="non-scaling-stroke"
/>
{(() => {
const stepCount = Math.max(4, stair.stepCount)
const stepCount = getFloorplanStairStepCount(stair, 4)
const stepSweep = normalizedSweepAngle / stepCount
const arrowAngle = sectorEndAngle - stepSweep * 0.8
const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, arrowAngle)
@@ -1,15 +1,13 @@
'use client'
import '../../three-types'
import { KeyboardControls } from '@react-three/drei'
import { sceneRegistry, useScene } from '@pascal-app/core'
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { KeyboardControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Euler, Vector3 } from 'three'
import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three'
import useEditor from '../../store/use-editor'
import BVHEcctrl from './first-person/bvh-ecctrl'
import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
import {
buildFirstPersonColliderWorldFromRegistry,
deriveFirstPersonSpawn,
@@ -17,10 +15,15 @@ import {
type FirstPersonColliderWorld,
type FirstPersonSpawn,
} from './first-person/build-collider-world'
import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
import BVHEcctrl from './first-person/bvh-ecctrl'
const CAMERA_EYE_OFFSET = 0.45
const LOOK_SENSITIVITY = 0.002
const CONTROLLER_CENTER_FROM_EYE = 0.85
const DOOR_INTERACTION_DISTANCE = 2.5
const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
const DOOR_LEAF_INTERACTION_DEPTH = 0.08
const keyboardMap = [
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
@@ -32,13 +35,21 @@ const keyboardMap = [
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
const centerScreenPoint = new Vector2(0, 0)
const doorInteractionRaycaster = new Raycaster()
const doorLeafBox = new Box3()
const doorLeafInverseMatrix = new Matrix4()
const doorLeafLocalHit = new Vector3()
const doorLeafLocalRay = new Ray()
const doorLeafMatrix = new Matrix4()
const doorLeafWorldHit = new Vector3()
const spawnWorldPosition = new Vector3()
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
const resolvePlacedSpawnNode = (
nodes: ReturnType<typeof useScene.getState>['nodes'],
_levelId: string | null,
) => {
) => {
const candidates = Object.values(nodes).filter((node) => node.type === 'spawn')
if (candidates.length === 0) return null
@@ -52,7 +63,101 @@ export const FirstPersonControls = () => {
const controllerRef = useRef<BVHEcctrlApi | null>(null)
const yawRef = useRef(0)
const pitchRef = useRef(0)
const interactableDoorIdRef = useRef<AnyNodeId | null>(null)
const worldRef = useRef<FirstPersonColliderWorld | null>(null)
const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
const [controllerStart, setControllerStart] = useState<{
position: [number, number, number]
yaw: number
} | null>(null)
const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => {
worldRef.current?.dispose()
worldRef.current = nextWorld
setWorld(nextWorld)
}, [])
const rebuildColliderWorld = useCallback(() => {
replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry())
}, [replaceColliderWorld])
const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
const nodes = useScene.getState().nodes
camera.updateMatrixWorld(true)
doorInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
let closestDoorId: AnyNodeId | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE
for (const doorId of sceneRegistry.byType.door) {
const node = nodes[doorId as AnyNodeId]
if (node?.type !== 'door') continue
if (node.openingKind === 'opening') continue
if (node.segments.every((segment) => segment.type === 'empty')) continue
const object = sceneRegistry.nodes.get(doorId)
if (!object) continue
object.updateWorldMatrix(true, true)
const placementHit = doorInteractionRaycaster
.intersectObject(object, true)
.find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE)
if (placementHit && placementHit.distance < closestDistance) {
closestDoorId = doorId as AnyNodeId
closestDistance = placementHit.distance
}
const leafW = node.width - 2 * node.frameThickness
const leafH = node.height - node.frameThickness
if (leafW <= 0 || leafH <= 0) continue
const leafCenterY = -node.frameThickness / 2
const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, node.swingAngle ?? 0))
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
doorLeafMatrix
.copy(object.matrixWorld)
.multiply(new Matrix4().makeTranslation(hingeX, 0, 0))
.multiply(new Matrix4().makeRotationY(leafSwingRotation))
.multiply(new Matrix4().makeTranslation(-hingeX, leafCenterY, 0))
doorLeafInverseMatrix.copy(doorLeafMatrix).invert()
doorLeafBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2)
doorLeafBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2)
doorLeafLocalRay.copy(doorInteractionRaycaster.ray).applyMatrix4(doorLeafInverseMatrix)
const localHit = doorLeafLocalRay.intersectBox(doorLeafBox, doorLeafLocalHit)
if (!localHit) continue
doorLeafWorldHit.copy(localHit).applyMatrix4(doorLeafMatrix)
const hitDistance = doorLeafWorldHit.distanceTo(doorInteractionRaycaster.ray.origin)
if (hitDistance <= DOOR_INTERACTION_DISTANCE && hitDistance < closestDistance) {
closestDoorId = doorId as AnyNodeId
closestDistance = hitDistance
}
}
return closestDoorId
}, [camera])
const toggleInteractableDoor = useCallback(() => {
const doorId = interactableDoorIdRef.current ?? resolveInteractableDoorId()
if (!doorId) return
const node = useScene.getState().nodes[doorId]
if (node?.type !== 'door' || node.openingKind === 'opening') return
const currentSwingAngle = node.swingAngle ?? 0
useScene.getState().updateNode(doorId, {
swingAngle: currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
})
requestAnimationFrame(rebuildColliderWorld)
}, [rebuildColliderWorld, resolveInteractableDoorId])
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
@@ -84,25 +189,28 @@ export const FirstPersonControls = () => {
}, [placedSpawnNode])
useEffect(() => {
const nextWorld = buildFirstPersonColliderWorldFromRegistry()
if (!nextWorld) {
setWorld(null)
return
}
setWorld(nextWorld)
rebuildColliderWorld()
return () => {
nextWorld.dispose()
worldRef.current?.dispose()
worldRef.current = null
setWorld(null)
}
}, [camera])
}, [rebuildColliderWorld])
useEffect(() => {
if (!world) return
yawRef.current = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw
if (controllerStart) return
const spawn = placedSpawn ?? deriveFirstPersonSpawn(camera, world)
const [x, y, z] = spawn.position
yawRef.current = spawn.yaw
pitchRef.current = 0
}, [camera, placedSpawn, world])
setControllerStart({
position: [x, y - CONTROLLER_CENTER_FROM_EYE, z],
yaw: spawn.yaw,
})
}, [camera, controllerStart, placedSpawn, world])
useEffect(() => {
const canvas = gl.domElement
@@ -152,6 +260,10 @@ export const FirstPersonControls = () => {
document.exitPointerLock()
}
useEditor.getState().setFirstPersonMode(false)
} else if (event.code === 'KeyE') {
event.preventDefault()
event.stopPropagation()
toggleInteractableDoor()
}
}
@@ -159,7 +271,7 @@ export const FirstPersonControls = () => {
return () => {
document.removeEventListener('keydown', handleKeyDown, true)
}
}, [gl])
}, [gl, toggleInteractableDoor])
useFrame((_, delta) => {
if (!controllerRef.current?.group) return
@@ -170,18 +282,21 @@ export const FirstPersonControls = () => {
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
camera.quaternion.setFromEuler(cameraEuler)
camera.updateMatrixWorld(true)
const nextInteractableDoorId = resolveInteractableDoorId()
if (interactableDoorIdRef.current !== nextInteractableDoorId) {
interactableDoorIdRef.current = nextInteractableDoorId
useViewer.getState().setHoveredId(nextInteractableDoorId)
}
})
const controllerPosition = useMemo(() => {
if (!world) return null
const [x, y, z] = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).position
return [x, y - CONTROLLER_CENTER_FROM_EYE, z] as const
}, [camera, placedSpawn, world])
const spawnYaw = useMemo(() => {
if (!world) return 0
return (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw
}, [camera, placedSpawn, world])
useEffect(() => {
return () => {
if (useViewer.getState().hoveredId === interactableDoorIdRef.current) {
useViewer.getState().setHoveredId(null)
}
}
}, [])
if (!world) {
return null
@@ -189,11 +304,11 @@ export const FirstPersonControls = () => {
return (
<>
{controllerPosition && (
{controllerStart && (
<KeyboardControls map={keyboardMap}>
<BVHEcctrl
ref={controllerRef}
key={`${world.mesh.uuid}:${controllerPosition.join(':')}:${spawnYaw}`}
key="first-person-controller"
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
colliderMeshes={[world.mesh]}
collisionCheckIteration={3}
@@ -213,7 +328,7 @@ export const FirstPersonControls = () => {
maxRunSpeed={5.5}
maxSlope={1.2}
maxWalkSpeed={4}
position={controllerPosition}
position={controllerStart.position}
acceleration={26}
airDragFactor={0.3}
deceleration={30}
@@ -293,7 +408,9 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
<InlineControlHint label="Jump" keyLabel="Space" />
<InlineControlHint label="Sprint" keyLabel="Shift" />
<div className="h-px w-full bg-border/30" />
<span className="text-center text-muted-foreground/60 text-xs">Click to look around</span>
<span className="text-center text-muted-foreground/60 text-xs">
Click to look around
</span>
</div>
</div>
)}
@@ -1,11 +1,7 @@
import { sceneRegistry, useScene } from '@pascal-app/core'
import {
acceleratedRaycast,
computeBoundsTree,
disposeBoundsTree,
} from 'three-mesh-bvh'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
const COLLIDER_NODE_TYPES = [
'wall',
@@ -16,6 +12,7 @@ const COLLIDER_NODE_TYPES = [
'roof',
'roof-segment',
'door',
'window',
'item',
] as const
@@ -25,6 +22,7 @@ const DOWN = new THREE.Vector3(0, -1, 0)
const UP = new THREE.Vector3(0, 1, 0)
const SPAWN_EYE_HEIGHT = 1.65
const RAYCAST_CLEARANCE = 25
const DOOR_LEAF_COLLIDER_DEPTH = 0.06
export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
@@ -45,12 +43,18 @@ function isMesh(object: THREE.Object3D): object is THREE.Mesh {
return 'isMesh' in object && (object as THREE.Mesh).isMesh
}
function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) {
return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
}
function cloneWorldGeometry(mesh: THREE.Mesh) {
const sourceGeometry = mesh.geometry
const position = sourceGeometry.getAttribute('position')
if (!position || position.count < 3) return null
const workingGeometry = sourceGeometry.index ? sourceGeometry.toNonIndexed() : sourceGeometry.clone()
const workingGeometry = sourceGeometry.index
? sourceGeometry.toNonIndexed()
: sourceGeometry.clone()
const cleanGeometry = new THREE.BufferGeometry()
cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone())
@@ -74,16 +78,59 @@ function cloneWorldGeometry(mesh: THREE.Mesh) {
}
function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) {
if (type === 'window') {
const node = useScene.getState().nodes[nodeId as AnyNodeId]
return node?.type === 'window' && node.openingKind === 'opening'
}
if (type !== 'door') return false
const node = useScene.getState().nodes[nodeId]
const node = useScene.getState().nodes[nodeId as AnyNodeId]
if (!node || node.type !== 'door') return false
if (node.openingKind === 'opening') return true
if (!node.segments.length) return true
return node.segments.every((segment) => segment.type === 'empty')
}
function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
const hasLeafContent = node.segments.some((segment) => segment.type !== 'empty')
if (!hasLeafContent) return null
const leafW = node.width - 2 * node.frameThickness
const leafH = node.height - node.frameThickness
if (leafW <= 0 || leafH <= 0) return null
const leafCenterY = -node.frameThickness / 2
const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0))
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
root.updateWorldMatrix(true, false)
const sourceGeometry = new THREE.BoxGeometry(
leafW,
leafH,
DOOR_LEAF_COLLIDER_DEPTH,
).toNonIndexed()
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
sourceGeometry.dispose()
const matrix = root.matrixWorld
.clone()
.multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0))
.multiply(new THREE.Matrix4().makeRotationY(leafSwingRotation))
.multiply(new THREE.Matrix4().makeTranslation(-hingeX, leafCenterY, 0))
geometry.applyMatrix4(matrix)
return geometry
}
function buildRegisteredNodeTypeLookup() {
const nodeTypes = new Map<string, ColliderNodeType>()
@@ -109,7 +156,12 @@ function collectColliderGeometriesFromNode(
if (visitedMeshes.has(object)) return
visitedMeshes.add(object)
if (isMesh(object) && object.visible && !SKIPPED_MESH_NAMES.has(object.name)) {
if (
isMesh(object) &&
object.visible &&
isColliderMaterialVisible(object.material) &&
!SKIPPED_MESH_NAMES.has(object.name)
) {
const geometry = cloneWorldGeometry(object)
if (geometry) {
geometries.push(geometry)
@@ -151,6 +203,17 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
const root = sceneRegistry.nodes.get(nodeId)
if (!root) continue
if (type === 'door') {
const node = useScene.getState().nodes[nodeId as AnyNodeId]
if (node?.type !== 'door') continue
const doorGeometry = createDoorLeafColliderGeometry(root, node)
if (doorGeometry) {
geometries.push(doorGeometry)
}
continue
}
root.updateMatrixWorld(true)
geometries.push(
...collectColliderGeometriesFromNode(
@@ -169,7 +232,9 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
}
const mergedGeometry = mergeGeometries(geometries, false)
geometries.forEach((geometry) => geometry.dispose())
geometries.forEach((geometry) => {
geometry.dispose()
})
if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
mergedGeometry?.dispose()
@@ -234,7 +299,8 @@ export function deriveFirstPersonSpawn(
}
for (const [x, z] of candidates) {
const topY = Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE
const topY =
Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE
raycaster.set(new THREE.Vector3(x, topY, z), DOWN)
const intersections = raycaster.intersectObject(world.mesh, false)
const hit = intersections.find((intersection) => {
@@ -252,11 +318,7 @@ export function deriveFirstPersonSpawn(
}
return {
position: [
camera.position.x,
Math.max(camera.position.y, SPAWN_EYE_HEIGHT),
camera.position.z,
],
position: [camera.position.x, Math.max(camera.position.y, SPAWN_EYE_HEIGHT), camera.position.z],
yaw,
}
}
File diff suppressed because it is too large Load Diff
@@ -1287,7 +1287,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
>
<bufferGeometry />
</lineSegments>
<Html center position={widthLabelPosition}>
<Html center position={widthLabelPosition} style={{ pointerEvents: 'none' }}>
<div
style={{
background: 'rgba(15, 23, 42, 0.86)',
@@ -1299,13 +1299,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
fontWeight: 600,
lineHeight: 1,
padding: '4px 8px',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
>
{widthLabel}
</div>
</Html>
<Html center position={depthLabelPosition}>
<Html center position={depthLabelPosition} style={{ pointerEvents: 'none' }}>
<div
style={{
background: 'rgba(15, 23, 42, 0.86)',
@@ -1317,13 +1318,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
fontWeight: 600,
lineHeight: 1,
padding: '4px 8px',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
>
{depthLabel}
</div>
</Html>
<Html center position={heightLabelPosition}>
<Html center position={heightLabelPosition} style={{ pointerEvents: 'none' }}>
<div
style={{
background: 'rgba(15, 23, 42, 0.86)',
@@ -1335,6 +1337,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
fontWeight: 600,
lineHeight: 1,
padding: '4px 8px',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
>
@@ -2,15 +2,17 @@
import {
type AnyNodeId,
type BuildingNode,
type GuideNode,
type LevelNode,
type ScanNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Check, ChevronDown, Plus, Trash2 } from 'lucide-react'
import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { createLocalGuideImage } from '../../../lib/local-guide-image'
import { cn } from '../../../lib/utils'
import useEditor, { type GridSnapStep } from '../../../store/use-editor'
import { useUploadStore } from '../../../store/use-upload'
@@ -59,37 +61,95 @@ function useLevelScans(): ScanNode[] {
)
}
function useLowerReferenceLevels(): LevelNode[] {
const levelId = useViewer((s) => s.selection.levelId)
return useScene(
useShallow((state) => {
if (!levelId) return [] as LevelNode[]
const activeLevel = state.nodes[levelId]
if (!activeLevel || activeLevel.type !== 'level') return [] as LevelNode[]
const buildingId = activeLevel.parentId as BuildingNode['id'] | undefined
const building = buildingId ? state.nodes[buildingId] : null
if (!building || building.type !== 'building') return [] as LevelNode[]
return (building.children ?? [])
.map((id) => state.nodes[id])
.filter(
(node): node is LevelNode =>
node?.type === 'level' && node.id !== levelId && node.level < activeLevel.level,
)
.sort((a, b) => b.level - a.level)
}),
)
}
function getLevelDisplayName(level: LevelNode) {
return level.name || `Level ${level.level}`
}
// ── Shared upload button for dropdowns ──────────────────────────────────────
function UploadButton() {
function UploadButton({ onError }: { onError: (message: string | null) => void }) {
const fileInputRef = useRef<HTMLInputElement>(null)
const levelId = useViewer((s) => s.selection.levelId)
const setSelection = useViewer((s) => s.setSelection)
const setShowGuides = useViewer((s) => s.setShowGuides)
const createNode = useScene((s) => s.createNode)
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
const [isAddingGuide, setIsAddingGuide] = useState(false)
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!(file && levelId)) return
e.target.value = ''
const { uploadHandler } = useUploadStore.getState()
if (!uploadHandler) return
onError(null)
if (file.size > MAX_FILE_SIZE) return
if (file.size > MAX_FILE_SIZE) {
onError('File is too large. Maximum size is 200 MB.')
return
}
const isScan =
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
const isImage = file.type.startsWith('image/')
if (!(isScan || isImage)) return
if (!(isScan || isImage)) {
onError('Upload a .glb/.gltf scan or an image.')
return
}
const type = isScan ? 'scan' : 'guide'
if (isImage) {
setIsAddingGuide(true)
try {
const guide = await createLocalGuideImage({ createNode, file, levelId })
setShowGuides(true)
setSelectedReferenceId(guide.id)
setSelection({ selectedIds: [], zoneId: null })
} catch {
onError('Could not add that guide image.')
} finally {
setIsAddingGuide(false)
}
return
}
const { uploadHandler } = useUploadStore.getState()
if (!uploadHandler) {
onError('Scan upload is unavailable.')
return
}
const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0]
if (!projectId) return
if (!projectId) {
onError('Open a project before uploading a scan.')
return
}
useUploadStore.getState().clearUpload(levelId)
uploadHandler(projectId, levelId, file, type)
uploadHandler(projectId, levelId, file, 'scan')
},
[levelId],
[createNode, levelId, onError, setSelectedReferenceId, setSelection, setShowGuides],
)
return (
@@ -97,6 +157,7 @@ function UploadButton() {
<button
aria-label="Upload scan or guide image"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
disabled={isAddingGuide}
onClick={() => fileInputRef.current?.click()}
type="button"
>
@@ -118,9 +179,13 @@ function UploadButton() {
function GuidesControl() {
const showGuides = useViewer((state) => state.showGuides)
const setShowGuides = useViewer((state) => state.setShowGuides)
const setSelection = useViewer((state) => state.setSelection)
const updateNode = useScene((state) => state.updateNode)
const deleteNode = useScene((state) => state.deleteNode)
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
const [isOpen, setIsOpen] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const guides = useLevelGuides()
const hasGuides = guides.length > 0
@@ -132,6 +197,15 @@ function GuidesControl() {
[updateNode],
)
const handleSelectGuide = useCallback(
(guideId: GuideNode['id']) => {
setShowGuides(true)
setSelectedReferenceId(guideId)
setSelection({ selectedIds: [], zoneId: null })
},
[setSelectedReferenceId, setSelection, setShowGuides],
)
return (
<Popover onOpenChange={setIsOpen} open={isOpen}>
<div className="flex items-center">
@@ -201,29 +275,55 @@ function GuidesControl() {
</p>
)}
</div>
<UploadButton />
<UploadButton onError={setUploadError} />
</div>
{uploadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-destructive text-xs">
{uploadError}
</div>
)}
{hasGuides ? (
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
{guides.map((guide, index) => (
<div
className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
className={cn(
'group/item space-y-2 rounded-xl border bg-background/75 p-2.5 transition-colors',
selectedReferenceId === guide.id
? 'border-foreground/35 bg-white/10'
: 'border-border/45',
)}
key={guide.id}
>
<div className="flex min-w-0 items-center gap-2">
<img
alt=""
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
src="/icons/floorplan.png"
/>
<p className="truncate font-medium text-foreground text-sm">
{guide.name || `Guide image ${index + 1}`}
</p>
<button
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => handleSelectGuide(guide.id)}
type="button"
>
<img
alt=""
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
src="/icons/floorplan.png"
/>
<p className="truncate font-medium text-foreground text-sm">
{guide.name || `Guide image ${index + 1}`}
</p>
{selectedReferenceId === guide.id && (
<Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
)}
</button>
<button
aria-label="Delete guide image"
className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
onClick={() => deleteNode(guide.id)}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
onClick={(event) => {
event.stopPropagation()
deleteNode(guide.id)
if (selectedReferenceId === guide.id) {
setSelectedReferenceId(null)
}
}}
type="button"
>
<Trash2 className="h-3 w-3" />
@@ -335,9 +435,13 @@ export function GridSnapControl() {
function ScansControl() {
const showScans = useViewer((state) => state.showScans)
const setShowScans = useViewer((state) => state.setShowScans)
const setSelection = useViewer((state) => state.setSelection)
const updateNode = useScene((state) => state.updateNode)
const deleteNode = useScene((state) => state.deleteNode)
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
const [isOpen, setIsOpen] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const scans = useLevelScans()
const hasScans = scans.length > 0
@@ -349,6 +453,15 @@ function ScansControl() {
[updateNode],
)
const handleSelectScan = useCallback(
(scanId: ScanNode['id']) => {
setShowScans(true)
setSelectedReferenceId(scanId)
setSelection({ selectedIds: [], zoneId: null })
},
[setSelectedReferenceId, setSelection, setShowScans],
)
return (
<Popover onOpenChange={setIsOpen} open={isOpen}>
<div className="flex items-center">
@@ -414,29 +527,55 @@ function ScansControl() {
</p>
)}
</div>
<UploadButton />
<UploadButton onError={setUploadError} />
</div>
{uploadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-destructive text-xs">
{uploadError}
</div>
)}
{hasScans ? (
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
{scans.map((scan, index) => (
<div
className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
className={cn(
'group/item space-y-2 rounded-xl border bg-background/75 p-2.5 transition-colors',
selectedReferenceId === scan.id
? 'border-foreground/35 bg-white/10'
: 'border-border/45',
)}
key={scan.id}
>
<div className="flex min-w-0 items-center gap-2">
<img
alt=""
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
src="/icons/mesh.png"
/>
<p className="truncate font-medium text-foreground text-sm">
{scan.name || `Scan ${index + 1}`}
</p>
<button
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => handleSelectScan(scan.id)}
type="button"
>
<img
alt=""
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
src="/icons/mesh.png"
/>
<p className="truncate font-medium text-foreground text-sm">
{scan.name || `Scan ${index + 1}`}
</p>
{selectedReferenceId === scan.id && (
<Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
)}
</button>
<button
aria-label="Delete scan"
className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
onClick={() => deleteNode(scan.id)}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
onClick={(event) => {
event.stopPropagation()
deleteNode(scan.id)
if (selectedReferenceId === scan.id) {
setSelectedReferenceId(null)
}
}}
type="button"
>
<Trash2 className="h-3 w-3" />
@@ -466,6 +605,158 @@ function ScansControl() {
)
}
function ReferenceFloorControl() {
const showReferenceFloor = useEditor((state) => state.showReferenceFloor)
const toggleReferenceFloor = useEditor((state) => state.toggleReferenceFloor)
const referenceFloorOffset = useEditor((state) => state.referenceFloorOffset)
const setReferenceFloorOffset = useEditor((state) => state.setReferenceFloorOffset)
const referenceFloorOpacity = useEditor((state) => state.referenceFloorOpacity)
const setReferenceFloorOpacity = useEditor((state) => state.setReferenceFloorOpacity)
const [isOpen, setIsOpen] = useState(false)
const lowerLevels = useLowerReferenceLevels()
const hasLowerLevels = lowerLevels.length > 0
const selectedLevel = lowerLevels[referenceFloorOffset - 1] ?? lowerLevels[0] ?? null
const selectedLevelName = selectedLevel ? getLevelDisplayName(selectedLevel) : null
return (
<Popover onOpenChange={setIsOpen} open={isOpen}>
<div className="flex items-center">
<ActionButton
className={cn(
'rounded-r-none p-0',
showReferenceFloor && selectedLevel
? 'bg-white/15'
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
)}
disabled={!hasLowerLevels}
label={
selectedLevelName && showReferenceFloor
? `Reference floor: ${selectedLevelName}`
: 'Reference floor'
}
onClick={() => {
if (hasLowerLevels) toggleReferenceFloor()
}}
size="icon"
variant="ghost"
>
<div className="relative">
<Layers2 className="h-4 w-4" />
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
{lowerLevels.length}
</span>
</div>
</ActionButton>
<PopoverTrigger asChild>
<button
aria-expanded={isOpen}
aria-label="Reference floor settings"
className={cn(
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
showReferenceFloor && selectedLevel
? isOpen
? 'bg-white/10'
: 'bg-white/5 hover:bg-white/8'
: isOpen
? 'bg-white/8'
: 'opacity-60 hover:bg-white/5 hover:opacity-100',
)}
disabled={!hasLowerLevels}
type="button"
>
<ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
</button>
</PopoverTrigger>
</div>
<PopoverContent
align="center"
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
side="top"
sideOffset={14}
>
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
<Layers2 className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground text-sm">Reference floor</p>
{selectedLevelName && (
<p className="truncate text-muted-foreground text-xs">{selectedLevelName}</p>
)}
</div>
<button
aria-label={showReferenceFloor ? 'Hide reference floor' : 'Show reference floor'}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
disabled={!hasLowerLevels}
onClick={toggleReferenceFloor}
type="button"
>
{showReferenceFloor ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
</button>
</div>
{hasLowerLevels ? (
<>
<div className="max-h-44 space-y-1 overflow-y-auto rounded-xl border border-border/45 bg-background/60 p-1.5">
{lowerLevels.map((level, index) => {
const isSelected = referenceFloorOffset === index + 1
const levelName = getLevelDisplayName(level)
return (
<button
className={cn(
'flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-white/8',
isSelected && showReferenceFloor && 'bg-white/10 text-foreground',
)}
key={level.id}
onClick={() => {
setReferenceFloorOffset(index + 1)
if (!showReferenceFloor) {
toggleReferenceFloor()
}
}}
type="button"
>
<span
className={cn(
'h-3.5 w-3.5 rounded-full border',
isSelected && showReferenceFloor
? 'border-foreground bg-foreground'
: 'border-muted-foreground/35',
)}
/>
<span className="min-w-0 flex-1 truncate">{levelName}</span>
<span className="text-[10px] text-muted-foreground">
{index + 1} below
</span>
</button>
)
})}
</div>
<SliderControl
label="Opacity"
max={0.8}
min={0.1}
onChange={setReferenceFloorOpacity}
precision={2}
step={0.05}
value={referenceFloorOpacity}
/>
</>
) : (
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
No lower floor available.
</div>
)}
</div>
</PopoverContent>
</Popover>
)
}
// ── Main ViewToggles ────────────────────────────────────────────────────────
export function ViewToggles() {
@@ -476,6 +767,8 @@ export function ViewToggles() {
{/* Guides (toggle + dropdown) */}
<GuidesControl />
<ReferenceFloorControl />
</div>
)
}
@@ -487,6 +780,7 @@ export function SecondaryToggles() {
<GridSnapControl />
<ScansControl />
<GuidesControl />
<ReferenceFloorControl />
</div>
)
}
@@ -15,6 +15,7 @@ interface SliderControlProps {
step?: number
className?: string
unit?: string
restoreOnCommit?: boolean
}
function stepPrecision(s: number): number {
@@ -56,6 +57,7 @@ export function SliderControl({
step = 1,
className,
unit = '',
restoreOnCommit = true,
}: SliderControlProps) {
const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
@@ -161,7 +163,10 @@ export function SliderControl({
const newValue = clamp(
Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
)
onChange(newValue)
if (newValue !== valueRef.current) {
valueRef.current = newValue
onChange(newValue)
}
},
[step, clamp, onChange],
)
@@ -175,7 +180,7 @@ export function SliderControl({
setIsDragging(false)
e.currentTarget.releasePointerCapture(e.pointerId)
if (originValue !== finalVal) {
if (originValue !== finalVal && restoreOnCommit) {
onChange(originValue)
useScene.temporal.getState().resume()
onChange(finalVal)
@@ -185,7 +190,7 @@ export function SliderControl({
onCommit?.(finalVal)
}
},
[onChange, onCommit],
[onChange, onCommit, restoreOnCommit],
)
const handleValueClick = useCallback(() => {
@@ -1,5 +1,23 @@
'use client'
import {
closestCenter,
DndContext,
type DragEndEvent,
type DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
type AnyNode,
type AnyNodeId,
@@ -8,8 +26,15 @@ import {
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, MoreVertical, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react'
import {
type ButtonHTMLAttributes,
type CSSProperties,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { useShallow } from 'zustand/react/shallow'
import {
buildLevelDuplicateCreateOps,
@@ -96,12 +121,18 @@ function LevelInlineRename({
function LevelRow({
level,
isSelected,
isDragging,
dragHandleProps,
dragHandleRef,
onSelect,
onDuplicate,
onRequestDelete,
}: {
level: LevelNode
isSelected: boolean
isDragging?: boolean
dragHandleProps?: ButtonHTMLAttributes<HTMLButtonElement>
dragHandleRef?: (element: HTMLButtonElement | null) => void
onSelect: () => void
onDuplicate: (preset?: LevelDuplicatePreset) => void
onRequestDelete: () => void
@@ -121,13 +152,32 @@ function LevelRow({
<div
className={cn(
'flex items-center rounded-lg transition-colors',
isDragging && 'bg-white/10 text-foreground shadow-lg',
isSelected
? 'bg-white/10 text-foreground'
: 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground',
)}
>
<button
className="flex min-w-0 flex-1 items-center justify-start px-2.5 py-1.5 font-medium text-xs"
{...dragHandleProps}
aria-label={`Reorder ${getLevelDisplayLabel(level)}`}
className={cn(
'ml-0.5 flex h-6 w-4 shrink-0 touch-none cursor-grab items-center justify-center rounded-md text-muted-foreground/35 opacity-0 transition-colors hover:bg-white/5 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 group-hover/level:opacity-100',
isDragging && 'cursor-grabbing opacity-100',
)}
onClick={(e) => {
e.stopPropagation()
dragHandleProps?.onClick?.(e)
}}
ref={dragHandleRef}
title="Drag to reorder"
type="button"
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<button
className="flex min-w-0 flex-1 items-center justify-start py-1.5 pr-2 pl-1 font-medium text-xs"
onClick={onSelect}
onDoubleClick={(e) => {
e.stopPropagation()
@@ -201,6 +251,53 @@ function LevelRow({
)
}
function SortableLevelRow({
level,
isSelected,
onSelect,
onDuplicate,
onRequestDelete,
}: {
level: LevelNode
isSelected: boolean
onSelect: () => void
onDuplicate: (preset?: LevelDuplicatePreset) => void
onRequestDelete: () => void
}) {
const {
attributes,
isDragging,
listeners,
setActivatorNodeRef,
setNodeRef,
transform,
transition,
} = useSortable({ id: level.id })
const style: CSSProperties = {
opacity: isDragging ? 0.86 : undefined,
position: 'relative',
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 30 : undefined,
}
return (
<div ref={setNodeRef} style={style}>
<LevelRow
dragHandleProps={{ ...attributes, ...listeners }}
dragHandleRef={setActivatorNodeRef}
isDragging={isDragging}
isSelected={isSelected}
level={level}
onDuplicate={onDuplicate}
onRequestDelete={onRequestDelete}
onSelect={onSelect}
/>
</div>
)
}
// ── Main component ──────────────────────────────────────────────────────────
export function FloatingLevelSelector() {
@@ -212,6 +309,15 @@ export function FloatingLevelSelector() {
const updateNodes = useScene((s) => s.updateNodes)
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 4 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const resolvedBuildingId = useScene((state) => {
if (selectedBuildingId) return selectedBuildingId
@@ -318,9 +424,52 @@ export function FloatingLevelSelector() {
[createNodes, levels, resolvedBuildingId, setSelection, updateNodes],
)
const handleDragStart = useCallback((event: DragStartEvent) => {
setDraggingLevelId(String(event.active.id))
}, [])
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setDraggingLevelId(null)
const { active, over } = event
if (!over || active.id === over.id) return
const visualLevels = [...levels].reverse()
const oldIndex = visualLevels.findIndex((level) => level.id === active.id)
const newIndex = visualLevels.findIndex((level) => level.id === over.id)
if (oldIndex === -1 || newIndex === -1) return
const reorderedVisualLevels = arrayMove(visualLevels, oldIndex, newIndex)
const levelNumbersDescending = levels.map((level) => level.level).sort((a, b) => b - a)
const updates = reorderedVisualLevels
.map((level, index) => ({
id: level.id as AnyNodeId,
nextLevel: levelNumbersDescending[index],
data: { level: levelNumbersDescending[index] } as Partial<AnyNode>,
}))
.filter(({ id, nextLevel }) => {
const currentLevel = levels.find((level) => level.id === id)
return currentLevel?.level !== nextLevel
})
.map(({ id, data }) => ({ id, data }))
if (updates.length > 0) {
updateNodes(updates)
}
},
[levels, updateNodes],
)
const handleDragCancel = useCallback(() => {
setDraggingLevelId(null)
}, [])
if (levels.length === 0) return null
const reversedLevels = [...levels].reverse()
const sortableLevelIds = reversedLevels.map((level) => level.id)
const addButtonClass =
'absolute left-1/2 z-10 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-full border border-border/80 bg-neutral-800 text-muted-foreground/60 shadow-md transition-colors hover:bg-neutral-700 hover:text-foreground'
@@ -330,62 +479,76 @@ export function FloatingLevelSelector() {
<div className="pointer-events-auto absolute top-14 left-3 z-20">
<div className="relative">
{/* Floating + at top edge */}
<button
className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
onClick={handleAddAbove}
title="Add level above"
type="button"
>
<Plus className="h-2.5 w-2.5" />
</button>
{!draggingLevelId && (
<button
className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
onClick={handleAddAbove}
title="Add level above"
type="button"
>
<Plus className="h-2.5 w-2.5" />
</button>
)}
{/* Floating + at bottom edge */}
<button
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
onClick={handleAddBelow}
title="Add level below"
type="button"
>
<Plus className="h-2.5 w-2.5" />
</button>
{!draggingLevelId && (
<button
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
onClick={handleAddBelow}
title="Add level below"
type="button"
>
<Plus className="h-2.5 w-2.5" />
</button>
)}
{/* Level list */}
<div className="flex flex-col gap-0.5 rounded-xl border border-border bg-background/90 p-1 shadow-2xl backdrop-blur-md">
{reversedLevels.map((level, i) => {
const isSelected = level.id === levelId
const sortedIndex = levels.indexOf(level)
const showGapBelow = i < reversedLevels.length - 1
<DndContext
collisionDetection={closestCenter}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
sensors={sensors}
>
<SortableContext items={sortableLevelIds} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-0.5 rounded-xl border border-border bg-background/90 p-1 shadow-2xl backdrop-blur-md">
{reversedLevels.map((level, i) => {
const isSelected = level.id === levelId
const sortedIndex = levels.indexOf(level)
const showGapBelow = i < reversedLevels.length - 1
return (
<div className="relative" key={level.id}>
<LevelRow
isSelected={isSelected}
level={level}
onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
onRequestDelete={() => setDeletingLevel(level)}
onSelect={() =>
setSelection(
resolvedBuildingId
? { buildingId: resolvedBuildingId, levelId: level.id }
: { levelId: level.id },
)
}
/>
return (
<div className="relative" key={level.id}>
<SortableLevelRow
isSelected={isSelected}
level={level}
onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
onRequestDelete={() => setDeletingLevel(level)}
onSelect={() =>
setSelection(
resolvedBuildingId
? { buildingId: resolvedBuildingId, levelId: level.id }
: { levelId: level.id },
)
}
/>
{showGapBelow && (
<button
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
onClick={() => handleInsertBetween(sortedIndex - 1)}
title="Insert level here"
type="button"
>
<Plus className="h-2.5 w-2.5" />
</button>
)}
</div>
)
})}
</div>
{showGapBelow && !draggingLevelId && (
<button
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
onClick={() => handleInsertBetween(sortedIndex - 1)}
title="Insert level here"
type="button"
>
<Plus className="h-2.5 w-2.5" />
</button>
)}
</div>
)
})}
</div>
</SortableContext>
</DndContext>
</div>
</div>
@@ -384,7 +384,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
tags: ['chair', 'seating', 'ergonomic', 'swivel', 'office', 'desk', 'mesh', 'leather', 'modern', 'task', 'workspace', 'professional', 'computer'],
thumbnail: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/office-chair/thumbnail.png',
src: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/office-chair/model.glb',
floorPlanUrl: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/office-chair/floor-plan.png',
floorPlanUrl: '/items/office-chair/floor-plan.svg',
dimensions: [1, 1.2, 1],
offset: [0.01, 0, 0.03],
rotation: [0, 0, 0],
@@ -438,7 +438,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
tags: ['floor', 'seating'],
thumbnail: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/sofa/thumbnail.png',
src: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/sofa/model.glb',
floorPlanUrl: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/sofa/floor-plan.png',
floorPlanUrl: '/items/sofa/floor-plan.svg',
dimensions: [2.5, 0.8, 1.5],
offset: [0, 0, 0.04],
rotation: [0, 0, 0],
@@ -9,7 +9,7 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { useCallback, useRef } from 'react'
import { usePresetsAdapter } from '../../../contexts/presets-context'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
@@ -22,12 +22,32 @@ import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
import { PresetsPopover } from './presets/presets-popover'
function isSameDoorValue(current: unknown, next: unknown): boolean {
if (typeof current === 'number' && typeof next === 'number') {
return Math.abs(current - next) < 1e-6
}
if (Array.isArray(current) && Array.isArray(next)) {
return (
current.length === next.length &&
current.every((value, index) => isSameDoorValue(value, next[index]))
)
}
return Object.is(current, next)
}
export function DoorPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const previewRef = useRef<{
id: AnyNodeId
key: keyof DoorNode
value: unknown
} | null>(null)
const adapter = usePresetsAdapter()
@@ -37,10 +57,57 @@ export function DoorPanel() {
const handleUpdate = useCallback(
(updates: Partial<DoorNode>) => {
if (!selectedId) return
if (!(selectedId && node)) return
const hasChange = Object.entries(updates).some(([key, value]) => {
const currentValue = node[key as keyof DoorNode]
return !isSameDoorValue(currentValue, value)
})
if (!hasChange) return
updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId, node, updateNode],
)
const previewDoorUpdate = useCallback(
<K extends keyof DoorNode>(key: K, value: DoorNode[K]) => {
if (!selectedId) return
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
if (liveNode?.type !== 'door') return
if (!(previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key)) {
previewRef.current = {
id: selectedId as AnyNodeId,
key,
value: liveNode[key],
}
}
if (isSameDoorValue(liveNode[key], value)) return
;(liveNode as DoorNode)[key] = value
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId],
)
const commitDoorPreview = useCallback(
<K extends keyof DoorNode>(key: K, value: DoorNode[K]) => {
if (!selectedId) return
const scene = useScene.getState()
const liveNode = scene.nodes[selectedId as AnyNodeId]
const preview = previewRef.current
if (liveNode?.type === 'door' && preview?.id === selectedId && preview.key === key) {
;(liveNode as DoorNode)[key] = preview.value as DoorNode[K]
scene.dirtyNodes.add(selectedId as AnyNodeId)
}
previewRef.current = null
updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<DoorNode>)
scene.dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId, updateNode],
)
@@ -85,9 +152,10 @@ export function DoorPanel() {
}, [node, setMovingNode, setSelection])
const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
const numSegs = node?.segments.length
const totalH = node?.segments.reduce((sum, s) => sum + s.heightRatio, 0)
const normH = node?.segments.map((s) => s.heightRatio / totalH)
if (!node) return
const numSegs = node.segments.length
const totalH = node.segments.reduce((sum, s) => sum + s.heightRatio, 0)
const normH = node.segments.map((s) => s.heightRatio / totalH)
const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
const delta = clamped - normH[segIdx]!
@@ -97,7 +165,7 @@ export function DoorPanel() {
if (i === neighborIdx) return neighborVal
return v
})
const updated = node?.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! }))
const updated = node.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! }))
handleUpdate({ segments: updated })
}
@@ -131,6 +199,13 @@ export function DoorPanel() {
height: node.height,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
openingKind: node.openingKind,
openingShape: node.openingShape,
openingRadiusMode: node.openingRadiusMode ?? 'all',
openingTopRadii: node.openingTopRadii ?? [0.15, 0.15],
cornerRadius: node.cornerRadius,
archHeight: node.archHeight,
openingRevealRadius: node.openingRevealRadius,
contentPadding: node.contentPadding,
hingesSide: node.hingesSide,
swingDirection: node.swingDirection,
@@ -177,6 +252,24 @@ export function DoorPanel() {
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
const isOpening = node.openingKind === 'opening'
const openingShape = node.openingShape ?? 'rectangle'
const openingRadiusMode = node.openingRadiusMode ?? 'all'
const openingTopRadii = node.openingTopRadii ?? [0.15, 0.15]
const cornerRadius = node.cornerRadius ?? 0.15
const archHeight = node.archHeight ?? 0.45
const openingRevealRadius = node.openingRevealRadius ?? 0.025
const maxRoundedRadius = Math.max(0.01, Math.min(node.width / 2, node.height))
const setOpeningTopRadius = (index: number, value: number, commit = false) => {
const next = [...openingTopRadii] as [number, number]
next[index] = value
if (commit) {
commitDoorPreview('openingTopRadii', next)
} else {
previewDoorUpdate('openingTopRadii', next)
}
}
return (
<PanelWrapper
@@ -206,6 +299,33 @@ export function DoorPanel() {
</PresetsPopover>
</div>
<PanelSection title="Type">
<div className="flex flex-col gap-2 px-1 pb-1">
<SegmentedControl
onChange={(v) =>
handleUpdate(
v === 'opening'
? {
openingKind: v,
openingShape,
openingRadiusMode,
openingTopRadii,
cornerRadius,
archHeight,
openingRevealRadius,
}
: { openingKind: v },
)
}
options={[
{ label: 'Door', value: 'door' },
{ label: 'Opening', value: 'opening' },
]}
value={node.openingKind}
/>
</div>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label={
@@ -221,14 +341,16 @@ export function DoorPanel() {
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<div className="px-1 pt-2 pb-1">
<ActionButton
className="w-full"
icon={<FlipHorizontal2 className="h-4 w-4" />}
label="Flip Side"
onClick={handleFlip}
/>
</div>
{!isOpening && (
<div className="px-1 pt-2 pb-1">
<ActionButton
className="w-full"
icon={<FlipHorizontal2 className="h-4 w-4" />}
label="Flip Side"
onClick={handleFlip}
/>
</div>
)}
</PanelSection>
<PanelSection title="Dimensions">
@@ -238,6 +360,7 @@ export function DoorPanel() {
min={0.5}
onChange={(v) => handleUpdate({ width: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(node.width * 100) / 100}
@@ -250,12 +373,111 @@ export function DoorPanel() {
handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })
}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(node.height * 100) / 100}
/>
</PanelSection>
{isOpening && (
<PanelSection title="Opening Shape">
<div className="flex flex-col gap-2 px-1 pb-1">
<SegmentedControl
onChange={(v) =>
handleUpdate({
openingShape: v,
...(v === 'rounded'
? { openingRadiusMode, openingTopRadii, cornerRadius, openingRevealRadius }
: {}),
...(v === 'arch' ? { archHeight } : {}),
})
}
options={[
{ label: 'Rect', value: 'rectangle' },
{ label: 'Rounded', value: 'rounded' },
{ label: 'Arch', value: 'arch' },
]}
value={openingShape}
/>
</div>
{openingShape === 'rounded' && (
<>
<div className="flex flex-col gap-2 px-1 pb-1">
<SegmentedControl
onChange={(v) =>
handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] })
}
options={[
{ label: 'All', value: 'all' },
{ label: 'Individual', value: 'individual' },
]}
value={openingRadiusMode}
/>
</div>
{openingRadiusMode === 'all' ? (
<SliderControl
label="Corner Radius"
max={maxRoundedRadius}
min={0}
onChange={(v) => previewDoorUpdate('cornerRadius', v)}
onCommit={(v) => commitDoorPreview('cornerRadius', v)}
precision={2}
step={0.05}
unit="m"
value={Math.round(cornerRadius * 100) / 100}
/>
) : (
<>
{[
['Top Left', 0],
['Top Right', 1],
].map(([label, index]) => (
<SliderControl
key={label}
label={label}
max={maxRoundedRadius}
min={0}
onChange={(v) => setOpeningTopRadius(index as number, v)}
onCommit={(v) => setOpeningTopRadius(index as number, v, true)}
precision={2}
step={0.05}
unit="m"
value={Math.round((openingTopRadii[index as number] ?? 0) * 100) / 100}
/>
))}
</>
)}
<SliderControl
label="Reveal Radius"
max={0.08}
min={0}
onChange={(v) => previewDoorUpdate('openingRevealRadius', v)}
onCommit={(v) => commitDoorPreview('openingRevealRadius', v)}
precision={3}
step={0.005}
unit="m"
value={Math.round(openingRevealRadius * 1000) / 1000}
/>
</>
)}
{openingShape === 'arch' && (
<SliderControl
label="Arch Height"
max={node.height}
min={0.05}
onChange={(v) => handleUpdate({ archHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(archHeight * 100) / 100}
/>
)}
</PanelSection>
)}
{!isOpening && (
<>
<PanelSection title="Frame">
<SliderControl
label="Thickness"
@@ -567,6 +789,9 @@ export function DoorPanel() {
</div>
</PanelSection>
</>
)}
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
@@ -1,21 +1,47 @@
'use client'
import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core'
import { Box, Image as ImageIcon } from 'lucide-react'
import { useCallback } from 'react'
import {
type AnyNode,
emitter,
type GuideNode,
loadAssetUrl,
saveAsset,
type ScanNode,
useScene,
} from '@pascal-app/core'
import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { getGuideImageName } from '../../../lib/local-guide-image'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
type ReferenceNode = ScanNode | GuideNode
function getScaleStatus(guide: GuideNode, scaleReferenceVisible: boolean) {
const reference = guide.scaleReference
if (!reference) {
return 'Uncalibrated'
}
return `${scaleReferenceVisible ? 'Scaled' : 'Scaled (hidden)'} · ${reference.label}`
}
export function ReferencePanel() {
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
const guideUi = useEditor((s) => (selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined))
const setGuideLocked = useEditor((s) => s.setGuideLocked)
const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible)
const clearGuideUi = useEditor((s) => s.clearGuideUi)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const replaceInputRef = useRef<HTMLInputElement>(null)
const [isReplacing, setIsReplacing] = useState(false)
const [replaceError, setReplaceError] = useState<string | null>(null)
const [isAssetMissing, setIsAssetMissing] = useState(false)
const node = useScene((s) =>
selectedReferenceId
@@ -35,17 +61,224 @@ export function ReferencePanel() {
setSelectedReferenceId(null)
}, [setSelectedReferenceId])
const handleReplaceFile = useCallback(
async (file: File) => {
if (!(selectedReferenceId && node?.type === 'guide')) {
return
}
if (!file.type.startsWith('image/')) {
setReplaceError('Choose a PNG, JPEG, or WebP image.')
return
}
setIsReplacing(true)
setReplaceError(null)
try {
const assetUrl = await saveAsset(file)
updateNode(selectedReferenceId as AnyNode['id'], {
name: getGuideImageName(file.name),
url: assetUrl,
scaleReference: null,
} as Partial<GuideNode>)
setGuideScaleReferenceVisible(selectedReferenceId, true)
} catch {
setReplaceError('Could not replace that image.')
} finally {
setIsReplacing(false)
}
},
[node?.type, selectedReferenceId, setGuideScaleReferenceVisible, updateNode],
)
const handleDeleteGuide = useCallback(() => {
if (!(selectedReferenceId && node?.type === 'guide')) {
return
}
deleteNode(selectedReferenceId as AnyNode['id'])
emitter.emit('guide:deleted', { guideId: selectedReferenceId as GuideNode['id'] })
clearGuideUi(selectedReferenceId)
setSelectedReferenceId(null)
}, [clearGuideUi, deleteNode, node?.type, selectedReferenceId, setSelectedReferenceId])
const handleStartScale = useCallback(() => {
if (node?.type !== 'guide') {
return
}
emitter.emit('guide:set-reference-scale', { guideId: node.id })
}, [node])
const handleCancelScale = useCallback(() => {
emitter.emit('guide:cancel-reference-scale')
}, [])
useEffect(() => {
if (node?.type !== 'guide' || !node.url.startsWith('asset://')) {
setIsAssetMissing(false)
return
}
let cancelled = false
loadAssetUrl(node.url).then((resolvedUrl) => {
if (!cancelled) {
setIsAssetMissing(!resolvedUrl)
}
})
return () => {
cancelled = true
}
}, [node])
if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null
const isScan = node.type === 'scan'
const guideLocked = !isScan && guideUi?.locked === true
const scaleReferenceVisible = !isScan && guideUi?.scaleReferenceVisible !== false
const scaleStatus = !isScan ? getScaleStatus(node, scaleReferenceVisible) : null
return (
<PanelWrapper
icon={isScan ? undefined : undefined}
onClose={handleClose}
title={node.name || (isScan ? '3D Scan' : 'Guide Image')}
width={300}
>
{!isScan && (
<>
<PanelSection title="Image">
<input
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.currentTarget.files?.[0]
event.currentTarget.value = ''
if (file) {
void handleReplaceFile(file)
}
}}
ref={replaceInputRef}
type="file"
/>
<ActionGroup>
<ActionButton
icon={<Upload className="h-3.5 w-3.5" />}
label={isReplacing ? 'Replacing...' : 'Replace'}
onClick={() => replaceInputRef.current?.click()}
disabled={isReplacing}
/>
<ActionButton
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete"
onClick={handleDeleteGuide}
className="text-destructive hover:bg-destructive/10"
/>
</ActionGroup>
<ActionGroup>
<ActionButton
icon={
node.visible === false ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)
}
label={node.visible === false ? 'Show' : 'Hide'}
onClick={() => handleUpdate({ visible: node.visible === false })}
/>
<ActionButton
icon={
guideLocked ? (
<Lock className="h-3.5 w-3.5" />
) : (
<Unlock className="h-3.5 w-3.5" />
)
}
label={guideLocked ? 'Unlock' : 'Lock'}
onClick={() => setGuideLocked(node.id, !guideLocked)}
/>
</ActionGroup>
{replaceError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-destructive text-xs">
{replaceError}
</div>
)}
{isAssetMissing && (
<div className="rounded-md border border-amber-500/35 bg-amber-500/10 px-2 py-1.5 text-amber-700 text-xs dark:text-amber-300">
Overlay image unavailable. Replace the image to restore it.
</div>
)}
</PanelSection>
<PanelSection title="Reference Scale">
<div className="flex items-center gap-2 rounded-md border border-border/50 bg-background/40 px-2.5 py-2 text-sm">
<Ruler className="h-4 w-4 shrink-0 text-primary" />
<span className="truncate text-muted-foreground">{scaleStatus}</span>
</div>
<ActionGroup>
<ActionButton
label={node.scaleReference ? 'Edit Scale' : 'Set Scale'}
onClick={handleStartScale}
/>
<ActionButton label="Cancel" onClick={handleCancelScale} />
</ActionGroup>
<ActionGroup>
<ActionButton
label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
disabled={!node.scaleReference}
onClick={() => {
if (!node.scaleReference) return
setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)
}}
/>
<ActionButton
label="Clear Scale"
disabled={!node.scaleReference}
onClick={() => handleUpdate({ scaleReference: null } as Partial<GuideNode>)}
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Quick Actions">
<ActionGroup>
<ActionButton
icon={<LocateFixed className="h-3.5 w-3.5" />}
label="Center"
onClick={() =>
handleUpdate({
position: [0, node.position[1], 0],
} as Partial<GuideNode>)
}
/>
<ActionButton
icon={<RotateCcw className="h-3.5 w-3.5" />}
label="Reset Rotation"
onClick={() =>
handleUpdate({
rotation: [node.rotation[0], 0, node.rotation[2]],
} as Partial<GuideNode>)
}
/>
</ActionGroup>
<ActionGroup>
<ActionButton
icon={<Ruler className="h-3.5 w-3.5" />}
label="Reset Image Scale"
onClick={() => handleUpdate({ scale: 1 } as Partial<GuideNode>)}
/>
</ActionGroup>
</PanelSection>
</>
)}
<PanelSection title="Position">
<SliderControl
label={
@@ -91,7 +91,7 @@ export function SpawnPanel() {
const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI)
return (
<PanelWrapper icon="/icons/spawn-point.svg" onClose={handleClose} title="Spawn Point" width={300}>
<PanelWrapper icon="/icons/site.png" onClose={handleClose} title="Spawn Point" width={300}>
<PanelSection title="Position">
<SliderControl
label="X"
@@ -9,24 +9,75 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { useCallback, useRef } from 'react'
import { usePresetsAdapter } from '../../../contexts/presets-context'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
import { PresetsPopover } from './presets/presets-popover'
function isSameWindowValue(current: unknown, next: unknown): boolean {
if (typeof current === 'number' && typeof next === 'number') {
return Math.abs(current - next) < 1e-6
}
if (Array.isArray(current) && Array.isArray(next)) {
return (
current.length === next.length &&
current.every((value, index) => isSameWindowValue(value, next[index]))
)
}
return Object.is(current, next)
}
function getMaxSharedWindowRadius(width: number, height: number) {
return Math.max(0, Math.min(width / 2, height / 2))
}
function normalizeWindowCornerRadii(
radii: [number, number, number, number],
width: number,
height: number,
): [number, number, number, number] {
const next = radii.map((radius) => Math.max(radius, 0)) as [number, number, number, number]
const scale = Math.min(
1,
Math.max(width, 0) / Math.max(next[0] + next[1], 1e-6),
Math.max(width, 0) / Math.max(next[3] + next[2], 1e-6),
Math.max(height, 0) / Math.max(next[0] + next[3], 1e-6),
Math.max(height, 0) / Math.max(next[1] + next[2], 1e-6),
)
if (scale >= 1) return next
return next.map((radius) => radius * scale) as [number, number, number, number]
}
function isSameRadiusTuple(
current: [number, number, number, number],
next: [number, number, number, number],
) {
return current.every((value, index) => Math.abs(value - next[index]) < 1e-6)
}
export function WindowPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const previewRef = useRef<{
id: AnyNodeId
key: keyof WindowNode
value: unknown
} | null>(null)
const adapter = usePresetsAdapter()
@@ -36,10 +87,59 @@ export function WindowPanel() {
const handleUpdate = useCallback(
(updates: Partial<WindowNode>) => {
if (!selectedId) return
if (!(selectedId && node)) return
const hasChange = Object.entries(updates).some(([key, value]) => {
const currentValue = node[key as keyof WindowNode]
return !isSameWindowValue(currentValue, value)
})
if (!hasChange) return
updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId, node, updateNode],
)
const previewWindowUpdate = useCallback(
<K extends keyof WindowNode>(key: K, value: WindowNode[K]) => {
if (!selectedId) return
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
if (liveNode?.type !== 'window') return
if (
!(previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key)
) {
previewRef.current = {
id: selectedId as AnyNodeId,
key,
value: liveNode[key],
}
}
if (isSameWindowValue(liveNode[key], value)) return
;(liveNode as WindowNode)[key] = value
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId],
)
const commitWindowPreview = useCallback(
<K extends keyof WindowNode>(key: K, value: WindowNode[K]) => {
if (!selectedId) return
const scene = useScene.getState()
const liveNode = scene.nodes[selectedId as AnyNodeId]
const preview = previewRef.current
if (liveNode?.type === 'window' && preview?.id === selectedId && preview.key === key) {
;(liveNode as WindowNode)[key] = preview.value as WindowNode[K]
scene.dirtyNodes.add(selectedId as AnyNodeId)
}
previewRef.current = null
updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<WindowNode>)
scene.dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId, updateNode],
)
@@ -84,6 +184,13 @@ export function WindowPanel() {
height: node.height,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
openingKind: node.openingKind,
openingShape: node.openingShape,
openingRadiusMode: node.openingRadiusMode ?? 'all',
openingCornerRadii: [...(node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15])],
cornerRadius: node.cornerRadius,
archHeight: node.archHeight,
openingRevealRadius: node.openingRevealRadius,
columnRatios: [...node.columnRatios],
rowRatios: [...node.rowRatios],
columnDividerThickness: node.columnDividerThickness,
@@ -105,6 +212,13 @@ export function WindowPanel() {
height: node.height,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
openingKind: node.openingKind,
openingShape: node.openingShape,
openingRadiusMode: node.openingRadiusMode ?? 'all',
openingCornerRadii: node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15],
cornerRadius: node.cornerRadius,
archHeight: node.archHeight,
openingRevealRadius: node.openingRevealRadius,
columnRatios: node.columnRatios,
rowRatios: node.rowRatios,
columnDividerThickness: node.columnDividerThickness,
@@ -151,6 +265,58 @@ export function WindowPanel() {
const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
const normCols = node.columnRatios.map((r) => r / colSum)
const normRows = node.rowRatios.map((r) => r / rowSum)
const isOpening = node.openingKind === 'opening'
const openingShape = node.openingShape ?? 'rectangle'
const openingRadiusMode = node.openingRadiusMode ?? 'all'
const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
const cornerRadius = node.cornerRadius ?? 0.15
const archHeight = node.archHeight ?? 0.35
const openingRevealRadius = node.openingRevealRadius ?? 0.025
const maxRoundedRadius = Math.max(0.01, getMaxSharedWindowRadius(node.width, node.height))
const getDimensionUpdates = (updates: Partial<Pick<WindowNode, 'width' | 'height'>>) => {
const nextWidth = updates.width ?? node.width
const nextHeight = updates.height ?? node.height
const nextUpdates: Partial<WindowNode> = { ...updates }
if (openingShape === 'rounded') {
if (openingRadiusMode === 'individual') {
const currentRadii = openingCornerRadii as [number, number, number, number]
const nextRadii = normalizeWindowCornerRadii(
openingCornerRadii as [number, number, number, number],
nextWidth,
nextHeight,
)
if (!isSameRadiusTuple(currentRadii, nextRadii)) {
nextUpdates.openingCornerRadii = nextRadii
}
} else {
const nextRadius = Math.min(Math.max(cornerRadius, 0), getMaxSharedWindowRadius(nextWidth, nextHeight))
if (Math.abs(nextRadius - cornerRadius) > 1e-6) {
nextUpdates.cornerRadius = nextRadius
}
}
}
if (openingShape === 'arch') {
const nextArchHeight = Math.min(Math.max(archHeight, 0.05), Math.max(nextHeight, 0.05))
if (Math.abs(nextArchHeight - archHeight) > 1e-6) {
nextUpdates.archHeight = nextArchHeight
}
}
return nextUpdates
}
const setOpeningCornerRadius = (index: number, value: number, commit = false) => {
const next = [...openingCornerRadii] as [number, number, number, number]
next[index] = value
if (commit) {
commitWindowPreview('openingCornerRadii', next)
} else {
previewWindowUpdate('openingCornerRadii', next)
}
}
const setColumnRatio = (index: number, newVal: number) => {
const clamped = Math.max(0.05, Math.min(0.95, newVal))
@@ -206,6 +372,31 @@ export function WindowPanel() {
</PresetsPopover>
</div>
<PanelSection title="Type">
<SegmentedControl
onChange={(value) =>
handleUpdate({
openingKind: value as WindowNode['openingKind'],
...(value === 'opening'
? {
openingShape,
openingRadiusMode,
openingCornerRadii,
cornerRadius,
archHeight,
openingRevealRadius,
}
: {}),
})
}
options={[
{ value: 'window', label: 'Window' },
{ value: 'opening', label: 'Opening' },
]}
value={node.openingKind ?? 'window'}
/>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label={
@@ -231,22 +422,25 @@ export function WindowPanel() {
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
<div className="px-1 pt-2 pb-1">
<ActionButton
className="w-full"
icon={<FlipHorizontal2 className="h-4 w-4" />}
label="Flip Side"
onClick={handleFlip}
/>
</div>
{!isOpening && (
<div className="px-1 pt-2 pb-1">
<ActionButton
className="w-full"
icon={<FlipHorizontal2 className="h-4 w-4" />}
label="Flip Side"
onClick={handleFlip}
/>
</div>
)}
</PanelSection>
<PanelSection title="Dimensions">
<SliderControl
label="Width"
min={0}
onChange={(v) => handleUpdate({ width: v })}
onChange={(v) => handleUpdate(getDimensionUpdates({ width: v }))}
precision={2}
restoreOnCommit={false}
step={0.1}
unit="m"
value={Math.round(node.width * 100) / 100}
@@ -254,157 +448,252 @@ export function WindowPanel() {
<SliderControl
label="Height"
min={0}
onChange={(v) => handleUpdate({ height: v })}
onChange={(v) => handleUpdate(getDimensionUpdates({ height: v }))}
precision={2}
restoreOnCommit={false}
step={0.1}
unit="m"
value={Math.round(node.height * 100) / 100}
/>
</PanelSection>
<PanelSection title="Frame">
<SliderControl
label="Thickness"
min={0}
onChange={(v) => handleUpdate({ frameThickness: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.frameThickness * 1000) / 1000}
/>
<SliderControl
label="Depth"
min={0}
onChange={(v) => handleUpdate({ frameDepth: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.frameDepth * 1000) / 1000}
/>
</PanelSection>
<PanelSection title="Grid">
<SliderControl
label="Columns"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
}}
precision={0}
step={1}
value={numCols}
/>
<SliderControl
label="Rows"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
}}
precision={0}
step={1}
value={numRows}
/>
{numCols > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Col Widths
</div>
{normCols.map((ratio, i) => (
<SliderControl
key={`c-${i}`}
label={`C${i + 1}`}
max={95}
min={5}
onChange={(v) => setColumnRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
{isOpening && (
<PanelSection title="Opening Shape">
<SegmentedControl
onChange={(value) =>
handleUpdate({ openingShape: value as WindowNode['openingShape'] })
}
options={[
{ value: 'rectangle', label: 'Rect' },
{ value: 'rounded', label: 'Rounded' },
{ value: 'arch', label: 'Arch' },
]}
value={openingShape}
/>
{openingShape === 'rounded' && (
<div className="mt-2 flex flex-col gap-1">
<SegmentedControl
onChange={(value) =>
handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] })
}
options={[
{ value: 'all', label: 'All' },
{ value: 'individual', label: 'Individual' },
]}
value={openingRadiusMode}
/>
))}
<div className="mt-1 border-border/50 border-t pt-1">
{openingRadiusMode === 'all' ? (
<SliderControl
label="Corner Radius"
max={maxRoundedRadius}
min={0}
onChange={(value) => previewWindowUpdate('cornerRadius', value)}
onCommit={(value) => commitWindowPreview('cornerRadius', value)}
precision={2}
step={0.05}
unit="m"
value={Math.round(cornerRadius * 100) / 100}
/>
) : (
<>
{[
['Top Left', 0],
['Top Right', 1],
['Bottom Right', 2],
['Bottom Left', 3],
].map(([label, index]) => (
<SliderControl
key={label}
label={label}
max={maxRoundedRadius}
min={0}
onChange={(value) => setOpeningCornerRadius(index as number, value)}
onCommit={(value) => setOpeningCornerRadius(index as number, value, true)}
precision={2}
step={0.05}
unit="m"
value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100}
/>
))}
</>
)}
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
label="Reveal Radius"
max={0.08}
min={0}
onChange={(value) => previewWindowUpdate('openingRevealRadius', value)}
onCommit={(value) => commitWindowPreview('openingRevealRadius', value)}
precision={3}
step={0.01}
step={0.005}
unit="m"
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
value={Math.round(openingRevealRadius * 1000) / 1000}
/>
</div>
</div>
)}
{numRows > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Row Heights
</div>
{normRows.map((ratio, i) => (
)}
{openingShape === 'arch' && (
<div className="mt-2 flex flex-col gap-1">
<SliderControl
key={`r-${i}`}
label={`R${i + 1}`}
max={95}
min={5}
onChange={(v) => setRowRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
/>
))}
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
precision={3}
step={0.01}
label="Arch Height"
max={Math.max(0.05, node.height)}
min={0.05}
onChange={(value) => handleUpdate({ archHeight: value })}
precision={2}
step={0.05}
unit="m"
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
value={Math.round(archHeight * 100) / 100}
/>
</div>
</div>
)}
</PanelSection>
)}
</PanelSection>
)}
<PanelSection title="Sill">
<ToggleControl
checked={node.sill}
label="Enable Sill"
onChange={(checked) => handleUpdate({ sill: checked })}
/>
{node.sill && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Depth"
min={0}
onChange={(v) => handleUpdate({ sillDepth: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.sillDepth * 1000) / 1000}
/>
{!isOpening && (
<>
<PanelSection title="Frame">
<SliderControl
label="Thickness"
min={0}
onChange={(v) => handleUpdate({ sillThickness: v })}
onChange={(v) => handleUpdate({ frameThickness: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.sillThickness * 1000) / 1000}
value={Math.round(node.frameThickness * 1000) / 1000}
/>
</div>
)}
</PanelSection>
<SliderControl
label="Depth"
min={0}
onChange={(v) => handleUpdate({ frameDepth: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.frameDepth * 1000) / 1000}
/>
</PanelSection>
<PanelSection title="Grid">
<SliderControl
label="Columns"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
}}
precision={0}
step={1}
value={numCols}
/>
<SliderControl
label="Rows"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
}}
precision={0}
step={1}
value={numRows}
/>
{numCols > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Col Widths
</div>
{normCols.map((ratio, i) => (
<SliderControl
key={`c-${i}`}
label={`C${i + 1}`}
max={95}
min={5}
onChange={(v) => setColumnRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
/>
))}
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
/>
</div>
</div>
)}
{numRows > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Row Heights
</div>
{normRows.map((ratio, i) => (
<SliderControl
key={`r-${i}`}
label={`R${i + 1}`}
max={95}
min={5}
onChange={(v) => setRowRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
/>
))}
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
/>
</div>
</div>
)}
</PanelSection>
<PanelSection title="Sill">
<ToggleControl
checked={node.sill}
label="Enable Sill"
onChange={(checked) => handleUpdate({ sill: checked })}
/>
{node.sill && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Depth"
min={0}
onChange={(v) => handleUpdate({ sillDepth: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.sillDepth * 1000) / 1000}
/>
<SliderControl
label="Thickness"
min={0}
onChange={(v) => handleUpdate({ sillThickness: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.sillThickness * 1000) / 1000}
/>
</div>
)}
</PanelSection>
</>
)}
<PanelSection title="Actions">
<ActionGroup>
@@ -33,10 +33,13 @@ import {
PopoverTrigger,
} from './../../../../../components/ui/primitives/popover'
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
import {
buildLevelDuplicateCreateOps,
type LevelDuplicatePreset,
} from './../../../../../lib/level-duplication'
import { cn } from './../../../../../lib/utils'
import useEditor from './../../../../../store/use-editor'
import { useUploadStore } from '../../../../../store/use-upload'
@@ -400,7 +403,10 @@ const LevelReferences = memo(function LevelReferences({
onUploadAsset,
onDeleteAsset,
}: LevelReferencesProps) {
const createNode = useScene((s) => s.createNode)
const deleteNode = useScene((s) => s.deleteNode)
const setSelection = useViewer((s) => s.setSelection)
const setShowGuides = useViewer((s) => s.setShowGuides)
const references = useScene(
useShallow((s) =>
Object.values(s.nodes).filter(
@@ -423,19 +429,27 @@ const LevelReferences = memo(function LevelReferences({
const scanInputRef = useRef<HTMLInputElement>(null)
const handleAddAsset = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleAddAsset = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
if (!projectId) {
useUploadStore.getState().startUpload(levelId, 'scan', file.name)
useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.')
// Auto-detect type based on file extension/mime type
const isScan =
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
const isImage = file.type.startsWith('image/')
const type = isScan ? 'scan' : 'guide'
if (!(isScan || isImage)) {
useUploadStore.getState().startUpload(levelId, type, file.name)
useUploadStore
.getState()
.setError(levelId, 'Invalid file type. Please upload a .glb/.gltf scan or an image.')
return
}
if (file.size > MAX_FILE_SIZE) {
useUploadStore.getState().startUpload(levelId, 'scan', file.name)
useUploadStore.getState().startUpload(levelId, type, file.name)
useUploadStore
.getState()
.setError(
@@ -445,20 +459,28 @@ const LevelReferences = memo(function LevelReferences({
return
}
// Auto-detect type based on file extension/mime type
const isScan =
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
const isImage = file.type.startsWith('image/')
if (isImage) {
useUploadStore.getState().startUpload(levelId, 'guide', file.name)
useUploadStore.getState().setStatus(levelId, 'uploading')
if (!(isScan || isImage)) {
useUploadStore.getState().startUpload(levelId, 'scan', file.name)
useUploadStore
.getState()
.setError(levelId, 'Invalid file type. Please upload a .glb/.gltf scan or an image.')
try {
const guide = await createLocalGuideImage({ createNode, file, levelId })
setShowGuides(true)
setSelectedReferenceId(guide.id)
setSelection({ selectedIds: [], zoneId: null })
useUploadStore.getState().setResult(levelId, guide.url)
window.setTimeout(() => useUploadStore.getState().clearUpload(levelId), 600)
} catch {
useUploadStore.getState().setError(levelId, 'Could not add that guide image.')
}
return
}
const type = isScan ? 'scan' : 'guide'
if (!projectId) {
useUploadStore.getState().startUpload(levelId, 'scan', file.name)
useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.')
return
}
clearUpload(levelId)
onUploadAsset?.(projectId, levelId, file, type)
@@ -54,7 +54,7 @@ export const SpawnTreeNode = memo(function SpawnTreeNode({
alt=""
className="object-contain"
height={14}
src="/icons/spawn-point.svg"
src="/icons/site.png"
width={14}
/>
}
@@ -2,7 +2,18 @@
import { Icon as IconifyIcon } from '@iconify/react'
import { useViewer } from '@pascal-app/viewer'
import { Check, ChevronsLeft, ChevronsRight, Columns2, Eye, Footprints, Moon, Sun } from 'lucide-react'
import {
Check,
ChevronsLeft,
ChevronsRight,
Columns2,
Eye,
EyeOff,
Footprints,
Grid2X2,
Moon,
Sun,
} from 'lucide-react'
import { useCallback } from 'react'
import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor'
@@ -271,6 +282,35 @@ function GridSnapToggle() {
)
}
function GridVisibilityToggle() {
const showGrid = useViewer((s) => s.showGrid)
const setShowGrid = useViewer((s) => s.setShowGrid)
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={`Grid: ${showGrid ? 'Visible' : 'Hidden'}`}
aria-pressed={showGrid}
className={cn(
TOOLBAR_BTN,
'w-auto gap-1.5 px-2.5',
showGrid
? 'bg-white/10 text-foreground/90'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0',
)}
onClick={() => setShowGrid(!showGrid)}
type="button"
>
<Grid2X2 className="h-3.5 w-3.5" />
{showGrid ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Grid: {showGrid ? 'Visible' : 'Hidden'}</TooltipContent>
</Tooltip>
)
}
// ── Wall mode toggle ────────────────────────────────────────────────────────
const wallModeOrder = ['cutaway', 'up', 'down'] as const
@@ -383,6 +423,7 @@ export function ViewerToolbarRight() {
<LevelModeToggle />
<WallModeToggle />
<GridSnapToggle />
<GridVisibilityToggle />
<div className="my-1.5 w-px bg-border/50" />
<UnitToggle />
<ThemeToggle />
+21 -2
View File
@@ -5,6 +5,8 @@ import { runRedo, runUndo } from '../lib/history'
import { sfxEmitter } from '../lib/sfx-bus'
import useEditor from '../store/use-editor'
const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
// so that the global Escape handler knows not to also switch to select mode.
let _toolCancelConsumed = false
@@ -145,10 +147,21 @@ export const useKeyboard = ({
}
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
// Doors use R to toggle their leaf open/closed around the hinge.
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node && 'rotation' in node) {
if (node?.type === 'door') {
e.preventDefault()
if (node.openingKind !== 'opening') {
const currentSwingAngle = node.swingAngle ?? 0
useScene.getState().updateNode(node.id, {
swingAngle:
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
})
sfxEmitter.emit('sfx:item-rotate')
}
} else if (node && 'rotation' in node) {
e.preventDefault()
const ROTATION_STEP = Math.PI / 4
@@ -168,7 +181,13 @@ export const useKeyboard = ({
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node && 'rotation' in node) {
if (node?.type === 'door') {
e.preventDefault()
if (node.openingKind !== 'opening') {
useScene.getState().updateNode(node.id, { swingAngle: 0 })
sfxEmitter.emit('sfx:item-rotate')
}
} else if (node && 'rotation' in node) {
e.preventDefault()
const ROTATION_STEP = Math.PI / 4
+38 -3
View File
@@ -139,6 +139,21 @@ export function buildFloorplanItemEntry(
return null
}
const dimensionPolygon = getItemDimensionPolygon(item, transform)
const [width, , depth] = getScaledDimensions(item)
if (shouldUseDimensionFloorplanFootprint(item)) {
return {
dimensionPolygon,
item,
polygon: dimensionPolygon,
usesRealMesh: true,
center: transform.position,
rotation: transform.rotation,
width,
depth,
}
}
const object = sceneRegistry.nodes.get(item.id)
const realMeshPolygon = object
? getRealMeshFloorplanPolygon(transform, object)
@@ -147,9 +162,6 @@ export function buildFloorplanItemEntry(
return null
}
const dimensionPolygon = getItemDimensionPolygon(item, transform)
const [width, , depth] = getScaledDimensions(item)
return {
dimensionPolygon,
item,
@@ -167,6 +179,29 @@ type Point = {
y: number
}
const DIMENSION_FOOTPRINT_ASSET_IDS = new Set(['tree', 'fir-tree', 'palm', 'bush'])
const DIMENSION_FOOTPRINT_TAGS = new Set([
'botanical',
'foliage',
'greenery',
'plant',
'tree',
'vegetation',
])
function shouldUseDimensionFloorplanFootprint(item: ItemNode) {
const asset = item.asset
if (asset.category !== 'outdoor') {
return false
}
if (DIMENSION_FOOTPRINT_ASSET_IDS.has(asset.id)) {
return true
}
return asset.tags?.some((tag) => DIMENSION_FOOTPRINT_TAGS.has(tag.toLowerCase())) ?? false
}
function getItemDimensionPolygon(item: ItemNode, transform: FloorplanNodeTransform): Point[] {
const [width, , depth] = getScaledDimensions(item)
const centerLocalZ = item.asset.attachTo === 'wall-side' ? -depth / 2 : 0
+21 -4
View File
@@ -200,8 +200,7 @@ function getFloorplanArcPoint(center: Point2D, radius: number, angle: number): P
function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
const stairType = stair.stairType ?? 'straight'
const baseSweepAngle =
stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
const baseSweepAngle = stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
@@ -210,11 +209,29 @@ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
return baseSweepAngle
}
function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
if (
(stair.stairType ?? 'straight') !== 'spiral' ||
(stair.topLandingMode ?? 'none') !== 'integrated'
) {
return 0
}
const innerRadius = Math.max(0.05, stair.innerRadius ?? 0.9)
const width = Math.max(stair.width ?? 1, 0.4)
const landingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max(width * 0.9, 0.8))
return (
Math.min(Math.PI * 0.75, landingDepth / Math.max(innerRadius + width / 2, 0.1)) *
Math.sign(sweepAngle || 1)
)
}
function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] {
const stairType = stair.stairType ?? 'straight'
const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair)
const startAngle = stair.rotation - sweepAngle / 2
const endAngle = startAngle + sweepAngle
const startAngle = -stair.rotation - sweepAngle / 2
const endAngle = startAngle + sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle)
const center = {
x: stair.position[0],
y: stair.position[2],
@@ -0,0 +1,42 @@
import {
type AnyNodeId,
GuideNode,
type GuideNode as GuideNodeType,
saveAsset,
} from '@pascal-app/core'
export function getGuideImageName(filename: string) {
const trimmed = filename.trim()
if (!trimmed) {
return 'Guide image'
}
const dotIndex = trimmed.lastIndexOf('.')
return dotIndex > 0 ? trimmed.slice(0, dotIndex) : trimmed
}
export async function createLocalGuideImage({
createNode,
file,
levelId,
position = [0, 0, 0],
}: {
createNode: (node: GuideNodeType, parentId: AnyNodeId) => void
file: File
levelId: string
position?: [number, number, number]
}) {
const assetUrl = await saveAsset(file)
const guide = GuideNode.parse({
name: getGuideImageName(file.name),
url: assetUrl,
position,
rotation: [0, 0, 0],
scale: 1,
opacity: 50,
scaleReference: null,
})
createNode(guide, levelId as AnyNodeId)
return guide
}
+81 -2
View File
@@ -11,10 +11,10 @@ import {
type LevelNode,
type RoofNode,
type RoofSegmentNode,
type SpawnNode,
type RoofSurfaceMaterialRole,
type SlabNode,
type Space,
type SpawnNode,
type StairNode,
type StairSegmentNode,
type StairSurfaceMaterialRole,
@@ -98,7 +98,11 @@ export type MovingFenceEndpoint = {
endpoint: 'start' | 'end'
}
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole | SingleSurfaceMaterialRole
export type MaterialTargetRole =
| WallSurfaceSide
| StairSurfaceMaterialRole
| RoofSurfaceMaterialRole
| SingleSurfaceMaterialRole
export type SelectedMaterialTarget = {
nodeId: AnyNodeId
@@ -111,6 +115,11 @@ type MaterialPaintSelectionSnapshot = {
activePaintMaterial: ActivePaintMaterial | null
}
export type GuideUiState = {
locked?: boolean
scaleReferenceVisible?: boolean
}
type EditorState = {
phase: Phase
setPhase: (phase: Phase) => void
@@ -177,6 +186,10 @@ type EditorState = {
setPaintPanelOpen: (open: boolean) => void
selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void
guideUi: Record<string, GuideUiState>
setGuideLocked: (guideId: string, locked: boolean) => void
setGuideScaleReferenceVisible: (guideId: string, visible: boolean) => void
clearGuideUi: (guideId: string) => void
// Space detection for cutaway mode
spaces: Record<string, Space>
setSpaces: (spaces: Record<string, Space>) => void
@@ -201,6 +214,13 @@ type EditorState = {
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
gridSnapStep: GridSnapStep
setGridSnapStep: (step: GridSnapStep) => void
showReferenceFloor: boolean
toggleReferenceFloor: () => void
setShowReferenceFloor: (show: boolean) => void
referenceFloorOffset: number
setReferenceFloorOffset: (offset: number) => void
referenceFloorOpacity: number
setReferenceFloorOpacity: (opacity: number) => void
// First-person walkthrough mode (street view)
isFirstPersonMode: boolean
_viewModeBeforeFirstPerson: ViewMode | null
@@ -230,6 +250,9 @@ type PersistedEditorLayoutState = Pick<
| 'splitOrientation'
| 'floorplanSelectionTool'
| 'gridSnapStep'
| 'showReferenceFloor'
| 'referenceFloorOffset'
| 'referenceFloorOpacity'
>
type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState
@@ -249,6 +272,9 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
splitOrientation: 'horizontal',
floorplanSelectionTool: 'click',
gridSnapStep: 0.5,
showReferenceFloor: false,
referenceFloorOffset: 1,
referenceFloorOpacity: 0.35,
}
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
@@ -358,6 +384,16 @@ function normalizePersistedEditorLayoutState(
gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep)
? (state?.gridSnapStep as GridSnapStep)
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
showReferenceFloor: state?.showReferenceFloor === true,
referenceFloorOffset:
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
? Math.floor(state.referenceFloorOffset)
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOffset,
referenceFloorOpacity:
typeof state?.referenceFloorOpacity === 'number' &&
Number.isFinite(state.referenceFloorOpacity)
? Math.min(0.8, Math.max(0.1, state.referenceFloorOpacity))
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOpacity,
}
}
@@ -605,6 +641,36 @@ const useEditor = create<EditorState>()(
setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
guideUi: {},
setGuideLocked: (guideId, locked) =>
set((state) => ({
guideUi: {
...state.guideUi,
[guideId]: {
...state.guideUi[guideId],
locked,
},
},
})),
setGuideScaleReferenceVisible: (guideId, visible) =>
set((state) => ({
guideUi: {
...state.guideUi,
[guideId]: {
...state.guideUi[guideId],
scaleReferenceVisible: visible,
},
},
})),
clearGuideUi: (guideId) =>
set((state) => {
if (!state.guideUi[guideId]) {
return state
}
const guideUi = { ...state.guideUi }
delete guideUi[guideId]
return { guideUi }
}),
spaces: {},
setSpaces: (spaces) => set({ spaces }),
editingHole: null,
@@ -636,6 +702,16 @@ const useEditor = create<EditorState>()(
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
setGridSnapStep: (step) => set({ gridSnapStep: step }),
showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
toggleReferenceFloor: () =>
set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
setShowReferenceFloor: (show) => set({ showReferenceFloor: show }),
referenceFloorOffset: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOffset,
setReferenceFloorOffset: (offset) =>
set({ referenceFloorOffset: Math.max(1, Math.floor(offset)) }),
referenceFloorOpacity: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOpacity,
setReferenceFloorOpacity: (opacity) =>
set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }),
allowUndergroundCamera: false,
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
isFirstPersonMode: false,
@@ -703,6 +779,9 @@ const useEditor = create<EditorState>()(
splitOrientation: state.splitOrientation,
floorplanSelectionTool: state.floorplanSelectionTool,
gridSnapStep: state.gridSnapStep,
showReferenceFloor: state.showReferenceFloor,
referenceFloorOffset: state.referenceFloorOffset,
referenceFloorOpacity: state.referenceFloorOpacity,
}),
},
),
+2
View File
@@ -30,6 +30,8 @@
},
"dependencies": {
"polygon-clipping": "^0.15.7",
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8",
"zustand": "^5"
},
"devDependencies": {
@@ -1,8 +1,9 @@
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import { useLayoutEffect, useRef } from 'react'
import { MeshBasicMaterial, type Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, DEFAULT_DOOR_MATERIAL } from '../../../lib/materials'
const doorHitboxMaterial = new MeshBasicMaterial({ visible: false })
export const DoorRenderer = ({ node }: { node: DoorNode }) => {
const ref = useRef<Mesh>(null!)
@@ -14,16 +15,10 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
const handlers = useNodeEvents(node, 'door')
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
const material = useMemo(() => {
const mat = node.material
if (!mat) return DEFAULT_DOOR_MATERIAL
return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
return (
<mesh
castShadow
material={material}
material={doorHitboxMaterial}
position={node.position}
receiveShadow
ref={ref}
@@ -19,7 +19,7 @@ export const GuideRenderer = ({ node }: { node: GuideNode }) => {
position={node.position}
ref={ref}
rotation={[0, node.rotation[1], 0]}
visible={showGuides}
visible={showGuides && node.visible !== false}
>
{resolvedUrl && (
<Suspense>
@@ -1,8 +1,6 @@
import {
type AnimationEffect,
type AnyNodeId,
baseMaterial,
glassMaterial,
type Interactive,
type ItemNode,
type LightEffect,
@@ -21,6 +19,7 @@ import { positionLocal, smoothstep, time } from 'three/tsl'
import { MeshStandardNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { resolveCdnUrl } from '../../../lib/asset-url'
import { baseMaterial, glassMaterial } from '../../../lib/materials'
import { useItemLightPool } from '../../../store/use-item-light-pool'
import {
requestItemMeshMetadataSync,
@@ -54,13 +54,13 @@ export const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
<meshStandardMaterial {...materialProps} />
</mesh>
<mesh position={[0, 0.36, 0]} {...handlers}>
<cylinderGeometry args={[0.08, 0.08, 0.54, 24]} />
<mesh position={[0, 0.41, 0]} {...handlers}>
<boxGeometry args={[0.3, 0.54, 0.16]} />
<meshStandardMaterial {...materialProps} />
</mesh>
<mesh position={[0, 0.68, 0]} {...handlers}>
<sphereGeometry args={[0.16, 24, 16]} />
<mesh position={[0, 0.83, 0]} {...handlers}>
<boxGeometry args={[0.18, 0.18, 0.18]} />
<meshStandardMaterial {...materialProps} />
</mesh>
</group>
@@ -1,27 +1,25 @@
'use client'
import {
CeilingSystem,
DoorSystem,
FenceSystem,
ItemSystem,
RoofSystem,
SlabSystem,
StairSystem,
WallSystem,
WindowSystem,
} from '@pascal-app/core'
import { Bvh } from '@react-three/drei'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu'
import useViewer from '../../store/use-viewer'
import { CeilingSystem } from '../../systems/ceiling/ceiling-system'
import { DoorSystem } from '../../systems/door/door-system'
import { FenceSystem } from '../../systems/fence/fence-system'
import { GuideSystem } from '../../systems/guide/guide-system'
import { ItemSystem } from '../../systems/item/item-system'
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
import { ItemMeshMetadataSystem } from '../../systems/item-mesh-metadata/item-mesh-metadata-system'
import { LevelSystem } from '../../systems/level/level-system'
import { RoofSystem } from '../../systems/roof/roof-system'
import { ScanSystem } from '../../systems/scan/scan-system'
import { SlabSystem } from '../../systems/slab/slab-system'
import { StairSystem } from '../../systems/stair/stair-system'
import { WallCutout } from '../../systems/wall/wall-cutout'
import { WallSystem } from '../../systems/wall/wall-system'
import { WindowSystem } from '../../systems/window/window-system'
import { ZoneSystem } from '../../systems/zone/zone-system'
import { ErrorBoundary } from '../error-boundary'
import { SceneRenderer } from '../renderers/scene-renderer'
+16
View File
@@ -7,6 +7,22 @@ import {
resolveMaterial,
} from '@pascal-app/core'
import * as THREE from 'three'
import { MeshStandardNodeMaterial } from 'three/webgpu'
export const baseMaterial = new MeshStandardNodeMaterial({
color: '#f2f0ed',
roughness: 0.5,
metalness: 0.0,
})
export const glassMaterial = new MeshStandardNodeMaterial({
color: '#e0f2fe',
roughness: 0.05,
metalness: 0.0,
transparent: true,
opacity: 0.35,
side: THREE.DoubleSide,
})
const sideMap: Record<MaterialProperties['side'], THREE.Side> = {
front: THREE.FrontSide,
@@ -1,8 +1,6 @@
import { useFrame } from '@react-three/fiber'
import { type AnyNodeId, type CeilingNode, sceneRegistry, useScene } from '@pascal-app/core'
import * as THREE from 'three'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNodeId, CeilingNode } from '../../schema'
import useScene from '../../store/use-scene'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
@@ -1,9 +1,12 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
type DoorNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import * as THREE from 'three'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { baseMaterial, glassMaterial } from '../../materials'
import type { AnyNodeId, DoorNode } from '../../schema'
import useScene from '../../store/use-scene'
import { baseMaterial, glassMaterial } from '../../lib/materials'
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -52,6 +55,12 @@ function addBox(
parent.add(m)
}
function disposeObject(object: THREE.Object3D) {
object.traverse((child) => {
if (child instanceof THREE.Mesh) child.geometry.dispose()
})
}
function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
// Root mesh is an invisible hitbox; all visuals live in child meshes
mesh.geometry.dispose()
@@ -65,13 +74,14 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
// Dispose and remove all old visual children; preserve 'cutout'
for (const child of [...mesh.children]) {
if (child.name === 'cutout') continue
if (child instanceof THREE.Mesh) child.geometry.dispose()
disposeObject(child)
mesh.remove(child)
}
const {
width,
height,
openingKind,
frameThickness,
frameDepth,
threshold,
@@ -85,8 +95,16 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
panicBarHeight,
contentPadding,
hingesSide,
swingDirection,
swingAngle = 0,
} = node
const hasLeafContent = segments.some((seg) => seg.type !== 'empty')
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle))
if (openingKind === 'opening') {
syncDoorCutout(node, mesh)
return
}
// Leaf occupies the full opening (no bottom frame bar — door opens to floor)
const leafW = width - 2 * frameThickness
@@ -94,6 +112,23 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
const leafDepth = 0.04
// Leaf center is shifted down from door center by half the top frame
const leafCenterY = -frameThickness / 2
const hingeX = hingesSide === 'right' ? leafW / 2 : -leafW / 2
const swingDirectionSign = swingDirection === 'inward' ? 1 : -1
const hingeDirectionSign = hingesSide === 'right' ? 1 : -1
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
const leafGroup = new THREE.Group()
leafGroup.position.set(hingeX, 0, 0)
leafGroup.rotation.y = leafSwingRotation
mesh.add(leafGroup)
const addLeafBox = (
material: THREE.Material,
w: number,
h: number,
d: number,
x: number,
y: number,
z: number,
) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z)
// ── Frame members ──
// Left post — full height
@@ -149,16 +184,16 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
const cpY = contentPadding[1]
if (hasLeafContent && cpY > 0) {
// Top strip
addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0)
addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0)
// Bottom strip
addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0)
addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0)
}
if (hasLeafContent && cpX > 0) {
const innerH = leafH - 2 * cpY
// Left strip
addBox(mesh, baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0)
addLeafBox(baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0)
// Right strip
addBox(mesh, baseMaterial, cpX, innerH, leafDepth, leafW / 2 - cpX / 2, leafCenterY, 0)
addLeafBox(baseMaterial, cpX, innerH, leafDepth, leafW / 2 - cpX / 2, leafCenterY, 0)
}
// Content area inside padding
@@ -193,8 +228,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
cx = -contentW / 2
for (let c = 0; c < numCols - 1; c++) {
cx += colWidths[c]!
addBox(
mesh,
addLeafBox(
baseMaterial,
seg.dividerThickness,
segH,
@@ -215,17 +249,17 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
if (seg.type === 'glass') {
// Glass only — no opaque backing so it's truly transparent
const glassDepth = Math.max(0.004, leafDepth * 0.15)
addBox(mesh, glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0)
addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0)
} else if (seg.type === 'panel') {
// Opaque leaf backing for this column
addBox(mesh, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0)
addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0)
// Raised panel detail
const panelW = colW - 2 * seg.panelInset
const panelH = segH - 2 * seg.panelInset
if (panelW > 0.01 && panelH > 0.01) {
const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth)
const panelZ = leafDepth / 2 + effectiveDepth / 2
addBox(mesh, baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ)
addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ)
}
} else {
// 'empty' leaves the opening unfilled
@@ -246,33 +280,24 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
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)
addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005)
// Grip lever
addBox(mesh, baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025)
addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025)
}
// ── Door closer (commercial hardware at top) ──
if (hasLeafContent && doorCloser) {
const closerY = leafCenterY + leafH / 2 - 0.04
// Body
addBox(mesh, baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03)
addLeafBox(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,
)
addLeafBox(baseMaterial, 0.14, 0.015, 0.015, leafW / 4, closerY + 0.025, leafDepth / 2 + 0.015)
}
// ── Panic bar ──
if (hasLeafContent && panicBar) {
const barY = panicBarHeight - height / 2
addBox(mesh, baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03)
addLeafBox(baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03)
}
// ── Hinges (3 knuckle-style hinges on the hinge side) ──
@@ -290,6 +315,10 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, hingeZ)
}
syncDoorCutout(node, mesh)
}
function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
// ── Cutout (for wall CSG) — always full door dimensions, 1m deep ──
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
if (!cutout) {
@@ -1,10 +1,14 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
type FenceNode,
getWallCurveFrameAt,
getWallCurveLength,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNodeId, FenceNode } from '../../schema'
import useScene from '../../store/use-scene'
import { getWallCurveFrameAt, getWallCurveLength } from '../wall/wall-curve'
type FencePart = {
position: [number, number, number]
@@ -1,10 +1,15 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
getScaledDimensions,
type ItemNode,
resolveLevelId,
sceneRegistry,
spatialGridManager,
useScene,
type WallNode,
} from '@pascal-app/core'
import type * as THREE from 'three'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import { type AnyNodeId, getScaledDimensions, type ItemNode, type WallNode } from '../../schema'
import useScene from '../../store/use-scene'
// ============================================================================
// ITEM SYSTEM
@@ -1,21 +1,30 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNode,
type AnyNodeId,
type RoofNode,
type RoofSegmentNode,
type RoofType,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import * as THREE from 'three'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
import { computeBoundsTree } from 'three-mesh-bvh'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNode, AnyNodeId, RoofNode, RoofSegmentNode } from '../../schema'
import type { RoofType } from '../../schema/nodes/roof-segment'
import useScene from '../../store/use-scene'
const csgEvaluator = new Evaluator()
csgEvaluator.useGroups = true
;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash
csgEvaluator.attributes = ['position', 'normal', 'uv']
function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) {
;(geometry as any).computeBoundsTree = computeBoundsTree
;(geometry as any).computeBoundsTree({ maxLeafSize: 10 })
}
function prepareBrushForCSG(brush: Brush) {
brush.geometry.computeBoundsTree = computeBoundsTree
brush.geometry.computeBoundsTree({ maxLeafSize: 10 })
computeGeometryBoundsTree(brush.geometry)
brush.updateMatrixWorld()
}
@@ -78,8 +87,7 @@ export const RoofSystem = () => {
mesh.geometry.dispose()
const placeholder = new THREE.BufferGeometry()
placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
placeholder.computeBoundsTree = computeBoundsTree
placeholder.computeBoundsTree({ maxLeafSize: 10 })
computeGeometryBoundsTree(placeholder)
mesh.geometry = placeholder
}
mesh.position.set(node.position[0], node.position[1], node.position[2])
@@ -134,8 +142,7 @@ function updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) {
mesh.geometry.dispose()
mesh.geometry = newGeo
newGeo.computeBoundsTree = computeBoundsTree
newGeo.computeBoundsTree({ maxLeafSize: 10 })
computeGeometryBoundsTree(newGeo)
mesh.position.set(node.position[0], node.position[1], node.position[2])
mesh.rotation.y = node.rotation
@@ -524,8 +531,7 @@ export function getRoofSegmentBrushes(
// when a group exists but covers no triangles (can happen after mergeVertices)
geo.groups = geo.groups.filter((g) => g.count > 0)
if (geo.groups.length === 0) return null
geo.computeBoundsTree = computeBoundsTree
geo.computeBoundsTree({ maxLeafSize: 10 })
computeGeometryBoundsTree(geo)
const brush = new Brush(geo, dummyMats)
brush.updateMatrixWorld()
return brush
@@ -1,9 +1,12 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
getRenderableSlabPolygon,
sceneRegistry,
type SlabNode,
useScene,
} from '@pascal-app/core'
import * as THREE from 'three'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { insetPolygonFromCentroid, simplifyClosedPolygon } from '../../lib/polygon-geometry'
import type { AnyNodeId, SlabNode } from '../../schema'
import useScene from '../../store/use-scene'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
@@ -58,71 +61,6 @@ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) {
mesh.position.y = elevation < 0 ? elevation : 0
}
/** Half of default wall thickness — used to extend slab geometry under walls */
const SLAB_OUTSET = 0.05
const AUTO_SLAB_INSET = 0.02
const AUTO_SLAB_SIMPLIFY_TOLERANCE = 0.08
function getRenderableSlabPolygon(slabNode: SlabNode): Array<[number, number]> {
return slabNode.autoFromWalls
? simplifyClosedPolygon(
insetPolygonFromCentroid(slabNode.polygon, AUTO_SLAB_INSET),
AUTO_SLAB_SIMPLIFY_TOLERANCE,
)
: outsetPolygon(slabNode.polygon, SLAB_OUTSET)
}
/**
* Expand a polygon outward by a uniform distance.
* Offsets each edge outward then intersects consecutive offset edges.
*/
function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<[number, number]> {
const n = polygon.length
if (n < 3) return polygon
// Determine winding via signed area
let area2 = 0
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
area2 += polygon[i]![0] * polygon[j]![1] - polygon[j]![0] * polygon[i]![1]
}
const s = area2 >= 0 ? 1 : -1
// Offset each edge outward by amount
const offEdges: Array<[number, number, number, number]> = []
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const dx = polygon[j]![0] - polygon[i]![0]
const dz = polygon[j]![1] - polygon[i]![1]
const len = Math.sqrt(dx * dx + dz * dz)
if (len < 1e-9) {
offEdges.push([polygon[i]![0], polygon[i]![1], dx, dz])
continue
}
const nx = ((s * dz) / len) * amount
const nz = ((s * -dx) / len) * amount
offEdges.push([polygon[i]![0] + nx, polygon[i]![1] + nz, dx, dz])
}
// Intersect consecutive offset edges to get new vertices
const result: Array<[number, number]> = []
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const [ax, az, adx, adz] = offEdges[i]!
const [bx, bz, bdx, bdz] = offEdges[j]!
const denom = adx * bdz - adz * bdx
if (Math.abs(denom) < 1e-9) {
// Parallel edges — use offset endpoint
result.push([ax + adx, az + adz])
} else {
const t = ((bx - ax) * bdz - (bz - az) * bdx) / denom
result.push([ax + t * adx, az + t * adz])
}
}
return result
}
/**
* Generates extruded slab geometry from polygon
*/
@@ -199,13 +137,7 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
uvs.push((x - bounds.min.x) / floorWidth, (z - bounds.min.y) / floorHeight)
}
const pushWallVertex = (
x: number,
y: number,
z: number,
u: number,
v: number,
) => {
const pushWallVertex = (x: number, y: number, z: number, u: number, v: number) => {
positions.push(x, y, z)
uvs.push(u, v)
}
@@ -1,13 +1,18 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNode,
type AnyNodeId,
resolveLevelId,
sceneRegistry,
spatialGridManager,
type StairNode,
type StairSegmentNode,
syncAutoStairOpenings,
useScene,
} from '@pascal-app/core'
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema'
import useScene from '../../store/use-scene'
import { syncAutoStairOpenings } from './stair-opening-sync'
const pendingStairUpdates = new Set<AnyNodeId>()
const MAX_STAIRS_PER_FRAME = 2
@@ -1,5 +1,4 @@
import {
baseMaterial,
getEffectiveWallSurfaceMaterial,
getMaterialPresetByRef,
getWallSurfaceMaterialSignature,
@@ -10,7 +9,7 @@ import {
import { Color, type Material } from 'three'
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
import { MeshStandardNodeMaterial } from 'three/webgpu'
import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
const DEFAULT_WALL_COLOR = '#f2f0ed'
@@ -2,21 +2,29 @@ import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
import { computeBoundsTree } from 'three-mesh-bvh'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
import useScene from '../../store/use-scene'
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
import {
calculateLevelMiters,
type AnyNode,
type AnyNodeId,
type DoorNode,
getAdjacentWallIds,
DEFAULT_WALL_HEIGHT,
getWallCurveFrameAt,
getWallMiterBoundaryPoints,
getWallPlanFootprint,
getWallSurfacePolygon,
getWallThickness,
isCurvedWall,
type Point2D,
pointToKey,
resolveLevelId,
sceneRegistry,
spatialGridManager,
useScene,
type WallNode,
type WallMiterData,
} from './wall-mitering'
type WindowNode,
} from '@pascal-app/core'
// Reusable CSG evaluator for better performance
const csgEvaluator = new Evaluator()
@@ -24,6 +32,11 @@ const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015
const WALL_FACE_NORMAL_Y_EPSILON = 0.6
const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003
function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) {
;(geometry as any).computeBoundsTree = computeBoundsTree
;(geometry as any).computeBoundsTree({ maxLeafSize: 10 })
}
type WallBoundaryEdgeTag = 'front' | 'back' | 'base'
type TaggedWallBoundaryEdge = {
@@ -495,8 +508,7 @@ export function generateExtrudedWall(
// Create wall brush from geometry
// Pre-compute BVH with new API to avoid deprecation warning
geometry.computeBoundsTree = computeBoundsTree
geometry.computeBoundsTree({ maxLeafSize: 10 })
computeGeometryBoundsTree(geometry)
const wallBrush = new Brush(geometry)
wallBrush.updateMatrixWorld()
@@ -546,6 +558,14 @@ function collectCutoutBrushes(
for (const child of childrenNodes) {
if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue
if (
(child.type === 'door' && child.openingKind === 'opening') ||
(child.type === 'window' && child.openingKind === 'opening')
) {
brushes.push(createShapedOpeningCutoutBrush(child, wallThickness))
continue
}
const childMesh = sceneRegistry.nodes.get(child.id)
if (!childMesh) continue
@@ -591,8 +611,7 @@ function collectCutoutBrushes(
)
// Pre-compute BVH with new API to avoid deprecation warning
boxGeo.computeBoundsTree = computeBoundsTree
boxGeo.computeBoundsTree({ maxLeafSize: 10 })
computeGeometryBoundsTree(boxGeo)
const brush = new Brush(boxGeo)
brushes.push(brush)
@@ -600,3 +619,184 @@ function collectCutoutBrushes(
return brushes
}
type ShapedOpeningNode = DoorNode | WindowNode
type CornerRadii = {
topLeft: number
topRight: number
bottomRight: number
bottomLeft: number
}
function createShapedOpeningCutoutBrush(opening: ShapedOpeningNode, wallThickness: number): Brush {
const shape = createShapedOpeningCutoutShape(opening)
const depth = wallThickness * 2
const bevelSize =
opening.openingShape === 'rounded'
? Math.min(
Math.max(opening.openingRevealRadius ?? 0.025, 0),
Math.max(wallThickness * 0.45, 0.001),
Math.max((opening.cornerRadius ?? 0.15) * 0.45, 0.001),
)
: 0
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
bevelEnabled: bevelSize > 0,
bevelSegments: bevelSize > 0 ? 8 : 0,
bevelSize,
bevelThickness: bevelSize,
curveSegments: 24,
})
geometry.translate(0, 0, -depth / 2)
computeGeometryBoundsTree(geometry)
return new Brush(geometry)
}
function createShapedOpeningCutoutShape(opening: ShapedOpeningNode): THREE.Shape {
const halfWidth = opening.width / 2
const bottom = opening.position[1] - opening.height / 2
const top = opening.position[1] + opening.height / 2
const centerX = opening.position[0]
const left = centerX - halfWidth
const right = centerX + halfWidth
const width = Math.max(opening.width, 1e-6)
const height = Math.max(opening.height, 1e-6)
const shape = new THREE.Shape()
if (opening.openingShape === 'arch') {
const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height)
const springY = top - archHeight
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
shape.lineTo(right, springY)
shape.quadraticCurveTo(centerX, top, left, springY)
shape.lineTo(left, bottom)
shape.closePath()
return shape
}
if (opening.openingShape === 'rounded') {
const radii = getRoundedOpeningRadii(opening, width, height)
applyRoundedOpeningShape(shape, left, right, bottom, top, radii)
return shape
}
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
shape.lineTo(right, top)
shape.lineTo(left, top)
shape.closePath()
return shape
}
function getRoundedOpeningRadii(
opening: ShapedOpeningNode,
width: number,
height: number,
): CornerRadii {
if (opening.type !== 'window') {
if (opening.openingRadiusMode === 'individual') {
const [topLeft = 0, topRight = 0] = opening.openingTopRadii ?? [0.15, 0.15]
return normalizeCornerRadii(
{
topLeft: Math.max(topLeft, 0),
topRight: Math.max(topRight, 0),
bottomRight: 0,
bottomLeft: 0,
},
width,
height,
)
}
const maxRadius = Math.min(width / 2, height)
const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius)
return { topLeft: radius, topRight: radius, bottomRight: 0, bottomLeft: 0 }
}
if (opening.openingRadiusMode === 'individual') {
const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] =
opening.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
return normalizeCornerRadii(
{
topLeft: Math.max(topLeft, 0),
topRight: Math.max(topRight, 0),
bottomRight: Math.max(bottomRight, 0),
bottomLeft: Math.max(bottomLeft, 0),
},
width,
height,
)
}
const maxRadius = Math.min(width / 2, height / 2)
const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius)
return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius }
}
function normalizeCornerRadii(radii: CornerRadii, width: number, height: number): CornerRadii {
const next = { ...radii }
const maxScale = Math.min(
1,
width / Math.max(next.topLeft + next.topRight, 1e-6),
width / Math.max(next.bottomLeft + next.bottomRight, 1e-6),
height / Math.max(next.topLeft + next.bottomLeft, 1e-6),
height / Math.max(next.topRight + next.bottomRight, 1e-6),
)
if (maxScale < 1) {
next.topLeft *= maxScale
next.topRight *= maxScale
next.bottomRight *= maxScale
next.bottomLeft *= maxScale
}
return next
}
function applyRoundedOpeningShape(
shape: THREE.Shape,
left: number,
right: number,
bottom: number,
top: number,
radii: CornerRadii,
) {
const { topLeft, topRight, bottomRight, bottomLeft } = radii
shape.moveTo(left + bottomLeft, bottom)
shape.lineTo(right - bottomRight, bottom)
if (bottomRight > 1e-6) {
shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false)
} else {
shape.lineTo(right, bottom)
}
shape.lineTo(right, top - topRight)
if (topRight > 1e-6) {
shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false)
} else {
shape.lineTo(right, top)
}
shape.lineTo(left + topLeft, top)
if (topLeft > 1e-6) {
shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false)
} else {
shape.lineTo(left, top)
}
shape.lineTo(left, bottom + bottomLeft)
if (bottomLeft > 1e-6) {
shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false)
} else {
shape.lineTo(left, bottom)
}
shape.closePath()
}
@@ -1,9 +1,12 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
sceneRegistry,
useScene,
type WindowNode,
} from '@pascal-app/core'
import * as THREE from 'three'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { baseMaterial, glassMaterial } from '../../materials'
import type { AnyNodeId, WindowNode } from '../../schema'
import useScene from '../../store/use-scene'
import { baseMaterial, glassMaterial } from '../../lib/materials'
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -81,8 +84,14 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
sill,
sillDepth,
sillThickness,
openingKind,
} = node
if (openingKind === 'opening') {
syncWindowCutout(node, mesh)
return
}
const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness
@@ -231,6 +240,10 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
)
}
syncWindowCutout(node, mesh)
}
function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
// ── Cutout (for wall CSG) — always full window dimensions, 1m deep ──
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
if (!cutout) {