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", "idb-keyval": "^6.2.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8",
"zod": "^4.3.5", "zod": "^4.3.5",
"zundo": "^2.3.0", "zundo": "^2.3.0",
"zustand": "^5", "zustand": "^5",
@@ -79,6 +77,9 @@
"name": "@pascal-app/editor", "name": "@pascal-app/editor",
"version": "0.6.0", "version": "0.6.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@number-flow/react": "^0.5.14", "@number-flow/react": "^0.5.14",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -193,6 +194,8 @@
"version": "0.6.0", "version": "0.6.0",
"dependencies": { "dependencies": {
"polygon-clipping": "^0.15.7", "polygon-clipping": "^0.15.7",
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8",
"zustand": "^5", "zustand": "^5",
}, },
"devDependencies": { "devDependencies": {
@@ -278,6 +281,14 @@
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], "@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=="], "@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=="], "@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", "idb-keyval": "^6.2.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8",
"zod": "^4.3.5", "zod": "^4.3.5",
"zundo": "^2.3.0", "zundo": "^2.3.0",
"zustand": "^5" "zustand": "^5"
+8
View File
@@ -6,6 +6,7 @@ import type {
CeilingNode, CeilingNode,
DoorNode, DoorNode,
FenceNode, FenceNode,
GuideNode,
ItemNode, ItemNode,
LevelNode, LevelNode,
RoofNode, RoofNode,
@@ -130,6 +131,12 @@ type ToolEvents = {
'tool:cancel': undefined 'tool:cancel': undefined
} }
type GuideEvents = {
'guide:set-reference-scale': { guideId: GuideNode['id'] }
'guide:cancel-reference-scale': undefined
'guide:deleted': { guideId: GuideNode['id'] }
}
type PresetEvents = { type PresetEvents = {
'preset:generate-thumbnail': { presetId: string; nodeId: string } 'preset:generate-thumbnail': { presetId: string; nodeId: string }
'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string }
@@ -170,6 +177,7 @@ type EditorEvents = GridEvents &
NodeEvents<'door', DoorEvent> & NodeEvents<'door', DoorEvent> &
CameraControlEvents & CameraControlEvents &
ToolEvents & ToolEvents &
GuideEvents &
PresetEvents & PresetEvents &
ThumbnailEvents & ThumbnailEvents &
SnapshotEvents & SnapshotEvents &
+9 -16
View File
@@ -33,6 +33,7 @@ export {
} from './hooks/spatial-grid/spatial-grid-sync' } from './hooks/spatial-grid/spatial-grid-sync'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
export { loadAssetUrl, saveAsset } from './lib/asset-storage' export { loadAssetUrl, saveAsset } from './lib/asset-storage'
export { getRenderableSlabPolygon } from './lib/slab-polygon'
export { export {
detectSpacesForLevel, detectSpacesForLevel,
initSpaceDetectionSync, initSpaceDetectionSync,
@@ -47,32 +48,25 @@ export {
LIBRARY_MATERIAL_REF_PREFIX, LIBRARY_MATERIAL_REF_PREFIX,
MATERIAL_CATALOG, MATERIAL_CATALOG,
MATERIAL_CATEGORIES, MATERIAL_CATEGORIES,
type MaterialCategory,
type MaterialCatalogItem, type MaterialCatalogItem,
type MaterialCategory,
toLibraryMaterialRef, toLibraryMaterialRef,
} from './material-library' } from './material-library'
export { baseMaterial, glassMaterial } from './materials'
export * from './schema' export * from './schema'
export {
type ControlValue,
type ItemInteractiveState,
useInteractive,
} from './store/use-interactive'
export { export {
getSceneHistoryPauseDepth, getSceneHistoryPauseDepth,
pauseSceneHistory, pauseSceneHistory,
resetSceneHistoryPauseDepth, resetSceneHistoryPauseDepth,
resumeSceneHistory, resumeSceneHistory,
} from './store/history-control' } 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 { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { clearSceneHistory, default as useScene } from './store/use-scene' export { clearSceneHistory, default as useScene } from './store/use-scene'
export { CeilingSystem } from './systems/ceiling/ceiling-system' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
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 { export {
getClampedWallCurveOffset, getClampedWallCurveOffset,
getMaxWallCurveOffset, getMaxWallCurveOffset,
@@ -94,14 +88,13 @@ export {
} from './systems/wall/wall-footprint' } from './systems/wall/wall-footprint'
export { export {
calculateLevelMiters, calculateLevelMiters,
getAdjacentWallIds,
getWallMiterBoundaryPoints, getWallMiterBoundaryPoints,
type Point2D, type Point2D,
pointToKey, pointToKey,
type WallMiterBoundaryPoints, type WallMiterBoundaryPoints,
type WallMiterData, type WallMiterData,
} from './systems/wall/wall-mitering' } 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 type { SceneGraph } from './utils/clone-scene-graph'
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
export { isObject } from './utils/types' 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 { CeilingNode } from './nodes/ceiling'
export { DoorNode, DoorSegment } from './nodes/door' export { DoorNode, DoorSegment } from './nodes/door'
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
export { GuideNode } from './nodes/guide' export { GuideNode, GuideScaleReference } from './nodes/guide'
export type { export type {
AnimationEffect, AnimationEffect,
Asset, Asset,
@@ -43,13 +43,14 @@ export type {
} from './nodes/item' } from './nodes/item'
export { getScaledDimensions, ItemNode } from './nodes/item' export { getScaledDimensions, ItemNode } from './nodes/item'
export { LevelNode } from './nodes/level' export { LevelNode } from './nodes/level'
export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
export { RoofSegmentNode, RoofType } from './nodes/roof-segment' export { RoofSegmentNode, RoofType } from './nodes/roof-segment'
export { ScanNode } from './nodes/scan' export { ScanNode } from './nodes/scan'
// Nodes // Nodes
export { SiteNode } from './nodes/site' export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab' export { SlabNode } from './nodes/slab'
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
export { SpawnNode } from './nodes/spawn' export { SpawnNode } from './nodes/spawn'
export { export {
getEffectiveStairSurfaceMaterial, getEffectiveStairSurfaceMaterial,
@@ -59,7 +60,6 @@ export {
StairTopLandingMode, StairTopLandingMode,
StairType, StairType,
} from './nodes/stair' } from './nodes/stair'
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall' 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), width: z.number().default(0.9),
height: z.number().default(2.1), 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 // Frame
frameThickness: z.number().default(0.05), frameThickness: z.number().default(0.05),
frameDepth: z.number().default(0.07), frameDepth: z.number().default(0.07),
@@ -41,6 +50,11 @@ export const DoorNode = BaseNode.extend({
// Swing // Swing
hingesSide: z.enum(['left', 'right']).default('left'), hingesSide: z.enum(['left', 'right']).default('left'),
swingDirection: z.enum(['inward', 'outward']).default('inward'), 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 // Leaf segments — stacked top to bottom, each with its own column split
segments: z.array(DoorSegment).default([ segments: z.array(DoorSegment).default([
@@ -76,9 +90,10 @@ export const DoorNode = BaseNode.extend({
panicBarHeight: z.number().default(1.0), panicBarHeight: z.number().default(1.0),
}).describe(dedent`Door node - a parametric door placed on a wall }).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) - 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 - 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 - 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 - doorCloser/panicBar: commercial and emergency hardware options
`) `)
+11
View File
@@ -2,6 +2,15 @@ import { z } from 'zod'
import { AssetUrl } from '../asset-url' import { AssetUrl } from '../asset-url'
import { BaseNode, nodeType, objectId } from '../base' 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({ export const GuideNode = BaseNode.extend({
id: objectId('guide'), id: objectId('guide'),
type: nodeType('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]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
scale: z.number().default(1), scale: z.number().default(1),
opacity: z.number().min(0).max(100).default(50), 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> 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), width: z.number().default(1.5),
height: 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 // Frame
frameThickness: z.number().default(0.05), frameThickness: z.number().default(0.05),
frameDepth: z.number().default(0.07), frameDepth: z.number().default(0.07),
+3
View File
@@ -21,6 +21,9 @@
"three": "^0.184" "three": "^0.184"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@number-flow/react": "^0.5.14", "@number-flow/react": "^0.5.14",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -15,6 +15,7 @@ export type FloorplanActionMenuEntry = {
position: SvgPoint | null position: SvgPoint | null
onDelete: FloorplanActionMenuHandler onDelete: FloorplanActionMenuHandler
onMove: FloorplanActionMenuHandler onMove: FloorplanActionMenuHandler
onAddHole?: FloorplanActionMenuHandler
onDuplicate?: FloorplanActionMenuHandler onDuplicate?: FloorplanActionMenuHandler
} }
@@ -76,6 +77,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
}} }}
> >
<NodeActionMenu <NodeActionMenu
onAddHole={entry.onAddHole}
onDelete={entry.onDelete} onDelete={entry.onDelete}
onDuplicate={entry.onDuplicate} onDuplicate={entry.onDuplicate}
onMove={entry.onMove} onMove={entry.onMove}
@@ -18,7 +18,10 @@ type FloorplanDraftLayerProps = {
draftAnchorPoints: Array<{ x: number; y: number; isPrimary: boolean }> draftAnchorPoints: Array<{ x: number; y: number; isPrimary: boolean }>
draftFill: string draftFill: string
draftStroke: string draftStroke: string
polygonDraftStroke?: string
polygonDraftStrokeWidth?: string
anchorFill: string anchorFill: string
unitsPerPixel: number
} }
export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
@@ -30,8 +33,15 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
draftAnchorPoints, draftAnchorPoints,
draftFill, draftFill,
draftStroke, draftStroke,
polygonDraftStroke,
polygonDraftStrokeWidth = '0.08',
anchorFill, anchorFill,
unitsPerPixel,
}: FloorplanDraftLayerProps) { }: FloorplanDraftLayerProps) {
const primaryAnchorRadius = 6 * unitsPerPixel
const secondaryAnchorRadius = 5 * unitsPerPixel
const activePolygonDraftStroke = polygonDraftStroke ?? draftStroke
return ( return (
<> <>
{draftPolygonPoints && ( {draftPolygonPoints && (
@@ -69,21 +79,21 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
<polyline <polyline
fill="none" fill="none"
points={polygonDraftPolylinePoints} points={polygonDraftPolylinePoints}
stroke={draftStroke} stroke={activePolygonDraftStroke}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="0.08" strokeWidth={polygonDraftStrokeWidth}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
)} )}
{polygonDraftClosingSegment && ( {polygonDraftClosingSegment && (
<line <line
stroke={draftStroke} stroke={activePolygonDraftStroke}
strokeDasharray="0.16 0.1" strokeDasharray="0.16 0.1"
strokeLinecap="round" strokeLinecap="round"
strokeOpacity={0.75} strokeOpacity={0.75}
strokeWidth="0.05" strokeWidth={polygonDraftStrokeWidth}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
x1={polygonDraftClosingSegment.x1} x1={polygonDraftClosingSegment.x1}
x2={polygonDraftClosingSegment.x2} x2={polygonDraftClosingSegment.x2}
@@ -100,7 +110,7 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
fillOpacity={0.95} fillOpacity={0.95}
key={`polygon-draft-${index}`} key={`polygon-draft-${index}`}
pointerEvents="none" pointerEvents="none"
r={point.isPrimary ? 0.12 : 0.1} r={point.isPrimary ? primaryAnchorRadius : secondaryAnchorRadius}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
))} ))}
@@ -1,7 +1,7 @@
'use client' 'use client'
import { memo } from 'react'
import type { Point2D, RoofNode, RoofSegmentNode } from '@pascal-app/core' import type { Point2D, RoofNode, RoofSegmentNode } from '@pascal-app/core'
import { memo } from 'react'
import { toSvgX, toSvgY } from '../svg-paths' import { toSvgX, toSvgY } from '../svg-paths'
type FloorplanLineSegment = { type FloorplanLineSegment = {
@@ -20,14 +20,27 @@ type FloorplanRoofEntry = {
segments: FloorplanRoofSegmentEntry[] segments: FloorplanRoofSegmentEntry[]
} }
type FloorplanRoofPalette = {
roofFill: string
roofActiveFill: string
roofSelectedFill: string
roofStroke: string
roofActiveStroke: string
roofSelectedStroke: string
roofRidgeStroke: string
roofSelectedRidgeStroke: string
}
type FloorplanRoofLayerProps = { type FloorplanRoofLayerProps = {
highlightedIdSet: ReadonlySet<string> highlightedIdSet: ReadonlySet<string>
palette: FloorplanRoofPalette
roofEntries: FloorplanRoofEntry[] roofEntries: FloorplanRoofEntry[]
selectedIdSet: ReadonlySet<string> selectedIdSet: ReadonlySet<string>
} }
export const FloorplanRoofLayer = memo(function FloorplanRoofLayer({ export const FloorplanRoofLayer = memo(function FloorplanRoofLayer({
highlightedIdSet, highlightedIdSet,
palette,
roofEntries, roofEntries,
selectedIdSet, selectedIdSet,
}: FloorplanRoofLayerProps) { }: FloorplanRoofLayerProps) {
@@ -59,18 +72,18 @@ export const FloorplanRoofLayer = memo(function FloorplanRoofLayer({
<polygon <polygon
fill={ fill={
isSegmentActive isSegmentActive
? 'rgba(14, 165, 233, 0.2)' ? palette.roofSelectedFill
: isRoofActive : isRoofActive
? 'rgba(14, 165, 233, 0.14)' ? palette.roofActiveFill
: 'rgba(14, 165, 233, 0.08)' : palette.roofFill
} }
points={points} points={points}
stroke={ stroke={
isSegmentActive isSegmentActive
? '#0369a1' ? palette.roofSelectedStroke
: isRoofActive : isRoofActive
? '#0ea5e9' ? palette.roofActiveStroke
: 'rgba(14, 165, 233, 0.65)' : palette.roofStroke
} }
strokeWidth={isSegmentActive ? '2.25' : isRoofActive ? '1.75' : '1.1'} strokeWidth={isSegmentActive ? '2.25' : isRoofActive ? '1.75' : '1.1'}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
@@ -78,7 +91,9 @@ export const FloorplanRoofLayer = memo(function FloorplanRoofLayer({
{ridgeLine ? ( {ridgeLine ? (
<line <line
fill="none" fill="none"
stroke={isSegmentActive ? '#0f172a' : 'rgba(3, 105, 161, 0.75)'} stroke={
isSegmentActive ? palette.roofSelectedRidgeStroke : palette.roofRidgeStroke
}
strokeWidth={isSegmentActive ? '2' : '1.4'} strokeWidth={isSegmentActive ? '2' : '1.4'}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
x1={toSvgX(ridgeLine.start.x)} x1={toSvgX(ridgeLine.start.x)}
@@ -1,7 +1,11 @@
'use client' 'use client'
import type { Point2D, StairNode, StairSegmentNode } from '@pascal-app/core' 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 { import {
buildSvgAnnularSectorPath, buildSvgAnnularSectorPath,
buildSvgArcPath, buildSvgArcPath,
@@ -38,6 +42,12 @@ type FloorplanStairEntry = {
type FloorplanPalette = { type FloorplanPalette = {
deleteFill: string deleteFill: string
deleteStroke: string deleteStroke: string
stairFill: string
stairSelectedFill: string
stairStroke: string
stairAccent: string
stairTread: string
stairSelectedTread: string
} }
type FloorplanStairLayerProps = { type FloorplanStairLayerProps = {
@@ -64,8 +74,7 @@ function clamp(value: number, min: number, max: number) {
function getNormalizedFloorplanStairSweepAngle(stair: StairNode) { function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
const stairType = stair.stairType ?? 'straight' const stairType = stair.stairType ?? 'straight'
const baseSweepAngle = const baseSweepAngle = stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
if (Math.abs(baseSweepAngle) >= Math.PI * 2) { if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001) return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
@@ -74,6 +83,25 @@ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
return baseSweepAngle 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({ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
canFocusStairs, canFocusStairs,
canSelectStairs, canSelectStairs,
@@ -108,8 +136,10 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
stairSelected || stairHighlighted || segmentSelected || segmentHighlighted stairSelected || stairHighlighted || segmentSelected || segmentHighlighted
const stairType = stair.stairType ?? 'straight' const stairType = stair.stairType ?? 'straight'
const normalizedSweepAngle = getNormalizedFloorplanStairSweepAngle(stair) const normalizedSweepAngle = getNormalizedFloorplanStairSweepAngle(stair)
const sectorStartAngle = stair.rotation - normalizedSweepAngle / 2 const sectorStartAngle = -stair.rotation - normalizedSweepAngle / 2
const sectorEndAngle = sectorStartAngle + normalizedSweepAngle const sectorEndAngle = sectorStartAngle + normalizedSweepAngle
const spiralLandingSweep = getFloorplanSpiralLandingSweep(stair, normalizedSweepAngle)
const visualSectorEndAngle = sectorEndAngle + spiralLandingSweep
const stairCenter = { const stairCenter = {
x: stair.position[0], x: stair.position[0],
y: stair.position[2], y: stair.position[2],
@@ -124,37 +154,37 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
? palette.deleteStroke ? palette.deleteStroke
: isSelectionActive : isSelectionActive
? '#2563eb' ? '#2563eb'
: 'rgba(31, 41, 55, 0.9)' : palette.stairStroke
const curvedAccent = isDeleteHovered const curvedAccent = isDeleteHovered
? palette.deleteStroke ? palette.deleteStroke
: isSelectionActive : isSelectionActive
? '#1d4ed8' ? '#1d4ed8'
: 'rgba(23, 23, 23, 0.96)' : palette.stairAccent
const curvedFill = isDeleteHovered const curvedFill = isDeleteHovered
? palette.deleteFill ? palette.deleteFill
: isSelectionActive : isSelectionActive
? 'rgba(59, 130, 246, 0.16)' ? palette.stairSelectedFill
: '#ffffff' : palette.stairFill
const straightAccent = isDeleteHovered const straightAccent = isDeleteHovered
? palette.deleteStroke ? palette.deleteStroke
: isSelectionActive : isSelectionActive
? '#1d4ed8' ? '#1d4ed8'
: 'rgba(23, 23, 23, 0.96)' : palette.stairAccent
const straightStroke = isDeleteHovered const straightStroke = isDeleteHovered
? palette.deleteStroke ? palette.deleteStroke
: isSelectionActive : isSelectionActive
? '#1d4ed8' ? '#1d4ed8'
: 'rgba(23, 23, 23, 0.88)' : palette.stairStroke
const straightTread = isDeleteHovered const straightTread = isDeleteHovered
? palette.deleteStroke ? palette.deleteStroke
: isSelectionActive : isSelectionActive
? 'rgba(37, 99, 235, 0.78)' ? palette.stairSelectedTread
: 'rgba(38, 38, 38, 0.62)' : palette.stairTread
const straightFill = isDeleteHovered const straightFill = isDeleteHovered
? palette.deleteFill ? palette.deleteFill
: isSelectionActive : isSelectionActive
? 'rgba(59, 130, 246, 0.08)' ? palette.stairSelectedFill
: 'rgba(255, 255, 255, 0.02)' : palette.stairFill
const curvedOuterLineWidth = isSelectionActive ? '2' : '1.4' const curvedOuterLineWidth = isSelectionActive ? '2' : '1.4'
const curvedInnerLineWidth = isSelectionActive ? '1.7' : '1.2' const curvedInnerLineWidth = isSelectionActive ? '1.7' : '1.2'
const stairSymbol = const stairSymbol =
@@ -166,13 +196,18 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
innerRadius, innerRadius,
outerRadius, outerRadius,
sectorStartAngle, sectorStartAngle,
sectorEndAngle, visualSectorEndAngle,
)} )}
fill={curvedFill} fill={curvedFill}
pointerEvents="none" pointerEvents="none"
/> />
<path <path
d={buildSvgArcPath(stairCenter, outerRadius, sectorStartAngle, sectorEndAngle)} d={buildSvgArcPath(
stairCenter,
outerRadius,
sectorStartAngle,
visualSectorEndAngle,
)}
fill="none" fill="none"
pointerEvents="none" pointerEvents="none"
stroke={curvedStroke} stroke={curvedStroke}
@@ -180,15 +215,20 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
<path <path
d={buildSvgArcPath(stairCenter, innerRadius, sectorStartAngle, sectorEndAngle)} d={buildSvgArcPath(
stairCenter,
innerRadius,
sectorStartAngle,
visualSectorEndAngle,
)}
fill="none" fill="none"
pointerEvents="none" pointerEvents="none"
stroke={curvedStroke} stroke={curvedStroke}
strokeWidth={curvedInnerLineWidth} strokeWidth={curvedInnerLineWidth}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
{Array.from({ length: Math.max(6, stair.stepCount) }, (_, index) => { {Array.from({ length: getFloorplanStairStepCount(stair, 6) + 1 }, (_, index) => {
const stepCount = Math.max(6, stair.stepCount) const stepCount = getFloorplanStairStepCount(stair, 6)
const stepSweep = normalizedSweepAngle / stepCount const stepSweep = normalizedSweepAngle / stepCount
const angle = sectorStartAngle + stepSweep * index const angle = sectorStartAngle + stepSweep * index
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle) const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
@@ -199,9 +239,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
<line <line
key={`${stair.id}:spiral-step:${index}`} key={`${stair.id}:spiral-step:${index}`}
pointerEvents="none" pointerEvents="none"
stroke={index === stepCount - 1 ? curvedAccent : curvedStroke} stroke={index === stepCount ? curvedAccent : curvedStroke}
strokeDasharray={index >= dashedFromIndex ? '0.1 0.08' : undefined} 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" vectorEffect="non-scaling-stroke"
x1={toSvgX(innerPoint.x)} x1={toSvgX(innerPoint.x)}
x2={toSvgX(outerPoint.x)} x2={toSvgX(outerPoint.x)}
@@ -213,7 +253,7 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
<circle <circle
cx={toSvgX(stairCenter.x)} cx={toSvgX(stairCenter.x)}
cy={toSvgY(stairCenter.y)} cy={toSvgY(stairCenter.y)}
fill="#ffffff" fill={curvedFill}
pointerEvents="none" pointerEvents="none"
r={Math.max(innerRadius * 0.18, 0.06)} r={Math.max(innerRadius * 0.18, 0.06)}
stroke={curvedAccent} stroke={curvedAccent}
@@ -221,7 +261,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
vectorEffect="non-scaling-stroke" 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 arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, directionAngle)
const tangentAngle = const tangentAngle =
directionAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2) directionAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2)
@@ -269,8 +311,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
strokeWidth={curvedInnerLineWidth} strokeWidth={curvedInnerLineWidth}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
{Array.from({ length: Math.max(4, stair.stepCount) + 1 }, (_, index) => { {Array.from({ length: getFloorplanStairStepCount(stair, 4) + 1 }, (_, index) => {
const stepCount = Math.max(4, stair.stepCount) const stepCount = getFloorplanStairStepCount(stair, 4)
const stepSweep = normalizedSweepAngle / stepCount const stepSweep = normalizedSweepAngle / stepCount
const angle = sectorStartAngle + stepSweep * index const angle = sectorStartAngle + stepSweep * index
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle) const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
@@ -294,8 +336,10 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
d={buildSvgArcPath( d={buildSvgArcPath(
stairCenter, stairCenter,
centerlineRadius, centerlineRadius,
sectorStartAngle + (normalizedSweepAngle / Math.max(4, stair.stepCount)) * 0.55, sectorStartAngle +
sectorEndAngle - (normalizedSweepAngle / Math.max(4, stair.stepCount)) * 0.55, (normalizedSweepAngle / getFloorplanStairStepCount(stair, 4)) * 0.55,
sectorEndAngle -
(normalizedSweepAngle / getFloorplanStairStepCount(stair, 4)) * 0.55,
)} )}
fill="none" fill="none"
pointerEvents="none" pointerEvents="none"
@@ -305,7 +349,7 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
{(() => { {(() => {
const stepCount = Math.max(4, stair.stepCount) const stepCount = getFloorplanStairStepCount(stair, 4)
const stepSweep = normalizedSweepAngle / stepCount const stepSweep = normalizedSweepAngle / stepCount
const arrowAngle = sectorEndAngle - stepSweep * 0.8 const arrowAngle = sectorEndAngle - stepSweep * 0.8
const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, arrowAngle) const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, arrowAngle)
@@ -1,15 +1,13 @@
'use client' 'use client'
import '../../three-types' import '../../three-types'
import { KeyboardControls } from '@react-three/drei' import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
import { sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { KeyboardControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber' import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 useEditor from '../../store/use-editor'
import BVHEcctrl from './first-person/bvh-ecctrl'
import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
import { import {
buildFirstPersonColliderWorldFromRegistry, buildFirstPersonColliderWorldFromRegistry,
deriveFirstPersonSpawn, deriveFirstPersonSpawn,
@@ -17,10 +15,15 @@ import {
type FirstPersonColliderWorld, type FirstPersonColliderWorld,
type FirstPersonSpawn, type FirstPersonSpawn,
} from './first-person/build-collider-world' } 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 CAMERA_EYE_OFFSET = 0.45
const LOOK_SENSITIVITY = 0.002 const LOOK_SENSITIVITY = 0.002
const CONTROLLER_CENTER_FROM_EYE = 0.85 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 = [ const keyboardMap = [
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] }, { name: 'forward', keys: ['ArrowUp', 'KeyW'] },
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] }, { name: 'backward', keys: ['ArrowDown', 'KeyS'] },
@@ -32,13 +35,21 @@ const keyboardMap = [
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
const cameraEuler = new Euler(0, 0, 0, 'YXZ') 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 spawnWorldPosition = new Vector3()
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
const resolvePlacedSpawnNode = ( const resolvePlacedSpawnNode = (
nodes: ReturnType<typeof useScene.getState>['nodes'], nodes: ReturnType<typeof useScene.getState>['nodes'],
_levelId: string | null, _levelId: string | null,
) => { ) => {
const candidates = Object.values(nodes).filter((node) => node.type === 'spawn') const candidates = Object.values(nodes).filter((node) => node.type === 'spawn')
if (candidates.length === 0) return null if (candidates.length === 0) return null
@@ -52,7 +63,101 @@ export const FirstPersonControls = () => {
const controllerRef = useRef<BVHEcctrlApi | null>(null) const controllerRef = useRef<BVHEcctrlApi | null>(null)
const yawRef = useRef(0) const yawRef = useRef(0)
const pitchRef = 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 [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>(() => { const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
@@ -84,25 +189,28 @@ export const FirstPersonControls = () => {
}, [placedSpawnNode]) }, [placedSpawnNode])
useEffect(() => { useEffect(() => {
const nextWorld = buildFirstPersonColliderWorldFromRegistry() rebuildColliderWorld()
if (!nextWorld) {
setWorld(null)
return
}
setWorld(nextWorld)
return () => { return () => {
nextWorld.dispose() worldRef.current?.dispose()
worldRef.current = null
setWorld(null) setWorld(null)
} }
}, [camera]) }, [rebuildColliderWorld])
useEffect(() => { useEffect(() => {
if (!world) return 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 pitchRef.current = 0
}, [camera, placedSpawn, world]) setControllerStart({
position: [x, y - CONTROLLER_CENTER_FROM_EYE, z],
yaw: spawn.yaw,
})
}, [camera, controllerStart, placedSpawn, world])
useEffect(() => { useEffect(() => {
const canvas = gl.domElement const canvas = gl.domElement
@@ -152,6 +260,10 @@ export const FirstPersonControls = () => {
document.exitPointerLock() document.exitPointerLock()
} }
useEditor.getState().setFirstPersonMode(false) useEditor.getState().setFirstPersonMode(false)
} else if (event.code === 'KeyE') {
event.preventDefault()
event.stopPropagation()
toggleInteractableDoor()
} }
} }
@@ -159,7 +271,7 @@ export const FirstPersonControls = () => {
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown, true) document.removeEventListener('keydown', handleKeyDown, true)
} }
}, [gl]) }, [gl, toggleInteractableDoor])
useFrame((_, delta) => { useFrame((_, delta) => {
if (!controllerRef.current?.group) return if (!controllerRef.current?.group) return
@@ -170,18 +282,21 @@ export const FirstPersonControls = () => {
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ') cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
camera.quaternion.setFromEuler(cameraEuler) camera.quaternion.setFromEuler(cameraEuler)
camera.updateMatrixWorld(true) camera.updateMatrixWorld(true)
const nextInteractableDoorId = resolveInteractableDoorId()
if (interactableDoorIdRef.current !== nextInteractableDoorId) {
interactableDoorIdRef.current = nextInteractableDoorId
useViewer.getState().setHoveredId(nextInteractableDoorId)
}
}) })
const controllerPosition = useMemo(() => { useEffect(() => {
if (!world) return null return () => {
const [x, y, z] = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).position if (useViewer.getState().hoveredId === interactableDoorIdRef.current) {
return [x, y - CONTROLLER_CENTER_FROM_EYE, z] as const useViewer.getState().setHoveredId(null)
}, [camera, placedSpawn, world]) }
}
const spawnYaw = useMemo(() => { }, [])
if (!world) return 0
return (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw
}, [camera, placedSpawn, world])
if (!world) { if (!world) {
return null return null
@@ -189,11 +304,11 @@ export const FirstPersonControls = () => {
return ( return (
<> <>
{controllerPosition && ( {controllerStart && (
<KeyboardControls map={keyboardMap}> <KeyboardControls map={keyboardMap}>
<BVHEcctrl <BVHEcctrl
ref={controllerRef} ref={controllerRef}
key={`${world.mesh.uuid}:${controllerPosition.join(':')}:${spawnYaw}`} key="first-person-controller"
colliderCapsuleArgs={[0.25, 0.8, 4, 8]} colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
colliderMeshes={[world.mesh]} colliderMeshes={[world.mesh]}
collisionCheckIteration={3} collisionCheckIteration={3}
@@ -213,7 +328,7 @@ export const FirstPersonControls = () => {
maxRunSpeed={5.5} maxRunSpeed={5.5}
maxSlope={1.2} maxSlope={1.2}
maxWalkSpeed={4} maxWalkSpeed={4}
position={controllerPosition} position={controllerStart.position}
acceleration={26} acceleration={26}
airDragFactor={0.3} airDragFactor={0.3}
deceleration={30} deceleration={30}
@@ -293,7 +408,9 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
<InlineControlHint label="Jump" keyLabel="Space" /> <InlineControlHint label="Jump" keyLabel="Space" />
<InlineControlHint label="Sprint" keyLabel="Shift" /> <InlineControlHint label="Sprint" keyLabel="Shift" />
<div className="h-px w-full bg-border/30" /> <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>
</div> </div>
)} )}
@@ -1,11 +1,7 @@
import { sceneRegistry, useScene } from '@pascal-app/core' import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core'
import {
acceleratedRaycast,
computeBoundsTree,
disposeBoundsTree,
} from 'three-mesh-bvh'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import * as THREE from 'three' 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 = [ const COLLIDER_NODE_TYPES = [
'wall', 'wall',
@@ -16,6 +12,7 @@ const COLLIDER_NODE_TYPES = [
'roof', 'roof',
'roof-segment', 'roof-segment',
'door', 'door',
'window',
'item', 'item',
] as const ] as const
@@ -25,6 +22,7 @@ const DOWN = new THREE.Vector3(0, -1, 0)
const UP = new THREE.Vector3(0, 1, 0) const UP = new THREE.Vector3(0, 1, 0)
const SPAWN_EYE_HEIGHT = 1.65 const SPAWN_EYE_HEIGHT = 1.65
const RAYCAST_CLEARANCE = 25 const RAYCAST_CLEARANCE = 25
const DOOR_LEAF_COLLIDER_DEPTH = 0.06
export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT 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 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) { function cloneWorldGeometry(mesh: THREE.Mesh) {
const sourceGeometry = mesh.geometry const sourceGeometry = mesh.geometry
const position = sourceGeometry.getAttribute('position') const position = sourceGeometry.getAttribute('position')
if (!position || position.count < 3) return null 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() const cleanGeometry = new THREE.BufferGeometry()
cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone()) 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]) { 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 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 || node.type !== 'door') return false
if (node.openingKind === 'opening') return true
if (!node.segments.length) return true if (!node.segments.length) return true
return node.segments.every((segment) => segment.type === 'empty') 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() { function buildRegisteredNodeTypeLookup() {
const nodeTypes = new Map<string, ColliderNodeType>() const nodeTypes = new Map<string, ColliderNodeType>()
@@ -109,7 +156,12 @@ function collectColliderGeometriesFromNode(
if (visitedMeshes.has(object)) return if (visitedMeshes.has(object)) return
visitedMeshes.add(object) 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) const geometry = cloneWorldGeometry(object)
if (geometry) { if (geometry) {
geometries.push(geometry) geometries.push(geometry)
@@ -151,6 +203,17 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
const root = sceneRegistry.nodes.get(nodeId) const root = sceneRegistry.nodes.get(nodeId)
if (!root) continue 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) root.updateMatrixWorld(true)
geometries.push( geometries.push(
...collectColliderGeometriesFromNode( ...collectColliderGeometriesFromNode(
@@ -169,7 +232,9 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
} }
const mergedGeometry = mergeGeometries(geometries, false) const mergedGeometry = mergeGeometries(geometries, false)
geometries.forEach((geometry) => geometry.dispose()) geometries.forEach((geometry) => {
geometry.dispose()
})
if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) { if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
mergedGeometry?.dispose() mergedGeometry?.dispose()
@@ -234,7 +299,8 @@ export function deriveFirstPersonSpawn(
} }
for (const [x, z] of candidates) { 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) raycaster.set(new THREE.Vector3(x, topY, z), DOWN)
const intersections = raycaster.intersectObject(world.mesh, false) const intersections = raycaster.intersectObject(world.mesh, false)
const hit = intersections.find((intersection) => { const hit = intersections.find((intersection) => {
@@ -252,11 +318,7 @@ export function deriveFirstPersonSpawn(
} }
return { return {
position: [ position: [camera.position.x, Math.max(camera.position.y, SPAWN_EYE_HEIGHT), camera.position.z],
camera.position.x,
Math.max(camera.position.y, SPAWN_EYE_HEIGHT),
camera.position.z,
],
yaw, yaw,
} }
} }
File diff suppressed because it is too large Load Diff
@@ -1287,7 +1287,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
> >
<bufferGeometry /> <bufferGeometry />
</lineSegments> </lineSegments>
<Html center position={widthLabelPosition}> <Html center position={widthLabelPosition} style={{ pointerEvents: 'none' }}>
<div <div
style={{ style={{
background: 'rgba(15, 23, 42, 0.86)', background: 'rgba(15, 23, 42, 0.86)',
@@ -1299,13 +1299,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
fontWeight: 600, fontWeight: 600,
lineHeight: 1, lineHeight: 1,
padding: '4px 8px', padding: '4px 8px',
pointerEvents: 'none',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
{widthLabel} {widthLabel}
</div> </div>
</Html> </Html>
<Html center position={depthLabelPosition}> <Html center position={depthLabelPosition} style={{ pointerEvents: 'none' }}>
<div <div
style={{ style={{
background: 'rgba(15, 23, 42, 0.86)', background: 'rgba(15, 23, 42, 0.86)',
@@ -1317,13 +1318,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
fontWeight: 600, fontWeight: 600,
lineHeight: 1, lineHeight: 1,
padding: '4px 8px', padding: '4px 8px',
pointerEvents: 'none',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
{depthLabel} {depthLabel}
</div> </div>
</Html> </Html>
<Html center position={heightLabelPosition}> <Html center position={heightLabelPosition} style={{ pointerEvents: 'none' }}>
<div <div
style={{ style={{
background: 'rgba(15, 23, 42, 0.86)', background: 'rgba(15, 23, 42, 0.86)',
@@ -1335,6 +1337,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
fontWeight: 600, fontWeight: 600,
lineHeight: 1, lineHeight: 1,
padding: '4px 8px', padding: '4px 8px',
pointerEvents: 'none',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
@@ -2,15 +2,17 @@
import { import {
type AnyNodeId, type AnyNodeId,
type BuildingNode,
type GuideNode, type GuideNode,
type LevelNode, type LevelNode,
type ScanNode, type ScanNode,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' 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 { useCallback, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { createLocalGuideImage } from '../../../lib/local-guide-image'
import { cn } from '../../../lib/utils' import { cn } from '../../../lib/utils'
import useEditor, { type GridSnapStep } from '../../../store/use-editor' import useEditor, { type GridSnapStep } from '../../../store/use-editor'
import { useUploadStore } from '../../../store/use-upload' 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 ────────────────────────────────────── // ── Shared upload button for dropdowns ──────────────────────────────────────
function UploadButton() { function UploadButton({ onError }: { onError: (message: string | null) => void }) {
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const levelId = useViewer((s) => s.selection.levelId) 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( const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!(file && levelId)) return if (!(file && levelId)) return
e.target.value = '' e.target.value = ''
const { uploadHandler } = useUploadStore.getState() onError(null)
if (!uploadHandler) return
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 = const isScan =
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf') file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
const isImage = file.type.startsWith('image/') 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] 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) useUploadStore.getState().clearUpload(levelId)
uploadHandler(projectId, levelId, file, type) uploadHandler(projectId, levelId, file, 'scan')
}, },
[levelId], [createNode, levelId, onError, setSelectedReferenceId, setSelection, setShowGuides],
) )
return ( return (
@@ -97,6 +157,7 @@ function UploadButton() {
<button <button
aria-label="Upload scan or guide image" 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" 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()} onClick={() => fileInputRef.current?.click()}
type="button" type="button"
> >
@@ -118,9 +179,13 @@ function UploadButton() {
function GuidesControl() { function GuidesControl() {
const showGuides = useViewer((state) => state.showGuides) const showGuides = useViewer((state) => state.showGuides)
const setShowGuides = useViewer((state) => state.setShowGuides) const setShowGuides = useViewer((state) => state.setShowGuides)
const setSelection = useViewer((state) => state.setSelection)
const updateNode = useScene((state) => state.updateNode) const updateNode = useScene((state) => state.updateNode)
const deleteNode = useScene((state) => state.deleteNode) const deleteNode = useScene((state) => state.deleteNode)
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const guides = useLevelGuides() const guides = useLevelGuides()
const hasGuides = guides.length > 0 const hasGuides = guides.length > 0
@@ -132,6 +197,15 @@ function GuidesControl() {
[updateNode], [updateNode],
) )
const handleSelectGuide = useCallback(
(guideId: GuideNode['id']) => {
setShowGuides(true)
setSelectedReferenceId(guideId)
setSelection({ selectedIds: [], zoneId: null })
},
[setSelectedReferenceId, setSelection, setShowGuides],
)
return ( return (
<Popover onOpenChange={setIsOpen} open={isOpen}> <Popover onOpenChange={setIsOpen} open={isOpen}>
<div className="flex items-center"> <div className="flex items-center">
@@ -201,17 +275,33 @@ function GuidesControl() {
</p> </p>
)} )}
</div> </div>
<UploadButton /> <UploadButton onError={setUploadError} />
</div> </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 ? ( {hasGuides ? (
<div className="max-h-56 space-y-2 overflow-y-auto pr-1"> <div className="max-h-56 space-y-2 overflow-y-auto pr-1">
{guides.map((guide, index) => ( {guides.map((guide, index) => (
<div <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} key={guide.id}
> >
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<button
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => handleSelectGuide(guide.id)}
type="button"
>
<img <img
alt="" alt=""
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70" className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
@@ -220,10 +310,20 @@ function GuidesControl() {
<p className="truncate font-medium text-foreground text-sm"> <p className="truncate font-medium text-foreground text-sm">
{guide.name || `Guide image ${index + 1}`} {guide.name || `Guide image ${index + 1}`}
</p> </p>
{selectedReferenceId === guide.id && (
<Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
)}
</button>
<button <button
aria-label="Delete guide image" 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" 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={() => deleteNode(guide.id)} onClick={(event) => {
event.stopPropagation()
deleteNode(guide.id)
if (selectedReferenceId === guide.id) {
setSelectedReferenceId(null)
}
}}
type="button" type="button"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
@@ -335,9 +435,13 @@ export function GridSnapControl() {
function ScansControl() { function ScansControl() {
const showScans = useViewer((state) => state.showScans) const showScans = useViewer((state) => state.showScans)
const setShowScans = useViewer((state) => state.setShowScans) const setShowScans = useViewer((state) => state.setShowScans)
const setSelection = useViewer((state) => state.setSelection)
const updateNode = useScene((state) => state.updateNode) const updateNode = useScene((state) => state.updateNode)
const deleteNode = useScene((state) => state.deleteNode) const deleteNode = useScene((state) => state.deleteNode)
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const scans = useLevelScans() const scans = useLevelScans()
const hasScans = scans.length > 0 const hasScans = scans.length > 0
@@ -349,6 +453,15 @@ function ScansControl() {
[updateNode], [updateNode],
) )
const handleSelectScan = useCallback(
(scanId: ScanNode['id']) => {
setShowScans(true)
setSelectedReferenceId(scanId)
setSelection({ selectedIds: [], zoneId: null })
},
[setSelectedReferenceId, setSelection, setShowScans],
)
return ( return (
<Popover onOpenChange={setIsOpen} open={isOpen}> <Popover onOpenChange={setIsOpen} open={isOpen}>
<div className="flex items-center"> <div className="flex items-center">
@@ -414,17 +527,33 @@ function ScansControl() {
</p> </p>
)} )}
</div> </div>
<UploadButton /> <UploadButton onError={setUploadError} />
</div> </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 ? ( {hasScans ? (
<div className="max-h-56 space-y-2 overflow-y-auto pr-1"> <div className="max-h-56 space-y-2 overflow-y-auto pr-1">
{scans.map((scan, index) => ( {scans.map((scan, index) => (
<div <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} key={scan.id}
> >
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<button
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => handleSelectScan(scan.id)}
type="button"
>
<img <img
alt="" alt=""
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70" className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
@@ -433,10 +562,20 @@ function ScansControl() {
<p className="truncate font-medium text-foreground text-sm"> <p className="truncate font-medium text-foreground text-sm">
{scan.name || `Scan ${index + 1}`} {scan.name || `Scan ${index + 1}`}
</p> </p>
{selectedReferenceId === scan.id && (
<Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
)}
</button>
<button <button
aria-label="Delete scan" 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" 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={() => deleteNode(scan.id)} onClick={(event) => {
event.stopPropagation()
deleteNode(scan.id)
if (selectedReferenceId === scan.id) {
setSelectedReferenceId(null)
}
}}
type="button" type="button"
> >
<Trash2 className="h-3 w-3" /> <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 ──────────────────────────────────────────────────────── // ── Main ViewToggles ────────────────────────────────────────────────────────
export function ViewToggles() { export function ViewToggles() {
@@ -476,6 +767,8 @@ export function ViewToggles() {
{/* Guides (toggle + dropdown) */} {/* Guides (toggle + dropdown) */}
<GuidesControl /> <GuidesControl />
<ReferenceFloorControl />
</div> </div>
) )
} }
@@ -487,6 +780,7 @@ export function SecondaryToggles() {
<GridSnapControl /> <GridSnapControl />
<ScansControl /> <ScansControl />
<GuidesControl /> <GuidesControl />
<ReferenceFloorControl />
</div> </div>
) )
} }
@@ -15,6 +15,7 @@ interface SliderControlProps {
step?: number step?: number
className?: string className?: string
unit?: string unit?: string
restoreOnCommit?: boolean
} }
function stepPrecision(s: number): number { function stepPrecision(s: number): number {
@@ -56,6 +57,7 @@ export function SliderControl({
step = 1, step = 1,
className, className,
unit = '', unit = '',
restoreOnCommit = true,
}: SliderControlProps) { }: SliderControlProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
@@ -161,7 +163,10 @@ export function SliderControl({
const newValue = clamp( const newValue = clamp(
Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))), Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
) )
if (newValue !== valueRef.current) {
valueRef.current = newValue
onChange(newValue) onChange(newValue)
}
}, },
[step, clamp, onChange], [step, clamp, onChange],
) )
@@ -175,7 +180,7 @@ export function SliderControl({
setIsDragging(false) setIsDragging(false)
e.currentTarget.releasePointerCapture(e.pointerId) e.currentTarget.releasePointerCapture(e.pointerId)
if (originValue !== finalVal) { if (originValue !== finalVal && restoreOnCommit) {
onChange(originValue) onChange(originValue)
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
onChange(finalVal) onChange(finalVal)
@@ -185,7 +190,7 @@ export function SliderControl({
onCommit?.(finalVal) onCommit?.(finalVal)
} }
}, },
[onChange, onCommit], [onChange, onCommit, restoreOnCommit],
) )
const handleValueClick = useCallback(() => { const handleValueClick = useCallback(() => {
@@ -1,5 +1,23 @@
'use client' '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 { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
@@ -8,8 +26,15 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Copy, MoreVertical, Plus, Trash2 } from 'lucide-react' import { Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import {
type ButtonHTMLAttributes,
type CSSProperties,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { import {
buildLevelDuplicateCreateOps, buildLevelDuplicateCreateOps,
@@ -96,12 +121,18 @@ function LevelInlineRename({
function LevelRow({ function LevelRow({
level, level,
isSelected, isSelected,
isDragging,
dragHandleProps,
dragHandleRef,
onSelect, onSelect,
onDuplicate, onDuplicate,
onRequestDelete, onRequestDelete,
}: { }: {
level: LevelNode level: LevelNode
isSelected: boolean isSelected: boolean
isDragging?: boolean
dragHandleProps?: ButtonHTMLAttributes<HTMLButtonElement>
dragHandleRef?: (element: HTMLButtonElement | null) => void
onSelect: () => void onSelect: () => void
onDuplicate: (preset?: LevelDuplicatePreset) => void onDuplicate: (preset?: LevelDuplicatePreset) => void
onRequestDelete: () => void onRequestDelete: () => void
@@ -121,13 +152,32 @@ function LevelRow({
<div <div
className={cn( className={cn(
'flex items-center rounded-lg transition-colors', 'flex items-center rounded-lg transition-colors',
isDragging && 'bg-white/10 text-foreground shadow-lg',
isSelected isSelected
? 'bg-white/10 text-foreground' ? 'bg-white/10 text-foreground'
: 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground', : 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground',
)} )}
> >
<button <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} onClick={onSelect}
onDoubleClick={(e) => { onDoubleClick={(e) => {
e.stopPropagation() 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 ────────────────────────────────────────────────────────── // ── Main component ──────────────────────────────────────────────────────────
export function FloatingLevelSelector() { export function FloatingLevelSelector() {
@@ -212,6 +309,15 @@ export function FloatingLevelSelector() {
const updateNodes = useScene((s) => s.updateNodes) const updateNodes = useScene((s) => s.updateNodes)
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null) 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) => { const resolvedBuildingId = useScene((state) => {
if (selectedBuildingId) return selectedBuildingId if (selectedBuildingId) return selectedBuildingId
@@ -318,9 +424,52 @@ export function FloatingLevelSelector() {
[createNodes, levels, resolvedBuildingId, setSelection, updateNodes], [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 if (levels.length === 0) return null
const reversedLevels = [...levels].reverse() const reversedLevels = [...levels].reverse()
const sortableLevelIds = reversedLevels.map((level) => level.id)
const addButtonClass = 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' '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,6 +479,7 @@ export function FloatingLevelSelector() {
<div className="pointer-events-auto absolute top-14 left-3 z-20"> <div className="pointer-events-auto absolute top-14 left-3 z-20">
<div className="relative"> <div className="relative">
{/* Floating + at top edge */} {/* Floating + at top edge */}
{!draggingLevelId && (
<button <button
className={cn(addButtonClass, 'top-0 -translate-y-1/2')} className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
onClick={handleAddAbove} onClick={handleAddAbove}
@@ -338,8 +488,10 @@ export function FloatingLevelSelector() {
> >
<Plus className="h-2.5 w-2.5" /> <Plus className="h-2.5 w-2.5" />
</button> </button>
)}
{/* Floating + at bottom edge */} {/* Floating + at bottom edge */}
{!draggingLevelId && (
<button <button
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')} className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
onClick={handleAddBelow} onClick={handleAddBelow}
@@ -348,8 +500,17 @@ export function FloatingLevelSelector() {
> >
<Plus className="h-2.5 w-2.5" /> <Plus className="h-2.5 w-2.5" />
</button> </button>
)}
{/* Level list */} {/* Level list */}
<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"> <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) => { {reversedLevels.map((level, i) => {
const isSelected = level.id === levelId const isSelected = level.id === levelId
@@ -358,7 +519,7 @@ export function FloatingLevelSelector() {
return ( return (
<div className="relative" key={level.id}> <div className="relative" key={level.id}>
<LevelRow <SortableLevelRow
isSelected={isSelected} isSelected={isSelected}
level={level} level={level}
onDuplicate={(preset) => handleDuplicateLevel(level, preset)} onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
@@ -372,7 +533,7 @@ export function FloatingLevelSelector() {
} }
/> />
{showGapBelow && ( {showGapBelow && !draggingLevelId && (
<button <button
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')} className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
onClick={() => handleInsertBetween(sortedIndex - 1)} onClick={() => handleInsertBetween(sortedIndex - 1)}
@@ -386,6 +547,8 @@ export function FloatingLevelSelector() {
) )
})} })}
</div> </div>
</SortableContext>
</DndContext>
</div> </div>
</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'], 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', 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', 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], dimensions: [1, 1.2, 1],
offset: [0.01, 0, 0.03], offset: [0.01, 0, 0.03],
rotation: [0, 0, 0], rotation: [0, 0, 0],
@@ -438,7 +438,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
tags: ['floor', 'seating'], tags: ['floor', 'seating'],
thumbnail: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/sofa/thumbnail.png', 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', 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], dimensions: [2.5, 0.8, 1.5],
offset: [0, 0, 0.04], offset: [0, 0, 0.04],
rotation: [0, 0, 0], rotation: [0, 0, 0],
@@ -9,7 +9,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' 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 { usePresetsAdapter } from '../../../contexts/presets-context'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
@@ -22,12 +22,32 @@ import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
import { PresetsPopover } from './presets/presets-popover' 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() { export function DoorPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode) const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const previewRef = useRef<{
id: AnyNodeId
key: keyof DoorNode
value: unknown
} | null>(null)
const adapter = usePresetsAdapter() const adapter = usePresetsAdapter()
@@ -37,10 +57,57 @@ export function DoorPanel() {
const handleUpdate = useCallback( const handleUpdate = useCallback(
(updates: Partial<DoorNode>) => { (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) updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) 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], [selectedId, updateNode],
) )
@@ -85,9 +152,10 @@ export function DoorPanel() {
}, [node, setMovingNode, setSelection]) }, [node, setMovingNode, setSelection])
const setSegmentHeightRatio = (segIdx: number, newVal: number) => { const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
const numSegs = node?.segments.length if (!node) return
const totalH = node?.segments.reduce((sum, s) => sum + s.heightRatio, 0) const numSegs = node.segments.length
const normH = node?.segments.map((s) => s.heightRatio / totalH) 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 clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1 const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
const delta = clamped - normH[segIdx]! const delta = clamped - normH[segIdx]!
@@ -97,7 +165,7 @@ export function DoorPanel() {
if (i === neighborIdx) return neighborVal if (i === neighborIdx) return neighborVal
return v 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 }) handleUpdate({ segments: updated })
} }
@@ -131,6 +199,13 @@ export function DoorPanel() {
height: node.height, height: node.height,
frameThickness: node.frameThickness, frameThickness: node.frameThickness,
frameDepth: node.frameDepth, 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, contentPadding: node.contentPadding,
hingesSide: node.hingesSide, hingesSide: node.hingesSide,
swingDirection: node.swingDirection, swingDirection: node.swingDirection,
@@ -177,6 +252,24 @@ export function DoorPanel() {
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0) const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum) 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 ( return (
<PanelWrapper <PanelWrapper
@@ -206,6 +299,33 @@ export function DoorPanel() {
</PresetsPopover> </PresetsPopover>
</div> </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"> <PanelSection title="Position">
<SliderControl <SliderControl
label={ label={
@@ -221,6 +341,7 @@ export function DoorPanel() {
unit="m" unit="m"
value={Math.round(node.position[0] * 100) / 100} value={Math.round(node.position[0] * 100) / 100}
/> />
{!isOpening && (
<div className="px-1 pt-2 pb-1"> <div className="px-1 pt-2 pb-1">
<ActionButton <ActionButton
className="w-full" className="w-full"
@@ -229,6 +350,7 @@ export function DoorPanel() {
onClick={handleFlip} onClick={handleFlip}
/> />
</div> </div>
)}
</PanelSection> </PanelSection>
<PanelSection title="Dimensions"> <PanelSection title="Dimensions">
@@ -238,6 +360,7 @@ export function DoorPanel() {
min={0.5} min={0.5}
onChange={(v) => handleUpdate({ width: v })} onChange={(v) => handleUpdate({ width: v })}
precision={2} precision={2}
restoreOnCommit={false}
step={0.05} step={0.05}
unit="m" unit="m"
value={Math.round(node.width * 100) / 100} 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]] }) handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })
} }
precision={2} precision={2}
restoreOnCommit={false}
step={0.05} step={0.05}
unit="m" unit="m"
value={Math.round(node.height * 100) / 100} value={Math.round(node.height * 100) / 100}
/> />
</PanelSection> </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"> <PanelSection title="Frame">
<SliderControl <SliderControl
label="Thickness" label="Thickness"
@@ -567,6 +789,9 @@ export function DoorPanel() {
</div> </div>
</PanelSection> </PanelSection>
</>
)}
<PanelSection title="Actions"> <PanelSection title="Actions">
<ActionGroup> <ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} /> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
@@ -1,21 +1,47 @@
'use client' 'use client'
import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core' import {
import { Box, Image as ImageIcon } from 'lucide-react' type AnyNode,
import { useCallback } from 'react' 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 useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
type ReferenceNode = ScanNode | GuideNode 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() { export function ReferencePanel() {
const selectedReferenceId = useEditor((s) => s.selectedReferenceId) const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId) 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 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) => const node = useScene((s) =>
selectedReferenceId selectedReferenceId
@@ -35,17 +61,224 @@ export function ReferencePanel() {
setSelectedReferenceId(null) setSelectedReferenceId(null)
}, [setSelectedReferenceId]) }, [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 if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null
const isScan = node.type === 'scan' 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 ( return (
<PanelWrapper <PanelWrapper
icon={isScan ? undefined : undefined}
onClose={handleClose} onClose={handleClose}
title={node.name || (isScan ? '3D Scan' : 'Guide Image')} title={node.name || (isScan ? '3D Scan' : 'Guide Image')}
width={300} 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"> <PanelSection title="Position">
<SliderControl <SliderControl
label={ label={
@@ -91,7 +91,7 @@ export function SpawnPanel() {
const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI) const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI)
return ( 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"> <PanelSection title="Position">
<SliderControl <SliderControl
label="X" label="X"
@@ -9,24 +9,75 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' 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 { usePresetsAdapter } from '../../../contexts/presets-context'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control' import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control' import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
import { PresetsPopover } from './presets/presets-popover' 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() { export function WindowPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode) const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const previewRef = useRef<{
id: AnyNodeId
key: keyof WindowNode
value: unknown
} | null>(null)
const adapter = usePresetsAdapter() const adapter = usePresetsAdapter()
@@ -36,10 +87,59 @@ export function WindowPanel() {
const handleUpdate = useCallback( const handleUpdate = useCallback(
(updates: Partial<WindowNode>) => { (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) updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) 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], [selectedId, updateNode],
) )
@@ -84,6 +184,13 @@ export function WindowPanel() {
height: node.height, height: node.height,
frameThickness: node.frameThickness, frameThickness: node.frameThickness,
frameDepth: node.frameDepth, 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], columnRatios: [...node.columnRatios],
rowRatios: [...node.rowRatios], rowRatios: [...node.rowRatios],
columnDividerThickness: node.columnDividerThickness, columnDividerThickness: node.columnDividerThickness,
@@ -105,6 +212,13 @@ export function WindowPanel() {
height: node.height, height: node.height,
frameThickness: node.frameThickness, frameThickness: node.frameThickness,
frameDepth: node.frameDepth, 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, columnRatios: node.columnRatios,
rowRatios: node.rowRatios, rowRatios: node.rowRatios,
columnDividerThickness: node.columnDividerThickness, columnDividerThickness: node.columnDividerThickness,
@@ -151,6 +265,58 @@ export function WindowPanel() {
const rowSum = node.rowRatios.reduce((a, b) => a + b, 0) const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
const normCols = node.columnRatios.map((r) => r / colSum) const normCols = node.columnRatios.map((r) => r / colSum)
const normRows = node.rowRatios.map((r) => r / rowSum) 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 setColumnRatio = (index: number, newVal: number) => {
const clamped = Math.max(0.05, Math.min(0.95, newVal)) const clamped = Math.max(0.05, Math.min(0.95, newVal))
@@ -206,6 +372,31 @@ export function WindowPanel() {
</PresetsPopover> </PresetsPopover>
</div> </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"> <PanelSection title="Position">
<SliderControl <SliderControl
label={ label={
@@ -231,6 +422,7 @@ export function WindowPanel() {
unit="m" unit="m"
value={Math.round(node.position[1] * 100) / 100} value={Math.round(node.position[1] * 100) / 100}
/> />
{!isOpening && (
<div className="px-1 pt-2 pb-1"> <div className="px-1 pt-2 pb-1">
<ActionButton <ActionButton
className="w-full" className="w-full"
@@ -239,14 +431,16 @@ export function WindowPanel() {
onClick={handleFlip} onClick={handleFlip}
/> />
</div> </div>
)}
</PanelSection> </PanelSection>
<PanelSection title="Dimensions"> <PanelSection title="Dimensions">
<SliderControl <SliderControl
label="Width" label="Width"
min={0} min={0}
onChange={(v) => handleUpdate({ width: v })} onChange={(v) => handleUpdate(getDimensionUpdates({ width: v }))}
precision={2} precision={2}
restoreOnCommit={false}
step={0.1} step={0.1}
unit="m" unit="m"
value={Math.round(node.width * 100) / 100} value={Math.round(node.width * 100) / 100}
@@ -254,14 +448,107 @@ export function WindowPanel() {
<SliderControl <SliderControl
label="Height" label="Height"
min={0} min={0}
onChange={(v) => handleUpdate({ height: v })} onChange={(v) => handleUpdate(getDimensionUpdates({ height: v }))}
precision={2} precision={2}
restoreOnCommit={false}
step={0.1} step={0.1}
unit="m" unit="m"
value={Math.round(node.height * 100) / 100} value={Math.round(node.height * 100) / 100}
/> />
</PanelSection> </PanelSection>
{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}
/>
{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="Reveal Radius"
max={0.08}
min={0}
onChange={(value) => previewWindowUpdate('openingRevealRadius', value)}
onCommit={(value) => commitWindowPreview('openingRevealRadius', value)}
precision={3}
step={0.005}
unit="m"
value={Math.round(openingRevealRadius * 1000) / 1000}
/>
</div>
)}
{openingShape === 'arch' && (
<div className="mt-2 flex flex-col gap-1">
<SliderControl
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(archHeight * 100) / 100}
/>
</div>
)}
</PanelSection>
)}
{!isOpening && (
<>
<PanelSection title="Frame"> <PanelSection title="Frame">
<SliderControl <SliderControl
label="Thickness" label="Thickness"
@@ -405,6 +692,8 @@ export function WindowPanel() {
</div> </div>
)} )}
</PanelSection> </PanelSection>
</>
)}
<PanelSection title="Actions"> <PanelSection title="Actions">
<ActionGroup> <ActionGroup>
@@ -33,10 +33,13 @@ import {
PopoverTrigger, PopoverTrigger,
} from './../../../../../components/ui/primitives/popover' } from './../../../../../components/ui/primitives/popover'
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection' import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
import { import {
buildLevelDuplicateCreateOps, buildLevelDuplicateCreateOps,
type LevelDuplicatePreset, type LevelDuplicatePreset,
} from './../../../../../lib/level-duplication' } from './../../../../../lib/level-duplication'
import { cn } from './../../../../../lib/utils' import { cn } from './../../../../../lib/utils'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { useUploadStore } from '../../../../../store/use-upload' import { useUploadStore } from '../../../../../store/use-upload'
@@ -400,7 +403,10 @@ const LevelReferences = memo(function LevelReferences({
onUploadAsset, onUploadAsset,
onDeleteAsset, onDeleteAsset,
}: LevelReferencesProps) { }: LevelReferencesProps) {
const createNode = useScene((s) => s.createNode)
const deleteNode = useScene((s) => s.deleteNode) const deleteNode = useScene((s) => s.deleteNode)
const setSelection = useViewer((s) => s.setSelection)
const setShowGuides = useViewer((s) => s.setShowGuides)
const references = useScene( const references = useScene(
useShallow((s) => useShallow((s) =>
Object.values(s.nodes).filter( Object.values(s.nodes).filter(
@@ -423,19 +429,27 @@ const LevelReferences = memo(function LevelReferences({
const scanInputRef = useRef<HTMLInputElement>(null) const scanInputRef = useRef<HTMLInputElement>(null)
const handleAddAsset = (e: React.ChangeEvent<HTMLInputElement>) => { const handleAddAsset = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
e.target.value = '' e.target.value = ''
if (!projectId) { // Auto-detect type based on file extension/mime type
useUploadStore.getState().startUpload(levelId, 'scan', file.name) const isScan =
useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.') 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 return
} }
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
useUploadStore.getState().startUpload(levelId, 'scan', file.name) useUploadStore.getState().startUpload(levelId, type, file.name)
useUploadStore useUploadStore
.getState() .getState()
.setError( .setError(
@@ -445,20 +459,28 @@ const LevelReferences = memo(function LevelReferences({
return return
} }
// Auto-detect type based on file extension/mime type if (isImage) {
const isScan = useUploadStore.getState().startUpload(levelId, 'guide', file.name)
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf') useUploadStore.getState().setStatus(levelId, 'uploading')
const isImage = file.type.startsWith('image/')
if (!(isScan || isImage)) { try {
useUploadStore.getState().startUpload(levelId, 'scan', file.name) const guide = await createLocalGuideImage({ createNode, file, levelId })
useUploadStore setShowGuides(true)
.getState() setSelectedReferenceId(guide.id)
.setError(levelId, 'Invalid file type. Please upload a .glb/.gltf scan or an image.') 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 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) clearUpload(levelId)
onUploadAsset?.(projectId, levelId, file, type) onUploadAsset?.(projectId, levelId, file, type)
@@ -54,7 +54,7 @@ export const SpawnTreeNode = memo(function SpawnTreeNode({
alt="" alt=""
className="object-contain" className="object-contain"
height={14} height={14}
src="/icons/spawn-point.svg" src="/icons/site.png"
width={14} width={14}
/> />
} }
@@ -2,7 +2,18 @@
import { Icon as IconifyIcon } from '@iconify/react' import { Icon as IconifyIcon } from '@iconify/react'
import { useViewer } from '@pascal-app/viewer' 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 { useCallback } from 'react'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor' 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 ──────────────────────────────────────────────────────── // ── Wall mode toggle ────────────────────────────────────────────────────────
const wallModeOrder = ['cutaway', 'up', 'down'] as const const wallModeOrder = ['cutaway', 'up', 'down'] as const
@@ -383,6 +423,7 @@ export function ViewerToolbarRight() {
<LevelModeToggle /> <LevelModeToggle />
<WallModeToggle /> <WallModeToggle />
<GridSnapToggle /> <GridSnapToggle />
<GridVisibilityToggle />
<div className="my-1.5 w-px bg-border/50" /> <div className="my-1.5 w-px bg-border/50" />
<UnitToggle /> <UnitToggle />
<ThemeToggle /> <ThemeToggle />
+21 -2
View File
@@ -5,6 +5,8 @@ import { runRedo, runUndo } from '../lib/history'
import { sfxEmitter } from '../lib/sfx-bus' import { sfxEmitter } from '../lib/sfx-bus'
import useEditor from '../store/use-editor' 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, // 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. // so that the global Escape handler knows not to also switch to select mode.
let _toolCancelConsumed = false let _toolCancelConsumed = false
@@ -145,10 +147,21 @@ export const useKeyboard = ({
} }
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) { } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.) // 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[] const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) { if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!] 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() e.preventDefault()
const ROTATION_STEP = Math.PI / 4 const ROTATION_STEP = Math.PI / 4
@@ -168,7 +181,13 @@ export const useKeyboard = ({
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) { if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!] 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() e.preventDefault()
const ROTATION_STEP = Math.PI / 4 const ROTATION_STEP = Math.PI / 4
+38 -3
View File
@@ -139,6 +139,21 @@ export function buildFloorplanItemEntry(
return null 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 object = sceneRegistry.nodes.get(item.id)
const realMeshPolygon = object const realMeshPolygon = object
? getRealMeshFloorplanPolygon(transform, object) ? getRealMeshFloorplanPolygon(transform, object)
@@ -147,9 +162,6 @@ export function buildFloorplanItemEntry(
return null return null
} }
const dimensionPolygon = getItemDimensionPolygon(item, transform)
const [width, , depth] = getScaledDimensions(item)
return { return {
dimensionPolygon, dimensionPolygon,
item, item,
@@ -167,6 +179,29 @@ type Point = {
y: number 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[] { function getItemDimensionPolygon(item: ItemNode, transform: FloorplanNodeTransform): Point[] {
const [width, , depth] = getScaledDimensions(item) const [width, , depth] = getScaledDimensions(item)
const centerLocalZ = item.asset.attachTo === 'wall-side' ? -depth / 2 : 0 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) { function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
const stairType = stair.stairType ?? 'straight' const stairType = stair.stairType ?? 'straight'
const baseSweepAngle = const baseSweepAngle = stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
if (Math.abs(baseSweepAngle) >= Math.PI * 2) { if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001) return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
@@ -210,11 +209,29 @@ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
return baseSweepAngle 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[] { function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] {
const stairType = stair.stairType ?? 'straight' const stairType = stair.stairType ?? 'straight'
const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair) const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair)
const startAngle = stair.rotation - sweepAngle / 2 const startAngle = -stair.rotation - sweepAngle / 2
const endAngle = startAngle + sweepAngle const endAngle = startAngle + sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle)
const center = { const center = {
x: stair.position[0], x: stair.position[0],
y: stair.position[2], 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 LevelNode,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type SpawnNode,
type RoofSurfaceMaterialRole, type RoofSurfaceMaterialRole,
type SlabNode, type SlabNode,
type Space, type Space,
type SpawnNode,
type StairNode, type StairNode,
type StairSegmentNode, type StairSegmentNode,
type StairSurfaceMaterialRole, type StairSurfaceMaterialRole,
@@ -98,7 +98,11 @@ export type MovingFenceEndpoint = {
endpoint: 'start' | 'end' endpoint: 'start' | 'end'
} }
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole | SingleSurfaceMaterialRole export type MaterialTargetRole =
| WallSurfaceSide
| StairSurfaceMaterialRole
| RoofSurfaceMaterialRole
| SingleSurfaceMaterialRole
export type SelectedMaterialTarget = { export type SelectedMaterialTarget = {
nodeId: AnyNodeId nodeId: AnyNodeId
@@ -111,6 +115,11 @@ type MaterialPaintSelectionSnapshot = {
activePaintMaterial: ActivePaintMaterial | null activePaintMaterial: ActivePaintMaterial | null
} }
export type GuideUiState = {
locked?: boolean
scaleReferenceVisible?: boolean
}
type EditorState = { type EditorState = {
phase: Phase phase: Phase
setPhase: (phase: Phase) => void setPhase: (phase: Phase) => void
@@ -177,6 +186,10 @@ type EditorState = {
setPaintPanelOpen: (open: boolean) => void setPaintPanelOpen: (open: boolean) => void
selectedReferenceId: string | null selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void 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 // Space detection for cutaway mode
spaces: Record<string, Space> spaces: Record<string, Space>
setSpaces: (spaces: Record<string, Space>) => void setSpaces: (spaces: Record<string, Space>) => void
@@ -201,6 +214,13 @@ type EditorState = {
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
gridSnapStep: GridSnapStep gridSnapStep: GridSnapStep
setGridSnapStep: (step: GridSnapStep) => void 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) // First-person walkthrough mode (street view)
isFirstPersonMode: boolean isFirstPersonMode: boolean
_viewModeBeforeFirstPerson: ViewMode | null _viewModeBeforeFirstPerson: ViewMode | null
@@ -230,6 +250,9 @@ type PersistedEditorLayoutState = Pick<
| 'splitOrientation' | 'splitOrientation'
| 'floorplanSelectionTool' | 'floorplanSelectionTool'
| 'gridSnapStep' | 'gridSnapStep'
| 'showReferenceFloor'
| 'referenceFloorOffset'
| 'referenceFloorOpacity'
> >
type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState
@@ -249,6 +272,9 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
splitOrientation: 'horizontal', splitOrientation: 'horizontal',
floorplanSelectionTool: 'click', floorplanSelectionTool: 'click',
gridSnapStep: 0.5, gridSnapStep: 0.5,
showReferenceFloor: false,
referenceFloorOffset: 1,
referenceFloorOpacity: 0.35,
} }
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05] 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) gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep)
? (state?.gridSnapStep as GridSnapStep) ? (state?.gridSnapStep as GridSnapStep)
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.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 }), setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }),
selectedReferenceId: null, selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), 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: {}, spaces: {},
setSpaces: (spaces) => set({ spaces }), setSpaces: (spaces) => set({ spaces }),
editingHole: null, editingHole: null,
@@ -636,6 +702,16 @@ const useEditor = create<EditorState>()(
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }), setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
setGridSnapStep: (step) => set({ gridSnapStep: step }), 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, allowUndergroundCamera: false,
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
isFirstPersonMode: false, isFirstPersonMode: false,
@@ -703,6 +779,9 @@ const useEditor = create<EditorState>()(
splitOrientation: state.splitOrientation, splitOrientation: state.splitOrientation,
floorplanSelectionTool: state.floorplanSelectionTool, floorplanSelectionTool: state.floorplanSelectionTool,
gridSnapStep: state.gridSnapStep, gridSnapStep: state.gridSnapStep,
showReferenceFloor: state.showReferenceFloor,
referenceFloorOffset: state.referenceFloorOffset,
referenceFloorOpacity: state.referenceFloorOpacity,
}), }),
}, },
), ),
+2
View File
@@ -30,6 +30,8 @@
}, },
"dependencies": { "dependencies": {
"polygon-clipping": "^0.15.7", "polygon-clipping": "^0.15.7",
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8",
"zustand": "^5" "zustand": "^5"
}, },
"devDependencies": { "devDependencies": {
@@ -1,8 +1,9 @@
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core' import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react' import { useLayoutEffect, useRef } from 'react'
import type { Mesh } from 'three' import { MeshBasicMaterial, type Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' 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 }) => { export const DoorRenderer = ({ node }: { node: DoorNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
@@ -14,16 +15,10 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
const handlers = useNodeEvents(node, 'door') const handlers = useNodeEvents(node, 'door')
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient 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 ( return (
<mesh <mesh
castShadow castShadow
material={material} material={doorHitboxMaterial}
position={node.position} position={node.position}
receiveShadow receiveShadow
ref={ref} ref={ref}
@@ -19,7 +19,7 @@ export const GuideRenderer = ({ node }: { node: GuideNode }) => {
position={node.position} position={node.position}
ref={ref} ref={ref}
rotation={[0, node.rotation[1], 0]} rotation={[0, node.rotation[1], 0]}
visible={showGuides} visible={showGuides && node.visible !== false}
> >
{resolvedUrl && ( {resolvedUrl && (
<Suspense> <Suspense>
@@ -1,8 +1,6 @@
import { import {
type AnimationEffect, type AnimationEffect,
type AnyNodeId, type AnyNodeId,
baseMaterial,
glassMaterial,
type Interactive, type Interactive,
type ItemNode, type ItemNode,
type LightEffect, type LightEffect,
@@ -21,6 +19,7 @@ import { positionLocal, smoothstep, time } from 'three/tsl'
import { MeshStandardNodeMaterial } from 'three/webgpu' import { MeshStandardNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { resolveCdnUrl } from '../../../lib/asset-url' import { resolveCdnUrl } from '../../../lib/asset-url'
import { baseMaterial, glassMaterial } from '../../../lib/materials'
import { useItemLightPool } from '../../../store/use-item-light-pool' import { useItemLightPool } from '../../../store/use-item-light-pool'
import { import {
requestItemMeshMetadataSync, requestItemMeshMetadataSync,
@@ -54,13 +54,13 @@ export const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
<meshStandardMaterial {...materialProps} /> <meshStandardMaterial {...materialProps} />
</mesh> </mesh>
<mesh position={[0, 0.36, 0]} {...handlers}> <mesh position={[0, 0.41, 0]} {...handlers}>
<cylinderGeometry args={[0.08, 0.08, 0.54, 24]} /> <boxGeometry args={[0.3, 0.54, 0.16]} />
<meshStandardMaterial {...materialProps} /> <meshStandardMaterial {...materialProps} />
</mesh> </mesh>
<mesh position={[0, 0.68, 0]} {...handlers}> <mesh position={[0, 0.83, 0]} {...handlers}>
<sphereGeometry args={[0.16, 24, 16]} /> <boxGeometry args={[0.18, 0.18, 0.18]} />
<meshStandardMaterial {...materialProps} /> <meshStandardMaterial {...materialProps} />
</mesh> </mesh>
</group> </group>
@@ -1,27 +1,25 @@
'use client' 'use client'
import {
CeilingSystem,
DoorSystem,
FenceSystem,
ItemSystem,
RoofSystem,
SlabSystem,
StairSystem,
WallSystem,
WindowSystem,
} from '@pascal-app/core'
import { Bvh } from '@react-three/drei' import { Bvh } from '@react-three/drei'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu' import * as THREE from 'three/webgpu'
import useViewer from '../../store/use-viewer' 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 { GuideSystem } from '../../systems/guide/guide-system'
import { ItemSystem } from '../../systems/item/item-system'
import { ItemLightSystem } from '../../systems/item-light/item-light-system' import { ItemLightSystem } from '../../systems/item-light/item-light-system'
import { ItemMeshMetadataSystem } from '../../systems/item-mesh-metadata/item-mesh-metadata-system' import { ItemMeshMetadataSystem } from '../../systems/item-mesh-metadata/item-mesh-metadata-system'
import { LevelSystem } from '../../systems/level/level-system' import { LevelSystem } from '../../systems/level/level-system'
import { RoofSystem } from '../../systems/roof/roof-system'
import { ScanSystem } from '../../systems/scan/scan-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 { 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 { ZoneSystem } from '../../systems/zone/zone-system'
import { ErrorBoundary } from '../error-boundary' import { ErrorBoundary } from '../error-boundary'
import { SceneRenderer } from '../renderers/scene-renderer' import { SceneRenderer } from '../renderers/scene-renderer'
+16
View File
@@ -7,6 +7,22 @@ import {
resolveMaterial, resolveMaterial,
} from '@pascal-app/core' } from '@pascal-app/core'
import * as THREE from 'three' 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> = { const sideMap: Record<MaterialProperties['side'], THREE.Side> = {
front: THREE.FrontSide, front: THREE.FrontSide,
@@ -1,8 +1,6 @@
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { type AnyNodeId, type CeilingNode, sceneRegistry, useScene } from '@pascal-app/core'
import * as THREE from 'three' 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) { function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv') const uv = geometry.getAttribute('uv')
@@ -1,9 +1,12 @@
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
type DoorNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import * as THREE from 'three' import * as THREE from 'three'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import { baseMaterial, glassMaterial } from '../../lib/materials'
import { baseMaterial, glassMaterial } from '../../materials'
import type { AnyNodeId, DoorNode } from '../../schema'
import useScene from '../../store/use-scene'
// Invisible material for root mesh — used as selection hitbox only // Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -52,6 +55,12 @@ function addBox(
parent.add(m) 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) { function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
// Root mesh is an invisible hitbox; all visuals live in child meshes // Root mesh is an invisible hitbox; all visuals live in child meshes
mesh.geometry.dispose() mesh.geometry.dispose()
@@ -65,13 +74,14 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
// Dispose and remove all old visual children; preserve 'cutout' // Dispose and remove all old visual children; preserve 'cutout'
for (const child of [...mesh.children]) { for (const child of [...mesh.children]) {
if (child.name === 'cutout') continue if (child.name === 'cutout') continue
if (child instanceof THREE.Mesh) child.geometry.dispose() disposeObject(child)
mesh.remove(child) mesh.remove(child)
} }
const { const {
width, width,
height, height,
openingKind,
frameThickness, frameThickness,
frameDepth, frameDepth,
threshold, threshold,
@@ -85,8 +95,16 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
panicBarHeight, panicBarHeight,
contentPadding, contentPadding,
hingesSide, hingesSide,
swingDirection,
swingAngle = 0,
} = node } = node
const hasLeafContent = segments.some((seg) => seg.type !== 'empty') 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) // Leaf occupies the full opening (no bottom frame bar — door opens to floor)
const leafW = width - 2 * frameThickness const leafW = width - 2 * frameThickness
@@ -94,6 +112,23 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
const leafDepth = 0.04 const leafDepth = 0.04
// Leaf center is shifted down from door center by half the top frame // Leaf center is shifted down from door center by half the top frame
const leafCenterY = -frameThickness / 2 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 ── // ── Frame members ──
// Left post — full height // Left post — full height
@@ -149,16 +184,16 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
const cpY = contentPadding[1] const cpY = contentPadding[1]
if (hasLeafContent && cpY > 0) { if (hasLeafContent && cpY > 0) {
// Top strip // 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 // 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) { if (hasLeafContent && cpX > 0) {
const innerH = leafH - 2 * cpY const innerH = leafH - 2 * cpY
// Left strip // 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 // 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 // Content area inside padding
@@ -193,8 +228,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
cx = -contentW / 2 cx = -contentW / 2
for (let c = 0; c < numCols - 1; c++) { for (let c = 0; c < numCols - 1; c++) {
cx += colWidths[c]! cx += colWidths[c]!
addBox( addLeafBox(
mesh,
baseMaterial, baseMaterial,
seg.dividerThickness, seg.dividerThickness,
segH, segH,
@@ -215,17 +249,17 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
if (seg.type === 'glass') { if (seg.type === 'glass') {
// Glass only — no opaque backing so it's truly transparent // Glass only — no opaque backing so it's truly transparent
const glassDepth = Math.max(0.004, leafDepth * 0.15) 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') { } else if (seg.type === 'panel') {
// Opaque leaf backing for this column // 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 // Raised panel detail
const panelW = colW - 2 * seg.panelInset const panelW = colW - 2 * seg.panelInset
const panelH = segH - 2 * seg.panelInset const panelH = segH - 2 * seg.panelInset
if (panelW > 0.01 && panelH > 0.01) { if (panelW > 0.01 && panelH > 0.01) {
const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth) const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth)
const panelZ = leafDepth / 2 + effectiveDepth / 2 const panelZ = leafDepth / 2 + effectiveDepth / 2
addBox(mesh, baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ)
} }
} else { } else {
// 'empty' leaves the opening unfilled // '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 const handleX = handleSide === 'right' ? leafW / 2 - 0.045 : -leafW / 2 + 0.045
// Backplate // 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 // 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) ── // ── Door closer (commercial hardware at top) ──
if (hasLeafContent && doorCloser) { if (hasLeafContent && doorCloser) {
const closerY = leafCenterY + leafH / 2 - 0.04 const closerY = leafCenterY + leafH / 2 - 0.04
// Body // 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) // Arm (simplified as thin bar to frame side)
addBox( addLeafBox(baseMaterial, 0.14, 0.015, 0.015, leafW / 4, closerY + 0.025, leafDepth / 2 + 0.015)
mesh,
baseMaterial,
0.14,
0.015,
0.015,
leafW / 4,
closerY + 0.025,
leafDepth / 2 + 0.015,
)
} }
// ── Panic bar ── // ── Panic bar ──
if (hasLeafContent && panicBar) { if (hasLeafContent && panicBar) {
const barY = panicBarHeight - height / 2 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) ── // ── 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) 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 ── // ── Cutout (for wall CSG) — always full door dimensions, 1m deep ──
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
if (!cutout) { if (!cutout) {
@@ -1,10 +1,14 @@
import { useFrame } from '@react-three/fiber' 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 * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' 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 = { type FencePart = {
position: [number, number, number] position: [number, number, number]
@@ -1,10 +1,15 @@
import { useFrame } from '@react-three/fiber' 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 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 // ITEM SYSTEM
@@ -1,21 +1,30 @@
import { useFrame } from '@react-three/fiber' 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 * as THREE from 'three'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
import { computeBoundsTree } from 'three-mesh-bvh' 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() const csgEvaluator = new Evaluator()
csgEvaluator.useGroups = true csgEvaluator.useGroups = true
;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash ;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash
csgEvaluator.attributes = ['position', 'normal', 'uv'] 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) { function prepareBrushForCSG(brush: Brush) {
brush.geometry.computeBoundsTree = computeBoundsTree computeGeometryBoundsTree(brush.geometry)
brush.geometry.computeBoundsTree({ maxLeafSize: 10 })
brush.updateMatrixWorld() brush.updateMatrixWorld()
} }
@@ -78,8 +87,7 @@ export const RoofSystem = () => {
mesh.geometry.dispose() mesh.geometry.dispose()
const placeholder = new THREE.BufferGeometry() const placeholder = new THREE.BufferGeometry()
placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
placeholder.computeBoundsTree = computeBoundsTree computeGeometryBoundsTree(placeholder)
placeholder.computeBoundsTree({ maxLeafSize: 10 })
mesh.geometry = placeholder mesh.geometry = placeholder
} }
mesh.position.set(node.position[0], node.position[1], node.position[2]) 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.dispose()
mesh.geometry = newGeo mesh.geometry = newGeo
newGeo.computeBoundsTree = computeBoundsTree computeGeometryBoundsTree(newGeo)
newGeo.computeBoundsTree({ maxLeafSize: 10 })
mesh.position.set(node.position[0], node.position[1], node.position[2]) mesh.position.set(node.position[0], node.position[1], node.position[2])
mesh.rotation.y = node.rotation mesh.rotation.y = node.rotation
@@ -524,8 +531,7 @@ export function getRoofSegmentBrushes(
// when a group exists but covers no triangles (can happen after mergeVertices) // when a group exists but covers no triangles (can happen after mergeVertices)
geo.groups = geo.groups.filter((g) => g.count > 0) geo.groups = geo.groups.filter((g) => g.count > 0)
if (geo.groups.length === 0) return null if (geo.groups.length === 0) return null
geo.computeBoundsTree = computeBoundsTree computeGeometryBoundsTree(geo)
geo.computeBoundsTree({ maxLeafSize: 10 })
const brush = new Brush(geo, dummyMats) const brush = new Brush(geo, dummyMats)
brush.updateMatrixWorld() brush.updateMatrixWorld()
return brush return brush
@@ -1,9 +1,12 @@
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
getRenderableSlabPolygon,
sceneRegistry,
type SlabNode,
useScene,
} from '@pascal-app/core'
import * as THREE from 'three' 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) { function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv') const uv = geometry.getAttribute('uv')
@@ -58,71 +61,6 @@ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) {
mesh.position.y = elevation < 0 ? elevation : 0 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 * 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) uvs.push((x - bounds.min.x) / floorWidth, (z - bounds.min.y) / floorHeight)
} }
const pushWallVertex = ( const pushWallVertex = (x: number, y: number, z: number, u: number, v: number) => {
x: number,
y: number,
z: number,
u: number,
v: number,
) => {
positions.push(x, y, z) positions.push(x, y, z)
uvs.push(u, v) uvs.push(u, v)
} }
@@ -1,13 +1,18 @@
import { useFrame } from '@react-three/fiber' 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 { useEffect, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' 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 pendingStairUpdates = new Set<AnyNodeId>()
const MAX_STAIRS_PER_FRAME = 2 const MAX_STAIRS_PER_FRAME = 2
@@ -1,5 +1,4 @@
import { import {
baseMaterial,
getEffectiveWallSurfaceMaterial, getEffectiveWallSurfaceMaterial,
getMaterialPresetByRef, getMaterialPresetByRef,
getWallSurfaceMaterialSignature, getWallSurfaceMaterialSignature,
@@ -10,7 +9,7 @@ import {
import { Color, type Material } from 'three' import { Color, type Material } from 'three'
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
import { MeshStandardNodeMaterial } from 'three/webgpu' import { MeshStandardNodeMaterial } from 'three/webgpu'
import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials' import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
const DEFAULT_WALL_COLOR = '#f2f0ed' const DEFAULT_WALL_COLOR = '#f2f0ed'
@@ -2,21 +2,29 @@ import { useFrame } from '@react-three/fiber'
import * as THREE from 'three' import * as THREE from 'three'
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
import { computeBoundsTree } from 'three-mesh-bvh' 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 { import {
calculateLevelMiters, calculateLevelMiters,
type AnyNode,
type AnyNodeId,
type DoorNode,
getAdjacentWallIds, getAdjacentWallIds,
DEFAULT_WALL_HEIGHT,
getWallCurveFrameAt,
getWallMiterBoundaryPoints, getWallMiterBoundaryPoints,
getWallPlanFootprint,
getWallSurfacePolygon,
getWallThickness,
isCurvedWall,
type Point2D, type Point2D,
pointToKey, pointToKey,
resolveLevelId,
sceneRegistry,
spatialGridManager,
useScene,
type WallNode,
type WallMiterData, type WallMiterData,
} from './wall-mitering' type WindowNode,
} from '@pascal-app/core'
// Reusable CSG evaluator for better performance // Reusable CSG evaluator for better performance
const csgEvaluator = new Evaluator() 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_NORMAL_Y_EPSILON = 0.6
const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003 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 WallBoundaryEdgeTag = 'front' | 'back' | 'base'
type TaggedWallBoundaryEdge = { type TaggedWallBoundaryEdge = {
@@ -495,8 +508,7 @@ export function generateExtrudedWall(
// Create wall brush from geometry // Create wall brush from geometry
// Pre-compute BVH with new API to avoid deprecation warning // Pre-compute BVH with new API to avoid deprecation warning
geometry.computeBoundsTree = computeBoundsTree computeGeometryBoundsTree(geometry)
geometry.computeBoundsTree({ maxLeafSize: 10 })
const wallBrush = new Brush(geometry) const wallBrush = new Brush(geometry)
wallBrush.updateMatrixWorld() wallBrush.updateMatrixWorld()
@@ -546,6 +558,14 @@ function collectCutoutBrushes(
for (const child of childrenNodes) { for (const child of childrenNodes) {
if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue 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) const childMesh = sceneRegistry.nodes.get(child.id)
if (!childMesh) continue if (!childMesh) continue
@@ -591,8 +611,7 @@ function collectCutoutBrushes(
) )
// Pre-compute BVH with new API to avoid deprecation warning // Pre-compute BVH with new API to avoid deprecation warning
boxGeo.computeBoundsTree = computeBoundsTree computeGeometryBoundsTree(boxGeo)
boxGeo.computeBoundsTree({ maxLeafSize: 10 })
const brush = new Brush(boxGeo) const brush = new Brush(boxGeo)
brushes.push(brush) brushes.push(brush)
@@ -600,3 +619,184 @@ function collectCutoutBrushes(
return brushes 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 { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
sceneRegistry,
useScene,
type WindowNode,
} from '@pascal-app/core'
import * as THREE from 'three' import * as THREE from 'three'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import { baseMaterial, glassMaterial } from '../../lib/materials'
import { baseMaterial, glassMaterial } from '../../materials'
import type { AnyNodeId, WindowNode } from '../../schema'
import useScene from '../../store/use-scene'
// Invisible material for root mesh — used as selection hitbox only // Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -81,8 +84,14 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
sill, sill,
sillDepth, sillDepth,
sillThickness, sillThickness,
openingKind,
} = node } = node
if (openingKind === 'opening') {
syncWindowCutout(node, mesh)
return
}
const innerW = width - 2 * frameThickness const innerW = width - 2 * frameThickness
const innerH = height - 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 ── // ── Cutout (for wall CSG) — always full window dimensions, 1m deep ──
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
if (!cutout) { if (!cutout) {