Merge pull request #286 from sudhir9297/feat/improvement-and-fixes
Feat/improvement and fixes
This commit is contained in:
@@ -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 |
@@ -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=="],
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 &
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
-3
@@ -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')
|
||||||
+57
-28
@@ -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) {
|
||||||
+8
-4
@@ -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]
|
||||||
+10
-5
@@ -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
|
||||||
+18
-12
@@ -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
|
||||||
+8
-76
@@ -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)
|
||||||
}
|
}
|
||||||
+11
-6
@@ -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'
|
||||||
|
|
||||||
|
|||||||
+212
-12
@@ -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()
|
||||||
|
}
|
||||||
+17
-4
@@ -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) {
|
||||||
Reference in New Issue
Block a user