feat: 2D editing — floorplan panel, measurements, command palette (v0.3.0)
* sync: port 2D editing features from monorepo (v0.3.0) ## New Features ### Floorplan Panel (7.5K LOC) - Full 2D editing interface with wall drawing, measurement, and unit display - Interactive floorplan view with pan/zoom, grid snapping - Wall thickness visualization, door/window placement - Zone and slab polygon editing - Guide image overlay support - Metric/imperial unit toggle ### Command Palette Overhaul - Complete rewrite with new command registry system - Keyboard shortcuts UI with ShortcutToken component - Editor-specific commands (floorplan, measurements, camera focus) - Improved search and action organization ### Wall Measurements - Real-time wall length labels in 3D view - Metric/imperial conversion - Wall measurement UI component ### Enhanced Tools - Wall drafting utilities (grid snapping, validation) - Node action menu for quick operations - Improved polygon editing for zones/slabs/sites - Roof segment panel for granular roof control ## Package Changes ### @pascal-app/core@0.3.0 - New wall-footprint.ts: 2D wall footprint calculation - Wall mitering exports for floorplan view - camera-controls:focus event - Space detection undo pause/resume - Mark sibling nodes dirty on deletion (miter recalc) ### @pascal-app/viewer@0.3.0 - ErrorBoundary component for robust item rendering - Broken item fallback UI - Wall renderer: mark dirty on mount - Ground occluder: only lowest level punches through - Unit state (metric/imperial) in viewer store ### @pascal-app/editor@0.1.0 - 48 file changes (16 new, 32 modified) - New stores: useCommandRegistry, usePaletteViewRegistry - Tree node drag-and-drop system - Level selection utilities - Enhanced scene graph operations ## Apps/Editor - GeistPixelSquare font for pixel-perfect UI - Blueprint icon asset - Updated layout and globals for font support ## Security - Zero AI imports or internal package refs - All monorepo-specific code excluded - Clean audit: no API keys or secrets ## Testing - Security audit passed ✅ - All AI/internal code excluded ✅ - Version bumps applied ✅ 79 files modified, 16 files added ~2600 insertions, ~1400 deletions * fix: lint cleanup — suppress intentional dep warnings, fix missing dep, remove stale ignores - Add biome-ignore for 3 intentional useEffect reset patterns (levelId, selectedGuide, selectedId) - Fix actual missing dependency: currentBuildingId in handleSiteEditShortcutSelect callback - Remove 3 stale biome-ignore comments in r3f.d.ts (rule not active) Build and lint pass clean. * fix: type errors — polygon area guards, selection cast, readonly keywords, door guard - ceiling-panel, slab-panel, ceiling-tree-node, slab-tree-node, zone-tree-node: guard polygon[i]/polygon[j] array access before arithmetic (TS2532) - scene.ts: introduce toViewerSelection() helper to cast persisted string IDs to branded template literal types expected by useViewer.setSelection (TS2345) - door-panel: guard early return when node is undefined in setSegmentHeightRatio (TS18048) - editor-commands: remove 'as const' from inline command object, keywords is mutable string[] (TS2322) All type checks pass. Build and lint clean. --------- Co-authored-by: Anton Pascal <anton-pascal@users.noreply.github.com>
This commit is contained in:
@@ -11,6 +11,9 @@
|
||||
--font-mono:
|
||||
var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco,
|
||||
Consolas, monospace;
|
||||
--font-pixel:
|
||||
var(--font-geist-pixel-square), var(--font-geist-mono), ui-monospace,
|
||||
SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--font-barlow:
|
||||
var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui,
|
||||
sans-serif;
|
||||
@@ -21,6 +24,8 @@
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-barlow), sans-serif;
|
||||
--font-mono: var(--font-geist-mono), monospace;
|
||||
--font-pixel:
|
||||
var(--font-geist-pixel-square), var(--font-geist-mono), monospace;
|
||||
--font-barlow: var(--font-barlow), sans-serif;
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
||||
+15
-20
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Agentation } from 'agentation'
|
||||
import { GeistPixelSquare } from 'geist/font/pixel'
|
||||
import { Barlow } from 'next/font/google'
|
||||
import localFont from 'next/font/local'
|
||||
import Script from 'next/script'
|
||||
@@ -20,35 +21,29 @@ const barlow = Barlow({
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Pascal Editor',
|
||||
description: 'Standalone building editor',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html className={`${geistSans.variable} ${geistMono.variable} ${barlow.variable}`} lang="en">
|
||||
<html
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable} ${barlow.variable}`}
|
||||
lang="en"
|
||||
>
|
||||
<head>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<>
|
||||
<Script
|
||||
crossOrigin="anonymous"
|
||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
<Script
|
||||
crossOrigin="anonymous"
|
||||
src="//unpkg.com/react-grab/dist/index.global.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
</>
|
||||
<Script
|
||||
crossOrigin="anonymous"
|
||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body className="font-sans">{children}</body>
|
||||
<body className="font-sans">
|
||||
{children}
|
||||
{process.env.NODE_ENV === 'development' && <Agentation />}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Editor } from '@pascal-app/editor'
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="h-screen w-screen">
|
||||
<Editor />
|
||||
<Editor projectId="local-editor" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,13 +17,14 @@
|
||||
"@pascal-app/viewer": "*",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"next": "16.1.6",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.7.0",
|
||||
"next": "16.2.1",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"three": "^0.183.1"
|
||||
},
|
||||
@@ -33,6 +34,7 @@
|
||||
"@types/node": "^22.19.12",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"agentation": "^2.3.2",
|
||||
"react-grab": "^0.1.25",
|
||||
"react-scan": "^0.5.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pascal-app/core",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Core library for Pascal 3D building editor",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface ThumbnailGenerateEvent {
|
||||
|
||||
type CameraControlEvents = {
|
||||
'camera-controls:view': CameraControlEvent
|
||||
'camera-controls:focus': CameraControlEvent
|
||||
'camera-controls:capture': CameraControlEvent
|
||||
'camera-controls:top-view': undefined
|
||||
'camera-controls:orbit-cw': undefined
|
||||
|
||||
@@ -54,6 +54,18 @@ export { DoorSystem } from './systems/door/door-system'
|
||||
export { ItemSystem } from './systems/item/item-system'
|
||||
export { RoofSystem } from './systems/roof/roof-system'
|
||||
export { SlabSystem } from './systems/slab/slab-system'
|
||||
export {
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
DEFAULT_WALL_THICKNESS,
|
||||
getWallPlanFootprint,
|
||||
getWallThickness,
|
||||
} from './systems/wall/wall-footprint'
|
||||
export {
|
||||
calculateLevelMiters,
|
||||
type Point2D,
|
||||
pointToKey,
|
||||
type WallMiterData,
|
||||
} from './systems/wall/wall-mitering'
|
||||
export { WallSystem } from './systems/wall/wall-system'
|
||||
export { WindowSystem } from './systems/window/window-system'
|
||||
export { isObject } from './utils/types'
|
||||
|
||||
@@ -104,9 +104,11 @@ export function initSpaceDetectionSync(
|
||||
// Run detection for affected levels
|
||||
if (levelsToUpdate.size > 0) {
|
||||
isProcessing = true
|
||||
sceneStore.temporal.getState().pause()
|
||||
try {
|
||||
runSpaceDetection(Array.from(levelsToUpdate), sceneStore, editorStore, nodes)
|
||||
} finally {
|
||||
sceneStore.temporal.getState().resume()
|
||||
isProcessing = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +343,18 @@ useScene.temporal.subscribe((state) => {
|
||||
// Nodes that were deleted (exist in prev but not current)
|
||||
for (const [id, node] of Object.entries(snapshotBefore) as [AnyNodeId, AnyNode][]) {
|
||||
if (!currentNodes[id]) {
|
||||
if (node.parentId) markDirty(node.parentId as AnyNodeId)
|
||||
const parentId = node.parentId as AnyNodeId | undefined
|
||||
if (parentId) {
|
||||
markDirty(parentId)
|
||||
// Mark sibling nodes dirty so they can update their geometry
|
||||
// (e.g. adjacent walls need to recalculate miter/junction geometry)
|
||||
const parent = currentNodes[parentId]
|
||||
if (parent && 'children' in parent) {
|
||||
for (const childId of (parent as AnyNode & { children: string[] }).children) {
|
||||
markDirty(childId as AnyNodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { WallNode } from '../../schema'
|
||||
import { type Point2D, pointToKey, type WallMiterData } from './wall-mitering'
|
||||
|
||||
export const DEFAULT_WALL_THICKNESS = 0.1
|
||||
export const DEFAULT_WALL_HEIGHT = 2.5
|
||||
|
||||
export function getWallThickness(wallNode: WallNode): number {
|
||||
return wallNode.thickness ?? DEFAULT_WALL_THICKNESS
|
||||
}
|
||||
|
||||
export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterData): Point2D[] {
|
||||
const { junctionData } = miterData
|
||||
|
||||
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
|
||||
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
|
||||
const thickness = getWallThickness(wallNode)
|
||||
const halfT = thickness / 2
|
||||
|
||||
const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y }
|
||||
const L = Math.sqrt(v.x * v.x + v.y * v.y)
|
||||
if (L < 1e-9) {
|
||||
return []
|
||||
}
|
||||
const nUnit = { x: -v.y / L, y: v.x / L }
|
||||
|
||||
const keyStart = pointToKey(wallStart)
|
||||
const keyEnd = pointToKey(wallEnd)
|
||||
|
||||
const startJunction = junctionData.get(keyStart)?.get(wallNode.id)
|
||||
const endJunction = junctionData.get(keyEnd)?.get(wallNode.id)
|
||||
|
||||
const pStartLeft: Point2D = startJunction?.left || {
|
||||
x: wallStart.x + nUnit.x * halfT,
|
||||
y: wallStart.y + nUnit.y * halfT,
|
||||
}
|
||||
const pStartRight: Point2D = startJunction?.right || {
|
||||
x: wallStart.x - nUnit.x * halfT,
|
||||
y: wallStart.y - nUnit.y * halfT,
|
||||
}
|
||||
|
||||
// Junction offsets are stored relative to the outgoing direction.
|
||||
const pEndLeft: Point2D = endJunction?.right || {
|
||||
x: wallEnd.x + nUnit.x * halfT,
|
||||
y: wallEnd.y + nUnit.y * halfT,
|
||||
}
|
||||
const pEndRight: Point2D = endJunction?.left || {
|
||||
x: wallEnd.x - nUnit.x * halfT,
|
||||
y: wallEnd.y - nUnit.y * halfT,
|
||||
}
|
||||
|
||||
const polygon: Point2D[] = [pStartRight, pEndRight]
|
||||
if (endJunction) {
|
||||
polygon.push(wallEnd)
|
||||
}
|
||||
polygon.push(pEndLeft, pStartLeft)
|
||||
if (startJunction) {
|
||||
polygon.push(wallStart)
|
||||
}
|
||||
|
||||
return polygon
|
||||
}
|
||||
@@ -7,11 +7,11 @@ import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manage
|
||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||
import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
|
||||
import {
|
||||
calculateLevelMiters,
|
||||
getAdjacentWallIds,
|
||||
type Point2D,
|
||||
pointToKey,
|
||||
type WallMiterData,
|
||||
} from './wall-mitering'
|
||||
|
||||
@@ -148,17 +148,14 @@ export function generateExtrudedWall(
|
||||
miterData: WallMiterData,
|
||||
slabElevation = 0,
|
||||
) {
|
||||
const { junctionData } = miterData
|
||||
|
||||
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
|
||||
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
|
||||
// Positive slab: shift the whole wall up (full height preserved)
|
||||
// Negative slab: extend wall downward so top stays fixed at wallNode.height
|
||||
const wallHeight = wallNode.height ?? 2.5
|
||||
const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT
|
||||
const height = slabElevation > 0 ? wallHeight : wallHeight - slabElevation
|
||||
|
||||
const thickness = wallNode.thickness ?? 0.1
|
||||
const halfT = thickness / 2
|
||||
const thickness = getWallThickness(wallNode)
|
||||
|
||||
// Wall direction and normal (exactly like demo)
|
||||
const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y }
|
||||
@@ -166,51 +163,9 @@ export function generateExtrudedWall(
|
||||
if (L < 1e-9) {
|
||||
return new THREE.BufferGeometry()
|
||||
}
|
||||
const nUnit = { x: -v.y / L, y: v.x / L }
|
||||
|
||||
// Get junction data for start and end (exactly like demo)
|
||||
const keyStart = pointToKey(wallStart)
|
||||
const keyEnd = pointToKey(wallEnd)
|
||||
|
||||
const startJunction = junctionData.get(keyStart)?.get(wallNode.id)
|
||||
const endJunction = junctionData.get(keyEnd)?.get(wallNode.id)
|
||||
|
||||
// Calculate polygon corners in world coordinates (exactly like demo)
|
||||
// p_start_L = left side at start
|
||||
// p_start_R = right side at start
|
||||
// p_end_L = left side at end
|
||||
// p_end_R = right side at end
|
||||
|
||||
const p_start_L: Point2D = startJunction?.left || {
|
||||
x: wallStart.x + nUnit.x * halfT,
|
||||
y: wallStart.y + nUnit.y * halfT,
|
||||
}
|
||||
const p_start_R: Point2D = startJunction?.right || {
|
||||
x: wallStart.x - nUnit.x * halfT,
|
||||
y: wallStart.y - nUnit.y * halfT,
|
||||
}
|
||||
|
||||
// At end, SWAP left/right from junction data (exactly like demo)
|
||||
// This is because junction stores left/right relative to OUTGOING direction,
|
||||
// which is reversed at the end of the wall
|
||||
const p_end_L: Point2D = endJunction?.right || {
|
||||
x: wallEnd.x + nUnit.x * halfT,
|
||||
y: wallEnd.y + nUnit.y * halfT,
|
||||
}
|
||||
const p_end_R: Point2D = endJunction?.left || {
|
||||
x: wallEnd.x - nUnit.x * halfT,
|
||||
y: wallEnd.y - nUnit.y * halfT,
|
||||
}
|
||||
|
||||
// Build polygon points (exactly like demo)
|
||||
// Order: start-right -> end-right -> [end center] -> end-left -> start-left -> [start center]
|
||||
const polyPoints: Point2D[] = [p_start_R, p_end_R]
|
||||
if (endJunction) {
|
||||
polyPoints.push(wallEnd) // Add center vertex at junction
|
||||
}
|
||||
polyPoints.push(p_end_L, p_start_L)
|
||||
if (startJunction) {
|
||||
polyPoints.push(wallStart) // Add center vertex at junction
|
||||
const polyPoints = getWallPlanFootprint(wallNode, miterData)
|
||||
if (polyPoints.length < 3) {
|
||||
return new THREE.BufferGeometry()
|
||||
}
|
||||
|
||||
// Transform world coordinates to wall-local coordinates
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"three": "^0.183"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@number-flow/react": "^0.5.14",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useViewer, ZONE_LAYER } from '@pascal-app/viewer'
|
||||
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
@@ -12,21 +12,29 @@ import useEditor from '../../store/use-editor'
|
||||
const currentTarget = new Vector3()
|
||||
const tempBox = new Box3()
|
||||
const tempCenter = new Vector3()
|
||||
const tempDelta = new Vector3()
|
||||
const tempPosition = new Vector3()
|
||||
const tempSize = new Vector3()
|
||||
const tempTarget = new Vector3()
|
||||
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
|
||||
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
|
||||
|
||||
export const CustomCameraControls = () => {
|
||||
const controls = useRef<CameraControlsImpl>(null!)
|
||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
||||
const selection = useViewer((s) => s.selection)
|
||||
const currentLevelId = selection.levelId
|
||||
const firstLoad = useRef(true)
|
||||
const maxPolarAngle =
|
||||
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
|
||||
|
||||
const camera = useThree((state) => state.camera)
|
||||
const raycaster = useThree((state) => state.raycaster)
|
||||
useEffect(() => {
|
||||
camera.layers.enable(EDITOR_LAYER)
|
||||
raycaster.layers.enable(EDITOR_LAYER)
|
||||
raycaster.layers.enable(2)
|
||||
raycaster.layers.enable(ZONE_LAYER)
|
||||
}, [camera, raycaster])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,6 +59,45 @@ export const CustomCameraControls = () => {
|
||||
)
|
||||
}, [currentLevelId, isPreviewMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!controls.current) return
|
||||
|
||||
controls.current.maxPolarAngle = maxPolarAngle
|
||||
controls.current.minPolarAngle = 0
|
||||
|
||||
if (controls.current.polarAngle > maxPolarAngle) {
|
||||
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
|
||||
}
|
||||
}, [maxPolarAngle])
|
||||
|
||||
const focusNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
if (isPreviewMode || !controls.current) return
|
||||
|
||||
const object3D = sceneRegistry.nodes.get(nodeId)
|
||||
if (!object3D) return
|
||||
|
||||
tempBox.setFromObject(object3D)
|
||||
if (tempBox.isEmpty()) return
|
||||
|
||||
tempBox.getCenter(tempCenter)
|
||||
controls.current.getPosition(tempPosition)
|
||||
controls.current.getTarget(tempTarget)
|
||||
tempDelta.copy(tempCenter).sub(tempTarget)
|
||||
|
||||
controls.current.setLookAt(
|
||||
tempPosition.x + tempDelta.x,
|
||||
tempPosition.y + tempDelta.y,
|
||||
tempPosition.z + tempDelta.z,
|
||||
tempCenter.x,
|
||||
tempCenter.y,
|
||||
tempCenter.z,
|
||||
true,
|
||||
)
|
||||
},
|
||||
[isPreviewMode],
|
||||
)
|
||||
|
||||
// Configure mouse buttons based on control mode and camera mode
|
||||
const cameraMode = useViewer((state) => state.cameraMode)
|
||||
const mouseButtons = useMemo(() => {
|
||||
@@ -233,7 +280,7 @@ export const CustomCameraControls = () => {
|
||||
if (!controls.current) return
|
||||
|
||||
const node = useScene.getState().nodes[nodeId]
|
||||
if (!(node && node.camera)) return
|
||||
if (!node?.camera) return
|
||||
const { position, target } = node.camera
|
||||
|
||||
controls.current.setLookAt(
|
||||
@@ -283,7 +330,12 @@ export const CustomCameraControls = () => {
|
||||
controls.current.rotateTo(target, currentPolar, true)
|
||||
}
|
||||
|
||||
const handleNodeFocus = ({ nodeId }: CameraControlEvent) => {
|
||||
focusNode(nodeId)
|
||||
}
|
||||
|
||||
emitter.on('camera-controls:capture', handleNodeCapture)
|
||||
emitter.on('camera-controls:focus', handleNodeFocus)
|
||||
emitter.on('camera-controls:view', handleNodeView)
|
||||
emitter.on('camera-controls:top-view', handleTopView)
|
||||
emitter.on('camera-controls:orbit-cw', handleOrbitCW)
|
||||
@@ -291,12 +343,13 @@ export const CustomCameraControls = () => {
|
||||
|
||||
return () => {
|
||||
emitter.off('camera-controls:capture', handleNodeCapture)
|
||||
emitter.off('camera-controls:focus', handleNodeFocus)
|
||||
emitter.off('camera-controls:view', handleNodeView)
|
||||
emitter.off('camera-controls:top-view', handleTopView)
|
||||
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
|
||||
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
||||
}
|
||||
}, [])
|
||||
}, [focusNode])
|
||||
|
||||
const onTransitionStart = useCallback(() => {
|
||||
useViewer.getState().setCameraDragging(true)
|
||||
@@ -310,7 +363,7 @@ export const CustomCameraControls = () => {
|
||||
<CameraControls
|
||||
makeDefault
|
||||
maxDistance={100}
|
||||
maxPolarAngle={Math.PI / 2 - 0.1}
|
||||
maxPolarAngle={maxPolarAngle}
|
||||
minDistance={10}
|
||||
minPolarAngle={0}
|
||||
mouseButtons={mouseButtons}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
type AnyNodeId,
|
||||
DoorNode,
|
||||
ItemNode,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
WindowNode,
|
||||
@@ -12,18 +14,19 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { Copy, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { NodeActionMenu } from './node-action-menu'
|
||||
|
||||
const ALLOWED_TYPES = ['item', 'door', 'window']
|
||||
const ALLOWED_TYPES = ['item', 'door', 'window', 'roof', 'roof-segment']
|
||||
|
||||
export function FloatingActionMenu() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
|
||||
@@ -54,7 +57,13 @@ export function FloatingActionMenu() {
|
||||
e.stopPropagation()
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
if (node.type === 'item' || node.type === 'window' || node.type === 'door') {
|
||||
if (
|
||||
node.type === 'item' ||
|
||||
node.type === 'window' ||
|
||||
node.type === 'door' ||
|
||||
node.type === 'roof' ||
|
||||
node.type === 'roof-segment'
|
||||
) {
|
||||
setMovingNode(node as any)
|
||||
}
|
||||
setSelection({ selectedIds: [] })
|
||||
@@ -65,7 +74,7 @@ export function FloatingActionMenu() {
|
||||
const handleDuplicate = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!(node && node.parentId)) return
|
||||
if (!node?.parentId) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
@@ -81,6 +90,10 @@ export function FloatingActionMenu() {
|
||||
duplicate = WindowNode.parse(duplicateInfo)
|
||||
} else if (node.type === 'item') {
|
||||
duplicate = ItemNode.parse(duplicateInfo)
|
||||
} else if (node.type === 'roof') {
|
||||
duplicate = RoofNode.parse(duplicateInfo)
|
||||
} else if (node.type === 'roof-segment') {
|
||||
duplicate = RoofSegmentNode.parse(duplicateInfo)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse duplicate', error)
|
||||
@@ -90,8 +103,43 @@ export function FloatingActionMenu() {
|
||||
if (duplicate) {
|
||||
if (duplicate.type === 'door' || duplicate.type === 'window') {
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
} else if (duplicate.type === 'roof' || duplicate.type === 'roof-segment') {
|
||||
// Add small offset to make it visible
|
||||
if ('position' in duplicate) {
|
||||
duplicate.position = [
|
||||
duplicate.position[0] + 1,
|
||||
duplicate.position[1],
|
||||
duplicate.position[2] + 1,
|
||||
]
|
||||
}
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
|
||||
// Duplicate children for roof nodes
|
||||
if (node.type === 'roof' && node.children) {
|
||||
const nodesState = useScene.getState().nodes
|
||||
for (const childId of node.children) {
|
||||
const childNode = nodesState[childId]
|
||||
if (childNode && childNode.type === 'roof-segment') {
|
||||
let childDuplicateInfo = structuredClone(childNode) as any
|
||||
delete childDuplicateInfo.id
|
||||
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
|
||||
try {
|
||||
const childDuplicate = RoofSegmentNode.parse(childDuplicateInfo)
|
||||
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate roof segment', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (duplicate.type === 'item' || duplicate.type === 'window' || duplicate.type === 'door') {
|
||||
if (
|
||||
duplicate.type === 'item' ||
|
||||
duplicate.type === 'window' ||
|
||||
duplicate.type === 'door' ||
|
||||
duplicate.type === 'roof' ||
|
||||
duplicate.type === 'roof-segment'
|
||||
) {
|
||||
setMovingNode(duplicate as any)
|
||||
}
|
||||
setSelection({ selectedIds: [] })
|
||||
@@ -112,7 +160,7 @@ export function FloatingActionMenu() {
|
||||
[selectedId, node, deleteNode, setSelection],
|
||||
)
|
||||
|
||||
if (!(selectedId && node && isValidType)) return null
|
||||
if (!(selectedId && node && isValidType && !isFloorplanHovered)) return null
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
@@ -124,33 +172,13 @@ export function FloatingActionMenu() {
|
||||
}}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 rounded-lg border border-border bg-background/95 p-1 shadow-xl backdrop-blur-md"
|
||||
<NodeActionMenu
|
||||
onDelete={handleDelete}
|
||||
onDuplicate={handleDuplicate}
|
||||
onMove={handleMove}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={handleMove}
|
||||
title="Move"
|
||||
>
|
||||
<Move className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={handleDuplicate}
|
||||
title="Duplicate"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={handleDelete}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
/>
|
||||
</Html>
|
||||
</group>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { Icon } from '@iconify/react'
|
||||
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react'
|
||||
import { ViewerOverlay } from '../../components/viewer-overlay'
|
||||
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
||||
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
|
||||
@@ -12,10 +13,12 @@ import {
|
||||
applySceneGraphToEditor,
|
||||
loadSceneFromLocalStorage,
|
||||
type SceneGraph,
|
||||
writePersistedSelection,
|
||||
} from '../../lib/scene'
|
||||
import { initSFXBus } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||
import { RoofEditSystem } from '../systems/roof/roof-edit-system'
|
||||
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||
import { ToolManager } from '../tools/tool-manager'
|
||||
@@ -24,6 +27,7 @@ import { HelperManager } from '../ui/helpers/helper-manager'
|
||||
import { PanelManager } from '../ui/panels/panel-manager'
|
||||
import { ErrorBoundary } from '../ui/primitives/error-boundary'
|
||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
|
||||
import { SceneLoader } from '../ui/scene-loader'
|
||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||
import type { SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
|
||||
@@ -31,47 +35,30 @@ import type { SitePanelProps } from '../ui/sidebar/panels/site-panel'
|
||||
import { CustomCameraControls } from './custom-camera-controls'
|
||||
import { ExportManager } from './export-manager'
|
||||
import { FloatingActionMenu } from './floating-action-menu'
|
||||
import { FloorplanPanel } from './floorplan-panel'
|
||||
import { Grid } from './grid'
|
||||
import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { SiteEdgeLabels } from './site-edge-labels'
|
||||
import { ThumbnailGenerator } from './thumbnail-generator'
|
||||
import { WallMeasurementLabel } from './wall-measurement-label'
|
||||
|
||||
// Load default scene initially (will be replaced when onLoad runs)
|
||||
useScene.getState().loadScene()
|
||||
initSpatialGridSync()
|
||||
initSpaceDetectionSync(useScene, useEditor)
|
||||
let hasInitializedEditorRuntime = false
|
||||
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
|
||||
|
||||
// Auto-select the first building and level for the default scene
|
||||
const sceneNodes = useScene.getState().nodes as Record<string, any>
|
||||
const sceneRootIds = useScene.getState().rootNodeIds
|
||||
const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
|
||||
const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child)
|
||||
const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
|
||||
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
|
||||
function initializeEditorRuntime() {
|
||||
if (hasInitializedEditorRuntime) return
|
||||
initSpatialGridSync()
|
||||
initSpaceDetectionSync(useScene, useEditor)
|
||||
initSFXBus()
|
||||
|
||||
if (firstBuilding && firstLevel) {
|
||||
useViewer.getState().setSelection({
|
||||
buildingId: firstBuilding.id,
|
||||
levelId: firstLevel.id,
|
||||
selectedIds: [],
|
||||
zoneId: null,
|
||||
})
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
|
||||
if (!firstLevel.children || firstLevel.children.length === 0) {
|
||||
useEditor.getState().setMode('build')
|
||||
useEditor.getState().setTool('wall')
|
||||
}
|
||||
hasInitializedEditorRuntime = true
|
||||
}
|
||||
|
||||
initSFXBus()
|
||||
|
||||
export interface EditorProps {
|
||||
// UI slots
|
||||
appMenuButton?: ReactNode
|
||||
sidebarTop?: ReactNode
|
||||
projectId?: string | null
|
||||
|
||||
// Persistence — defaults to localStorage when omitted
|
||||
onLoad?: () => Promise<SceneGraph | null>
|
||||
@@ -99,7 +86,7 @@ export interface EditorProps {
|
||||
|
||||
function EditorSceneCrashFallback() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 p-4 text-foreground">
|
||||
<div className="fixed inset-0 z-80 flex items-center justify-center bg-background/95 p-4 text-foreground">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
|
||||
<h2 className="font-semibold text-lg">The editor scene failed to render</h2>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
@@ -125,9 +112,201 @@ function EditorSceneCrashFallback() {
|
||||
)
|
||||
}
|
||||
|
||||
function SelectionPersistenceManager({ enabled }: { enabled: boolean }) {
|
||||
const selection = useViewer((state) => state.selection)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
writePersistedSelection(selection)
|
||||
}, [enabled, selection])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
type ShortcutKey = {
|
||||
value: string
|
||||
}
|
||||
|
||||
type CameraControlHint = {
|
||||
action: string
|
||||
keys: ShortcutKey[]
|
||||
alternativeKeys?: ShortcutKey[]
|
||||
}
|
||||
|
||||
const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
|
||||
{
|
||||
action: 'Pan',
|
||||
keys: [{ value: 'Space' }, { value: 'Left click' }],
|
||||
},
|
||||
{ action: 'Rotate', keys: [{ value: 'Right click' }] },
|
||||
{ action: 'Zoom', keys: [{ value: 'Scroll' }] },
|
||||
]
|
||||
|
||||
const PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
|
||||
{ action: 'Pan', keys: [{ value: 'Left click' }] },
|
||||
{ action: 'Rotate', keys: [{ value: 'Right click' }] },
|
||||
{ action: 'Zoom', keys: [{ value: 'Scroll' }] },
|
||||
]
|
||||
|
||||
const CAMERA_SHORTCUT_KEY_META: Record<string, { icon?: string; label: string; text?: string }> = {
|
||||
'Left click': {
|
||||
icon: 'ph:mouse-left-click-fill',
|
||||
label: 'Left click',
|
||||
},
|
||||
'Middle click': {
|
||||
icon: 'qlementine-icons:mouse-middle-button-16',
|
||||
label: 'Middle click',
|
||||
},
|
||||
'Right click': {
|
||||
icon: 'ph:mouse-right-click-fill',
|
||||
label: 'Right click',
|
||||
},
|
||||
Scroll: {
|
||||
icon: 'qlementine-icons:mouse-middle-button-16',
|
||||
label: 'Scroll wheel',
|
||||
},
|
||||
Space: {
|
||||
icon: 'lucide:space',
|
||||
label: 'Space',
|
||||
},
|
||||
}
|
||||
|
||||
function readCameraControlsHintDismissed(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function writeCameraControlsHintDismissed(dismissed: boolean) {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (dismissed) {
|
||||
window.localStorage.setItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY, '1')
|
||||
return
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function InlineShortcutKey({ shortcutKey }: { shortcutKey: ShortcutKey }) {
|
||||
const meta = CAMERA_SHORTCUT_KEY_META[shortcutKey.value]
|
||||
|
||||
if (meta?.icon) {
|
||||
return (
|
||||
<span
|
||||
aria-label={meta.label}
|
||||
className="inline-flex items-center text-foreground/90"
|
||||
role="img"
|
||||
title={meta.label}
|
||||
>
|
||||
<Icon aria-hidden="true" color="currentColor" height={16} icon={meta.icon} width={16} />
|
||||
<span className="sr-only">{meta.label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="font-medium text-[11px] text-foreground/90">
|
||||
{meta?.text ?? shortcutKey.value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ShortcutSequence({ keys }: { keys: ShortcutKey[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{keys.map((key, index) => (
|
||||
<div className="flex items-center gap-1" key={`${key.value}-${index}`}>
|
||||
{index > 0 ? <span className="text-[10px] text-muted-foreground/70">+</span> : null}
|
||||
<InlineShortcutKey shortcutKey={key} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CameraControlHintItem({ hint }: { hint: CameraControlHint }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-center gap-1.5 px-4 text-center first:pl-0 last:pr-0">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
|
||||
{hint.action}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center justify-center gap-1.5">
|
||||
<ShortcutSequence keys={hint.keys} />
|
||||
{hint.alternativeKeys ? (
|
||||
<>
|
||||
<span className="text-[10px] text-muted-foreground/40">/</span>
|
||||
<ShortcutSequence keys={hint.alternativeKeys} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ViewerCanvasControlsHint({
|
||||
isPreviewMode,
|
||||
onDismiss,
|
||||
}: {
|
||||
isPreviewMode: boolean
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-4 left-1/2 z-40 max-w-[calc(100vw-2rem)] -translate-x-1/2">
|
||||
<section
|
||||
aria-label="Camera controls hint"
|
||||
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-[0_22px_40px_-28px_rgba(15,23,42,0.65),0_10px_24px_-20px_rgba(15,23,42,0.55)] backdrop-blur-xl"
|
||||
>
|
||||
<div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18">
|
||||
{hints.map((hint) => (
|
||||
<CameraControlHintItem hint={hint} key={hint.action} />
|
||||
))}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label="Dismiss camera controls hint"
|
||||
className="flex h-5 shrink-0 items-center justify-center self-center border-border/18 border-l pl-3 text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
color="currentColor"
|
||||
height={14}
|
||||
icon="lucide:x"
|
||||
width={14}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={8}>
|
||||
Dismiss
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Editor({
|
||||
appMenuButton,
|
||||
sidebarTop,
|
||||
projectId,
|
||||
onLoad,
|
||||
onSave,
|
||||
onDirty,
|
||||
@@ -150,7 +329,24 @@ export default function Editor({
|
||||
})
|
||||
|
||||
const [isSceneLoading, setIsSceneLoading] = useState(false)
|
||||
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
|
||||
const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
|
||||
null,
|
||||
)
|
||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
||||
|
||||
useEffect(() => {
|
||||
initializeEditorRuntime()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
useViewer.getState().setProjectId(projectId ?? null)
|
||||
|
||||
return () => {
|
||||
useViewer.getState().setProjectId(null)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
// Load scene on mount (or when onLoad identity changes, e.g. project switch)
|
||||
useEffect(() => {
|
||||
@@ -158,6 +354,7 @@ export default function Editor({
|
||||
|
||||
async function load() {
|
||||
isLoadingSceneRef.current = true
|
||||
setHasLoadedInitialScene(false)
|
||||
setIsSceneLoading(true)
|
||||
|
||||
try {
|
||||
@@ -170,6 +367,7 @@ export default function Editor({
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsSceneLoading(false)
|
||||
setHasLoadedInitialScene(true)
|
||||
requestAnimationFrame(() => {
|
||||
isLoadingSceneRef.current = false
|
||||
})
|
||||
@@ -198,19 +396,39 @@ export default function Editor({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
|
||||
}, [])
|
||||
|
||||
const showLoader = isLoading || isSceneLoading
|
||||
const dismissCameraControlsHint = useCallback(() => {
|
||||
setIsCameraControlsHintVisible(false)
|
||||
writeCameraControlsHintDismissed(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PresetsProvider adapter={presetsAdapter}>
|
||||
<div className="dark h-full w-full text-foreground">
|
||||
{showLoader && <SceneLoader />}
|
||||
{showLoader && (
|
||||
<div className="fixed inset-0 z-60">
|
||||
<SceneLoader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPreviewMode ? (
|
||||
{!showLoader && isCameraControlsHintVisible ? (
|
||||
<ViewerCanvasControlsHint
|
||||
isPreviewMode={isPreviewMode}
|
||||
onDismiss={dismissCameraControlsHint}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && isPreviewMode ? (
|
||||
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||
) : (
|
||||
<>
|
||||
<ActionMenu />
|
||||
<PanelManager />
|
||||
{isFloorplanOpen && <FloorplanPanel />}
|
||||
<HelperManager />
|
||||
|
||||
<SidebarProvider className="fixed z-20">
|
||||
@@ -225,21 +443,26 @@ export default function Editor({
|
||||
)}
|
||||
|
||||
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
||||
<Viewer selectionManager={isPreviewMode ? 'default' : 'custom'}>
|
||||
{!isPreviewMode && <SelectionManager />}
|
||||
{!isPreviewMode && <FloatingActionMenu />}
|
||||
<ExportManager />
|
||||
{isPreviewMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
<CeilingSystem />
|
||||
{!isPreviewMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
|
||||
{!isPreviewMode && <ToolManager />}
|
||||
<CustomCameraControls />
|
||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||
<PresetThumbnailGenerator />
|
||||
{!isPreviewMode && <SiteEdgeLabels />}
|
||||
{isPreviewMode && <InteractiveSystem />}
|
||||
</Viewer>
|
||||
{!isPreviewMode && <ZoneLabelEditorSystem />}
|
||||
<div className="h-full w-full">
|
||||
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
||||
<Viewer selectionManager={isPreviewMode ? 'default' : 'custom'}>
|
||||
{!isPreviewMode && <SelectionManager />}
|
||||
{!isPreviewMode && <FloatingActionMenu />}
|
||||
{!isPreviewMode && <WallMeasurementLabel />}
|
||||
<ExportManager />
|
||||
{isPreviewMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
<CeilingSystem />
|
||||
<RoofEditSystem />
|
||||
{!isPreviewMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
|
||||
{!(isPreviewMode || isLoading) && <ToolManager />}
|
||||
<CustomCameraControls />
|
||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||
<PresetThumbnailGenerator />
|
||||
{!isPreviewMode && <SiteEdgeLabels />}
|
||||
{isPreviewMode && <InteractiveSystem />}
|
||||
</Viewer>
|
||||
</div>
|
||||
{!(isPreviewMode || isLoading) && <ZoneLabelEditorSystem />}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</PresetsProvider>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { Copy, Move, Trash2 } from 'lucide-react'
|
||||
import type { MouseEventHandler, PointerEventHandler } from 'react'
|
||||
|
||||
type NodeActionMenuProps = {
|
||||
onDelete: MouseEventHandler<HTMLButtonElement>
|
||||
onDuplicate: MouseEventHandler<HTMLButtonElement>
|
||||
onMove: MouseEventHandler<HTMLButtonElement>
|
||||
onPointerDown?: PointerEventHandler<HTMLDivElement>
|
||||
onPointerUp?: PointerEventHandler<HTMLDivElement>
|
||||
onPointerEnter?: PointerEventHandler<HTMLDivElement>
|
||||
onPointerLeave?: PointerEventHandler<HTMLDivElement>
|
||||
}
|
||||
|
||||
export function NodeActionMenu({
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onMove,
|
||||
onPointerDown,
|
||||
onPointerUp,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: NodeActionMenuProps) {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-lg border border-border bg-background/95 p-1 shadow-xl backdrop-blur-md"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<button
|
||||
aria-label="Move"
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onMove}
|
||||
title="Move"
|
||||
type="button"
|
||||
>
|
||||
<Move className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Duplicate"
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onDuplicate}
|
||||
title="Duplicate"
|
||||
type="button"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Delete"
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
title="Delete"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
emitter,
|
||||
type ItemNode,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import useEditor from './../../store/use-editor'
|
||||
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
|
||||
|
||||
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
@@ -28,6 +29,7 @@ type SelectableNodeType =
|
||||
| 'slab'
|
||||
| 'ceiling'
|
||||
| 'roof'
|
||||
| 'roof-segment'
|
||||
| 'window'
|
||||
| 'door'
|
||||
|
||||
@@ -43,6 +45,11 @@ interface SelectionStrategy {
|
||||
isValid: (node: AnyNode) => boolean
|
||||
}
|
||||
|
||||
type SelectionTarget = {
|
||||
phase: Phase
|
||||
structureLayer?: StructureLayer
|
||||
}
|
||||
|
||||
export const resolveBuildingId = (
|
||||
levelId: string,
|
||||
nodes: Record<string, AnyNode>,
|
||||
@@ -64,16 +71,6 @@ const computeNextIds = (
|
||||
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta
|
||||
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl
|
||||
|
||||
console.log('computeNextIds:', {
|
||||
nodeId: node.id,
|
||||
selectedIds,
|
||||
isMeta,
|
||||
isCtrl,
|
||||
eventMeta: event?.metaKey,
|
||||
nativeMeta: event?.nativeEvent?.metaKey,
|
||||
modMeta: modifierKeys?.meta,
|
||||
})
|
||||
|
||||
if (isMeta || isCtrl) {
|
||||
if (selectedIds.includes(node.id)) {
|
||||
return selectedIds.filter((id) => id !== node.id)
|
||||
@@ -98,7 +95,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
},
|
||||
|
||||
structure: {
|
||||
types: ['wall', 'item', 'zone', 'slab', 'ceiling', 'roof', 'window', 'door'],
|
||||
types: ['wall', 'item', 'zone', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'],
|
||||
handleSelect: (node, nativeEvent, modifierKeys) => {
|
||||
const { selection, setSelection } = useViewer.getState()
|
||||
const nodes = useScene.getState().nodes
|
||||
@@ -142,7 +139,8 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
node.type === 'wall' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof'
|
||||
node.type === 'roof' ||
|
||||
node.type === 'roof-segment'
|
||||
)
|
||||
return true
|
||||
if (node.type === 'item') {
|
||||
@@ -188,6 +186,46 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
},
|
||||
}
|
||||
|
||||
const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
||||
if (node.type === 'zone') {
|
||||
return {
|
||||
phase: 'structure',
|
||||
structureLayer: 'zones',
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
node.type === 'wall' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof' ||
|
||||
node.type === 'roof-segment' ||
|
||||
node.type === 'window' ||
|
||||
node.type === 'door'
|
||||
) {
|
||||
return {
|
||||
phase: 'structure',
|
||||
structureLayer: 'elements',
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'item') {
|
||||
const item = node as ItemNode
|
||||
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
||||
return {
|
||||
phase: 'structure',
|
||||
structureLayer: 'elements',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phase: 'furnish',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const SelectionManager = () => {
|
||||
const phase = useEditor((s) => s.phase)
|
||||
const mode = useEditor((s) => s.mode)
|
||||
@@ -233,35 +271,26 @@ export const SelectionManager = () => {
|
||||
const onClick = (event: NodeEvent) => {
|
||||
const node = event.node
|
||||
let currentPhase = useEditor.getState().phase
|
||||
let targetPhase = currentPhase
|
||||
let currentStructureLayer = useEditor.getState().structureLayer
|
||||
|
||||
// Auto-switch between structure and furnish phases when clicking elements on the same level
|
||||
// Auto-switch between zones, structure, and furnish when clicking elements on the same level.
|
||||
if (currentPhase === 'structure' || currentPhase === 'furnish') {
|
||||
if (isNodeInCurrentLevel(node)) {
|
||||
if (
|
||||
node.type === 'wall' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof' ||
|
||||
node.type === 'window' ||
|
||||
node.type === 'door'
|
||||
) {
|
||||
targetPhase = 'structure'
|
||||
} else if (node.type === 'item') {
|
||||
const item = node as ItemNode
|
||||
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
||||
targetPhase = 'structure'
|
||||
} else {
|
||||
targetPhase = 'furnish'
|
||||
const target = getSelectionTarget(node)
|
||||
if (target) {
|
||||
if (target.phase !== currentPhase) {
|
||||
useEditor.getState().setPhase(target.phase)
|
||||
currentPhase = target.phase
|
||||
}
|
||||
}
|
||||
|
||||
if (targetPhase !== currentPhase) {
|
||||
useEditor.getState().setPhase(targetPhase)
|
||||
if (targetPhase === 'structure' && useEditor.getState().structureLayer === 'zones') {
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
if (
|
||||
target.phase === 'structure' &&
|
||||
target.structureLayer &&
|
||||
target.structureLayer !== currentStructureLayer
|
||||
) {
|
||||
useEditor.getState().setStructureLayer(target.structureLayer)
|
||||
currentStructureLayer = target.structureLayer
|
||||
}
|
||||
currentPhase = targetPhase
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,14 +300,15 @@ export const SelectionManager = () => {
|
||||
event.stopPropagation()
|
||||
clickHandledRef.current = true
|
||||
|
||||
console.log(
|
||||
'[SelectionManager] Valid click on:',
|
||||
node.type,
|
||||
node.id,
|
||||
'Shift:',
|
||||
event.nativeEvent.shiftKey,
|
||||
)
|
||||
activeStrategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current)
|
||||
let nodeToSelect = node
|
||||
if (node.type === 'roof-segment' && node.parentId) {
|
||||
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
|
||||
if (parentNode && parentNode.type === 'roof') {
|
||||
nodeToSelect = parentNode
|
||||
}
|
||||
}
|
||||
|
||||
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
|
||||
|
||||
// Reset the handled flag after a short delay to allow grid:click to be ignored
|
||||
setTimeout(() => {
|
||||
@@ -295,6 +325,7 @@ export const SelectionManager = () => {
|
||||
'slab',
|
||||
'ceiling',
|
||||
'roof',
|
||||
'roof-segment',
|
||||
'window',
|
||||
'door',
|
||||
]
|
||||
@@ -304,7 +335,6 @@ export const SelectionManager = () => {
|
||||
|
||||
const onGridClick = () => {
|
||||
if (clickHandledRef.current) return
|
||||
console.log('onGridClick triggered! Deselecting.')
|
||||
const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase]
|
||||
if (activeStrategy) activeStrategy.handleDeselect()
|
||||
}
|
||||
@@ -351,7 +381,8 @@ export const SelectionManager = () => {
|
||||
}
|
||||
|
||||
const onLeave = (event: NodeEvent) => {
|
||||
if (useViewer.getState().hoveredId === event.node.id) {
|
||||
const nodeId = event?.node?.id
|
||||
if (nodeId && useViewer.getState().hoveredId === nodeId) {
|
||||
useViewer.setState({ hoveredId: null })
|
||||
}
|
||||
}
|
||||
@@ -361,6 +392,7 @@ export const SelectionManager = () => {
|
||||
const currentPhase = useEditor.getState().phase
|
||||
|
||||
let targetPhase: 'site' | 'structure' | 'furnish' | null = null
|
||||
let forceSelect = false
|
||||
|
||||
if (node.type === 'building' || node.type === 'site') {
|
||||
if (currentPhase === 'structure' || currentPhase === 'furnish') {
|
||||
@@ -374,10 +406,14 @@ export const SelectionManager = () => {
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof' ||
|
||||
node.type === 'roof-segment' ||
|
||||
node.type === 'window' ||
|
||||
node.type === 'door'
|
||||
) {
|
||||
targetPhase = 'structure'
|
||||
if (node.type === 'roof-segment' && currentPhase === 'structure') {
|
||||
forceSelect = true // allow double click to dive into roof-segment even if already in structure phase
|
||||
}
|
||||
} else if (node.type === 'item') {
|
||||
const item = node as ItemNode
|
||||
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
||||
@@ -391,16 +427,18 @@ export const SelectionManager = () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (targetPhase && targetPhase !== useEditor.getState().phase) {
|
||||
if ((targetPhase && targetPhase !== useEditor.getState().phase) || forceSelect) {
|
||||
event.stopPropagation()
|
||||
|
||||
useEditor.getState().setPhase(targetPhase)
|
||||
if (targetPhase && targetPhase !== useEditor.getState().phase) {
|
||||
useEditor.getState().setPhase(targetPhase)
|
||||
}
|
||||
|
||||
if (targetPhase === 'structure' && useEditor.getState().structureLayer === 'zones') {
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
}
|
||||
|
||||
const strategy = SELECTION_STRATEGIES[targetPhase]
|
||||
const strategy = SELECTION_STRATEGIES[targetPhase || currentPhase]
|
||||
if (strategy) {
|
||||
strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current)
|
||||
}
|
||||
@@ -414,6 +452,7 @@ export const SelectionManager = () => {
|
||||
'slab',
|
||||
'ceiling',
|
||||
'roof',
|
||||
'roof-segment',
|
||||
'window',
|
||||
'door',
|
||||
'zone',
|
||||
@@ -434,7 +473,44 @@ export const SelectionManager = () => {
|
||||
}
|
||||
}, [mode, movingNode])
|
||||
|
||||
return <EditorOutlinerSync />
|
||||
return (
|
||||
<>
|
||||
<SelectionStateSync />
|
||||
<EditorOutlinerSync />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectionStateSync = () => {
|
||||
useEffect(() => {
|
||||
return useScene.subscribe((state) => {
|
||||
const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection
|
||||
|
||||
if (buildingId && !state.nodes[buildingId as AnyNodeId]) {
|
||||
useViewer.getState().setSelection({ buildingId: null })
|
||||
return
|
||||
}
|
||||
|
||||
if (levelId && !state.nodes[levelId as AnyNodeId]) {
|
||||
useViewer.getState().setSelection({ levelId: null })
|
||||
return
|
||||
}
|
||||
|
||||
if (zoneId && !state.nodes[zoneId as AnyNodeId]) {
|
||||
useViewer.getState().setSelection({ zoneId: null })
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedIds.length === 0) return
|
||||
|
||||
const nextSelectedIds = selectedIds.filter((id) => state.nodes[id as AnyNodeId])
|
||||
if (nextSelectedIds.length !== selectedIds.length) {
|
||||
useViewer.getState().setSelection({ selectedIds: nextSelectedIds })
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const EditorOutlinerSync = () => {
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
calculateLevelMiters,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
getWallPlanFootprint,
|
||||
type Point2D,
|
||||
pointToKey,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
type WallMiterData,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { createPortal, useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
|
||||
const GUIDE_Y_OFFSET = 0.08
|
||||
const LABEL_LIFT = 0.08
|
||||
const BAR_THICKNESS = 0.012
|
||||
const LINE_OPACITY = 0.95
|
||||
|
||||
const BAR_AXIS = new THREE.Vector3(0, 1, 0)
|
||||
|
||||
type Vec3 = [number, number, number]
|
||||
|
||||
type MeasurementGuide = {
|
||||
guideStart: Vec3
|
||||
guideEnd: Vec3
|
||||
extStartStart: Vec3
|
||||
extStartEnd: Vec3
|
||||
extEndStart: Vec3
|
||||
extEndEnd: Vec3
|
||||
labelPosition: Vec3
|
||||
}
|
||||
|
||||
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
||||
if (unit === 'imperial') {
|
||||
const feet = value * 3.280_84
|
||||
const wholeFeet = Math.floor(feet)
|
||||
const inches = Math.round((feet - wholeFeet) * 12)
|
||||
if (inches === 12) return `${wholeFeet + 1}'0"`
|
||||
return `${wholeFeet}'${inches}"`
|
||||
}
|
||||
return `${Number.parseFloat(value.toFixed(2))}m`
|
||||
}
|
||||
|
||||
export function WallMeasurementLabel() {
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
|
||||
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
||||
const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null
|
||||
const wall = selectedNode?.type === 'wall' ? selectedNode : null
|
||||
|
||||
const [wallObject, setWallObject] = useState<THREE.Object3D | null>(null)
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset cached object when selection changes
|
||||
useEffect(() => {
|
||||
setWallObject(null)
|
||||
}, [selectedId])
|
||||
|
||||
useFrame(() => {
|
||||
if (!selectedId || wallObject) return
|
||||
|
||||
const nextWallObject = sceneRegistry.nodes.get(selectedId)
|
||||
if (nextWallObject) {
|
||||
setWallObject(nextWallObject)
|
||||
}
|
||||
})
|
||||
|
||||
if (!(wall && wallObject)) return null
|
||||
|
||||
return createPortal(<WallMeasurementAnnotation wall={wall} />, wallObject)
|
||||
}
|
||||
|
||||
function getLevelWalls(
|
||||
wall: WallNode,
|
||||
nodes: Record<string, WallNode | { type: string; children?: string[] }>,
|
||||
): WallNode[] {
|
||||
if (!wall.parentId) return [wall]
|
||||
|
||||
const levelNode = nodes[wall.parentId as AnyNodeId]
|
||||
if (!(levelNode && levelNode.type === 'level' && Array.isArray(levelNode.children))) {
|
||||
return [wall]
|
||||
}
|
||||
|
||||
return levelNode.children
|
||||
.map((childId) => nodes[childId as AnyNodeId])
|
||||
.filter((node): node is WallNode => Boolean(node && node.type === 'wall'))
|
||||
}
|
||||
|
||||
function getWallMiddlePoints(
|
||||
wall: WallNode,
|
||||
miterData: WallMiterData,
|
||||
): { start: Point2D; end: Point2D } | null {
|
||||
const footprint = getWallPlanFootprint(wall, miterData)
|
||||
if (footprint.length < 4) return null
|
||||
|
||||
const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] })
|
||||
const startJunction = miterData.junctionData.get(startKey)?.get(wall.id)
|
||||
|
||||
const rightStart = footprint[0]
|
||||
const rightEnd = footprint[1]
|
||||
const leftEnd = footprint[startJunction ? footprint.length - 3 : footprint.length - 2]
|
||||
const leftStart = footprint[startJunction ? footprint.length - 2 : footprint.length - 1]
|
||||
|
||||
if (!(leftStart && leftEnd && rightStart && rightEnd)) return null
|
||||
|
||||
return {
|
||||
start: {
|
||||
x: (leftStart.x + rightStart.x) / 2,
|
||||
y: (leftStart.y + rightStart.y) / 2,
|
||||
},
|
||||
end: {
|
||||
x: (leftEnd.x + rightEnd.x) / 2,
|
||||
y: (leftEnd.y + rightEnd.y) / 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {
|
||||
const dx = point.x - wall.start[0]
|
||||
const dz = point.y - wall.start[1]
|
||||
const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
|
||||
const cosA = Math.cos(-angle)
|
||||
const sinA = Math.sin(-angle)
|
||||
|
||||
return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
|
||||
}
|
||||
|
||||
function buildMeasurementGuide(
|
||||
wall: WallNode,
|
||||
nodes: Record<string, WallNode | { type: string; children?: string[] }>,
|
||||
): MeasurementGuide | null {
|
||||
const levelWalls = getLevelWalls(wall, nodes)
|
||||
const miterData = calculateLevelMiters(levelWalls)
|
||||
const middlePoints = getWallMiddlePoints(wall, miterData)
|
||||
if (!middlePoints) return null
|
||||
|
||||
const height = wall.height ?? DEFAULT_WALL_HEIGHT
|
||||
const startLocal = worldPointToWallLocal(wall, middlePoints.start)
|
||||
const endLocal = worldPointToWallLocal(wall, middlePoints.end)
|
||||
|
||||
const guideStart: Vec3 = [startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]]
|
||||
const guideEnd: Vec3 = [endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]]
|
||||
|
||||
const dirX = guideEnd[0] - guideStart[0]
|
||||
const dirZ = guideEnd[2] - guideStart[2]
|
||||
const dirLength = Math.hypot(dirX, dirZ)
|
||||
|
||||
if (!Number.isFinite(dirLength) || dirLength < 0.001) return null
|
||||
|
||||
// Extension lines coming out of the extremity markers of the wall
|
||||
const extOvershoot = 0.04
|
||||
|
||||
return {
|
||||
guideStart,
|
||||
guideEnd,
|
||||
extStartStart: [startLocal[0], height, startLocal[2]],
|
||||
extStartEnd: [startLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, startLocal[2]],
|
||||
extEndStart: [endLocal[0], height, endLocal[2]],
|
||||
extEndEnd: [endLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, endLocal[2]],
|
||||
labelPosition: [
|
||||
(guideStart[0] + guideEnd[0]) / 2,
|
||||
guideStart[1] + LABEL_LIFT,
|
||||
(guideStart[2] + guideEnd[2]) / 2,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color: string }) {
|
||||
const segment = useMemo(() => {
|
||||
const startVector = new THREE.Vector3(...start)
|
||||
const endVector = new THREE.Vector3(...end)
|
||||
const direction = endVector.clone().sub(startVector)
|
||||
const length = direction.length()
|
||||
|
||||
if (!Number.isFinite(length) || length < 0.0001) return null
|
||||
|
||||
return {
|
||||
length,
|
||||
position: startVector.clone().add(endVector).multiplyScalar(0.5),
|
||||
quaternion: new THREE.Quaternion().setFromUnitVectors(BAR_AXIS, direction.normalize()),
|
||||
}
|
||||
}, [end, start])
|
||||
|
||||
if (!segment) return null
|
||||
|
||||
return (
|
||||
<mesh
|
||||
position={[segment.position.x, segment.position.y, segment.position.z]}
|
||||
quaternion={segment.quaternion}
|
||||
renderOrder={1000}
|
||||
>
|
||||
<boxGeometry args={[BAR_THICKNESS, segment.length, BAR_THICKNESS]} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={LINE_OPACITY}
|
||||
toneMapped={false}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const theme = useViewer((state) => state.theme)
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const isNight = theme === 'dark'
|
||||
const color = isNight ? '#ffffff' : '#111111'
|
||||
const shadowColor = isNight ? '#111111' : '#ffffff'
|
||||
|
||||
const dx = wall.end[0] - wall.start[0]
|
||||
const dz = wall.end[1] - wall.start[1]
|
||||
const length = Math.hypot(dx, dz)
|
||||
const label = formatMeasurement(length, unit)
|
||||
const guide = useMemo(
|
||||
() =>
|
||||
buildMeasurementGuide(
|
||||
wall,
|
||||
nodes as Record<string, WallNode | { type: string; children?: string[] }>,
|
||||
),
|
||||
[nodes, wall],
|
||||
)
|
||||
|
||||
if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
|
||||
|
||||
return (
|
||||
<group>
|
||||
<MeasurementBar color={color} end={guide.guideEnd} start={guide.guideStart} />
|
||||
<MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} />
|
||||
<MeasurementBar color={color} end={guide.extEndEnd} start={guide.extEndStart} />
|
||||
|
||||
<Html
|
||||
center
|
||||
position={guide.labelPosition}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[20, 0]}
|
||||
>
|
||||
<div
|
||||
className="whitespace-nowrap font-bold font-mono text-[15px]"
|
||||
style={{
|
||||
color,
|
||||
textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { type AnyNodeId, type RoofNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Imperatively toggles the Three.js visibility of roof objects based on the
|
||||
* editor selection — without causing React re-renders in RoofRenderer.
|
||||
*
|
||||
* When a roof (or one of its segments) is selected:
|
||||
* - merged-roof mesh is hidden
|
||||
* - segments-wrapper group is shown (individual segments visible for editing)
|
||||
* - all children are marked dirty so RoofSystem rebuilds their geometry
|
||||
*
|
||||
* When deselected:
|
||||
* - merged-roof mesh is shown
|
||||
* - segments-wrapper group is hidden
|
||||
*/
|
||||
export const RoofEditSystem = () => {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const prevActiveRoofIds = useRef(new Set<string>())
|
||||
|
||||
useEffect(() => {
|
||||
const nodes = useScene.getState().nodes
|
||||
|
||||
// Collect which roof nodes should be in "edit mode"
|
||||
const activeRoofIds = new Set<string>()
|
||||
for (const id of selectedIds) {
|
||||
const node = nodes[id as AnyNodeId]
|
||||
if (!node) continue
|
||||
if (node.type === 'roof') {
|
||||
activeRoofIds.add(id)
|
||||
} else if (node.type === 'roof-segment' && node.parentId) {
|
||||
activeRoofIds.add(node.parentId)
|
||||
}
|
||||
}
|
||||
|
||||
// Update all roofs that are currently active OR were previously active
|
||||
const roofIdsToUpdate = new Set([...activeRoofIds, ...prevActiveRoofIds.current])
|
||||
|
||||
for (const roofId of roofIdsToUpdate) {
|
||||
const group = sceneRegistry.nodes.get(roofId)
|
||||
if (!group) continue
|
||||
|
||||
const mergedMesh = group.getObjectByName('merged-roof')
|
||||
const segmentsWrapper = group.getObjectByName('segments-wrapper')
|
||||
const isActive = activeRoofIds.has(roofId)
|
||||
|
||||
if (mergedMesh) mergedMesh.visible = !isActive
|
||||
if (segmentsWrapper) segmentsWrapper.visible = isActive
|
||||
|
||||
const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined
|
||||
if (roofNode?.children?.length) {
|
||||
const wasActive = prevActiveRoofIds.current.has(roofId)
|
||||
if (isActive !== wasActive) {
|
||||
// Entering edit mode: rebuild individual segment geometries
|
||||
// Exiting edit mode: sync transforms + rebuild merged mesh
|
||||
const { markDirty } = useScene.getState()
|
||||
for (const childId of roofNode.children) {
|
||||
markDirty(childId as AnyNodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prevActiveRoofIds.current = activeRoofIds
|
||||
}, [selectedIds])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export const CeilingBoundaryEditor: React.FC<CeilingBoundaryEditorProps> = ({ ce
|
||||
[ceilingId, updateNode, setSelection],
|
||||
)
|
||||
|
||||
if (!(ceiling && ceiling.polygon) || ceiling.polygon.length < 3) return null
|
||||
if (!ceiling?.polygon || ceiling.polygon.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
|
||||
@@ -377,6 +377,7 @@ export const CeilingTool: React.FC = () => {
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
// @ts-expect-error
|
||||
ref={mainLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
@@ -390,6 +391,7 @@ export const CeilingTool: React.FC = () => {
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
// @ts-expect-error
|
||||
ref={closingLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
@@ -410,6 +412,7 @@ export const CeilingTool: React.FC = () => {
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
// @ts-expect-error
|
||||
ref={groundMainLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
@@ -430,6 +433,7 @@ export const CeilingTool: React.FC = () => {
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
// @ts-expect-error
|
||||
ref={groundClosingLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type WallEvent,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
@@ -33,9 +33,9 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
||||
export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
|
||||
const cursorGroupRef = useRef<Group>(null!)
|
||||
|
||||
const exitMoveMode = () => {
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
@@ -352,7 +352,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
emitter.off('wall:leave', onWallLeave)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [movingDoorNode])
|
||||
}, [movingDoorNode, exitMoveMode])
|
||||
|
||||
const edgesGeo = useMemo(() => {
|
||||
const boxGeo = new BoxGeometry(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { DoorNode, ItemNode, WindowNode } from '@pascal-app/core'
|
||||
import type { DoorNode, ItemNode, RoofNode, RoofSegmentNode, WindowNode } from '@pascal-app/core'
|
||||
import { Vector3 } from 'three'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { MoveDoorTool } from '../door/move-door-tool'
|
||||
import { MoveRoofTool } from '../roof/move-roof-tool'
|
||||
import { MoveWindowTool } from '../window/move-window-tool'
|
||||
import type { PlacementState } from './placement-types'
|
||||
import { useDraftNode } from './use-draft-node'
|
||||
@@ -73,5 +74,7 @@ export const MoveTool: React.FC = () => {
|
||||
if (!movingNode) return null
|
||||
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
||||
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
||||
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
||||
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
||||
return <MoveItemContent movingNode={movingNode as ItemNode} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
|
||||
node: movingNode,
|
||||
}) => {
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
|
||||
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
|
||||
const obj = sceneRegistry.nodes.get(movingNode.id)
|
||||
if (obj) {
|
||||
const pos = new THREE.Vector3()
|
||||
obj.getWorldPosition(pos)
|
||||
return [pos.x, pos.y, pos.z]
|
||||
}
|
||||
// Fallback if not registered (e.g. newly created duplicate without mesh yet)
|
||||
if (movingNode.type === 'roof-segment' && movingNode.parentId) {
|
||||
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
|
||||
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
|
||||
const parentAngle = parentNode.rotation as number
|
||||
const px = parentNode.position[0] as number
|
||||
const py = parentNode.position[1] as number
|
||||
const pz = parentNode.position[2] as number
|
||||
const lx = movingNode.position[0]
|
||||
const ly = movingNode.position[1]
|
||||
const lz = movingNode.position[2]
|
||||
|
||||
const wx = lx * Math.cos(parentAngle) - lz * Math.sin(parentAngle) + px
|
||||
const wz = lx * Math.sin(parentAngle) + lz * Math.cos(parentAngle) + pz
|
||||
return [wx, py + ly, wz]
|
||||
}
|
||||
}
|
||||
return [movingNode.position[0], movingNode.position[1], movingNode.position[2]]
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const meta =
|
||||
typeof movingNode.metadata === 'object' && movingNode.metadata !== null
|
||||
? (movingNode.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
const committedMeta: RoofNode['metadata'] = (() => {
|
||||
if (
|
||||
typeof movingNode.metadata !== 'object' ||
|
||||
movingNode.metadata === null ||
|
||||
Array.isArray(movingNode.metadata)
|
||||
) {
|
||||
return movingNode.metadata
|
||||
}
|
||||
|
||||
const nextMeta = { ...movingNode.metadata } as Record<string, unknown>
|
||||
delete nextMeta.isNew
|
||||
delete nextMeta.isTransient
|
||||
return nextMeta as RoofNode['metadata']
|
||||
})()
|
||||
|
||||
const original = {
|
||||
position: [...movingNode.position] as [number, number, number],
|
||||
rotation: movingNode.rotation,
|
||||
parentId: movingNode.parentId,
|
||||
metadata: movingNode.metadata,
|
||||
}
|
||||
|
||||
// Track whether the move was committed so cleanup knows whether to revert.
|
||||
// We avoid setting isTransient on the store to prevent RoofSystem from
|
||||
// resetting the mesh position (it resets on dirty) and from triggering
|
||||
// expensive merged-mesh CSG rebuilds on every frame.
|
||||
let wasCommitted = false
|
||||
|
||||
// Track pending rotation — no store updates during drag
|
||||
let pendingRotation: number = movingNode.rotation as number
|
||||
|
||||
// For roof-segment moves: the selection was cleared before entering move mode,
|
||||
// so isSelected=false on the parent roof, hiding individual segment meshes and
|
||||
// showing only the merged mesh. We directly flip Three.js visibility so the
|
||||
// user sees the individual segment tracking the cursor.
|
||||
let segmentWrapperGroup: THREE.Object3D | null = null
|
||||
let mergedRoofMesh: THREE.Object3D | null = null
|
||||
if (movingNode.type === 'roof-segment') {
|
||||
const segmentMesh = sceneRegistry.nodes.get(movingNode.id)
|
||||
if (segmentMesh?.parent) {
|
||||
// segmentMesh.parent = <group visible={isSelected}> wrapper in RoofRenderer
|
||||
// segmentMesh.parent.parent = the registered roof group
|
||||
segmentWrapperGroup = segmentMesh.parent
|
||||
mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName('merged-roof') ?? null
|
||||
segmentWrapperGroup.visible = true
|
||||
if (mergedRoofMesh) mergedRoofMesh.visible = false
|
||||
}
|
||||
}
|
||||
|
||||
const computeLocal = (gridX: number, gridZ: number, y: number): [number, number] => {
|
||||
let localX = gridX
|
||||
let localZ = gridZ
|
||||
|
||||
if (movingNode.type === 'roof-segment' && movingNode.parentId) {
|
||||
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
|
||||
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
|
||||
const parentObj = sceneRegistry.nodes.get(movingNode.parentId)
|
||||
if (parentObj) {
|
||||
const worldVec = new THREE.Vector3(gridX, y, gridZ)
|
||||
parentObj.worldToLocal(worldVec)
|
||||
localX = worldVec.x
|
||||
localZ = worldVec.z
|
||||
} else {
|
||||
const dx = gridX - (parentNode.position[0] as number)
|
||||
const dz = gridZ - (parentNode.position[2] as number)
|
||||
const angle = -(parentNode.rotation as number)
|
||||
localX = dx * Math.cos(angle) - dz * Math.sin(angle)
|
||||
localZ = dx * Math.sin(angle) + dz * Math.cos(angle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [localX, localZ]
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
const y = event.position[1]
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
previousGridPosRef.current = [gridX, gridZ]
|
||||
setCursorWorldPos([gridX, y, gridZ])
|
||||
|
||||
const [localX, localZ] = computeLocal(gridX, gridZ, y)
|
||||
|
||||
// Directly update the Three.js mesh — no store update during drag
|
||||
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
||||
if (mesh) {
|
||||
mesh.position.x = localX
|
||||
mesh.position.z = localZ
|
||||
}
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
const y = event.position[1]
|
||||
|
||||
const [localX, localZ] = computeLocal(gridX, gridZ, y)
|
||||
|
||||
wasCommitted = true
|
||||
|
||||
// The store still holds the original values (we didn't update during drag).
|
||||
// Resume temporal and apply the final state as a single undoable step.
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
useScene.getState().updateNode(movingNode.id, {
|
||||
position: [localX, movingNode.position[1], localZ],
|
||||
rotation: pendingRotation,
|
||||
metadata: committedMeta,
|
||||
})
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [movingNode.id] })
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
if (isNew) {
|
||||
useScene.getState().deleteNode(movingNode.id)
|
||||
} else {
|
||||
useScene.getState().updateNode(movingNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
let rotationDelta = 0
|
||||
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
|
||||
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
|
||||
|
||||
if (rotationDelta !== 0) {
|
||||
event.preventDefault()
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
|
||||
pendingRotation += rotationDelta
|
||||
|
||||
// Directly update the Three.js mesh — no store update during drag
|
||||
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
||||
if (mesh) mesh.rotation.y = pendingRotation
|
||||
}
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => {
|
||||
// Restore segment wrapper visibility (React will re-sync on next render)
|
||||
if (segmentWrapperGroup) segmentWrapperGroup.visible = false
|
||||
if (mergedRoofMesh) mergedRoofMesh.visible = true
|
||||
|
||||
if (!wasCommitted) {
|
||||
if (isNew) {
|
||||
useScene.getState().deleteNode(movingNode.id)
|
||||
} else {
|
||||
useScene.getState().updateNode(movingNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [movingNode, exitMoveMode])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorWorldPos} showTooltip={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +1,118 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'three'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
// Default roof dimensions
|
||||
const DEFAULT_HEIGHT = 1.5
|
||||
const CEILING_HEIGHT = 2.52
|
||||
const DEFAULT_WALL_HEIGHT = 0.5
|
||||
const DEFAULT_ROOF_HEIGHT = 2.5
|
||||
const GRID_OFFSET = 0.02
|
||||
|
||||
/**
|
||||
* Creates a roof with the given corners
|
||||
* Creates a roof group with one default gable segment
|
||||
*/
|
||||
const commitRoofPlacement = (
|
||||
levelId: LevelNode['id'],
|
||||
corner1: [number, number, number],
|
||||
corner2: [number, number, number],
|
||||
): RoofNode['id'] => {
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
selectedIds: string[],
|
||||
): AnyNode['id'] => {
|
||||
const { createNode, createNodes, nodes } = useScene.getState()
|
||||
|
||||
// Calculate center position and dimensions from corners
|
||||
const centerX = (corner1[0] + corner2[0]) / 2
|
||||
const centerZ = (corner1[2] + corner2[2]) / 2
|
||||
|
||||
const length = Math.abs(corner2[0] - corner1[0])
|
||||
const width = Math.abs(corner2[2] - corner1[2])
|
||||
const width = Math.max(Math.abs(corner2[0] - corner1[0]), 1)
|
||||
const depth = Math.max(Math.abs(corner2[2] - corner1[2]), 1)
|
||||
|
||||
// Split width evenly between left and right slopes
|
||||
const slopeWidth = Math.max(width / 2, 0.5)
|
||||
// Determine if there is an active roof node we should add to
|
||||
let targetRoofId: RoofNode['id'] | null = null
|
||||
const selectedId = selectedIds[0]
|
||||
if (selectedIds.length === 1 && selectedId) {
|
||||
const selectedNode = nodes[selectedId as AnyNodeId]
|
||||
if (selectedNode?.type === 'roof') {
|
||||
targetRoofId = selectedNode.id
|
||||
} else if (selectedNode?.type === 'roof-segment' && selectedNode.parentId) {
|
||||
targetRoofId = selectedNode.parentId as RoofNode['id']
|
||||
}
|
||||
}
|
||||
|
||||
if (targetRoofId) {
|
||||
const targetRoof = nodes[targetRoofId] as RoofNode
|
||||
let localX = centerX
|
||||
let localZ = centerZ
|
||||
|
||||
// Convert world coordinates to the local space of the parent roof
|
||||
const targetObj = sceneRegistry.nodes.get(targetRoofId)
|
||||
if (targetObj) {
|
||||
const worldVec = new THREE.Vector3(centerX, 0, centerZ)
|
||||
targetObj.worldToLocal(worldVec)
|
||||
localX = worldVec.x
|
||||
localZ = worldVec.z
|
||||
} else {
|
||||
// Math fallback if mesh isn't ready
|
||||
const dx = centerX - targetRoof.position[0]
|
||||
const dz = centerZ - targetRoof.position[2]
|
||||
const angle = -targetRoof.rotation
|
||||
localX = dx * Math.cos(angle) - dz * Math.sin(angle)
|
||||
localZ = dx * Math.sin(angle) + dz * Math.cos(angle)
|
||||
}
|
||||
|
||||
const segment = RoofSegmentNode.parse({
|
||||
width,
|
||||
depth,
|
||||
wallHeight: DEFAULT_WALL_HEIGHT,
|
||||
roofHeight: DEFAULT_ROOF_HEIGHT,
|
||||
roofType: 'gable',
|
||||
position: [localX, 0, localZ],
|
||||
})
|
||||
|
||||
createNode(segment, targetRoofId as AnyNode['id'])
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
return segment.id // Returns segment ID so it can be selected immediately
|
||||
}
|
||||
|
||||
// Count existing roofs for naming
|
||||
const roofCount = Object.values(nodes).filter((n) => n.type === 'roof').length
|
||||
const name = `Roof ${roofCount + 1}`
|
||||
|
||||
const roof = RoofNode.parse({
|
||||
name,
|
||||
position: [centerX, 0, centerZ], // Y is always 0
|
||||
length: Math.max(length, 0.5),
|
||||
height: DEFAULT_HEIGHT,
|
||||
leftWidth: slopeWidth,
|
||||
rightWidth: slopeWidth,
|
||||
// Create the segment first (centered in its new parent)
|
||||
const segment = RoofSegmentNode.parse({
|
||||
width,
|
||||
depth,
|
||||
wallHeight: DEFAULT_WALL_HEIGHT,
|
||||
roofHeight: DEFAULT_ROOF_HEIGHT,
|
||||
roofType: 'gable',
|
||||
position: [0, 0, 0],
|
||||
})
|
||||
|
||||
createNode(roof, levelId)
|
||||
// Create the roof container
|
||||
const roof = RoofNode.parse({
|
||||
name,
|
||||
position: [centerX, 0, centerZ],
|
||||
children: [segment.id],
|
||||
})
|
||||
|
||||
// Create roof first (so segment can be parented to it), then segment
|
||||
createNodes([
|
||||
{ node: roof, parentId: levelId },
|
||||
{ node: segment, parentId: roof.id },
|
||||
])
|
||||
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
return roof.id
|
||||
}
|
||||
@@ -67,10 +127,16 @@ export const RoofTool: React.FC = () => {
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const outlineRef = useRef<Line>(null!)
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setTool = useEditor((state) => state.setTool)
|
||||
const setMode = useEditor((state) => state.setMode)
|
||||
|
||||
const selectedIdsRef = useRef(selectedIds)
|
||||
useEffect(() => {
|
||||
selectedIdsRef.current = selectedIds
|
||||
}, [selectedIds])
|
||||
|
||||
const corner1Ref = useRef<[number, number, number] | null>(null)
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const [preview, setPreview] = useState<PreviewState>({
|
||||
@@ -82,7 +148,6 @@ export const RoofTool: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Initialize outline geometry
|
||||
outlineRef.current.geometry = new BufferGeometry()
|
||||
|
||||
const updateOutline = (
|
||||
@@ -96,7 +161,7 @@ export const RoofTool: React.FC = () => {
|
||||
new Vector3(corner2[0], gridY, corner1[2]),
|
||||
new Vector3(corner2[0], gridY, corner2[2]),
|
||||
new Vector3(corner1[0], gridY, corner2[2]),
|
||||
new Vector3(corner1[0], gridY, corner1[2]), // Close the loop
|
||||
new Vector3(corner1[0], gridY, corner1[2]),
|
||||
]
|
||||
|
||||
outlineRef.current.geometry.dispose()
|
||||
@@ -107,19 +172,15 @@ export const RoofTool: React.FC = () => {
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return
|
||||
|
||||
// Snap to 0.5 grid
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
const y = event.position[1]
|
||||
|
||||
const cursorPosition: [number, number, number] = [gridX, y, gridZ]
|
||||
|
||||
// Update cursors
|
||||
const gridY = y + GRID_OFFSET
|
||||
|
||||
cursorRef.current.position.set(gridX, gridY, gridZ)
|
||||
|
||||
// Play snap sound when grid position changes (only when placing)
|
||||
if (
|
||||
corner1Ref.current &&
|
||||
previousGridPosRef.current &&
|
||||
@@ -136,7 +197,6 @@ export const RoofTool: React.FC = () => {
|
||||
levelY: y,
|
||||
})
|
||||
|
||||
// Update outline if we have first corner
|
||||
if (corner1Ref.current) {
|
||||
updateOutline(corner1Ref.current, cursorPosition)
|
||||
}
|
||||
@@ -150,17 +210,18 @@ export const RoofTool: React.FC = () => {
|
||||
const y = event.position[1]
|
||||
|
||||
if (corner1Ref.current) {
|
||||
// Second click - create the roof
|
||||
const roofId = commitRoofPlacement(currentLevelId, corner1Ref.current, [gridX, y, gridZ])
|
||||
const roofId = commitRoofPlacement(
|
||||
currentLevelId,
|
||||
corner1Ref.current,
|
||||
[gridX, y, gridZ],
|
||||
selectedIdsRef.current,
|
||||
)
|
||||
|
||||
// Auto-select the newly created roof
|
||||
setSelection({ selectedIds: [roofId as AnyNode['id']] })
|
||||
|
||||
// Reset state
|
||||
corner1Ref.current = null
|
||||
outlineRef.current.visible = false
|
||||
} else {
|
||||
// First click - set corner 1
|
||||
corner1Ref.current = [gridX, y, gridZ]
|
||||
setPreview((prev) => ({
|
||||
...prev,
|
||||
@@ -177,7 +238,6 @@ export const RoofTool: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to events
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
@@ -187,14 +247,12 @@ export const RoofTool: React.FC = () => {
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
|
||||
// Reset state on unmount
|
||||
corner1Ref.current = null
|
||||
}
|
||||
}, [currentLevelId, setTool, setSelection, setMode])
|
||||
}, [currentLevelId, setSelection])
|
||||
|
||||
const { corner1, cursorPosition, levelY } = preview
|
||||
|
||||
// Calculate preview dimensions for display
|
||||
const previewDimensions = useMemo(() => {
|
||||
if (!corner1) return null
|
||||
const length = Math.abs(cursorPosition[0] - corner1[0])
|
||||
@@ -206,14 +264,13 @@ export const RoofTool: React.FC = () => {
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Cursor at ground height */}
|
||||
<CursorSphere ref={cursorRef} />
|
||||
|
||||
{/* Outline showing rectangle being drawn (Ground) */}
|
||||
{/* @ts-ignore */}
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
// @ts-expect-error
|
||||
ref={outlineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
@@ -229,7 +286,6 @@ export const RoofTool: React.FC = () => {
|
||||
/>
|
||||
</line>
|
||||
|
||||
{/* First corner marker */}
|
||||
{corner1 && (
|
||||
<CursorSphere
|
||||
color="#818cf8"
|
||||
@@ -238,7 +294,6 @@ export const RoofTool: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Thin preview fill when drawing (Ground) */}
|
||||
{previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && (
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
|
||||
@@ -15,12 +15,13 @@ interface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {
|
||||
}
|
||||
|
||||
export const CursorSphere = forwardRef<Group, CursorSphereProps>(function CursorSphere(
|
||||
{ color = '#818cf8', showTooltip = true, height = 2.5, ...props },
|
||||
{ color = '#818cf8', showTooltip = true, height = 2.5, visible = true, ...props },
|
||||
ref,
|
||||
) {
|
||||
const tool = useEditor((s) => s.tool)
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const catalogCategory = useEditor((s) => s.catalogCategory)
|
||||
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||
|
||||
// Find the icon for the current tool
|
||||
let activeToolConfig = null
|
||||
@@ -32,8 +33,10 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
||||
}
|
||||
}
|
||||
|
||||
const isVisible = visible && !isFloorplanHovered
|
||||
|
||||
return (
|
||||
<group ref={ref} {...props}>
|
||||
<group ref={ref} {...props} visible={isVisible}>
|
||||
{/* Flat marker on the ground */}
|
||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||
{/* Center dot */}
|
||||
@@ -76,7 +79,7 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
||||
)}
|
||||
|
||||
{/* Tool Icon Tooltip at the top of the line */}
|
||||
{showTooltip && activeToolConfig && (
|
||||
{isVisible && showTooltip && activeToolConfig && (
|
||||
<Html
|
||||
center
|
||||
position={[0, height > 0 ? height + 0.2 : 0.6, 0]}
|
||||
|
||||
@@ -236,10 +236,10 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
<group>
|
||||
{/* Border line */}
|
||||
<line
|
||||
// @ts-expect-error R3F <line> element conflicts with SVG <line> type
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
raycast={() => {}}
|
||||
// @ts-expect-error R3F <line> element conflicts with SVG <line> type
|
||||
ref={lineRef}
|
||||
renderOrder={10}
|
||||
>
|
||||
|
||||
@@ -29,7 +29,7 @@ export const SiteBoundaryEditor: React.FC = () => {
|
||||
[site, updateNode],
|
||||
)
|
||||
|
||||
if (!(site && site.polygon?.points) || site.polygon.points.length < 3) return null
|
||||
if (!site?.polygon?.points || site.polygon.points.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
|
||||
@@ -27,7 +27,7 @@ export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }
|
||||
[slabId, updateNode, setSelection],
|
||||
)
|
||||
|
||||
if (!(slab && slab.polygon) || slab.polygon.length < 3) return null
|
||||
if (!slab?.polygon || slab.polygon.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
|
||||
@@ -275,6 +275,7 @@ export const SlabTool: React.FC = () => {
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
// @ts-expect-error
|
||||
ref={mainLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
@@ -288,6 +289,7 @@ export const SlabTool: React.FC = () => {
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
// @ts-expect-error
|
||||
ref={closingLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
|
||||
export type WallPlanPoint = [number, number]
|
||||
|
||||
export const WALL_GRID_STEP = 0.5
|
||||
export const WALL_JOIN_SNAP_RADIUS = 0.35
|
||||
export const WALL_MIN_LENGTH = 0.01
|
||||
|
||||
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
||||
const dx = a[0] - b[0]
|
||||
const dz = a[1] - b[1]
|
||||
return dx * dx + dz * dz
|
||||
}
|
||||
|
||||
function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): WallPlanPoint {
|
||||
return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
|
||||
}
|
||||
|
||||
export function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint): WallPlanPoint {
|
||||
const dx = cursor[0] - start[0]
|
||||
const dz = cursor[1] - start[1]
|
||||
const angle = Math.atan2(dz, dx)
|
||||
const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
|
||||
const distance = Math.sqrt(dx * dx + dz * dz)
|
||||
|
||||
return snapPointToGrid([
|
||||
start[0] + Math.cos(snappedAngle) * distance,
|
||||
start[1] + Math.sin(snappedAngle) * distance,
|
||||
])
|
||||
}
|
||||
|
||||
function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
|
||||
const [x1, z1] = wall.start
|
||||
const [x2, z2] = wall.end
|
||||
const dx = x2 - x1
|
||||
const dz = z2 - z1
|
||||
const lengthSquared = dx * dx + dz * dz
|
||||
if (lengthSquared < 1e-9) {
|
||||
return null
|
||||
}
|
||||
|
||||
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
|
||||
if (t <= 0 || t >= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [x1 + dx * t, z1 + dz * t]
|
||||
}
|
||||
|
||||
export function findWallSnapTarget(
|
||||
point: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
options?: { ignoreWallIds?: string[]; radius?: number },
|
||||
): WallPlanPoint | null {
|
||||
const ignoreWallIds = new Set(options?.ignoreWallIds ?? [])
|
||||
const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
|
||||
let bestTarget: WallPlanPoint | null = null
|
||||
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const wall of walls) {
|
||||
if (ignoreWallIds.has(wall.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const candidates: Array<WallPlanPoint | null> = [
|
||||
wall.start,
|
||||
wall.end,
|
||||
projectPointOntoWall(point, wall),
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
|
||||
const candidateDistanceSquared = distanceSquared(point, candidate)
|
||||
if (
|
||||
candidateDistanceSquared > radiusSquared ||
|
||||
candidateDistanceSquared >= bestDistanceSquared
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
bestTarget = candidate
|
||||
bestDistanceSquared = candidateDistanceSquared
|
||||
}
|
||||
}
|
||||
|
||||
return bestTarget
|
||||
}
|
||||
|
||||
export function snapWallDraftPoint(args: {
|
||||
point: WallPlanPoint
|
||||
walls: WallNode[]
|
||||
start?: WallPlanPoint
|
||||
angleSnap?: boolean
|
||||
ignoreWallIds?: string[]
|
||||
}): WallPlanPoint {
|
||||
const { point, walls, start, angleSnap = false, ignoreWallIds } = args
|
||||
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
|
||||
|
||||
return (
|
||||
findWallSnapTarget(basePoint, walls, {
|
||||
ignoreWallIds,
|
||||
}) ?? basePoint
|
||||
)
|
||||
}
|
||||
|
||||
export function isWallLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
|
||||
return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
|
||||
}
|
||||
|
||||
export function createWallOnCurrentLevel(
|
||||
start: WallPlanPoint,
|
||||
end: WallPlanPoint,
|
||||
): WallNode | null {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
|
||||
if (!(currentLevelId && isWallLongEnough(start, end))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
|
||||
const wall = WallSchema.parse({
|
||||
name: `Wall ${wallCount + 1}`,
|
||||
start,
|
||||
end,
|
||||
})
|
||||
|
||||
createNode(wall, currentLevelId)
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
|
||||
return wall
|
||||
}
|
||||
@@ -1,41 +1,13 @@
|
||||
import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core'
|
||||
import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
|
||||
|
||||
const WALL_HEIGHT = 2.5
|
||||
const WALL_THICKNESS = 0.15
|
||||
|
||||
/**
|
||||
* Snap point to 45° angle increments relative to start point
|
||||
* Also snaps end point to 0.5 grid
|
||||
*/
|
||||
const snapTo45Degrees = (start: Vector3, cursor: Vector3): Vector3 => {
|
||||
const dx = cursor.x - start.x
|
||||
const dz = cursor.z - start.z
|
||||
|
||||
// Calculate angle in radians
|
||||
const angle = Math.atan2(dz, dx)
|
||||
|
||||
// Round to nearest 45° (π/4 radians)
|
||||
const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
|
||||
|
||||
// Calculate distance from start to cursor
|
||||
const distance = Math.sqrt(dx * dx + dz * dz)
|
||||
|
||||
// Project end point along snapped angle
|
||||
let snappedX = start.x + Math.cos(snappedAngle) * distance
|
||||
let snappedZ = start.z + Math.sin(snappedAngle) * distance
|
||||
|
||||
// Snap to 0.5 grid
|
||||
snappedX = Math.round(snappedX * 2) / 2
|
||||
snappedZ = Math.round(snappedZ * 2) / 2
|
||||
|
||||
return new Vector3(snappedX, cursor.y, snappedZ)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update wall preview mesh geometry to create a vertical plane between two points
|
||||
@@ -53,9 +25,6 @@ const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
|
||||
mesh.visible = true
|
||||
direction.normalize()
|
||||
|
||||
// Perpendicular vector for thickness
|
||||
const perpendicular = new Vector3(-direction.z, 0, direction.x).multiplyScalar(WALL_THICKNESS / 2)
|
||||
|
||||
// Create wall shape (vertical rectangle in XY plane)
|
||||
const shape = new Shape()
|
||||
shape.moveTo(0, 0)
|
||||
@@ -82,19 +51,18 @@ const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
|
||||
mesh.geometry = geometry
|
||||
}
|
||||
|
||||
const commitWallDrawing = (start: [number, number], end: [number, number]) => {
|
||||
const getCurrentLevelWalls = (): WallNode[] => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
const { nodes } = useScene.getState()
|
||||
|
||||
if (!currentLevelId) return
|
||||
if (!currentLevelId) return []
|
||||
|
||||
const wallCount = Object.values(nodes).filter((n) => n.type === 'wall').length
|
||||
const name = `Wall ${wallCount + 1}`
|
||||
const levelNode = nodes[currentLevelId]
|
||||
if (!levelNode || levelNode.type !== 'level') return []
|
||||
|
||||
const wall = WallNode.parse({ name, start, end })
|
||||
|
||||
createNode(wall, currentLevelId)
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
return (levelNode as LevelNode).children
|
||||
.map((childId) => nodes[childId])
|
||||
.filter((node): node is WallNode => node?.type === 'wall')
|
||||
}
|
||||
|
||||
export const WallTool: React.FC = () => {
|
||||
@@ -106,20 +74,27 @@ export const WallTool: React.FC = () => {
|
||||
const shiftPressed = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
let gridPosition: [number, number] = [0, 0]
|
||||
let gridPosition: WallPlanPoint = [0, 0]
|
||||
let previousWallEnd: [number, number] | null = null
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!(cursorRef.current && wallPreviewRef.current)) return
|
||||
|
||||
gridPosition = [Math.round(event.position[0] * 2) / 2, Math.round(event.position[2] * 2) / 2]
|
||||
const cursorPosition = new Vector3(gridPosition[0], event.position[1], gridPosition[1])
|
||||
const walls = getCurrentLevelWalls()
|
||||
const cursorPoint: WallPlanPoint = [event.position[0], event.position[2]]
|
||||
gridPosition = snapWallDraftPoint({
|
||||
point: cursorPoint,
|
||||
walls,
|
||||
})
|
||||
|
||||
if (buildingState.current === 1) {
|
||||
// Snap to 45° angles only if shift is not pressed
|
||||
const snapped = shiftPressed.current
|
||||
? cursorPosition
|
||||
: snapTo45Degrees(startingPoint.current, cursorPosition)
|
||||
const snappedPoint = snapWallDraftPoint({
|
||||
point: cursorPoint,
|
||||
walls,
|
||||
start: [startingPoint.current.x, startingPoint.current.z],
|
||||
angleSnap: !shiftPressed.current,
|
||||
})
|
||||
const snapped = new Vector3(snappedPoint[0], event.position[1], snappedPoint[1])
|
||||
endingPoint.current.copy(snapped)
|
||||
|
||||
// Position the cursor at the end of the wall being drawn
|
||||
@@ -138,21 +113,37 @@ export const WallTool: React.FC = () => {
|
||||
// Update wall preview geometry
|
||||
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
|
||||
} else {
|
||||
// Not drawing a wall, just follow the grid position
|
||||
// Not drawing a wall yet, show the snapped anchor point.
|
||||
cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1])
|
||||
}
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const walls = getCurrentLevelWalls()
|
||||
const clickPoint: WallPlanPoint = [event.position[0], event.position[2]]
|
||||
|
||||
if (buildingState.current === 0) {
|
||||
startingPoint.current.set(gridPosition[0], event.position[1], gridPosition[1])
|
||||
const snappedStart = snapWallDraftPoint({
|
||||
point: clickPoint,
|
||||
walls,
|
||||
})
|
||||
gridPosition = snappedStart
|
||||
startingPoint.current.set(snappedStart[0], event.position[1], snappedStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
buildingState.current = 1
|
||||
wallPreviewRef.current.visible = true
|
||||
} else if (buildingState.current === 1) {
|
||||
const snappedEnd = snapWallDraftPoint({
|
||||
point: clickPoint,
|
||||
walls,
|
||||
start: [startingPoint.current.x, startingPoint.current.z],
|
||||
angleSnap: !shiftPressed.current,
|
||||
})
|
||||
endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1])
|
||||
const dx = endingPoint.current.x - startingPoint.current.x
|
||||
const dz = endingPoint.current.z - startingPoint.current.z
|
||||
if (dx * dx + dz * dz < 0.01 * 0.01) return
|
||||
commitWallDrawing(
|
||||
createWallOnCurrentLevel(
|
||||
[startingPoint.current.x, startingPoint.current.z],
|
||||
[endingPoint.current.x, endingPoint.current.z],
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
@@ -45,9 +45,9 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
||||
export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => {
|
||||
const cursorGroupRef = useRef<Group>(null!)
|
||||
|
||||
const exitMoveMode = () => {
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
@@ -389,7 +389,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
emitter.off('wall:leave', onWallLeave)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [movingWindowNode])
|
||||
}, [movingWindowNode, exitMoveMode])
|
||||
|
||||
const edgesGeo = useMemo(() => {
|
||||
const boxGeo = new BoxGeometry(
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ZoneBoundaryEditor: React.FC<ZoneBoundaryEditorProps> = ({ zoneId }
|
||||
[zoneId, updateNode],
|
||||
)
|
||||
|
||||
if (!(zone && zone.polygon) || zone.polygon.length < 3) return null
|
||||
if (!zone?.polygon || zone.polygon.length < 3) return null
|
||||
|
||||
const zoneColor = zone.color || '#3b82f6'
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ export const ZoneTool: React.FC = () => {
|
||||
// Reset state on unmount
|
||||
pointsRef.current = []
|
||||
}
|
||||
}, [currentLevelId, setTool])
|
||||
}, [currentLevelId])
|
||||
|
||||
const { points, cursorPoint, levelY } = preview
|
||||
|
||||
@@ -318,6 +318,7 @@ export const ZoneTool: React.FC = () => {
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
// @ts-expect-error
|
||||
ref={mainLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
@@ -331,6 +332,7 @@ export const ZoneTool: React.FC = () => {
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
// @ts-expect-error
|
||||
ref={closingLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cn } from '../../../lib/utils'
|
||||
import useEditor, {
|
||||
type CatalogCategory,
|
||||
type StructureTool,
|
||||
Tool,
|
||||
type Tool,
|
||||
} from '../../../store/use-editor'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { Icon } from '@iconify/react'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Box, Camera, Diamond, Image, Layers, Layers2 } from 'lucide-react'
|
||||
import { Diamond } from 'lucide-react'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
||||
@@ -11,6 +13,13 @@ const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
||||
solo: 'Solo',
|
||||
}
|
||||
|
||||
const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = {
|
||||
manual: 'Stack',
|
||||
stacked: 'Stack',
|
||||
exploded: 'Exploded',
|
||||
solo: 'Solo',
|
||||
}
|
||||
|
||||
const levelModeOrder: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
|
||||
|
||||
type WallMode = 'up' | 'cutaway' | 'down'
|
||||
@@ -50,6 +59,8 @@ export function ViewToggles() {
|
||||
const setShowScans = useViewer((state) => state.setShowScans)
|
||||
const showGuides = useViewer((state) => state.showGuides)
|
||||
const setShowGuides = useViewer((state) => state.setShowGuides)
|
||||
const isFloorplanOpen = useEditor((state) => state.isFloorplanOpen)
|
||||
const toggleFloorplanOpen = useEditor((state) => state.toggleFloorplanOpen)
|
||||
|
||||
const toggleCameraMode = () => {
|
||||
setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
|
||||
@@ -87,22 +98,41 @@ export function ViewToggles() {
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Camera className="h-6 w-6" />
|
||||
{cameraMode === 'perspective' ? (
|
||||
<Icon color="currentColor" height={24} icon="icon-park-outline:perspective" width={24} />
|
||||
) : (
|
||||
<Icon color="currentColor" height={24} icon="vaadin:grid" width={24} />
|
||||
)}
|
||||
</ActionButton>
|
||||
|
||||
{/* Level Mode */}
|
||||
<ActionButton
|
||||
className={cn(
|
||||
levelMode !== 'stacked' ? 'bg-amber-500/20 text-amber-400' : 'hover:text-amber-400',
|
||||
'p-0',
|
||||
levelMode === 'stacked' || levelMode === 'manual'
|
||||
? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
|
||||
: 'bg-white/10 text-foreground',
|
||||
)}
|
||||
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
|
||||
onClick={cycleLevelMode}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
|
||||
{levelMode === 'exploded' && <Layers2 className="h-6 w-6" />}
|
||||
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-6 w-6" />}
|
||||
<span className="relative flex h-full w-full items-center justify-center pb-1">
|
||||
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
|
||||
{levelMode === 'exploded' && (
|
||||
<Icon color="currentColor" height={24} icon="charm:stack-pop" width={24} />
|
||||
)}
|
||||
{(levelMode === 'stacked' || levelMode === 'manual') && (
|
||||
<Icon color="currentColor" height={24} icon="charm:stack-push" width={24} />
|
||||
)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-1 bottom-1 left-1 rounded border border-border/50 bg-background/70 px-0.5 py-[2px] text-center font-medium font-pixel text-[8px] text-foreground/85 leading-none tracking-[-0.02em] backdrop-blur-sm"
|
||||
>
|
||||
{levelModeBadgeLabels[levelMode]}
|
||||
</span>
|
||||
</span>
|
||||
</ActionButton>
|
||||
|
||||
{/* Wall Mode */}
|
||||
@@ -155,6 +185,37 @@ export function ViewToggles() {
|
||||
>
|
||||
<img alt="Guides" className="h-[28px] w-[28px] object-contain" src="/icons/floorplan.png" />
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
className={cn('overflow-visible p-0', isFloorplanOpen ? 'bg-white/10' : 'hover:bg-white/5')}
|
||||
label={`2D floor plan: ${isFloorplanOpen ? 'Visible' : 'Hidden'}`}
|
||||
onClick={toggleFloorplanOpen}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<span className="relative flex h-full w-full items-center justify-center pb-1">
|
||||
<img
|
||||
alt="2D floor plan"
|
||||
className={cn(
|
||||
'h-[28px] w-[28px] object-contain transition-[filter,opacity] duration-200',
|
||||
isFloorplanOpen ? 'opacity-100 grayscale-0' : 'opacity-60 grayscale',
|
||||
)}
|
||||
src="/icons/blueprint.png"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -top-1 -right-1 z-10 rounded-full border border-background/80 bg-emerald-600 px-1.5 py-0.5 font-semibold text-[7px] text-white leading-none shadow-[0_4px_10px_rgba(5,150,105,0.24)]"
|
||||
>
|
||||
New
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-1 bottom-1 left-1 rounded border border-border/50 bg-background/70 px-0.5 py-[2px] text-center font-medium font-pixel text-[8px] text-foreground/85 leading-none tracking-[-0.02em] backdrop-blur-sm"
|
||||
>
|
||||
2D
|
||||
</span>
|
||||
</span>
|
||||
</ActionButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
'use client'
|
||||
|
||||
import type { AnyNodeId } from '@pascal-app/core'
|
||||
import { LevelNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
AppWindow,
|
||||
ArrowRight,
|
||||
Box,
|
||||
Building2,
|
||||
Camera,
|
||||
Copy,
|
||||
DoorOpen,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileJson,
|
||||
Grid3X3,
|
||||
Hexagon,
|
||||
Layers,
|
||||
Map,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Moon,
|
||||
MousePointer2,
|
||||
Package,
|
||||
PencilLine,
|
||||
Plus,
|
||||
Redo2,
|
||||
Square,
|
||||
SquareStack,
|
||||
Sun,
|
||||
Trash2,
|
||||
Undo2,
|
||||
Video,
|
||||
} from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
|
||||
import { useCommandRegistry } from '../../../store/use-command-registry'
|
||||
import type { StructureTool } from '../../../store/use-editor'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { useCommandPalette } from './index'
|
||||
|
||||
export function EditorCommands() {
|
||||
const register = useCommandRegistry((s) => s.register)
|
||||
const { navigateTo, setInputValue, setOpen } = useCommandPalette()
|
||||
|
||||
const { setPhase, setMode, setTool, setStructureLayer, isPreviewMode, setPreviewMode } =
|
||||
useEditor()
|
||||
|
||||
const exportScene = useViewer((s) => s.exportScene)
|
||||
|
||||
// Re-register when exportScene availability changes (it's a conditional action)
|
||||
useEffect(() => {
|
||||
const run = (fn: () => void) => {
|
||||
fn()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const activateTool = (tool: StructureTool) => {
|
||||
run(() => {
|
||||
setPhase('structure')
|
||||
setMode('build')
|
||||
if (tool === 'zone') setStructureLayer('zones')
|
||||
setTool(tool)
|
||||
})
|
||||
}
|
||||
|
||||
return register([
|
||||
// ── Scene ────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'editor.tool.wall',
|
||||
label: 'Wall Tool',
|
||||
group: 'Scene',
|
||||
icon: <Square className="h-4 w-4" />,
|
||||
keywords: ['draw', 'build', 'structure'],
|
||||
execute: () => activateTool('wall'),
|
||||
},
|
||||
{
|
||||
id: 'editor.tool.slab',
|
||||
label: 'Slab Tool',
|
||||
group: 'Scene',
|
||||
icon: <Layers className="h-4 w-4" />,
|
||||
keywords: ['floor', 'build'],
|
||||
execute: () => activateTool('slab'),
|
||||
},
|
||||
{
|
||||
id: 'editor.tool.ceiling',
|
||||
label: 'Ceiling Tool',
|
||||
group: 'Scene',
|
||||
icon: <Grid3X3 className="h-4 w-4" />,
|
||||
keywords: ['top', 'build'],
|
||||
execute: () => activateTool('ceiling'),
|
||||
},
|
||||
{
|
||||
id: 'editor.tool.door',
|
||||
label: 'Door Tool',
|
||||
group: 'Scene',
|
||||
icon: <DoorOpen className="h-4 w-4" />,
|
||||
keywords: ['opening', 'entrance'],
|
||||
execute: () => activateTool('door'),
|
||||
},
|
||||
{
|
||||
id: 'editor.tool.window',
|
||||
label: 'Window Tool',
|
||||
group: 'Scene',
|
||||
icon: <AppWindow className="h-4 w-4" />,
|
||||
keywords: ['opening', 'glass'],
|
||||
execute: () => activateTool('window'),
|
||||
},
|
||||
{
|
||||
id: 'editor.tool.item',
|
||||
label: 'Item Tool',
|
||||
group: 'Scene',
|
||||
icon: <Package className="h-4 w-4" />,
|
||||
keywords: ['furniture', 'object', 'asset', 'furnish'],
|
||||
execute: () => activateTool('item'),
|
||||
},
|
||||
{
|
||||
id: 'editor.tool.zone',
|
||||
label: 'Zone Tool',
|
||||
group: 'Scene',
|
||||
icon: <Hexagon className="h-4 w-4" />,
|
||||
keywords: ['area', 'room', 'space'],
|
||||
execute: () => activateTool('zone'),
|
||||
},
|
||||
{
|
||||
id: 'editor.delete-selection',
|
||||
label: 'Delete Selection',
|
||||
group: 'Scene',
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
keywords: ['remove', 'erase'],
|
||||
shortcut: ['⌫'],
|
||||
when: () => useViewer.getState().selection.selectedIds.length > 0,
|
||||
execute: () =>
|
||||
run(() => {
|
||||
const { selectedIds } = useViewer.getState().selection
|
||||
useScene.getState().deleteNodes(selectedIds as any[])
|
||||
}),
|
||||
},
|
||||
|
||||
// ── Levels ───────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'editor.level.goto',
|
||||
label: 'Go to Level',
|
||||
group: 'Levels',
|
||||
icon: <ArrowRight className="h-4 w-4" />,
|
||||
keywords: ['level', 'floor', 'go', 'navigate', 'switch', 'select'],
|
||||
navigate: true,
|
||||
when: () => Object.values(useScene.getState().nodes).some((n) => n.type === 'level'),
|
||||
execute: () => navigateTo('goto-level'),
|
||||
},
|
||||
{
|
||||
id: 'editor.level.add',
|
||||
label: 'Add Level',
|
||||
group: 'Levels',
|
||||
icon: <Plus className="h-4 w-4" />,
|
||||
keywords: ['level', 'floor', 'add', 'create', 'new'],
|
||||
execute: () =>
|
||||
run(() => {
|
||||
const { nodes } = useScene.getState()
|
||||
const building = Object.values(nodes).find((n) => n.type === 'building')
|
||||
if (!building) return
|
||||
const newLevel = LevelNode.parse({
|
||||
level: building.children.length,
|
||||
children: [],
|
||||
parentId: building.id,
|
||||
})
|
||||
useScene.getState().createNode(newLevel, building.id)
|
||||
useViewer.getState().setSelection({ levelId: newLevel.id })
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'editor.level.rename',
|
||||
label: 'Rename Level',
|
||||
group: 'Levels',
|
||||
icon: <PencilLine className="h-4 w-4" />,
|
||||
keywords: ['level', 'floor', 'rename', 'name'],
|
||||
navigate: true,
|
||||
when: () => !!useViewer.getState().selection.levelId,
|
||||
execute: () => {
|
||||
const activeLevelId = useViewer.getState().selection.levelId
|
||||
if (!activeLevelId) return
|
||||
const level = useScene.getState().nodes[activeLevelId as AnyNodeId] as LevelNode
|
||||
setInputValue(level?.name ?? '')
|
||||
navigateTo('rename-level')
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'editor.level.delete',
|
||||
label: 'Delete Level',
|
||||
group: 'Levels',
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
keywords: ['level', 'floor', 'delete', 'remove'],
|
||||
when: () => {
|
||||
const levelId = useViewer.getState().selection.levelId
|
||||
if (!levelId) return false
|
||||
const node = useScene.getState().nodes[levelId as AnyNodeId] as LevelNode
|
||||
return node?.type === 'level' && node.level !== 0
|
||||
},
|
||||
execute: () =>
|
||||
run(() => {
|
||||
const activeLevelId = useViewer.getState().selection.levelId
|
||||
if (!activeLevelId) return
|
||||
deleteLevelWithFallbackSelection(activeLevelId as AnyNodeId)
|
||||
}),
|
||||
},
|
||||
|
||||
// ── Viewer Controls ──────────────────────────────────────────────────
|
||||
{
|
||||
id: 'editor.viewer.wall-mode',
|
||||
label: 'Wall Mode',
|
||||
group: 'Viewer Controls',
|
||||
icon: <Layers className="h-4 w-4" />,
|
||||
keywords: ['wall', 'cutaway', 'up', 'down', 'view'],
|
||||
badge: () => {
|
||||
const mode = useViewer.getState().wallMode
|
||||
return { cutaway: 'Cutaway', up: 'Up', down: 'Down' }[mode]
|
||||
},
|
||||
navigate: true,
|
||||
execute: () => navigateTo('wall-mode'),
|
||||
},
|
||||
{
|
||||
id: 'editor.viewer.level-mode',
|
||||
label: 'Level Mode',
|
||||
group: 'Viewer Controls',
|
||||
icon: <SquareStack className="h-4 w-4" />,
|
||||
keywords: ['level', 'floor', 'exploded', 'stacked', 'solo'],
|
||||
badge: () => {
|
||||
const mode = useViewer.getState().levelMode
|
||||
return { manual: 'Manual', stacked: 'Stacked', exploded: 'Exploded', solo: 'Solo' }[mode]
|
||||
},
|
||||
navigate: true,
|
||||
execute: () => navigateTo('level-mode'),
|
||||
},
|
||||
{
|
||||
id: 'editor.viewer.camera-mode',
|
||||
label: () => {
|
||||
const mode = useViewer.getState().cameraMode
|
||||
return `Camera: Switch to ${mode === 'perspective' ? 'Orthographic' : 'Perspective'}`
|
||||
},
|
||||
group: 'Viewer Controls',
|
||||
icon: <Video className="h-4 w-4" />,
|
||||
keywords: ['camera', 'ortho', 'perspective', '2d', '3d', 'view'],
|
||||
execute: () =>
|
||||
run(() => {
|
||||
const { cameraMode, setCameraMode } = useViewer.getState()
|
||||
setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'editor.viewer.theme',
|
||||
label: () => {
|
||||
const theme = useViewer.getState().theme
|
||||
return theme === 'dark' ? 'Switch to Light Theme' : 'Switch to Dark Theme'
|
||||
},
|
||||
group: 'Viewer Controls',
|
||||
icon: <Sun className="h-4 w-4" />, // icon is static; label conveys the action
|
||||
keywords: ['theme', 'dark', 'light', 'appearance', 'color'],
|
||||
execute: () =>
|
||||
run(() => {
|
||||
const { theme, setTheme } = useViewer.getState()
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark')
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'editor.viewer.camera-snapshot',
|
||||
label: 'Camera Snapshot',
|
||||
group: 'Viewer Controls',
|
||||
icon: <Camera className="h-4 w-4" />,
|
||||
keywords: ['camera', 'snapshot', 'capture', 'save', 'view', 'bookmark'],
|
||||
navigate: true,
|
||||
execute: () => navigateTo('camera-view'),
|
||||
},
|
||||
|
||||
// ── View ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'editor.view.preview',
|
||||
label: () => (isPreviewMode ? 'Exit Preview' : 'Enter Preview'),
|
||||
group: 'View',
|
||||
icon: isPreviewMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />,
|
||||
keywords: ['preview', 'view', 'read-only', 'present'],
|
||||
execute: () => run(() => setPreviewMode(!isPreviewMode)),
|
||||
},
|
||||
{
|
||||
id: 'editor.view.fullscreen',
|
||||
label: 'Toggle Fullscreen',
|
||||
group: 'View',
|
||||
icon: <Maximize2 className="h-4 w-4" />,
|
||||
keywords: ['fullscreen', 'maximize', 'expand', 'window'],
|
||||
execute: () =>
|
||||
run(() => {
|
||||
if (document.fullscreenElement) document.exitFullscreen()
|
||||
else document.documentElement.requestFullscreen()
|
||||
}),
|
||||
},
|
||||
|
||||
// ── History ──────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'editor.history.undo',
|
||||
label: 'Undo',
|
||||
group: 'History',
|
||||
icon: <Undo2 className="h-4 w-4" />,
|
||||
keywords: ['undo', 'revert', 'back'],
|
||||
execute: () => run(() => useScene.temporal.getState().undo()),
|
||||
},
|
||||
{
|
||||
id: 'editor.history.redo',
|
||||
label: 'Redo',
|
||||
group: 'History',
|
||||
icon: <Redo2 className="h-4 w-4" />,
|
||||
keywords: ['redo', 'forward', 'repeat'],
|
||||
execute: () => run(() => useScene.temporal.getState().redo()),
|
||||
},
|
||||
|
||||
// ── Export & Share ───────────────────────────────────────────────────
|
||||
{
|
||||
id: 'editor.export.json',
|
||||
label: 'Export Scene (JSON)',
|
||||
group: 'Export & Share',
|
||||
icon: <FileJson className="h-4 w-4" />,
|
||||
keywords: ['export', 'download', 'json', 'save', 'data'],
|
||||
execute: () =>
|
||||
run(() => {
|
||||
const { nodes, rootNodeIds } = useScene.getState()
|
||||
const blob = new Blob([JSON.stringify({ nodes, rootNodeIds }, null, 2)], {
|
||||
type: 'application/json',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
Object.assign(document.createElement('a'), {
|
||||
href: url,
|
||||
download: `scene_${new Date().toISOString().split('T')[0]}.json`,
|
||||
}).click()
|
||||
URL.revokeObjectURL(url)
|
||||
}),
|
||||
},
|
||||
...(exportScene
|
||||
? [
|
||||
{
|
||||
id: 'editor.export.glb',
|
||||
label: 'Export 3D Model (GLB)',
|
||||
group: 'Export & Share',
|
||||
icon: <Box className="h-4 w-4" />,
|
||||
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
|
||||
execute: () => run(() => exportScene()),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'editor.export.share-link',
|
||||
label: 'Copy Share Link',
|
||||
group: 'Export & Share',
|
||||
icon: <Copy className="h-4 w-4" />,
|
||||
keywords: ['share', 'copy', 'url', 'link'],
|
||||
execute: () => run(() => navigator.clipboard.writeText(window.location.href)),
|
||||
},
|
||||
{
|
||||
id: 'editor.export.screenshot',
|
||||
label: 'Take Screenshot',
|
||||
group: 'Export & Share',
|
||||
icon: <Camera className="h-4 w-4" />,
|
||||
keywords: ['screenshot', 'capture', 'image', 'photo', 'png'],
|
||||
execute: () =>
|
||||
run(() => {
|
||||
const canvas = document.querySelector('canvas')
|
||||
if (!canvas) return
|
||||
Object.assign(document.createElement('a'), {
|
||||
href: canvas.toDataURL('image/png'),
|
||||
download: `screenshot_${new Date().toISOString().split('T')[0]}.png`,
|
||||
}).click()
|
||||
}),
|
||||
},
|
||||
])
|
||||
}, [
|
||||
register,
|
||||
navigateTo,
|
||||
setInputValue,
|
||||
setOpen,
|
||||
setPhase,
|
||||
setMode,
|
||||
setTool,
|
||||
setStructureLayer,
|
||||
isPreviewMode,
|
||||
setPreviewMode,
|
||||
exportScene,
|
||||
])
|
||||
|
||||
return null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
@@ -27,10 +28,17 @@ export function MetricControl({
|
||||
className,
|
||||
unit = '',
|
||||
}: MetricControlProps) {
|
||||
const viewerUnit = useViewer((state) => state.unit)
|
||||
const isImperial = viewerUnit === 'imperial' && unit === 'm'
|
||||
const multiplier = isImperial ? 3.280_84 : 1
|
||||
const displayUnit = isImperial ? 'ft' : unit
|
||||
|
||||
const displayValue = value * multiplier
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(value.toFixed(precision))
|
||||
const [inputValue, setInputValue] = useState(displayValue.toFixed(precision))
|
||||
const startXRef = useRef(0)
|
||||
const startValueRef = useRef(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -47,9 +55,9 @@ export function MetricControl({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setInputValue(value.toFixed(precision))
|
||||
setInputValue(displayValue.toFixed(precision))
|
||||
}
|
||||
}, [value, precision, isEditing])
|
||||
}, [displayValue, precision, isEditing])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
@@ -61,21 +69,21 @@ export function MetricControl({
|
||||
e.preventDefault()
|
||||
|
||||
const direction = e.deltaY < 0 ? 1 : -1
|
||||
let scrollStep = step
|
||||
if (e.shiftKey) scrollStep = step * 10
|
||||
else if (e.altKey) scrollStep = step * 0.1
|
||||
let scrollStep = step / multiplier
|
||||
if (e.shiftKey) scrollStep = (step * 10) / multiplier
|
||||
else if (e.altKey) scrollStep = (step * 0.1) / multiplier
|
||||
|
||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
||||
const finalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
const finalValue = Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
|
||||
|
||||
if (finalValue !== valueRef.current) {
|
||||
if (Math.abs(finalValue - valueRef.current) > 1e-6) {
|
||||
onChange(finalValue)
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => container.removeEventListener('wheel', handleWheel)
|
||||
}, [isEditing, step, clamp, onChange, precision])
|
||||
}, [isEditing, step, clamp, onChange, precision, multiplier])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHovered || isEditing) return
|
||||
@@ -87,14 +95,15 @@ export function MetricControl({
|
||||
|
||||
if (direction !== 0) {
|
||||
e.preventDefault()
|
||||
let scrollStep = step
|
||||
if (e.shiftKey) scrollStep = step * 10
|
||||
else if (e.altKey) scrollStep = step * 0.1
|
||||
let scrollStep = step / multiplier
|
||||
if (e.shiftKey) scrollStep = (step * 10) / multiplier
|
||||
else if (e.altKey) scrollStep = (step * 0.1) / multiplier
|
||||
|
||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
||||
const finalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
const finalValue =
|
||||
Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
|
||||
|
||||
if (finalValue !== valueRef.current) {
|
||||
if (Math.abs(finalValue - valueRef.current) > 1e-6) {
|
||||
onChange(finalValue)
|
||||
}
|
||||
}
|
||||
@@ -102,7 +111,7 @@ export function MetricControl({
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isHovered, isEditing, step, clamp, onChange, precision])
|
||||
}, [isHovered, isEditing, step, clamp, onChange, precision, multiplier])
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
@@ -119,15 +128,16 @@ export function MetricControl({
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const deltaX = moveEvent.clientX - startXRef.current
|
||||
|
||||
let dragStep = step
|
||||
if (moveEvent.shiftKey) dragStep = step * 10
|
||||
else if (moveEvent.altKey) dragStep = step * 0.1
|
||||
let dragStep = step / multiplier
|
||||
if (moveEvent.shiftKey) dragStep = (step * 10) / multiplier
|
||||
else if (moveEvent.altKey) dragStep = (step * 0.1) / multiplier
|
||||
|
||||
const deltaValue = deltaX * dragStep
|
||||
const newValue = clamp(startValueRef.current + deltaValue)
|
||||
const newFinalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
const newFinalValue =
|
||||
Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
|
||||
|
||||
if (newFinalValue !== finalValue) {
|
||||
if (Math.abs(newFinalValue - finalValue) > 1e-6) {
|
||||
finalValue = newFinalValue
|
||||
onChange(finalValue)
|
||||
}
|
||||
@@ -138,7 +148,7 @@ export function MetricControl({
|
||||
document.removeEventListener('pointermove', handlePointerMove)
|
||||
document.removeEventListener('pointerup', handlePointerUp)
|
||||
|
||||
if (finalValue !== startValueRef.current) {
|
||||
if (Math.abs(finalValue - startValueRef.current) > 1e-6) {
|
||||
onChange(startValueRef.current)
|
||||
useScene.temporal.getState().resume()
|
||||
onChange(finalValue)
|
||||
@@ -150,13 +160,13 @@ export function MetricControl({
|
||||
document.addEventListener('pointermove', handlePointerMove)
|
||||
document.addEventListener('pointerup', handlePointerUp)
|
||||
},
|
||||
[isEditing, value, onChange, clamp, precision, step],
|
||||
[isEditing, value, onChange, clamp, precision, step, multiplier],
|
||||
)
|
||||
|
||||
const handleValueClick = useCallback(() => {
|
||||
setIsEditing(true)
|
||||
setInputValue(value.toFixed(precision))
|
||||
}, [value, precision])
|
||||
setInputValue((value * multiplier).toFixed(precision))
|
||||
}, [value, multiplier, precision])
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
@@ -165,12 +175,12 @@ export function MetricControl({
|
||||
const submitValue = useCallback(() => {
|
||||
const numValue = Number.parseFloat(inputValue)
|
||||
if (Number.isNaN(numValue)) {
|
||||
setInputValue(value.toFixed(precision))
|
||||
setInputValue((value * multiplier).toFixed(precision))
|
||||
} else {
|
||||
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
|
||||
onChange(clamp(numValue / multiplier))
|
||||
}
|
||||
setIsEditing(false)
|
||||
}, [inputValue, onChange, clamp, precision, value])
|
||||
}, [inputValue, onChange, clamp, multiplier, value, precision])
|
||||
|
||||
const handleInputBlur = useCallback(() => {
|
||||
submitValue()
|
||||
@@ -181,21 +191,21 @@ export function MetricControl({
|
||||
if (e.key === 'Enter') {
|
||||
submitValue()
|
||||
} else if (e.key === 'Escape') {
|
||||
setInputValue(value.toFixed(precision))
|
||||
setInputValue((value * multiplier).toFixed(precision))
|
||||
setIsEditing(false)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const newV = clamp(value + step)
|
||||
const newV = clamp(value + step / multiplier)
|
||||
onChange(newV)
|
||||
setInputValue(newV.toFixed(precision))
|
||||
setInputValue((newV * multiplier).toFixed(precision))
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
const newV = clamp(value - step)
|
||||
const newV = clamp(value - step / multiplier)
|
||||
onChange(newV)
|
||||
setInputValue(newV.toFixed(precision))
|
||||
setInputValue((newV * multiplier).toFixed(precision))
|
||||
}
|
||||
},
|
||||
[submitValue, value, precision, step, clamp, onChange],
|
||||
[submitValue, value, multiplier, precision, step, clamp, onChange],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -233,7 +243,7 @@ export function MetricControl({
|
||||
type="text"
|
||||
value={inputValue}
|
||||
/>
|
||||
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
|
||||
{displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@@ -241,9 +251,9 @@ export function MetricControl({
|
||||
onClick={handleValueClick}
|
||||
>
|
||||
<span className="font-mono tabular-nums tracking-tight">
|
||||
{Number(value.toFixed(precision)).toFixed(precision)}
|
||||
{Number(displayValue.toFixed(precision)).toFixed(precision)}
|
||||
</span>
|
||||
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
|
||||
{displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -172,7 +172,7 @@ export function SliderControl({
|
||||
document.addEventListener('pointermove', handlePointerMove)
|
||||
document.addEventListener('pointerup', handlePointerUp)
|
||||
},
|
||||
[isEditing, min, max, step, precision, clamp, onChange],
|
||||
[isEditing, min, max, step, precision, clamp, onChange, dragStartValue, value],
|
||||
)
|
||||
|
||||
const handleValueClick = useCallback(() => {
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
||||
|
||||
export function CeilingHelper() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Shift</kbd>
|
||||
<ShortcutToken value="Left click" />
|
||||
<span className="text-muted-foreground">Add point</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ShortcutToken value="Shift" />
|
||||
<span className="text-muted-foreground">Allow non-45° angles</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Esc</kbd>
|
||||
<ShortcutToken value="Esc" />
|
||||
<span className="text-muted-foreground">Cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
||||
|
||||
interface ItemHelperProps {
|
||||
showEsc?: boolean
|
||||
}
|
||||
@@ -6,20 +8,30 @@ export function ItemHelper({ showEsc }: ItemHelperProps) {
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">R</kbd>
|
||||
<ShortcutToken value="Left click" />
|
||||
<span className="text-muted-foreground">Place item</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ShortcutToken value="R" />
|
||||
<span className="text-muted-foreground">Rotate counterclockwise</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">T</kbd>
|
||||
<ShortcutToken value="T" />
|
||||
<span className="text-muted-foreground">Rotate clockwise</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Shift</kbd>
|
||||
<ShortcutToken value="Shift" />
|
||||
<span className="text-muted-foreground">Free place</span>
|
||||
</div>
|
||||
{showEsc && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Esc</kbd>
|
||||
<ShortcutToken value="Esc" />
|
||||
<span className="text-muted-foreground">Cancel</span>
|
||||
</div>
|
||||
)}
|
||||
{!showEsc && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ShortcutToken value="Right click" />
|
||||
<span className="text-muted-foreground">Cancel</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
||||
|
||||
export function RoofHelper() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Esc</kbd>
|
||||
<ShortcutToken value="Left click" />
|
||||
<span className="text-muted-foreground">Set corner</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ShortcutToken value="Esc" />
|
||||
<span className="text-muted-foreground">Cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
||||
|
||||
export function SlabHelper() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Shift</kbd>
|
||||
<ShortcutToken value="Left click" />
|
||||
<span className="text-muted-foreground">Add point</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ShortcutToken value="Shift" />
|
||||
<span className="text-muted-foreground">Allow non-45° angles</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Esc</kbd>
|
||||
<ShortcutToken value="Esc" />
|
||||
<span className="text-muted-foreground">Cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
||||
|
||||
export function WallHelper() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Shift</kbd>
|
||||
<ShortcutToken value="Left click" />
|
||||
<span className="text-muted-foreground">Set wall start / end</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ShortcutToken value="Shift" />
|
||||
<span className="text-muted-foreground">Allow non-45° angles</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Esc</kbd>
|
||||
<ShortcutToken value="Esc" />
|
||||
<span className="text-muted-foreground">Cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,8 +102,12 @@ export function CeilingPanel() {
|
||||
const n = polygon.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
area += polygon[i]![0] * polygon[j]![1]
|
||||
area -= polygon[j]![0] * polygon[i]![1]
|
||||
const pi = polygon[i]
|
||||
const pj = polygon[j]
|
||||
if (pi && pj) {
|
||||
area += pi[0] * pj[1]
|
||||
area -= pj[0] * pi[1]
|
||||
}
|
||||
}
|
||||
return Math.abs(area) / 2
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function DoorPanel() {
|
||||
}, [selectedId, node, deleteNode, setSelection])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!(node && node.parentId)) return
|
||||
if (!node?.parentId) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useScene.temporal.getState().pause()
|
||||
const cloned = structuredClone(node) as any
|
||||
@@ -79,9 +79,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]!
|
||||
@@ -91,12 +92,13 @@ 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 })
|
||||
}
|
||||
|
||||
const setSegmentColumnRatio = (segIdx: number, colIdx: number, newVal: number) => {
|
||||
const seg = node!.segments[segIdx]!
|
||||
const seg = node?.segments[segIdx]
|
||||
if (!seg) return
|
||||
const normRatios = (() => {
|
||||
const sum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
||||
return seg.columnRatios.map((r) => r / sum)
|
||||
@@ -111,7 +113,7 @@ export function DoorPanel() {
|
||||
if (i === neighborIdx) return neighborVal
|
||||
return v
|
||||
})
|
||||
const updated = node!.segments.map((s, idx) =>
|
||||
const updated = node?.segments.map((s, idx) =>
|
||||
idx === segIdx ? { ...s, columnRatios: newRatios } : s,
|
||||
)
|
||||
handleUpdate({ segments: updated })
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DoorPanel } from './door-panel'
|
||||
import { ItemPanel } from './item-panel'
|
||||
import { ReferencePanel } from './reference-panel'
|
||||
import { RoofPanel } from './roof-panel'
|
||||
import { RoofSegmentPanel } from './roof-segment-panel'
|
||||
import { SlabPanel } from './slab-panel'
|
||||
import { WallPanel } from './wall-panel'
|
||||
import { WindowPanel } from './window-panel'
|
||||
@@ -32,6 +33,8 @@ export function PanelManager() {
|
||||
return <ItemPanel />
|
||||
case 'roof':
|
||||
return <RoofPanel />
|
||||
case 'roof-segment':
|
||||
return <RoofSegmentPanel />
|
||||
case 'slab':
|
||||
return <SlabPanel />
|
||||
case 'ceiling':
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Moon, RotateCcw, X } from 'lucide-react'
|
||||
import { ChevronLeft, RotateCcw, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
@@ -9,6 +9,7 @@ interface PanelWrapperProps {
|
||||
icon?: string
|
||||
onClose?: () => void
|
||||
onReset?: () => void
|
||||
onBack?: () => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
width?: number | string
|
||||
@@ -19,6 +20,7 @@ export function PanelWrapper({
|
||||
icon,
|
||||
onClose,
|
||||
onReset,
|
||||
onBack,
|
||||
children,
|
||||
className,
|
||||
width = 320, // default width
|
||||
@@ -34,6 +36,15 @@ export function PanelWrapper({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-border/50 border-b px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{onBack && (
|
||||
<button
|
||||
className="mr-1 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{icon && (
|
||||
<Image alt="" className="shrink-0 object-contain" height={16} src={icon} width={16} />
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type RoofNode,
|
||||
RoofNode as RoofNodeSchema,
|
||||
type RoofSegmentNode,
|
||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { ActionButton } from '../controls/action-button'
|
||||
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 { SliderControl } from '../controls/slider-control'
|
||||
@@ -14,6 +25,8 @@ export function RoofPanel() {
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined
|
||||
@@ -30,9 +43,90 @@ export function RoofPanel() {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleAddSegment = useCallback(() => {
|
||||
if (!node) return
|
||||
const segment = RoofSegmentNodeSchema.parse({
|
||||
width: 6,
|
||||
depth: 6,
|
||||
wallHeight: 0.5,
|
||||
roofHeight: 2.5,
|
||||
roofType: 'gable',
|
||||
position: [2, 0, 2],
|
||||
})
|
||||
createNode(segment, node.id as AnyNodeId)
|
||||
}, [node, createNode])
|
||||
|
||||
const handleSelectSegment = useCallback(
|
||||
(segmentId: string) => {
|
||||
setSelection({ selectedIds: [segmentId as AnyNode['id']] })
|
||||
},
|
||||
[setSelection],
|
||||
)
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node?.parentId) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
|
||||
let duplicateInfo = structuredClone(node) as any
|
||||
delete duplicateInfo.id
|
||||
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
||||
// Offset slightly so it's visible
|
||||
duplicateInfo.position = [
|
||||
duplicateInfo.position[0] + 1,
|
||||
duplicateInfo.position[1],
|
||||
duplicateInfo.position[2] + 1,
|
||||
]
|
||||
|
||||
try {
|
||||
const duplicate = RoofNodeSchema.parse(duplicateInfo)
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
|
||||
// Also duplicate all child segments
|
||||
const nodesState = useScene.getState().nodes
|
||||
const children = node.children || []
|
||||
|
||||
for (const childId of children) {
|
||||
const childNode = nodesState[childId]
|
||||
if (childNode && childNode.type === 'roof-segment') {
|
||||
let childDuplicateInfo = structuredClone(childNode) as any
|
||||
delete childDuplicateInfo.id
|
||||
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
|
||||
const childDuplicate = RoofSegmentNodeSchema.parse(childDuplicateInfo)
|
||||
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
setSelection({ selectedIds: [] })
|
||||
setMovingNode(duplicate)
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate roof', e)
|
||||
}
|
||||
}, [node, setSelection, setMovingNode])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (node) {
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
const parentId = node.parentId
|
||||
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
||||
if (parentId) {
|
||||
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
|
||||
}
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [selectedId, node, setSelection])
|
||||
|
||||
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
|
||||
|
||||
const totalWidth = node.leftWidth + node.rightWidth
|
||||
const segments = (node.children ?? [])
|
||||
.map((childId) => nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||
.filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
@@ -41,93 +135,30 @@ export function RoofPanel() {
|
||||
title={node.name || 'Roof'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Length"
|
||||
max={20}
|
||||
min={0.5}
|
||||
onChange={(v) => handleUpdate({ length: v })}
|
||||
precision={2}
|
||||
step={0.5}
|
||||
unit="m"
|
||||
value={Math.round(node.length * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={10}
|
||||
min={0.1}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Slope Widths">
|
||||
<div className="flex items-center justify-between px-2 pb-2 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
<span>Widths</span>
|
||||
<span>Total: {totalWidth.toFixed(1)}m</span>
|
||||
<PanelSection title="Segments">
|
||||
<div className="flex flex-col gap-1">
|
||||
{segments.map((seg, i) => (
|
||||
<button
|
||||
className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]"
|
||||
key={seg.id}
|
||||
onClick={() => handleSelectSegment(seg.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{seg.name || `Segment ${i + 1}`}</span>
|
||||
<span className="text-muted-foreground text-xs capitalize">{seg.roofType}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SliderControl
|
||||
label="Left"
|
||||
max={10}
|
||||
min={0.1}
|
||||
onChange={(v) => handleUpdate({ leftWidth: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.leftWidth * 100) / 100}
|
||||
<ActionButton
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add Segment"
|
||||
onClick={handleAddSegment}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Right"
|
||||
max={10}
|
||||
min={0.1}
|
||||
onChange={(v) => handleUpdate({ rightWidth: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.rightWidth * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Rotation">
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
R<sub className="ml-[1px] text-[11px] opacity-70">rot</sub>
|
||||
</>
|
||||
}
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(degrees) => {
|
||||
const radians = (degrees * Math.PI) / 180
|
||||
handleUpdate({ rotation: radians })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-90°"
|
||||
onClick={() => handleUpdate({ rotation: node.rotation - Math.PI / 2 })}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+90°"
|
||||
onClick={() => handleUpdate({ rotation: node.rotation + Math.PI / 2 })}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
<MetricControl
|
||||
label="X"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
@@ -136,16 +167,12 @@ export function RoofPanel() {
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
<MetricControl
|
||||
label="Y"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
@@ -154,16 +181,12 @@ export function RoofPanel() {
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
Z<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
<MetricControl
|
||||
label="Z"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
@@ -172,10 +195,55 @@ export function RoofPanel() {
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rotation"
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(degrees) => {
|
||||
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="hover:bg-red-500/20"
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type RoofSegmentNode,
|
||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||
type RoofType,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
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 { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
const ROOF_TYPE_OPTIONS: { label: string; value: RoofType }[] = [
|
||||
{ label: 'Hip', value: 'hip' },
|
||||
{ label: 'Gable', value: 'gable' },
|
||||
{ label: 'Shed', value: 'shed' },
|
||||
{ label: 'Flat', value: 'flat' },
|
||||
]
|
||||
|
||||
const ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [
|
||||
{ label: 'Gambrel', value: 'gambrel' },
|
||||
{ label: 'Dutch', value: 'dutch' },
|
||||
{ label: 'Mansard', value: 'mansard' },
|
||||
]
|
||||
|
||||
export function RoofSegmentPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined)
|
||||
: undefined
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<RoofSegmentNode>) => {
|
||||
if (!selectedId) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
},
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (node?.parentId) {
|
||||
setSelection({ selectedIds: [node.parentId] })
|
||||
}
|
||||
}, [node?.parentId, setSelection])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node?.parentId) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
|
||||
let duplicateInfo = structuredClone(node) as any
|
||||
delete duplicateInfo.id
|
||||
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
||||
// Offset slightly so it's visible
|
||||
duplicateInfo.position = [
|
||||
duplicateInfo.position[0] + 1,
|
||||
duplicateInfo.position[1],
|
||||
duplicateInfo.position[2] + 1,
|
||||
]
|
||||
|
||||
try {
|
||||
const duplicate = RoofSegmentNodeSchema.parse(duplicateInfo)
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
setSelection({ selectedIds: [] })
|
||||
setMovingNode(duplicate)
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate roof segment', e)
|
||||
}
|
||||
}, [node, setSelection, setMovingNode])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (node) {
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
const parentId = node.parentId
|
||||
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
||||
if (parentId) {
|
||||
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
|
||||
setSelection({ selectedIds: [parentId] })
|
||||
} else {
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}, [selectedId, node, setSelection])
|
||||
|
||||
if (!node || node.type !== 'roof-segment' || selectedIds.length !== 1) return null
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/roof.png"
|
||||
onBack={handleBack}
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Roof Segment'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Roof Type">
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ roofType: v })}
|
||||
options={ROOF_TYPE_OPTIONS}
|
||||
value={node.roofType}
|
||||
/>
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ roofType: v })}
|
||||
options={ROOF_TYPE_OPTIONS_2}
|
||||
value={node.roofType}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Footprint">
|
||||
<SliderControl
|
||||
label="Width"
|
||||
max={25}
|
||||
min={0.5}
|
||||
onChange={(v) => handleUpdate({ width: v })}
|
||||
precision={2}
|
||||
step={0.5}
|
||||
unit="m"
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
max={25}
|
||||
min={0.5}
|
||||
onChange={(v) => handleUpdate({ depth: v })}
|
||||
precision={2}
|
||||
step={0.5}
|
||||
unit="m"
|
||||
value={Math.round(node.depth * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Heights">
|
||||
<SliderControl
|
||||
label="Wall"
|
||||
max={5}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ wallHeight: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.wallHeight * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Roof"
|
||||
max={15}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ roofHeight: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.roofHeight * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Structure">
|
||||
<SliderControl
|
||||
label="Wall Thick."
|
||||
max={1}
|
||||
min={0.05}
|
||||
onChange={(v) => handleUpdate({ wallThickness: v })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.wallThickness * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Deck Thick."
|
||||
max={0.3}
|
||||
min={0.04}
|
||||
onChange={(v) => handleUpdate({ deckThickness: v })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.deckThickness * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Overhang"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ overhang: v })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.overhang * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Shingle Thick."
|
||||
max={0.3}
|
||||
min={0.02}
|
||||
onChange={(v) => handleUpdate({ shingleThickness: v })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.shingleThickness * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Position">
|
||||
<MetricControl
|
||||
label="X"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[0] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Y"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[1] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Z"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[2] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rotation"
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(degrees) => {
|
||||
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="hover:bg-red-500/20"
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -100,8 +100,12 @@ export function SlabPanel() {
|
||||
const n = polygon.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
area += polygon[i]![0] * polygon[j]![1]
|
||||
area -= polygon[j]![0] * polygon[i]![1]
|
||||
const pi = polygon[i]
|
||||
const pj = polygon[j]
|
||||
if (pi && pj) {
|
||||
area += pi[0] * pj[1]
|
||||
area -= pj[0] * pi[1]
|
||||
}
|
||||
}
|
||||
return Math.abs(area) / 2
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function WindowPanel() {
|
||||
}, [selectedId, node, deleteNode, setSelection])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!(node && node.parentId)) return
|
||||
if (!node?.parentId) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useScene.temporal.getState().pause()
|
||||
const duplicate = WindowNode.parse({
|
||||
|
||||
@@ -95,7 +95,7 @@ export function NumberInput({
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
},
|
||||
[isEditing, value, onChange, clamp, precision],
|
||||
[isEditing, value, onChange, clamp, precision, step],
|
||||
)
|
||||
|
||||
const handleValueClick = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Icon } from '@iconify/react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
const MOUSE_SHORTCUTS = {
|
||||
Click: {
|
||||
icon: 'ph:mouse-left-click-fill',
|
||||
label: 'Left click',
|
||||
},
|
||||
'Left click': {
|
||||
icon: 'ph:mouse-left-click-fill',
|
||||
label: 'Left click',
|
||||
},
|
||||
'Middle click': {
|
||||
icon: 'qlementine-icons:mouse-middle-button-16',
|
||||
label: 'Middle click',
|
||||
},
|
||||
'Right click': {
|
||||
icon: 'ph:mouse-right-click-fill',
|
||||
label: 'Right click',
|
||||
},
|
||||
} as const
|
||||
|
||||
type ShortcutTokenProps = React.ComponentProps<'kbd'> & {
|
||||
value: string
|
||||
displayValue?: string
|
||||
}
|
||||
|
||||
function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTokenProps) {
|
||||
const mouseShortcut =
|
||||
value in MOUSE_SHORTCUTS ? MOUSE_SHORTCUTS[value as keyof typeof MOUSE_SHORTCUTS] : null
|
||||
|
||||
return (
|
||||
<kbd
|
||||
aria-label={mouseShortcut?.label ?? displayValue ?? value}
|
||||
className={cn(
|
||||
'inline-flex h-6 items-center rounded border border-border bg-muted px-2 font-medium font-mono text-[11px] text-muted-foreground',
|
||||
mouseShortcut && 'justify-center px-1.5',
|
||||
className,
|
||||
)}
|
||||
title={mouseShortcut?.label ?? value}
|
||||
{...props}
|
||||
>
|
||||
{mouseShortcut ? (
|
||||
<>
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
color="currentColor"
|
||||
height={14}
|
||||
icon={mouseShortcut.icon}
|
||||
width={14}
|
||||
/>
|
||||
<span className="sr-only">{mouseShortcut.label}</span>
|
||||
</>
|
||||
) : (
|
||||
(displayValue ?? value)
|
||||
)}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
export { ShortcutToken }
|
||||
@@ -208,7 +208,7 @@ function SidebarResizer({ side }: { side: 'left' | 'right' }) {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [setWidth, side])
|
||||
}, [setWidth, side, setIsDragging])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { CommandPalette } from './../../../components/ui/command-palette'
|
||||
import { EditorCommands } from './../../../components/ui/command-palette/editor-commands'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -72,6 +73,7 @@ export function AppSidebar({
|
||||
</div>
|
||||
</div>
|
||||
</Sidebar>
|
||||
<EditorCommands />
|
||||
<CommandPalette />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { Moon, Ruler, Sun } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import {
|
||||
@@ -28,6 +28,8 @@ const panels: { id: PanelId; iconSrc: string; label: string }[] = [
|
||||
export function IconRail({ activePanel, onPanelChange, appMenuButton, className }: IconRailProps) {
|
||||
const theme = useViewer((state) => state.theme)
|
||||
const setTheme = useViewer((state) => state.setTheme)
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const setUnit = useViewer((state) => state.setUnit)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,6 +80,24 @@ export function IconRail({ activePanel, onPanelChange, appMenuButton, className
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Unit Toggle */}
|
||||
{mounted && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="mb-1 flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-accent/40 text-foreground transition-all hover:bg-accent"
|
||||
onClick={() => setUnit(unit === 'metric' ? 'imperial' : 'metric')}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 font-medium text-[10px] leading-none">
|
||||
{unit === 'metric' ? 'm' : 'ft'}
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Toggle units (metric/imperial)</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Theme Toggle */}
|
||||
{mounted && (
|
||||
<Tooltip>
|
||||
|
||||
@@ -182,6 +182,7 @@ export function SettingsPanel({
|
||||
const clearScene = useScene((state) => state.clearScene)
|
||||
const resetSelection = useViewer((state) => state.resetSelection)
|
||||
const exportScene = useViewer((state) => state.exportScene)
|
||||
const showGrid = useViewer((state) => state.showGrid)
|
||||
const setPhase = useEditor((state) => state.setPhase)
|
||||
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
|
||||
const sceneGraphValue = useMemo(
|
||||
@@ -307,7 +308,7 @@ export function SettingsPanel({
|
||||
<div className="text-muted-foreground text-xs">Visible only in the editor</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={useViewer((state) => state.showGrid)}
|
||||
checked={showGrid}
|
||||
onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+11
-10
@@ -9,6 +9,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './../../../../../components/ui/primitives/dialog'
|
||||
import { ShortcutToken } from './../../../../../components/ui/primitives/shortcut-token'
|
||||
|
||||
type Shortcut = {
|
||||
keys: string[]
|
||||
@@ -57,7 +58,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
{ keys: ['B'], action: 'Switch to Build mode' },
|
||||
{
|
||||
keys: ['Esc'],
|
||||
action: 'Cancel active tool, clear selection, and exit build mode',
|
||||
action: 'Cancel the active tool and return to Select mode',
|
||||
},
|
||||
{ keys: ['Delete / Backspace'], action: 'Delete selected objects' },
|
||||
{ keys: ['Cmd/Ctrl', 'Z'], action: 'Undo' },
|
||||
@@ -68,7 +69,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
title: 'Selection',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ['Cmd/Ctrl', 'Click'],
|
||||
keys: ['Cmd/Ctrl', 'Left click'],
|
||||
action: 'Add or remove an object from multi-selection',
|
||||
note: 'Works while in Select mode.',
|
||||
},
|
||||
@@ -100,9 +101,14 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
title: 'Camera',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ['Space', 'Drag'],
|
||||
keys: ['Middle click'],
|
||||
action: 'Pan camera',
|
||||
note: 'Hold Space while dragging with the mouse.',
|
||||
note: 'Drag with the middle mouse button, or hold Space while dragging with the left mouse button.',
|
||||
},
|
||||
{
|
||||
keys: ['Right click'],
|
||||
action: 'Orbit camera',
|
||||
note: 'Drag with the right mouse button.',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -126,12 +132,7 @@ function ShortcutKeys({ keys }: { keys: string[] }) {
|
||||
{keys.map((key, index) => (
|
||||
<div className="flex items-center gap-1" key={`${key}-${index}`}>
|
||||
{index > 0 ? <span className="text-[10px] text-muted-foreground">+</span> : null}
|
||||
<kbd
|
||||
className="inline-flex h-6 items-center rounded border border-border bg-muted px-2 font-medium font-mono text-[11px] text-muted-foreground"
|
||||
title={key}
|
||||
>
|
||||
{getDisplayKey(key, isMac)}
|
||||
</kbd>
|
||||
<ShortcutToken displayValue={getDisplayKey(key, isMac)} value={key} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from './../../../../../components/ui/primitives/tooltip'
|
||||
import { TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface BuildingTreeNodeProps {
|
||||
@@ -64,6 +64,7 @@ export function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps)
|
||||
isSelected={isSelected}
|
||||
label={node.name || 'Building'}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={() => focusTreeNode(node.id)}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
>
|
||||
{node.children.map((childId, index) => (
|
||||
|
||||
@@ -4,7 +4,7 @@ import Image from 'next/image'
|
||||
import { useEffect, useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface CeilingTreeNodeProps {
|
||||
@@ -28,7 +28,7 @@ export function CeilingTreeNode({ node, depth, isLast }: CeilingTreeNodeProps) {
|
||||
let isDescendant = false
|
||||
for (const id of selectedIds) {
|
||||
let current = nodes[id as AnyNodeId]
|
||||
while (current && current.parentId) {
|
||||
while (current?.parentId) {
|
||||
if (current.parentId === node.id) {
|
||||
isDescendant = true
|
||||
break
|
||||
@@ -51,7 +51,7 @@ export function CeilingTreeNode({ node, depth, isLast }: CeilingTreeNodeProps) {
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true)
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
@@ -118,8 +118,12 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
area += polygon[i]![0] * polygon[j]![1]
|
||||
area -= polygon[j]![0] * polygon[i]![1]
|
||||
const pi = polygon[i]
|
||||
const pj = polygon[j]
|
||||
if (pi && pj) {
|
||||
area += pi[0] * pj[1]
|
||||
area -= pj[0] * pi[1]
|
||||
}
|
||||
}
|
||||
|
||||
return Math.abs(area) / 2
|
||||
|
||||
@@ -6,7 +6,7 @@ import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface DoorTreeNodeProps {
|
||||
@@ -55,7 +55,7 @@ export function DoorTreeNode({ node, depth, isLast }: DoorTreeNodeProps) {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
onDoubleClick={() => focusTreeNode(node.id)}
|
||||
onMouseEnter={() => setHoveredId(node.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onToggle={() => {}}
|
||||
|
||||
@@ -12,12 +12,8 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
Box,
|
||||
Building2,
|
||||
Camera,
|
||||
ChevronDown,
|
||||
Image as ImageIcon,
|
||||
Layers,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@@ -34,11 +30,13 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from './../../../../../components/ui/primitives/popover'
|
||||
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
|
||||
import { cn } from './../../../../../lib/utils'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { useUploadStore } from '../../../../../store/use-upload'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { TreeNode } from './tree-node'
|
||||
import { focusTreeNode, TreeNode } from './tree-node'
|
||||
import { TreeNodeDragProvider } from './tree-node-drag'
|
||||
|
||||
// ============================================================================
|
||||
// PROPERTY LINE SECTION
|
||||
@@ -61,8 +59,10 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
||||
const n = polygon.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
area += polygon[i]![0] * polygon[j]![1]
|
||||
area -= polygon[j]![0] * polygon[i]![1]
|
||||
const [currentX, currentY] = polygon[i]!
|
||||
const [nextX, nextY] = polygon[j]!
|
||||
area += currentX * nextY
|
||||
area -= nextX * currentY
|
||||
}
|
||||
return Math.abs(area) / 2
|
||||
}
|
||||
@@ -316,9 +316,20 @@ function ReferenceItem({
|
||||
handleDelete: (id: string, e: React.MouseEvent) => void
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const handleSelect = () => {
|
||||
setSelectedReferenceId(refNode.id)
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
focusTreeNode(refNode.id as AnyNodeId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/ref relative flex h-8 select-none items-center border-border/50 border-b pr-2 text-xs transition-colors hover:bg-accent/30">
|
||||
<div
|
||||
className="group/ref relative flex h-8 cursor-pointer select-none items-center border-border/50 border-b pr-2 text-xs transition-colors hover:bg-accent/30"
|
||||
onClick={handleSelect}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute z-10 w-px bg-border/50',
|
||||
@@ -331,11 +342,7 @@ function ReferenceItem({
|
||||
style={{ left: 45, width: 8 }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 py-0 pl-[60px] text-muted-foreground group-hover/ref:text-foreground"
|
||||
onClick={() => setSelectedReferenceId(refNode.id)}
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
>
|
||||
<div className="flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 py-0 pl-[60px] text-muted-foreground group-hover/ref:text-foreground">
|
||||
{refNode.type === 'scan' ? (
|
||||
<img
|
||||
alt="Scan"
|
||||
@@ -547,7 +554,6 @@ function LevelItem({
|
||||
level,
|
||||
selectedLevelId,
|
||||
setSelection,
|
||||
deleteNode,
|
||||
updateNode,
|
||||
isLast,
|
||||
projectId,
|
||||
@@ -557,7 +563,6 @@ function LevelItem({
|
||||
level: LevelNode
|
||||
selectedLevelId: string | null
|
||||
setSelection: (selection: any) => void
|
||||
deleteNode: (id: AnyNodeId) => void
|
||||
updateNode: (id: AnyNodeId, updates: Partial<AnyNode>) => void
|
||||
isLast?: boolean
|
||||
projectId?: string
|
||||
@@ -568,6 +573,7 @@ function LevelItem({
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
const isSelected = selectedLevelId === level.id
|
||||
const canDeleteLevel = level.level !== 0
|
||||
const [isExpanded, setIsExpanded] = useState(isSelected)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -580,15 +586,25 @@ function LevelItem({
|
||||
}
|
||||
}, [isSelected])
|
||||
|
||||
const handleSelect = () => {
|
||||
setSelection({ levelId: level.id })
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
focusTreeNode(level.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
'group/level relative flex h-8 select-none items-center border-border/50 border-b pr-2 transition-all duration-200',
|
||||
'group/level relative flex h-8 cursor-pointer select-none items-center border-border/50 border-b pr-2 transition-all duration-200',
|
||||
isSelected
|
||||
? 'bg-accent/50 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
ref={itemRef}
|
||||
>
|
||||
{/* Vertical tree line */}
|
||||
@@ -631,11 +647,7 @@ function LevelItem({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 py-0 pl-0.5 text-sm"
|
||||
onClick={() => setSelection({ levelId: level.id })}
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
>
|
||||
<div className="flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 py-0 pl-0.5 text-sm">
|
||||
<img
|
||||
alt="Level"
|
||||
className={cn(
|
||||
@@ -733,15 +745,15 @@ function LevelItem({
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-40 p-1" side="right">
|
||||
{level.level !== 0 && (
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-sm hover:bg-accent hover:text-red-600"
|
||||
onClick={() => deleteNode(level.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors enabled:cursor-pointer enabled:hover:bg-accent enabled:hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!canDeleteLevel}
|
||||
onClick={() => deleteLevelWithFallbackSelection(level.id)}
|
||||
title={canDeleteLevel ? 'Delete level' : 'The ground level cannot be deleted'}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -780,7 +792,6 @@ function LevelsSection({
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const createNode = useScene((state) => state.createNode)
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const deleteNode = useScene((state) => state.deleteNode)
|
||||
const selectedBuildingId = useViewer((state) => state.selection.buildingId)
|
||||
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
@@ -832,7 +843,6 @@ function LevelsSection({
|
||||
)}
|
||||
{[...levels].reverse().map((level, index) => (
|
||||
<LevelItem
|
||||
deleteNode={deleteNode}
|
||||
isLast={index === levels.length - 1}
|
||||
key={level.id}
|
||||
level={level}
|
||||
@@ -1012,7 +1022,7 @@ function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true)
|
||||
focusTreeNode(zone.id)
|
||||
}
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
@@ -1229,16 +1239,18 @@ function ContentSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{elementChildren.map((childId, index) => (
|
||||
<TreeNode
|
||||
depth={0}
|
||||
isLast={index === elementChildren.length - 1}
|
||||
key={childId}
|
||||
nodeId={childId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<TreeNodeDragProvider>
|
||||
<div className="flex flex-col">
|
||||
{elementChildren.map((childId, index) => (
|
||||
<TreeNode
|
||||
depth={0}
|
||||
isLast={index === elementChildren.length - 1}
|
||||
key={childId}
|
||||
nodeId={childId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TreeNodeDragProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1271,6 +1283,17 @@ function BuildingItem({
|
||||
}
|
||||
}, [isBuildingActive])
|
||||
|
||||
const handleSelect = () => {
|
||||
setSelection({ buildingId: building.id })
|
||||
if (phase === 'site') {
|
||||
setPhase('structure')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
focusTreeNode(building.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn('flex shrink-0 flex-col overflow-hidden', isBuildingActive && 'min-h-0 flex-1')}
|
||||
@@ -1279,23 +1302,17 @@ function BuildingItem({
|
||||
>
|
||||
<motion.div
|
||||
className={cn(
|
||||
'group/building flex h-10 shrink-0 items-center border-border/50 border-b pr-2 transition-all duration-200',
|
||||
'group/building flex h-10 shrink-0 cursor-pointer items-center border-border/50 border-b pr-2 transition-all duration-200',
|
||||
isBuildingActive
|
||||
? 'bg-accent/50 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
|
||||
)}
|
||||
layout="position"
|
||||
onClick={handleSelect}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
ref={itemRef}
|
||||
>
|
||||
<button
|
||||
className="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 py-2 pl-3"
|
||||
onClick={() => {
|
||||
setSelection({ buildingId: building.id })
|
||||
if (phase === 'site') {
|
||||
setPhase('structure')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 py-2 pl-3">
|
||||
<img
|
||||
alt="Building"
|
||||
className={cn(
|
||||
@@ -1305,7 +1322,7 @@ function BuildingItem({
|
||||
src="/icons/building.png"
|
||||
/>
|
||||
<span className="truncate font-medium text-sm">{building.name || 'Building'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<Popover
|
||||
onOpenChange={(open) => setBuildingCameraOpen(open ? building.id : null)}
|
||||
open={buildingCameraOpen === building.id}
|
||||
@@ -1395,7 +1412,7 @@ function BuildingItem({
|
||||
/>
|
||||
<LayerToggle />
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="subtle-scrollbar relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<MultiSelectionBadge />
|
||||
<ContentSection />
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ export function InlineRenameInput({
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const [value, setValue] = useState(node.name || '')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inputSize = Math.max((value || defaultName).length, 1)
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
@@ -79,7 +80,7 @@ export function InlineRenameInput({
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'm-0 h-5 w-full flex-1 rounded-none border-primary/50 border-b bg-transparent px-0 py-0 text-foreground text-sm outline-none focus:border-primary',
|
||||
'm-0 h-5 min-w-[1ch] max-w-full flex-none rounded-none border-primary/50 border-b bg-transparent px-0 py-0 text-foreground text-sm outline-none focus:border-primary',
|
||||
className,
|
||||
)}
|
||||
onBlur={handleSave}
|
||||
@@ -89,6 +90,7 @@ export function InlineRenameInput({
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={defaultName}
|
||||
ref={inputRef}
|
||||
size={inputSize}
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Image from 'next/image'
|
||||
import { useEffect, useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
@@ -39,7 +39,7 @@ export function ItemTreeNode({ node, depth, isLast }: ItemTreeNodeProps) {
|
||||
let isDescendant = false
|
||||
for (const id of selectedIds) {
|
||||
let current = nodes[id as AnyNodeId]
|
||||
while (current && current.parentId) {
|
||||
while (current?.parentId) {
|
||||
if (current.parentId === node.id) {
|
||||
isDescendant = true
|
||||
break
|
||||
@@ -62,7 +62,7 @@ export function ItemTreeNode({ node, depth, isLast }: ItemTreeNodeProps) {
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true)
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { Layers } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface LevelTreeNodeProps {
|
||||
@@ -24,7 +24,7 @@ export function LevelTreeNode({ node, depth, isLast }: LevelTreeNodeProps) {
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true)
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
|
||||
const defaultName = `Level ${node.level}`
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { RoofNode } from '@pascal-app/core'
|
||||
import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { AnimatePresence } from 'motion/react'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import useEditor from '../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
import { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag'
|
||||
|
||||
interface RoofTreeNodeProps {
|
||||
node: RoofNode
|
||||
@@ -15,11 +17,14 @@ interface RoofTreeNodeProps {
|
||||
|
||||
export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const { drag, dropTarget } = useTreeNodeDrag()
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -30,7 +35,7 @@ export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true)
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
@@ -41,39 +46,169 @@ export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
|
||||
setHoveredId(null)
|
||||
}
|
||||
|
||||
// Calculate dimensions: length × total width (leftWidth + rightWidth)
|
||||
const totalWidth = node.leftWidth + node.rightWidth
|
||||
const sizeLabel = `${node.length.toFixed(1)}×${totalWidth.toFixed(1)}m`
|
||||
const defaultName = `Roof (${sizeLabel})`
|
||||
const segments = (node.children ?? [])
|
||||
.map((childId) => nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||
.filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
|
||||
|
||||
const hasSelectedChild = segments.some((seg) => selectedIds.includes(seg.id))
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected || hasSelectedChild) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [isSelected, hasSelectedChild])
|
||||
|
||||
// Auto-expand when a segment is being dragged over this roof
|
||||
const isDropTarget = drag !== null && dropTarget?.parentId === node.id
|
||||
useEffect(() => {
|
||||
if (isDropTarget && !expanded) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [isDropTarget, expanded])
|
||||
|
||||
const segmentCount = segments.length
|
||||
const defaultName = `Roof (${segmentCount} segment${segmentCount !== 1 ? 's' : ''})`
|
||||
|
||||
// Hide the dragged segment from every roof while dragging
|
||||
const visibleSegments = drag ? segments.filter((seg) => seg.id !== drag.nodeId) : segments
|
||||
|
||||
const isValidDropTarget = drag !== null && drag.nodeId !== node.id
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/roof.png" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
}
|
||||
nodeId={node.id}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
<div data-drop-target={node.id}>
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
depth={depth}
|
||||
expanded={expanded}
|
||||
hasChildren={segments.length > 0}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/roof.png" width={14} />
|
||||
}
|
||||
isDropTarget={isValidDropTarget && isDropTarget}
|
||||
isHovered={isHovered || isDropTarget}
|
||||
isLast={isLast && !expanded}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
}
|
||||
nodeId={node.id}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
>
|
||||
{visibleSegments.map((seg, i) => {
|
||||
const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i
|
||||
const showIndicatorAfter =
|
||||
isDropTarget &&
|
||||
i === visibleSegments.length - 1 &&
|
||||
dropTarget?.insertIndex !== undefined &&
|
||||
dropTarget.insertIndex > i
|
||||
|
||||
return (
|
||||
<div key={seg.id}>
|
||||
<AnimatePresence>
|
||||
{showIndicatorBefore && <DropIndicatorLine key="indicator-before" />}
|
||||
</AnimatePresence>
|
||||
<RoofSegmentTreeNode
|
||||
depth={depth + 1}
|
||||
isLast={isLast && i === visibleSegments.length - 1 && !showIndicatorAfter}
|
||||
node={seg}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{showIndicatorAfter && <DropIndicatorLine key="indicator-after" />}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<AnimatePresence>
|
||||
{isDropTarget && visibleSegments.length === 0 && <DropIndicatorLine />}
|
||||
</AnimatePresence>
|
||||
</TreeNodeWrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoofSegmentTreeNode({
|
||||
node,
|
||||
depth,
|
||||
isLast,
|
||||
}: {
|
||||
node: RoofSegmentNode
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
const { startDrag, isDragging } = useTreeNodeDrag()
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isDragging) return
|
||||
e.stopPropagation()
|
||||
handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
}
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return
|
||||
const label = `${node.roofType.charAt(0).toUpperCase() + node.roofType.slice(1)} (${node.width.toFixed(1)}×${node.depth.toFixed(1)}m)`
|
||||
startDrag(node.id, node.type, node.parentId as string, label, e.clientX, e.clientY)
|
||||
},
|
||||
[node.id, node.type, node.parentId, node.roofType, node.width, node.depth, startDrag],
|
||||
)
|
||||
|
||||
const defaultName = `${node.roofType.charAt(0).toUpperCase() + node.roofType.slice(1)} (${node.width.toFixed(1)}x${node.depth.toFixed(1)}m)`
|
||||
|
||||
return (
|
||||
<div data-drop-child={node.id}>
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<Image
|
||||
alt=""
|
||||
className="object-contain opacity-60"
|
||||
height={14}
|
||||
src="/icons/roof.png"
|
||||
width={14}
|
||||
/>
|
||||
}
|
||||
isDraggable
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
}
|
||||
nodeId={node.id}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={() => focusTreeNode(node.id)}
|
||||
onMouseEnter={() => setHoveredId(node.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface SlabTreeNodeProps {
|
||||
@@ -30,7 +30,7 @@ export function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) {
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true)
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
@@ -88,8 +88,12 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
area += polygon[i]![0] * polygon[j]![1]
|
||||
area -= polygon[j]![0] * polygon[i]![1]
|
||||
const pi = polygon[i]
|
||||
const pj = polygon[j]
|
||||
if (pi && pj) {
|
||||
area += pi[0] * pj[1]
|
||||
area -= pj[0] * pi[1]
|
||||
}
|
||||
}
|
||||
|
||||
return Math.abs(area) / 2
|
||||
|
||||
@@ -23,7 +23,7 @@ export function TreeNodeActions({ node }: TreeNodeActionsProps) {
|
||||
const toggleVisibility = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const newVisibility = !isVisible
|
||||
if (selectedIds && selectedIds.includes(node.id)) {
|
||||
if (selectedIds?.includes(node.id)) {
|
||||
updateNodes(
|
||||
selectedIds.map((id) => ({
|
||||
id: id as AnyNodeId,
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { motion } from 'motion/react'
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reparenting rules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Maps a draggable node type to the parent types it can be dropped into.
|
||||
const REPARENT_TARGETS: Record<string, string[]> = {
|
||||
'roof-segment': ['roof'],
|
||||
}
|
||||
|
||||
// Container types that should be auto-removed when all children are moved out.
|
||||
const REMOVE_WHEN_EMPTY = new Set(['roof'])
|
||||
|
||||
export function canDrag(node: AnyNode): boolean {
|
||||
return node.type in REPARENT_TARGETS
|
||||
}
|
||||
|
||||
export function canDrop(draggedType: string, targetType: string): boolean {
|
||||
return REPARENT_TARGETS[draggedType]?.includes(targetType) ?? false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coordinate preservation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Transform = {
|
||||
position: [number, number, number]
|
||||
rotation: number
|
||||
}
|
||||
|
||||
function getTransform(node: AnyNode): Transform {
|
||||
const pos =
|
||||
'position' in node && Array.isArray(node.position)
|
||||
? (node.position as [number, number, number])
|
||||
: ([0, 0, 0] as [number, number, number])
|
||||
const rot = 'rotation' in node && typeof node.rotation === 'number' ? node.rotation : 0
|
||||
return { position: pos, rotation: rot }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute new local position + rotation so the child stays at the same
|
||||
* absolute grid position when moved from oldParent to newParent.
|
||||
*/
|
||||
function computeReparentTransform(
|
||||
child: Transform,
|
||||
oldParent: Transform,
|
||||
newParent: Transform,
|
||||
): Transform {
|
||||
// child → world: world = parentPos + rotateY(childPos, parentRot)
|
||||
const cosOld = Math.cos(oldParent.rotation)
|
||||
const sinOld = Math.sin(oldParent.rotation)
|
||||
const absX = oldParent.position[0] + child.position[0] * cosOld + child.position[2] * sinOld
|
||||
const absY = oldParent.position[1] + child.position[1]
|
||||
const absZ = oldParent.position[2] - child.position[0] * sinOld + child.position[2] * cosOld
|
||||
|
||||
// world → newParent local: rotateY_inverse(world - newParentPos, newParentRot)
|
||||
const dx = absX - newParent.position[0]
|
||||
const dy = absY - newParent.position[1]
|
||||
const dz = absZ - newParent.position[2]
|
||||
const cosNew = Math.cos(-newParent.rotation)
|
||||
const sinNew = Math.sin(-newParent.rotation)
|
||||
|
||||
return {
|
||||
position: [dx * cosNew + dz * sinNew, dy, -dx * sinNew + dz * cosNew],
|
||||
rotation: oldParent.rotation + child.rotation - newParent.rotation,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DragState = {
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
sourceParentId: string
|
||||
label: string
|
||||
pointerX: number
|
||||
pointerY: number
|
||||
} | null
|
||||
|
||||
type DropTarget = {
|
||||
parentId: string
|
||||
insertIndex: number
|
||||
} | null
|
||||
|
||||
type TreeNodeDragContextValue = {
|
||||
drag: DragState
|
||||
dropTarget: DropTarget
|
||||
startDrag: (
|
||||
nodeId: string,
|
||||
nodeType: string,
|
||||
sourceParentId: string,
|
||||
label: string,
|
||||
x: number,
|
||||
y: number,
|
||||
) => void
|
||||
isDragging: boolean
|
||||
}
|
||||
|
||||
const TreeNodeDragContext = createContext<TreeNodeDragContextValue>({
|
||||
drag: null,
|
||||
dropTarget: null,
|
||||
startDrag: () => {},
|
||||
isDragging: false,
|
||||
})
|
||||
|
||||
export const useTreeNodeDrag = () => useContext(TreeNodeDragContext)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 4
|
||||
|
||||
export function TreeNodeDragProvider({ children }: { children: ReactNode }) {
|
||||
const [drag, setDrag] = useState<DragState>(null)
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget>(null)
|
||||
const pendingRef = useRef<{
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
sourceParentId: string
|
||||
label: string
|
||||
startX: number
|
||||
startY: number
|
||||
} | null>(null)
|
||||
|
||||
const commitDrop = useCallback(() => {
|
||||
if (!(drag && dropTarget)) return
|
||||
|
||||
const state = useScene.getState()
|
||||
|
||||
if (dropTarget.parentId === drag.sourceParentId) {
|
||||
// --- Reorder within same parent ---
|
||||
const parent = state.nodes[dropTarget.parentId as AnyNodeId]
|
||||
if (parent && 'children' in parent && Array.isArray(parent.children)) {
|
||||
const currentChildren = [...parent.children] as string[]
|
||||
const fromIndex = currentChildren.indexOf(drag.nodeId)
|
||||
if (fromIndex === -1) return
|
||||
currentChildren.splice(fromIndex, 1)
|
||||
const toIndex = Math.min(dropTarget.insertIndex, currentChildren.length)
|
||||
currentChildren.splice(toIndex, 0, drag.nodeId)
|
||||
state.updateNode(dropTarget.parentId as AnyNodeId, { children: currentChildren } as any)
|
||||
}
|
||||
} else {
|
||||
// --- Reparent to different parent, preserving world position ---
|
||||
const node = state.nodes[drag.nodeId as AnyNodeId]
|
||||
const oldParent = state.nodes[drag.sourceParentId as AnyNodeId]
|
||||
const newParent = state.nodes[dropTarget.parentId as AnyNodeId]
|
||||
if (!(node && oldParent && newParent)) return
|
||||
|
||||
const newLocal = computeReparentTransform(
|
||||
getTransform(node),
|
||||
getTransform(oldParent),
|
||||
getTransform(newParent),
|
||||
)
|
||||
|
||||
state.updateNode(
|
||||
drag.nodeId as AnyNodeId,
|
||||
{
|
||||
parentId: dropTarget.parentId,
|
||||
position: newLocal.position,
|
||||
rotation: newLocal.rotation,
|
||||
} as any,
|
||||
)
|
||||
|
||||
// Place at the correct index within the new parent's children
|
||||
const updatedParent = state.nodes[dropTarget.parentId as AnyNodeId]
|
||||
if (updatedParent && 'children' in updatedParent && Array.isArray(updatedParent.children)) {
|
||||
const children = [...updatedParent.children] as string[]
|
||||
const idx = children.indexOf(drag.nodeId)
|
||||
if (idx !== -1) {
|
||||
children.splice(idx, 1)
|
||||
const toIndex = Math.min(dropTarget.insertIndex, children.length)
|
||||
children.splice(toIndex, 0, drag.nodeId)
|
||||
state.updateNode(dropTarget.parentId as AnyNodeId, { children } as any)
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle: remove old parent if it's now empty and in REMOVE_WHEN_EMPTY
|
||||
const staleParent = state.nodes[drag.sourceParentId as AnyNodeId]
|
||||
if (
|
||||
staleParent &&
|
||||
REMOVE_WHEN_EMPTY.has(staleParent.type) &&
|
||||
'children' in staleParent &&
|
||||
Array.isArray(staleParent.children) &&
|
||||
staleParent.children.length === 0
|
||||
) {
|
||||
state.deleteNode(drag.sourceParentId as AnyNodeId)
|
||||
}
|
||||
}
|
||||
}, [drag, dropTarget])
|
||||
|
||||
const startDrag = useCallback(
|
||||
(
|
||||
nodeId: string,
|
||||
nodeType: string,
|
||||
sourceParentId: string,
|
||||
label: string,
|
||||
x: number,
|
||||
y: number,
|
||||
) => {
|
||||
pendingRef.current = { nodeId, nodeType, sourceParentId, label, startX: x, startY: y }
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (pendingRef.current && !drag) {
|
||||
const dx = e.clientX - pendingRef.current.startX
|
||||
const dy = e.clientY - pendingRef.current.startY
|
||||
if (Math.abs(dx) + Math.abs(dy) >= DRAG_THRESHOLD) {
|
||||
const p = pendingRef.current
|
||||
setDrag({
|
||||
nodeId: p.nodeId,
|
||||
nodeType: p.nodeType,
|
||||
sourceParentId: p.sourceParentId,
|
||||
label: p.label,
|
||||
pointerX: e.clientX,
|
||||
pointerY: e.clientY,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!drag) return
|
||||
|
||||
setDrag((prev) => (prev ? { ...prev, pointerX: e.clientX, pointerY: e.clientY } : null))
|
||||
|
||||
// Hit-test for drop targets
|
||||
const els = document.elementsFromPoint(e.clientX, e.clientY)
|
||||
let foundTarget: DropTarget = null
|
||||
|
||||
for (const el of els) {
|
||||
const targetEl = (el as HTMLElement).closest?.('[data-drop-target]') as HTMLElement | null
|
||||
if (!targetEl) continue
|
||||
|
||||
const parentId = targetEl.dataset.dropTarget!
|
||||
|
||||
// Validate this is a legal drop
|
||||
const targetNode = useScene.getState().nodes[parentId as AnyNodeId]
|
||||
if (!(targetNode && canDrop(drag.nodeType, targetNode.type))) continue
|
||||
|
||||
// Find child rows to determine insert index
|
||||
const childRows = targetEl.querySelectorAll<HTMLElement>('[data-drop-child]')
|
||||
let insertIndex = childRows.length
|
||||
|
||||
for (let i = 0; i < childRows.length; i++) {
|
||||
const row = childRows[i]!
|
||||
const rect = row.getBoundingClientRect()
|
||||
const midY = rect.top + rect.height / 2
|
||||
if (e.clientY < midY) {
|
||||
insertIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
foundTarget = { parentId, insertIndex }
|
||||
break
|
||||
}
|
||||
|
||||
setDropTarget(foundTarget)
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (drag) {
|
||||
commitDrop()
|
||||
}
|
||||
pendingRef.current = null
|
||||
setDrag(null)
|
||||
setDropTarget(null)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [drag, commitDrop])
|
||||
|
||||
const isDragging = drag !== null
|
||||
|
||||
return (
|
||||
<TreeNodeDragContext.Provider value={{ drag, dropTarget, startDrag, isDragging }}>
|
||||
{isDragging && <style>{'* { cursor: grabbing !important; }'}</style>}
|
||||
{children}
|
||||
{drag && <FloatingPreview drag={drag} />}
|
||||
</TreeNodeDragContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floating preview (portal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FloatingPreview({ drag }: { drag: NonNullable<DragState> }) {
|
||||
return createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed z-[200] flex items-center gap-1.5 rounded-lg border border-accent bg-background/95 px-2.5 py-1.5 font-medium text-foreground text-xs shadow-xl backdrop-blur-sm"
|
||||
style={{
|
||||
left: drag.pointerX + 12,
|
||||
top: drag.pointerY - 14,
|
||||
}}
|
||||
>
|
||||
<span className="opacity-60">↕</span>
|
||||
{drag.label}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drop indicator line
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function DropIndicatorLine() {
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ height: 2, opacity: 1 }}
|
||||
className="pointer-events-none mx-3 rounded-full bg-blue-500"
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
transition={{ type: 'spring', bounce: 0.3, duration: 0.25 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { forwardRef, useEffect, useRef } from 'react'
|
||||
import { cn } from './../../../../../lib/utils'
|
||||
|
||||
export function handleTreeSelection(
|
||||
e: React.MouseEvent,
|
||||
@@ -50,6 +49,11 @@ export function handleTreeSelection(
|
||||
return false
|
||||
}
|
||||
|
||||
export function focusTreeNode(nodeId: AnyNodeId) {
|
||||
emitter.emit('camera-controls:focus', { nodeId })
|
||||
}
|
||||
|
||||
import { cn } from '../../../../../lib/utils'
|
||||
import { BuildingTreeNode } from './building-tree-node'
|
||||
import { CeilingTreeNode } from './ceiling-tree-node'
|
||||
import { DoorTreeNode } from './door-tree-node'
|
||||
@@ -110,12 +114,15 @@ interface TreeNodeWrapperProps {
|
||||
onDoubleClick?: () => void
|
||||
onMouseEnter?: () => void
|
||||
onMouseLeave?: () => void
|
||||
onPointerDown?: (e: React.PointerEvent) => void
|
||||
actions?: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
isSelected?: boolean
|
||||
isHovered?: boolean
|
||||
isVisible?: boolean
|
||||
isLast?: boolean
|
||||
isDraggable?: boolean
|
||||
isDropTarget?: boolean
|
||||
}
|
||||
|
||||
export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
||||
@@ -132,12 +139,15 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
||||
onDoubleClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onPointerDown,
|
||||
actions,
|
||||
children,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isVisible = true,
|
||||
isLast,
|
||||
isDraggable,
|
||||
isDropTarget,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -156,13 +166,19 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
||||
'group/row relative flex h-8 cursor-pointer select-none items-center border-border/50 border-r border-r-transparent border-b text-sm transition-all duration-200',
|
||||
isSelected
|
||||
? 'border-r-3 border-r-white bg-accent/50 text-foreground'
|
||||
: isHovered
|
||||
? 'bg-accent/30 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
|
||||
: isDropTarget
|
||||
? 'bg-blue-500/15 text-foreground ring-1 ring-blue-500/40 ring-inset'
|
||||
: isHovered
|
||||
? 'bg-accent/30 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
|
||||
!isVisible && 'opacity-50',
|
||||
isDraggable && 'cursor-grab active:cursor-grabbing',
|
||||
)}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onPointerDown={onPointerDown}
|
||||
ref={rowRef}
|
||||
style={{ paddingLeft: depth * 12 + 12, paddingRight: 12 }}
|
||||
>
|
||||
@@ -195,18 +211,16 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
||||
}}
|
||||
>
|
||||
{hasChildren ? (
|
||||
expanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<motion.div
|
||||
animate={{ rotate: expanded ? 90 : 0 }}
|
||||
initial={false}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)
|
||||
</motion.div>
|
||||
) : null}
|
||||
</button>
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5"
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200',
|
||||
|
||||
@@ -4,7 +4,7 @@ import Image from 'next/image'
|
||||
import { useEffect, useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface WallTreeNodeProps {
|
||||
@@ -28,7 +28,7 @@ export function WallTreeNode({ node, depth, isLast }: WallTreeNodeProps) {
|
||||
let isDescendant = false
|
||||
for (const id of selectedIds) {
|
||||
let current = nodes[id as AnyNodeId]
|
||||
while (current && current.parentId) {
|
||||
while (current?.parentId) {
|
||||
if (current.parentId === node.id) {
|
||||
isDescendant = true
|
||||
break
|
||||
@@ -51,7 +51,7 @@ export function WallTreeNode({ node, depth, isLast }: WallTreeNodeProps) {
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true)
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface WindowTreeNodeProps {
|
||||
@@ -55,7 +55,7 @@ export function WindowTreeNode({ node, depth, isLast }: WindowTreeNodeProps) {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
onDoubleClick={() => focusTreeNode(node.id)}
|
||||
onMouseEnter={() => setHoveredId(node.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onToggle={() => {}}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useState } from 'react'
|
||||
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { TreeNodeWrapper } from './tree-node'
|
||||
import { focusTreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface ZoneTreeNodeProps {
|
||||
@@ -25,7 +25,7 @@ export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true)
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
@@ -79,8 +79,12 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
area += polygon[i]![0] * polygon[j]![1]
|
||||
area -= polygon[j]![0] * polygon[i]![1]
|
||||
const pi = polygon[i]
|
||||
const pj = polygon[j]
|
||||
if (pi && pj) {
|
||||
area += pi[0] * pj[1]
|
||||
area -= pj[0] * pi[1]
|
||||
}
|
||||
}
|
||||
|
||||
return Math.abs(area) / 2
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Icon } from '@iconify/react'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Layers2, Moon, Sun } from 'lucide-react'
|
||||
import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Sun } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import Link from 'next/link'
|
||||
import { cn } from '../lib/utils'
|
||||
@@ -30,6 +31,13 @@ const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
||||
solo: 'Solo',
|
||||
}
|
||||
|
||||
const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = {
|
||||
manual: 'Stack',
|
||||
stacked: 'Stack',
|
||||
exploded: 'Exploded',
|
||||
solo: 'Solo',
|
||||
}
|
||||
|
||||
const wallModeConfig = {
|
||||
up: {
|
||||
icon: (props: any) => (
|
||||
@@ -58,6 +66,7 @@ const getNodeName = (node: AnyNode): string => {
|
||||
if (node.type === 'slab') return 'Slab'
|
||||
if (node.type === 'ceiling') return 'Ceiling'
|
||||
if (node.type === 'roof') return 'Roof'
|
||||
if (node.type === 'roof-segment') return 'Roof Segment'
|
||||
return node.type
|
||||
}
|
||||
|
||||
@@ -378,11 +387,12 @@ export const ViewerOverlay = ({
|
||||
|
||||
{/* Level Mode */}
|
||||
<ActionButton
|
||||
className={
|
||||
levelMode !== 'stacked'
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
: 'hover:bg-white/5 hover:text-amber-400'
|
||||
}
|
||||
className={cn(
|
||||
'p-0',
|
||||
levelMode === 'stacked' || levelMode === 'manual'
|
||||
? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
|
||||
: 'bg-white/10 text-foreground',
|
||||
)}
|
||||
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
|
||||
onClick={() => {
|
||||
if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked')
|
||||
@@ -394,11 +404,21 @@ export const ViewerOverlay = ({
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
|
||||
{levelMode === 'exploded' && <Layers2 className="h-6 w-6" />}
|
||||
{(levelMode === 'stacked' || levelMode === 'manual') && (
|
||||
<Layers className="h-6 w-6" />
|
||||
)}
|
||||
<span className="relative flex h-full w-full items-center justify-center pb-1">
|
||||
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
|
||||
{levelMode === 'exploded' && (
|
||||
<Icon color="currentColor" height={24} icon="charm:stack-pop" width={24} />
|
||||
)}
|
||||
{(levelMode === 'stacked' || levelMode === 'manual') && (
|
||||
<Icon color="currentColor" height={24} icon="charm:stack-push" width={24} />
|
||||
)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-1 bottom-1 left-1 rounded border border-border/50 bg-background/70 px-0.5 py-[2px] text-center font-medium font-pixel text-[8px] text-foreground/85 leading-none tracking-[-0.02em] backdrop-blur-sm"
|
||||
>
|
||||
{levelModeBadgeLabels[levelMode]}
|
||||
</span>
|
||||
</span>
|
||||
</ActionButton>
|
||||
|
||||
{/* Wall Mode */}
|
||||
|
||||
@@ -16,7 +16,11 @@ export const useKeyboard = () => {
|
||||
e.preventDefault()
|
||||
emitter.emit('tool:cancel')
|
||||
|
||||
// Clear selections to close UI panels, but KEEP the active building and level context
|
||||
// Return to the default select tool while keeping the active building/level context.
|
||||
useEditor.getState().setEditingHole(null)
|
||||
useEditor.getState().setMode('select')
|
||||
|
||||
// Clear selections to close UI panels, but KEEP the active building and level context.
|
||||
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
||||
useEditor.getState().setSelectedReferenceId(null)
|
||||
} else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
|
||||
@@ -87,6 +91,29 @@ export const useKeyboard = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'r' || e.key === 'R') {
|
||||
// Rotate selected node if it supports rotation (items, roofs, etc.)
|
||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||
if (selectedNodeIds.length === 1) {
|
||||
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||
if (node && 'rotation' in node) {
|
||||
e.preventDefault()
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
let newRotationY = 0
|
||||
|
||||
// Handle different rotation types (number for roof, array for items/windows/doors)
|
||||
if (typeof node.rotation === 'number') {
|
||||
newRotationY = node.rotation + ROTATION_STEP
|
||||
useScene.getState().updateNode(node.id, { rotation: newRotationY })
|
||||
} else if (Array.isArray(node.rotation)) {
|
||||
newRotationY = node.rotation[1] + ROTATION_STEP
|
||||
useScene.getState().updateNode(node.id, {
|
||||
rotation: [node.rotation[0], newRotationY, node.rotation[2]],
|
||||
})
|
||||
}
|
||||
sfxEmitter.emit('sfx:item-rotate') // Play a sound for feedback
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault()
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type { EditorProps } from './components/editor'
|
||||
export { default as Editor } from './components/editor'
|
||||
export { useCommandPalette } from './components/ui/command-palette'
|
||||
export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items'
|
||||
export { Slider } from './components/ui/primitives/slider'
|
||||
export { SceneLoader } from './components/ui/scene-loader'
|
||||
export type {
|
||||
ProjectVisibility,
|
||||
@@ -12,5 +14,12 @@ export { PresetsProvider } from './contexts/presets-context'
|
||||
export type { SaveStatus } from './hooks/use-auto-save'
|
||||
export type { SceneGraph } from './lib/scene'
|
||||
export { applySceneGraphToEditor } from './lib/scene'
|
||||
export { default as useAudio } from './store/use-audio'
|
||||
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
|
||||
export { default as useEditor } from './store/use-editor'
|
||||
export {
|
||||
type PaletteView,
|
||||
type PaletteViewProps,
|
||||
usePaletteViewRegistry,
|
||||
} from './store/use-palette-view-registry'
|
||||
export { useUploadStore } from './store/use-upload'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AnyNodeId, BuildingNode, LevelNode } from '@pascal-app/core'
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
|
||||
function getAdjacentLevelIdForDeletion(levelId: AnyNodeId): LevelNode['id'] | null {
|
||||
const { nodes } = useScene.getState()
|
||||
const level = nodes[levelId]
|
||||
if (!level || level.type !== 'level' || !level.parentId) return null
|
||||
|
||||
const building = nodes[level.parentId as AnyNodeId]
|
||||
if (!building || building.type !== 'building') return null
|
||||
|
||||
const siblingLevelIds = (building as BuildingNode).children.filter(
|
||||
(childId): childId is LevelNode['id'] => nodes[childId as AnyNodeId]?.type === 'level',
|
||||
)
|
||||
const currentIndex = siblingLevelIds.indexOf(level.id)
|
||||
if (currentIndex === -1) return null
|
||||
|
||||
return siblingLevelIds[currentIndex - 1] ?? siblingLevelIds[currentIndex + 1] ?? null
|
||||
}
|
||||
|
||||
export function deleteLevelWithFallbackSelection(levelId: AnyNodeId) {
|
||||
const isSelectedLevel = useViewer.getState().selection.levelId === levelId
|
||||
const nextLevelId = getAdjacentLevelIdForDeletion(levelId)
|
||||
|
||||
useScene.getState().deleteNode(levelId)
|
||||
|
||||
if (isSelectedLevel) {
|
||||
useViewer.getState().setSelection({ levelId: nextLevelId })
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,261 @@
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import useEditor from '../store/use-editor'
|
||||
import useEditor, {
|
||||
hasCustomPersistedEditorUiState,
|
||||
normalizePersistedEditorUiState,
|
||||
type PersistedEditorUiState,
|
||||
} from '../store/use-editor'
|
||||
|
||||
export type SceneGraph = {
|
||||
nodes: Record<string, unknown>
|
||||
rootNodeIds: string[]
|
||||
}
|
||||
|
||||
type PersistedSelectionPath = {
|
||||
buildingId: string | null
|
||||
levelId: string | null
|
||||
zoneId: string | null
|
||||
selectedIds: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs are stored as plain strings in localStorage. Cast them back to their
|
||||
* branded template-literal types before passing to the viewer store.
|
||||
*/
|
||||
function toViewerSelection(s: PersistedSelectionPath) {
|
||||
return s as unknown as Parameters<ReturnType<typeof useViewer.getState>['setSelection']>[0]
|
||||
}
|
||||
|
||||
const EMPTY_PERSISTED_SELECTION: PersistedSelectionPath = {
|
||||
buildingId: null,
|
||||
levelId: null,
|
||||
zoneId: null,
|
||||
selectedIds: [],
|
||||
}
|
||||
|
||||
const SELECTION_STORAGE_KEY = 'pascal-editor-selection'
|
||||
|
||||
function getSelectionStorageKey(): string {
|
||||
const projectId = useViewer.getState().projectId
|
||||
return projectId ? `${SELECTION_STORAGE_KEY}:${projectId}` : SELECTION_STORAGE_KEY
|
||||
}
|
||||
|
||||
function getSelectionStorageReadKeys(): string[] {
|
||||
const scopedKey = getSelectionStorageKey()
|
||||
return scopedKey === SELECTION_STORAGE_KEY ? [scopedKey] : [scopedKey, SELECTION_STORAGE_KEY]
|
||||
}
|
||||
|
||||
function getDefaultLevelIdForBuilding(
|
||||
sceneNodes: Record<string, any>,
|
||||
buildingId: string | null,
|
||||
): string | null {
|
||||
if (!buildingId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const buildingNode = sceneNodes[buildingId]
|
||||
if (buildingNode?.type !== 'building' || !Array.isArray(buildingNode.children)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let firstLevelId: string | null = null
|
||||
|
||||
for (const childId of buildingNode.children) {
|
||||
const levelNode = sceneNodes[childId]
|
||||
if (levelNode?.type !== 'level') {
|
||||
continue
|
||||
}
|
||||
|
||||
firstLevelId ??= levelNode.id
|
||||
|
||||
if (levelNode.level === 0) {
|
||||
return levelNode.id
|
||||
}
|
||||
}
|
||||
|
||||
return firstLevelId
|
||||
}
|
||||
|
||||
function normalizePersistedSelectionPath(
|
||||
selection: Partial<PersistedSelectionPath> | null | undefined,
|
||||
): PersistedSelectionPath {
|
||||
return {
|
||||
buildingId: typeof selection?.buildingId === 'string' ? selection.buildingId : null,
|
||||
levelId: typeof selection?.levelId === 'string' ? selection.levelId : null,
|
||||
zoneId: typeof selection?.zoneId === 'string' ? selection.zoneId : null,
|
||||
selectedIds: Array.isArray(selection?.selectedIds)
|
||||
? selection.selectedIds.filter((id): id is string => typeof id === 'string')
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
function hasPersistedSelectionValue(selection: PersistedSelectionPath): boolean {
|
||||
return Boolean(
|
||||
selection.buildingId ||
|
||||
selection.levelId ||
|
||||
selection.zoneId ||
|
||||
selection.selectedIds.length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
function readPersistedSelection(): PersistedSelectionPath | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
for (const key of getSelectionStorageReadKeys()) {
|
||||
const rawSelection = window.localStorage.getItem(key)
|
||||
if (!rawSelection) {
|
||||
continue
|
||||
}
|
||||
|
||||
return normalizePersistedSelectionPath(
|
||||
JSON.parse(rawSelection) as Partial<PersistedSelectionPath>,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function writePersistedSelection(selection: {
|
||||
buildingId: string | null
|
||||
levelId: string | null
|
||||
zoneId: string | null
|
||||
selectedIds: string[]
|
||||
}) {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const sceneNodes = useScene.getState().nodes as Record<string, any>
|
||||
const normalizedSelection = normalizePersistedSelectionPath(selection)
|
||||
const validatedSelection =
|
||||
getValidatedSelectionForScene(sceneNodes, normalizedSelection) ?? normalizedSelection
|
||||
|
||||
window.localStorage.setItem(getSelectionStorageKey(), JSON.stringify(validatedSelection))
|
||||
} catch {
|
||||
// Swallow storage quota errors
|
||||
}
|
||||
}
|
||||
|
||||
function getEditorUiStateForRestoredSelection(
|
||||
sceneNodes: Record<string, any>,
|
||||
selection: PersistedSelectionPath,
|
||||
fallbackUiState: PersistedEditorUiState,
|
||||
): PersistedEditorUiState {
|
||||
if (!selection.levelId) {
|
||||
return {
|
||||
...fallbackUiState,
|
||||
phase: 'site',
|
||||
mode: fallbackUiState.phase === 'site' ? fallbackUiState.mode : 'select',
|
||||
tool: null,
|
||||
structureLayer: 'elements',
|
||||
catalogCategory: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.zoneId) {
|
||||
return {
|
||||
...fallbackUiState,
|
||||
phase: 'structure',
|
||||
mode: 'select',
|
||||
tool: null,
|
||||
structureLayer: 'zones',
|
||||
catalogCategory: null,
|
||||
}
|
||||
}
|
||||
|
||||
const selectedNodes = selection.selectedIds
|
||||
.map((id) => sceneNodes[id])
|
||||
.filter((node): node is Record<string, any> => Boolean(node))
|
||||
|
||||
const shouldRestoreFurnishPhase =
|
||||
selectedNodes.length > 0 &&
|
||||
selectedNodes.every(
|
||||
(node) =>
|
||||
node.type === 'item' &&
|
||||
node.asset?.category !== 'door' &&
|
||||
node.asset?.category !== 'window',
|
||||
)
|
||||
|
||||
return {
|
||||
...fallbackUiState,
|
||||
phase: shouldRestoreFurnishPhase ? 'furnish' : 'structure',
|
||||
mode: 'select',
|
||||
tool: null,
|
||||
structureLayer: 'elements',
|
||||
catalogCategory: null,
|
||||
}
|
||||
}
|
||||
|
||||
function getValidatedSelectionForScene(
|
||||
sceneNodes: Record<string, any>,
|
||||
selection: PersistedSelectionPath,
|
||||
): PersistedSelectionPath | null {
|
||||
const levelNode = selection.levelId ? sceneNodes[selection.levelId] : null
|
||||
const hasValidLevel = levelNode?.type === 'level'
|
||||
const buildingNodeFromLevel =
|
||||
hasValidLevel && levelNode.parentId ? sceneNodes[levelNode.parentId] : null
|
||||
const explicitBuildingNode = selection.buildingId ? sceneNodes[selection.buildingId] : null
|
||||
const buildingId =
|
||||
buildingNodeFromLevel?.type === 'building'
|
||||
? buildingNodeFromLevel.id
|
||||
: explicitBuildingNode?.type === 'building'
|
||||
? explicitBuildingNode.id
|
||||
: null
|
||||
|
||||
if (!buildingId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const levelId = hasValidLevel
|
||||
? levelNode.id
|
||||
: getDefaultLevelIdForBuilding(sceneNodes, buildingId)
|
||||
|
||||
if (levelId) {
|
||||
const zoneNode = selection.zoneId ? sceneNodes[selection.zoneId] : null
|
||||
const zoneId =
|
||||
zoneNode?.type === 'zone' && resolveLevelId(zoneNode, sceneNodes) === levelId
|
||||
? zoneNode.id
|
||||
: null
|
||||
|
||||
const selectedIds = selection.selectedIds.filter((id) => {
|
||||
const node = sceneNodes[id]
|
||||
return Boolean(node) && resolveLevelId(node, sceneNodes) === levelId
|
||||
})
|
||||
|
||||
return {
|
||||
buildingId,
|
||||
levelId,
|
||||
zoneId,
|
||||
selectedIds,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...EMPTY_PERSISTED_SELECTION,
|
||||
buildingId,
|
||||
}
|
||||
}
|
||||
|
||||
function getRestoredSelectionForScene(
|
||||
sceneNodes: Record<string, any>,
|
||||
): PersistedSelectionPath | null {
|
||||
const persistedSelection = readPersistedSelection()
|
||||
if (!(persistedSelection && hasPersistedSelectionValue(persistedSelection))) {
|
||||
return null
|
||||
}
|
||||
|
||||
return getValidatedSelectionForScene(sceneNodes, persistedSelection)
|
||||
}
|
||||
|
||||
export function syncEditorSelectionFromCurrentScene() {
|
||||
const sceneNodes = useScene.getState().nodes as Record<string, any>
|
||||
const sceneRootIds = useScene.getState().rootNodeIds
|
||||
@@ -16,8 +263,45 @@ export function syncEditorSelectionFromCurrentScene() {
|
||||
const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child)
|
||||
const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
|
||||
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
|
||||
const restoredEditorUiState = normalizePersistedEditorUiState(useEditor.getState())
|
||||
const shouldRestoreEditorUiState = hasCustomPersistedEditorUiState(restoredEditorUiState)
|
||||
const restoredSelection = getRestoredSelectionForScene(sceneNodes)
|
||||
const selectionDrivenEditorUiState = restoredSelection
|
||||
? getEditorUiStateForRestoredSelection(sceneNodes, restoredSelection, restoredEditorUiState)
|
||||
: null
|
||||
|
||||
if (firstBuilding && firstLevel) {
|
||||
if (shouldRestoreEditorUiState) {
|
||||
if (restoredSelection) {
|
||||
useViewer.getState().setSelection(toViewerSelection(restoredSelection))
|
||||
useEditor.setState(
|
||||
restoredEditorUiState.phase === 'site'
|
||||
? (selectionDrivenEditorUiState ?? restoredEditorUiState)
|
||||
: restoredEditorUiState,
|
||||
)
|
||||
} else if (restoredEditorUiState.phase === 'site') {
|
||||
useViewer.getState().resetSelection()
|
||||
useEditor.setState(restoredEditorUiState)
|
||||
} else {
|
||||
useViewer.getState().setSelection({
|
||||
buildingId: firstBuilding.id,
|
||||
levelId: firstLevel.id,
|
||||
selectedIds: [],
|
||||
zoneId: null,
|
||||
})
|
||||
useEditor.setState(restoredEditorUiState)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (restoredSelection) {
|
||||
useViewer.getState().setSelection(toViewerSelection(restoredSelection))
|
||||
if (selectionDrivenEditorUiState) {
|
||||
useEditor.setState(selectionDrivenEditorUiState)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
useViewer.getState().setSelection({
|
||||
buildingId: firstBuilding.id,
|
||||
levelId: firstLevel.id,
|
||||
@@ -42,8 +326,40 @@ export function syncEditorSelectionFromCurrentScene() {
|
||||
}
|
||||
}
|
||||
|
||||
function resetEditorInteractionState() {
|
||||
useViewer.getState().setHoveredId(null)
|
||||
useViewer.getState().resetSelection()
|
||||
// Clear outliner arrays synchronously so stale Object3D refs from the old
|
||||
// scene don't leak into the post-processing pipeline's outline passes.
|
||||
const outliner = useViewer.getState().outliner
|
||||
outliner.selectedObjects.length = 0
|
||||
outliner.hoveredObjects.length = 0
|
||||
sceneRegistry.clear()
|
||||
useEditor.setState({
|
||||
phase: 'site',
|
||||
mode: 'select',
|
||||
tool: null,
|
||||
structureLayer: 'elements',
|
||||
catalogCategory: null,
|
||||
selectedItem: null,
|
||||
movingNode: null,
|
||||
selectedReferenceId: null,
|
||||
spaces: {},
|
||||
editingHole: null,
|
||||
isPreviewMode: false,
|
||||
})
|
||||
}
|
||||
|
||||
function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is SceneGraph {
|
||||
return (
|
||||
!!sceneGraph &&
|
||||
Object.keys(sceneGraph.nodes ?? {}).length > 0 &&
|
||||
(sceneGraph.rootNodeIds?.length ?? 0) > 0
|
||||
)
|
||||
}
|
||||
|
||||
export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
|
||||
if (sceneGraph?.nodes && sceneGraph.rootNodeIds) {
|
||||
if (hasUsableSceneGraph(sceneGraph)) {
|
||||
const { nodes, rootNodeIds } = sceneGraph
|
||||
useScene.getState().setScene(nodes as any, rootNodeIds as any)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type CommandAction = {
|
||||
id: string
|
||||
/** Static string or a function evaluated at render time (for reactive labels). */
|
||||
label: string | (() => string)
|
||||
group: string
|
||||
icon?: ReactNode
|
||||
keywords?: string[]
|
||||
shortcut?: string[]
|
||||
/** Static string or a function evaluated at render time (for reactive badges). */
|
||||
badge?: string | (() => string)
|
||||
/** Show a chevron to indicate this action navigates to a sub-page. */
|
||||
navigate?: boolean
|
||||
/** Called at render time — returning false disables the item. */
|
||||
when?: () => boolean
|
||||
execute: () => void
|
||||
}
|
||||
|
||||
interface CommandRegistryStore {
|
||||
actions: CommandAction[]
|
||||
/** Register actions and return an unsubscribe function. */
|
||||
register: (actions: CommandAction[]) => () => void
|
||||
}
|
||||
|
||||
export const useCommandRegistry = create<CommandRegistryStore>((set) => ({
|
||||
actions: [],
|
||||
register: (newActions) => {
|
||||
const ids = newActions.map((a) => a.id)
|
||||
set((s) => ({
|
||||
actions: [...s.actions.filter((a) => !ids.includes(a.id)), ...newActions],
|
||||
}))
|
||||
return () => set((s) => ({ actions: s.actions.filter((a) => !ids.includes(a.id)) }))
|
||||
},
|
||||
}))
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
type DoorNode,
|
||||
type ItemNode,
|
||||
type LevelNode,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
type Space,
|
||||
useScene,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
export type Phase = 'site' | 'structure' | 'furnish'
|
||||
|
||||
@@ -66,8 +69,10 @@ type EditorState = {
|
||||
setCatalogCategory: (category: CatalogCategory | null) => void
|
||||
selectedItem: AssetInput | null
|
||||
setSelectedItem: (item: AssetInput) => void
|
||||
movingNode: ItemNode | WindowNode | DoorNode | null
|
||||
setMovingNode: (node: ItemNode | WindowNode | DoorNode | null) => void
|
||||
movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null
|
||||
setMovingNode: (
|
||||
node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,
|
||||
) => void
|
||||
selectedReferenceId: string | null
|
||||
setSelectedReferenceId: (id: string | null) => void
|
||||
// Space detection for cutaway mode
|
||||
@@ -79,159 +84,286 @@ type EditorState = {
|
||||
// Preview mode (viewer-like experience inside the editor)
|
||||
isPreviewMode: boolean
|
||||
setPreviewMode: (preview: boolean) => void
|
||||
// Toggleable 2D floorplan overlay
|
||||
isFloorplanOpen: boolean
|
||||
setFloorplanOpen: (open: boolean) => void
|
||||
toggleFloorplanOpen: () => void
|
||||
isFloorplanHovered: boolean
|
||||
setFloorplanHovered: (hovered: boolean) => void
|
||||
// Development-only camera debug flag for inspecting underside geometry
|
||||
allowUndergroundCamera: boolean
|
||||
setAllowUndergroundCamera: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
const useEditor = create<EditorState>()((set, get) => ({
|
||||
export type PersistedEditorUiState = Pick<
|
||||
EditorState,
|
||||
'phase' | 'mode' | 'tool' | 'structureLayer' | 'catalogCategory' | 'isFloorplanOpen'
|
||||
>
|
||||
|
||||
export const DEFAULT_PERSISTED_EDITOR_UI_STATE: PersistedEditorUiState = {
|
||||
phase: 'site',
|
||||
setPhase: (phase) => {
|
||||
const currentPhase = get().phase
|
||||
if (currentPhase === phase) return
|
||||
mode: 'select',
|
||||
tool: null,
|
||||
structureLayer: 'elements',
|
||||
catalogCategory: null,
|
||||
isFloorplanOpen: false,
|
||||
}
|
||||
|
||||
set({ phase })
|
||||
function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
|
||||
if (phase === 'site') {
|
||||
return mode === 'edit' ? 'edit' : 'select'
|
||||
}
|
||||
|
||||
const { mode, structureLayer } = get()
|
||||
return mode === 'build' || mode === 'delete' ? mode : 'select'
|
||||
}
|
||||
|
||||
if (mode === 'build') {
|
||||
// Stay in build mode, select the first tool for the new phase
|
||||
if (phase === 'site') {
|
||||
set({ tool: 'property-line', catalogCategory: null })
|
||||
} else if (phase === 'structure' && structureLayer === 'zones') {
|
||||
set({ tool: 'zone', catalogCategory: null })
|
||||
} else if (phase === 'structure') {
|
||||
set({ tool: 'wall', catalogCategory: null })
|
||||
} else if (phase === 'furnish') {
|
||||
set({ tool: 'item', catalogCategory: 'furniture' })
|
||||
}
|
||||
} else {
|
||||
// Reset to select mode and clear tool/catalog when switching phases
|
||||
set({ mode: 'select', tool: null, catalogCategory: null })
|
||||
export function normalizePersistedEditorUiState(
|
||||
state: Partial<PersistedEditorUiState> | null | undefined,
|
||||
): PersistedEditorUiState {
|
||||
const phase = state?.phase === 'structure' || state?.phase === 'furnish' ? state.phase : 'site'
|
||||
const mode = normalizeModeForPhase(phase, state?.mode)
|
||||
const isFloorplanOpen = Boolean(state?.isFloorplanOpen)
|
||||
|
||||
if (phase === 'site') {
|
||||
return {
|
||||
...DEFAULT_PERSISTED_EDITOR_UI_STATE,
|
||||
phase,
|
||||
mode,
|
||||
isFloorplanOpen,
|
||||
}
|
||||
}
|
||||
|
||||
const viewer = useViewer.getState()
|
||||
const scene = useScene.getState()
|
||||
if (phase === 'furnish') {
|
||||
return {
|
||||
phase,
|
||||
mode,
|
||||
tool: mode === 'build' ? 'item' : null,
|
||||
structureLayer: 'elements',
|
||||
catalogCategory: mode === 'build' ? (state?.catalogCategory ?? 'furniture') : null,
|
||||
isFloorplanOpen,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to find building and level 0
|
||||
const selectBuildingAndLevel0 = () => {
|
||||
let buildingId = viewer.selection.buildingId
|
||||
const structureLayer = state?.structureLayer === 'zones' ? 'zones' : 'elements'
|
||||
|
||||
// If no building selected, find the first one from site's children
|
||||
if (!buildingId) {
|
||||
const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null
|
||||
if (siteNode?.type === 'site') {
|
||||
const firstBuilding = siteNode.children
|
||||
.map((child) => (typeof child === 'string' ? scene.nodes[child] : child))
|
||||
.find((node) => node?.type === 'building')
|
||||
if (firstBuilding) {
|
||||
buildingId = firstBuilding.id as BuildingNode['id']
|
||||
viewer.setSelection({ buildingId })
|
||||
if (mode !== 'build') {
|
||||
return {
|
||||
phase,
|
||||
mode,
|
||||
tool: null,
|
||||
structureLayer,
|
||||
catalogCategory: null,
|
||||
isFloorplanOpen,
|
||||
}
|
||||
}
|
||||
|
||||
if (structureLayer === 'zones') {
|
||||
return {
|
||||
phase,
|
||||
mode,
|
||||
tool: 'zone',
|
||||
structureLayer,
|
||||
catalogCategory: null,
|
||||
isFloorplanOpen,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phase,
|
||||
mode,
|
||||
tool:
|
||||
state?.tool && state.tool !== 'property-line' && state.tool !== 'zone' ? state.tool : 'wall',
|
||||
structureLayer,
|
||||
catalogCategory: state?.tool === 'item' ? (state.catalogCategory ?? null) : null,
|
||||
isFloorplanOpen,
|
||||
}
|
||||
}
|
||||
|
||||
export function hasCustomPersistedEditorUiState(
|
||||
state: Partial<PersistedEditorUiState> | null | undefined,
|
||||
): boolean {
|
||||
const normalizedState = normalizePersistedEditorUiState(state)
|
||||
|
||||
return (
|
||||
normalizedState.phase !== DEFAULT_PERSISTED_EDITOR_UI_STATE.phase ||
|
||||
normalizedState.mode !== DEFAULT_PERSISTED_EDITOR_UI_STATE.mode ||
|
||||
normalizedState.tool !== DEFAULT_PERSISTED_EDITOR_UI_STATE.tool ||
|
||||
normalizedState.structureLayer !== DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer ||
|
||||
normalizedState.catalogCategory !== DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory ||
|
||||
normalizedState.isFloorplanOpen !== DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen
|
||||
)
|
||||
}
|
||||
|
||||
const useEditor = create<EditorState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
phase: DEFAULT_PERSISTED_EDITOR_UI_STATE.phase,
|
||||
setPhase: (phase) => {
|
||||
const currentPhase = get().phase
|
||||
if (currentPhase === phase) return
|
||||
|
||||
set({ phase })
|
||||
|
||||
const { mode, structureLayer } = get()
|
||||
|
||||
if (mode === 'build') {
|
||||
// Stay in build mode, select the first tool for the new phase
|
||||
if (phase === 'site') {
|
||||
set({ tool: 'property-line', catalogCategory: null })
|
||||
} else if (phase === 'structure' && structureLayer === 'zones') {
|
||||
set({ tool: 'zone', catalogCategory: null })
|
||||
} else if (phase === 'structure') {
|
||||
set({ tool: 'wall', catalogCategory: null })
|
||||
} else if (phase === 'furnish') {
|
||||
set({ tool: 'item', catalogCategory: 'furniture' })
|
||||
}
|
||||
} else {
|
||||
// Reset to select mode and clear tool/catalog when switching phases
|
||||
set({ mode: 'select', tool: null, catalogCategory: null })
|
||||
}
|
||||
|
||||
const viewer = useViewer.getState()
|
||||
const scene = useScene.getState()
|
||||
|
||||
// Helper to find building and level 0
|
||||
const selectBuildingAndLevel0 = () => {
|
||||
let buildingId = viewer.selection.buildingId
|
||||
|
||||
// If no building selected, find the first one from site's children
|
||||
if (!buildingId) {
|
||||
const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null
|
||||
if (siteNode?.type === 'site') {
|
||||
const firstBuilding = siteNode.children
|
||||
.map((child) => (typeof child === 'string' ? scene.nodes[child] : child))
|
||||
.find((node) => node?.type === 'building')
|
||||
if (firstBuilding) {
|
||||
buildingId = firstBuilding.id as BuildingNode['id']
|
||||
viewer.setSelection({ buildingId })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no level selected, find level 0 in the building
|
||||
if (buildingId && !viewer.selection.levelId) {
|
||||
const buildingNode = scene.nodes[buildingId] as BuildingNode
|
||||
const level0Id = buildingNode.children.find((childId) => {
|
||||
const levelNode = scene.nodes[childId] as LevelNode
|
||||
return levelNode?.type === 'level' && levelNode.level === 0
|
||||
})
|
||||
if (level0Id) {
|
||||
viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
|
||||
} else if (buildingNode.children[0]) {
|
||||
// Fallback to first level if level 0 doesn't exist
|
||||
viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no level selected, find level 0 in the building
|
||||
if (buildingId && !viewer.selection.levelId) {
|
||||
const buildingNode = scene.nodes[buildingId] as BuildingNode
|
||||
const level0Id = buildingNode.children.find((childId) => {
|
||||
const levelNode = scene.nodes[childId] as LevelNode
|
||||
return levelNode?.type === 'level' && levelNode.level === 0
|
||||
switch (phase) {
|
||||
case 'site':
|
||||
// In Site mode, we zoom out and deselect specific levels/buildings
|
||||
viewer.resetSelection()
|
||||
break
|
||||
|
||||
case 'structure':
|
||||
selectBuildingAndLevel0()
|
||||
break
|
||||
|
||||
case 'furnish':
|
||||
selectBuildingAndLevel0()
|
||||
// Furnish mode only supports elements layer, not zones
|
||||
set({ structureLayer: 'elements' })
|
||||
break
|
||||
}
|
||||
},
|
||||
mode: DEFAULT_PERSISTED_EDITOR_UI_STATE.mode,
|
||||
setMode: (mode) => {
|
||||
set({ mode })
|
||||
|
||||
const { phase, structureLayer, tool } = get()
|
||||
|
||||
if (mode === 'build') {
|
||||
// Ensure a tool is selected in build mode
|
||||
if (!tool) {
|
||||
if (phase === 'structure' && structureLayer === 'zones') {
|
||||
set({ tool: 'zone' })
|
||||
} else if (phase === 'structure' && structureLayer === 'elements') {
|
||||
set({ tool: 'wall' })
|
||||
} else if (phase === 'furnish') {
|
||||
set({ tool: 'item', catalogCategory: 'furniture' })
|
||||
}
|
||||
}
|
||||
}
|
||||
// When leaving build mode, clear tool
|
||||
else if (tool) {
|
||||
set({ tool: null })
|
||||
}
|
||||
},
|
||||
tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
|
||||
setTool: (tool) => set({ tool }),
|
||||
structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
|
||||
setStructureLayer: (layer) => {
|
||||
const { mode } = get()
|
||||
|
||||
if (mode === 'build') {
|
||||
const tool = layer === 'zones' ? 'zone' : 'wall'
|
||||
set({ structureLayer: layer, tool })
|
||||
} else {
|
||||
set({ structureLayer: layer, mode: 'select', tool: null })
|
||||
}
|
||||
|
||||
const viewer = useViewer.getState()
|
||||
viewer.setSelection({
|
||||
selectedIds: [],
|
||||
zoneId: null,
|
||||
})
|
||||
if (level0Id) {
|
||||
viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
|
||||
} else if (buildingNode.children[0]) {
|
||||
// Fallback to first level if level 0 doesn't exist
|
||||
viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] })
|
||||
},
|
||||
catalogCategory: DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory,
|
||||
setCatalogCategory: (category) => set({ catalogCategory: category }),
|
||||
selectedItem: null,
|
||||
setSelectedItem: (item) => set({ selectedItem: item }),
|
||||
movingNode: null as ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,
|
||||
setMovingNode: (node) => set({ movingNode: node }),
|
||||
selectedReferenceId: null,
|
||||
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||
spaces: {},
|
||||
setSpaces: (spaces) => set({ spaces }),
|
||||
editingHole: null,
|
||||
setEditingHole: (hole) => set({ editingHole: hole }),
|
||||
isPreviewMode: false,
|
||||
setPreviewMode: (preview) => {
|
||||
if (preview) {
|
||||
set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null })
|
||||
// Clear zone/item selection for clean viewer drill-down hierarchy
|
||||
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
||||
} else {
|
||||
set({ isPreviewMode: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
case 'site':
|
||||
// In Site mode, we zoom out and deselect specific levels/buildings
|
||||
viewer.resetSelection()
|
||||
break
|
||||
|
||||
case 'structure':
|
||||
selectBuildingAndLevel0()
|
||||
break
|
||||
|
||||
case 'furnish':
|
||||
selectBuildingAndLevel0()
|
||||
// Furnish mode only supports elements layer, not zones
|
||||
set({ structureLayer: 'elements' })
|
||||
break
|
||||
}
|
||||
},
|
||||
mode: 'select',
|
||||
setMode: (mode) => {
|
||||
set({ mode })
|
||||
|
||||
const { phase, structureLayer, tool } = get()
|
||||
|
||||
if (mode === 'build') {
|
||||
// Clear selection when entering build mode
|
||||
const viewer = useViewer.getState()
|
||||
viewer.setSelection({
|
||||
selectedIds: [],
|
||||
zoneId: null,
|
||||
})
|
||||
|
||||
// Ensure a tool is selected in build mode
|
||||
if (!tool) {
|
||||
if (phase === 'structure' && structureLayer === 'zones') {
|
||||
set({ tool: 'zone' })
|
||||
} else if (phase === 'structure' && structureLayer === 'elements') {
|
||||
set({ tool: 'wall' })
|
||||
} else if (phase === 'furnish') {
|
||||
set({ tool: 'item', catalogCategory: 'furniture' })
|
||||
}
|
||||
}
|
||||
}
|
||||
// When leaving build mode, clear tool
|
||||
else if (tool) {
|
||||
set({ tool: null })
|
||||
}
|
||||
},
|
||||
tool: null,
|
||||
setTool: (tool) => set({ tool }),
|
||||
structureLayer: 'elements',
|
||||
setStructureLayer: (layer) => {
|
||||
const { mode } = get()
|
||||
|
||||
if (mode === 'build') {
|
||||
const tool = layer === 'zones' ? 'zone' : 'wall'
|
||||
set({ structureLayer: layer, tool })
|
||||
} else {
|
||||
set({ structureLayer: layer, mode: 'select', tool: null })
|
||||
}
|
||||
|
||||
const viewer = useViewer.getState()
|
||||
viewer.setSelection({
|
||||
selectedIds: [],
|
||||
zoneId: null,
|
||||
})
|
||||
},
|
||||
catalogCategory: null,
|
||||
setCatalogCategory: (category) => set({ catalogCategory: category }),
|
||||
selectedItem: null,
|
||||
setSelectedItem: (item) => set({ selectedItem: item }),
|
||||
movingNode: null as ItemNode | WindowNode | DoorNode | null,
|
||||
setMovingNode: (node) => set({ movingNode: node }),
|
||||
selectedReferenceId: null,
|
||||
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||
spaces: {},
|
||||
setSpaces: (spaces) => set({ spaces }),
|
||||
editingHole: null,
|
||||
setEditingHole: (hole) => set({ editingHole: hole }),
|
||||
isPreviewMode: false,
|
||||
setPreviewMode: (preview) => {
|
||||
if (preview) {
|
||||
set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null })
|
||||
// Clear zone/item selection for clean viewer drill-down hierarchy
|
||||
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
||||
} else {
|
||||
set({ isPreviewMode: false })
|
||||
}
|
||||
},
|
||||
}))
|
||||
},
|
||||
isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen,
|
||||
setFloorplanOpen: (open) => set({ isFloorplanOpen: open }),
|
||||
toggleFloorplanOpen: () => set((state) => ({ isFloorplanOpen: !state.isFloorplanOpen })),
|
||||
isFloorplanHovered: false,
|
||||
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
|
||||
allowUndergroundCamera: false,
|
||||
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
|
||||
}),
|
||||
{
|
||||
name: 'pascal-editor-ui-preferences',
|
||||
merge: (persistedState, currentState) => ({
|
||||
...currentState,
|
||||
...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorUiState>),
|
||||
}),
|
||||
partialize: (state) => ({
|
||||
phase: state.phase,
|
||||
mode: state.mode,
|
||||
tool: state.tool,
|
||||
structureLayer: state.structureLayer,
|
||||
catalogCategory: state.catalogCategory,
|
||||
isFloorplanOpen: state.isFloorplanOpen,
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export default useEditor
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type PaletteViewProps = {
|
||||
onClose: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export type PaletteView = {
|
||||
/** Unique key — matches a page name or a mode name. */
|
||||
key: string
|
||||
/**
|
||||
* `'page'` — renders inside the cmdk Command shell (list area only).
|
||||
* Filtering and keyboard navigation still work.
|
||||
*
|
||||
* `'mode'` — replaces the entire cmdk shell inside the Dialog.
|
||||
* Used for full-screen states like ai-executing or ai-review.
|
||||
*/
|
||||
type: 'page' | 'mode'
|
||||
/** Human-readable label shown as the breadcrumb for page views. */
|
||||
label?: string
|
||||
Component: ComponentType<PaletteViewProps>
|
||||
}
|
||||
|
||||
interface PaletteViewRegistryStore {
|
||||
views: Map<string, PaletteView>
|
||||
register: (view: PaletteView) => () => void
|
||||
}
|
||||
|
||||
export const usePaletteViewRegistry = create<PaletteViewRegistryStore>((set) => ({
|
||||
views: new Map(),
|
||||
register: (view) => {
|
||||
set((s) => {
|
||||
const next = new Map(s.views)
|
||||
next.set(view.key, view)
|
||||
return { views: next }
|
||||
})
|
||||
return () =>
|
||||
set((s) => {
|
||||
const next = new Map(s.views)
|
||||
next.delete(view.key)
|
||||
return { views: next }
|
||||
})
|
||||
},
|
||||
}))
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pascal-app/viewer",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "3D viewer component for Pascal building editor",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ErrorInfo, ReactNode } from 'react'
|
||||
import { Component } from 'react'
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state = { hasError: false }
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
componentDidCatch(_e: Error, _i: ErrorInfo) {}
|
||||
render() {
|
||||
return this.state.hasError ? this.props.fallback : this.props.children
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { resolveCdnUrl } from '../../../lib/asset-url'
|
||||
import { useItemLightPool } from '../../../store/use-item-light-pool'
|
||||
import { ErrorBoundary } from '../../error-boundary'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
// Shared materials to avoid creating new instances for every mesh
|
||||
@@ -47,6 +48,17 @@ const getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial =>
|
||||
return defaultMaterial
|
||||
}
|
||||
|
||||
const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
||||
const handlers = useNodeEvents(node, 'item')
|
||||
const [w, h, d] = node.asset.dimensions
|
||||
return (
|
||||
<mesh position-y={h / 2} {...handlers}>
|
||||
<boxGeometry args={[w, h, d]} />
|
||||
<meshStandardMaterial color="#ef4444" opacity={0.6} transparent wireframe />
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
export const ItemRenderer = ({ node }: { node: ItemNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
|
||||
@@ -54,9 +66,11 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
|
||||
|
||||
return (
|
||||
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
|
||||
<Suspense fallback={<PreviewModel node={node} />}>
|
||||
<ModelRenderer node={node} />
|
||||
</Suspense>
|
||||
<ErrorBoundary fallback={<BrokenItemFallback node={node} />}>
|
||||
<Suspense fallback={<PreviewModel node={node} />}>
|
||||
<ModelRenderer node={node} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
{node.children?.map((childId) => (
|
||||
<NodeRenderer key={childId} nodeId={childId} />
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRegistry, type WallNode } from '@pascal-app/core'
|
||||
import { useRef } from 'react'
|
||||
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
@@ -9,6 +9,11 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||
|
||||
useRegistry(node.id, 'wall', ref)
|
||||
|
||||
// Mark dirty on mount so WallSystem rebuilds geometry when wall (re)appears
|
||||
useLayoutEffect(() => {
|
||||
useScene.getState().markDirty(node.id)
|
||||
}, [node.id])
|
||||
|
||||
const handlers = useNodeEvents(node, 'wall')
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { type LevelNode, useScene } from '@pascal-app/core'
|
||||
import polygonClipping from 'polygon-clipping'
|
||||
import { useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
@@ -20,13 +20,39 @@ export const GroundOccluder = () => {
|
||||
s.lineTo(-size, size)
|
||||
s.closePath()
|
||||
|
||||
// Collect all polygons for slabs and zones
|
||||
const levelIndexById = new Map<LevelNode['id'], number>()
|
||||
let lowestLevelIndex = Number.POSITIVE_INFINITY
|
||||
|
||||
Object.values(nodes).forEach((node) => {
|
||||
if (node.type !== 'level') {
|
||||
return
|
||||
}
|
||||
|
||||
levelIndexById.set(node.id, node.level)
|
||||
lowestLevelIndex = Math.min(lowestLevelIndex, node.level)
|
||||
})
|
||||
|
||||
// Only the lowest level should punch through the ground plane.
|
||||
// Upper-level slabs should still cast shadows, but they should not
|
||||
// reveal their footprint on the level-zero ground material.
|
||||
const polygons: [number, number][][] = []
|
||||
|
||||
Object.values(nodes).forEach((node) => {
|
||||
if (node.type === 'slab' && node.polygon && node.polygon.length >= 3) {
|
||||
polygons.push(node.polygon as [number, number][])
|
||||
if (!(node.type === 'slab' && node.visible && node.polygon.length >= 3)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Number.isFinite(lowestLevelIndex)) {
|
||||
const parentLevelIndex = node.parentId
|
||||
? levelIndexById.get(node.parentId as LevelNode['id'])
|
||||
: undefined
|
||||
|
||||
if (parentLevelIndex !== lowestLevelIndex) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
polygons.push(node.polygon as [number, number][])
|
||||
})
|
||||
|
||||
if (polygons.length > 0) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { loadAssetUrl } from '@pascal-app/core'
|
||||
|
||||
// @ts-expect-error
|
||||
export const ASSETS_CDN_URL = process.env.NEXT_PUBLIC_ASSETS_CDN_URL || 'https://editor.pascal.app'
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+94
-6
@@ -1,8 +1,96 @@
|
||||
// Augment @react-three/fiber's ThreeElements to include all Three.js JSX intrinsic elements.
|
||||
// This must be a project-wide declaration so all files can use <directionalLight />, etc.
|
||||
import type { ThreeToJSXElements } from '@react-three/fiber'
|
||||
import * as THREE from 'three/webgpu'
|
||||
/**
|
||||
* R3F JSX intrinsic element declarations for three.js primitives.
|
||||
*
|
||||
* @react-three/fiber augments JSX.IntrinsicElements globally via module
|
||||
* augmentation, but the augmentation doesn't reliably propagate during
|
||||
* composite tsc --build in CI because bun resolves @react-three/fiber's
|
||||
* peer deps into variant directories where @types/three is unreachable.
|
||||
*
|
||||
* This file replicates the module augmentation pattern R3F uses, declaring
|
||||
* the subset of three.js elements we actually use.
|
||||
*
|
||||
* The empty export makes this file a module, which is required for
|
||||
* `declare module` to augment existing modules rather than replace them.
|
||||
*/
|
||||
|
||||
declare module '@react-three/fiber' {
|
||||
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
|
||||
export {}
|
||||
|
||||
interface ThreeJSXElements {
|
||||
// Containers
|
||||
group: any
|
||||
scene: any
|
||||
// Geometries
|
||||
boxGeometry: any
|
||||
planeGeometry: any
|
||||
circleGeometry: any
|
||||
cylinderGeometry: any
|
||||
sphereGeometry: any
|
||||
extrudeGeometry: any
|
||||
shapeGeometry: any
|
||||
bufferGeometry: any
|
||||
edgesGeometry: any
|
||||
ringGeometry: any
|
||||
// Meshes & lines
|
||||
mesh: any
|
||||
instancedMesh: any
|
||||
line: any
|
||||
lineSegments: any
|
||||
lineLoop: any
|
||||
points: any
|
||||
// Materials
|
||||
meshStandardMaterial: any
|
||||
meshBasicMaterial: any
|
||||
meshPhongMaterial: any
|
||||
meshLambertMaterial: any
|
||||
meshPhysicalMaterial: any
|
||||
meshNormalMaterial: any
|
||||
shadowMaterial: any
|
||||
lineBasicMaterial: any
|
||||
lineDashedMaterial: any
|
||||
pointsMaterial: any
|
||||
shaderMaterial: any
|
||||
rawShaderMaterial: any
|
||||
spriteMaterial: any
|
||||
// Lights
|
||||
ambientLight: any
|
||||
directionalLight: any
|
||||
pointLight: any
|
||||
spotLight: any
|
||||
hemisphereLight: any
|
||||
rectAreaLight: any
|
||||
// Cameras
|
||||
perspectiveCamera: any
|
||||
orthographicCamera: any
|
||||
// Helpers
|
||||
gridHelper: any
|
||||
axesHelper: any
|
||||
arrowHelper: any
|
||||
// Misc
|
||||
sprite: any
|
||||
lOD: any
|
||||
fog: any
|
||||
color: any
|
||||
// Buffer attribute
|
||||
bufferAttribute: any
|
||||
instancedBufferAttribute: any
|
||||
// Primitive (R3F-specific)
|
||||
primitive: any
|
||||
}
|
||||
|
||||
declare module 'react' {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements extends ThreeJSXElements {}
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'react/jsx-runtime' {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements extends ThreeJSXElements {}
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'react/jsx-dev-runtime' {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements extends ThreeJSXElements {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ type ViewerState = {
|
||||
theme: 'light' | 'dark'
|
||||
setTheme: (theme: 'light' | 'dark') => void
|
||||
|
||||
unit: 'metric' | 'imperial'
|
||||
setUnit: (unit: 'metric' | 'imperial') => void
|
||||
|
||||
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
|
||||
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
|
||||
|
||||
@@ -81,6 +84,9 @@ const useViewer = create<ViewerState>()(
|
||||
theme: 'light',
|
||||
setTheme: (theme) => set({ theme }),
|
||||
|
||||
unit: 'metric',
|
||||
setUnit: (unit) => set({ unit }),
|
||||
|
||||
levelMode: 'stacked',
|
||||
setLevelMode: (mode) => set({ levelMode: mode }),
|
||||
|
||||
@@ -187,6 +193,7 @@ const useViewer = create<ViewerState>()(
|
||||
partialize: (state) => ({
|
||||
cameraMode: state.cameraMode,
|
||||
theme: state.theme,
|
||||
unit: state.unit,
|
||||
levelMode: state.levelMode,
|
||||
wallMode: state.wallMode,
|
||||
projectPreferences: state.projectPreferences,
|
||||
|
||||
@@ -286,7 +286,7 @@ export function ItemLightSystem() {
|
||||
castShadow={false}
|
||||
intensity={0}
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
ref={(el: any) => {
|
||||
lightRefs.current[i] = el
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user