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