@pascal-app/editor 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -5
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +20 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +32 -55
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +9855 -3298
- package/src/components/editor/index.tsx +269 -21
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/thumbnail-generator.tsx +38 -7
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +267 -36
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +7 -0
- package/src/components/tools/door/move-door-tool.tsx +28 -8
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
- package/src/components/tools/fence/move-fence-tool.tsx +101 -34
- package/src/components/tools/item/move-tool.tsx +10 -1
- package/src/components/tools/item/placement-math.ts +30 -1
- package/src/components/tools/item/placement-strategies.ts +109 -31
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
- package/src/components/tools/roof/move-roof-tool.tsx +22 -15
- package/src/components/tools/shared/polygon-editor.tsx +153 -28
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/tool-manager.tsx +18 -3
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +28 -1
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +18 -1
- package/src/components/ui/controls/material-picker.tsx +152 -165
- package/src/components/ui/controls/slider-control.tsx +66 -18
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +1 -25
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +981 -289
- package/src/components/ui/panels/fence-panel.tsx +3 -45
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +210 -1
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +238 -5
- package/src/components/ui/panels/roof-panel.tsx +4 -105
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
- package/src/components/ui/panels/slab-panel.tsx +4 -30
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +11 -117
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +1 -95
- package/src/components/ui/panels/window-panel.tsx +660 -139
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +42 -1
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-keyboard.ts +64 -7
- package/src/hooks/use-mobile.ts +12 -12
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +164 -8
|
@@ -2,7 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import { Icon as IconifyIcon } from '@iconify/react'
|
|
4
4
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
Check,
|
|
7
|
+
ChevronsLeft,
|
|
8
|
+
ChevronsRight,
|
|
9
|
+
Columns2,
|
|
10
|
+
Eye,
|
|
11
|
+
EyeOff,
|
|
12
|
+
Footprints,
|
|
13
|
+
Grid2X2,
|
|
14
|
+
Moon,
|
|
15
|
+
Sun,
|
|
16
|
+
} from 'lucide-react'
|
|
6
17
|
import { useCallback } from 'react'
|
|
7
18
|
import { cn } from '../../lib/utils'
|
|
8
19
|
import useEditor from '../../store/use-editor'
|
|
@@ -271,6 +282,35 @@ function GridSnapToggle() {
|
|
|
271
282
|
)
|
|
272
283
|
}
|
|
273
284
|
|
|
285
|
+
function GridVisibilityToggle() {
|
|
286
|
+
const showGrid = useViewer((s) => s.showGrid)
|
|
287
|
+
const setShowGrid = useViewer((s) => s.setShowGrid)
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<Tooltip>
|
|
291
|
+
<TooltipTrigger asChild>
|
|
292
|
+
<button
|
|
293
|
+
aria-label={`Grid: ${showGrid ? 'Visible' : 'Hidden'}`}
|
|
294
|
+
aria-pressed={showGrid}
|
|
295
|
+
className={cn(
|
|
296
|
+
TOOLBAR_BTN,
|
|
297
|
+
'w-auto gap-1.5 px-2.5',
|
|
298
|
+
showGrid
|
|
299
|
+
? 'bg-white/10 text-foreground/90'
|
|
300
|
+
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0',
|
|
301
|
+
)}
|
|
302
|
+
onClick={() => setShowGrid(!showGrid)}
|
|
303
|
+
type="button"
|
|
304
|
+
>
|
|
305
|
+
<Grid2X2 className="h-3.5 w-3.5" />
|
|
306
|
+
{showGrid ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
|
307
|
+
</button>
|
|
308
|
+
</TooltipTrigger>
|
|
309
|
+
<TooltipContent side="bottom">Grid: {showGrid ? 'Visible' : 'Hidden'}</TooltipContent>
|
|
310
|
+
</Tooltip>
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
274
314
|
// ── Wall mode toggle ────────────────────────────────────────────────────────
|
|
275
315
|
|
|
276
316
|
const wallModeOrder = ['cutaway', 'up', 'down'] as const
|
|
@@ -383,6 +423,7 @@ export function ViewerToolbarRight() {
|
|
|
383
423
|
<LevelModeToggle />
|
|
384
424
|
<WallModeToggle />
|
|
385
425
|
<GridSnapToggle />
|
|
426
|
+
<GridVisibilityToggle />
|
|
386
427
|
<div className="my-1.5 w-px bg-border/50" />
|
|
387
428
|
<UnitToggle />
|
|
388
429
|
<ThemeToggle />
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { emitter, useScene } from '@pascal-app/core'
|
|
4
|
+
import { useEffect, useRef } from 'react'
|
|
5
|
+
import { computeSceneBoundsXZ } from '../lib/scene-bounds'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Auto-frame the camera onto a freshly loaded scene.
|
|
9
|
+
*
|
|
10
|
+
* Motivation: when the MCP `setScene` tool (or any other entry point) swaps
|
|
11
|
+
* the scene graph while the default camera is pointing at empty space, the
|
|
12
|
+
* user sees a black viewport. This hook subscribes to the core scene store
|
|
13
|
+
* and, whenever `nodes` transitions from empty → non-empty, computes the
|
|
14
|
+
* XZ bounds of the new scene and emits `camera-controls:fit-scene`. The
|
|
15
|
+
* `<CustomCameraControls />` component picks up that event and frames the
|
|
16
|
+
* camera onto the bounds.
|
|
17
|
+
*
|
|
18
|
+
* Mount in exactly ONE component (the Editor). It holds no state of its own;
|
|
19
|
+
* the subscription is torn down on unmount.
|
|
20
|
+
*/
|
|
21
|
+
export function useAutoFrame(): void {
|
|
22
|
+
// Track the previous node count so we can detect the empty → non-empty edge.
|
|
23
|
+
const wasEmptyRef = useRef(true)
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
// Initialise from current store state so a remount after a setScene
|
|
27
|
+
// doesn't re-frame an already-populated scene.
|
|
28
|
+
wasEmptyRef.current = Object.keys(useScene.getState().nodes).length === 0
|
|
29
|
+
|
|
30
|
+
const unsubscribe = useScene.subscribe((state) => {
|
|
31
|
+
const isEmpty = Object.keys(state.nodes).length === 0
|
|
32
|
+
const wasEmpty = wasEmptyRef.current
|
|
33
|
+
wasEmptyRef.current = isEmpty
|
|
34
|
+
|
|
35
|
+
// Only react to empty → non-empty transitions. Normal edits keep both
|
|
36
|
+
// flags false; a `clearScene()` goes non-empty → empty and is ignored.
|
|
37
|
+
if (!wasEmpty || isEmpty) return
|
|
38
|
+
|
|
39
|
+
const bounds = computeSceneBoundsXZ(state.nodes)
|
|
40
|
+
emitter.emit('camera-controls:fit-scene', bounds ? { bounds } : {})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return unsubscribe
|
|
44
|
+
}, [])
|
|
45
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import { useEffect } from 'react'
|
|
4
|
+
import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
|
|
4
5
|
import { runRedo, runUndo } from '../lib/history'
|
|
5
6
|
import { sfxEmitter } from '../lib/sfx-bus'
|
|
7
|
+
import { closeWindowOpenState, toggleWindowOpenState } from '../lib/window-interaction'
|
|
6
8
|
import useEditor from '../store/use-editor'
|
|
7
9
|
|
|
8
10
|
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
|
|
@@ -12,8 +14,18 @@ export const markToolCancelConsumed = () => {
|
|
|
12
14
|
_toolCancelConsumed = true
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
export const useKeyboard = ({
|
|
17
|
+
export const useKeyboard = ({
|
|
18
|
+
isVersionPreviewMode = false,
|
|
19
|
+
disabled = false,
|
|
20
|
+
}: {
|
|
21
|
+
isVersionPreviewMode?: boolean
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
} = {}) => {
|
|
16
24
|
useEffect(() => {
|
|
25
|
+
if (disabled) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
18
30
|
// Don't handle shortcuts if user is typing in an input
|
|
19
31
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
|
@@ -21,9 +33,6 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
|
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
if (e.key === 'Escape') {
|
|
24
|
-
// If in walkthrough mode, let WalkthroughControls handle ESC
|
|
25
|
-
if (useViewer.getState().walkthroughMode) return
|
|
26
|
-
|
|
27
36
|
e.preventDefault()
|
|
28
37
|
_toolCancelConsumed = false
|
|
29
38
|
emitter.emit('tool:cancel')
|
|
@@ -89,6 +98,13 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
|
|
|
89
98
|
if (isVersionPreviewMode) return
|
|
90
99
|
e.preventDefault()
|
|
91
100
|
useEditor.getState().setMode('delete')
|
|
101
|
+
} else if (e.key === 'p' && !e.metaKey && !e.ctrlKey) {
|
|
102
|
+
if (isVersionPreviewMode) return
|
|
103
|
+
e.preventDefault()
|
|
104
|
+
useEditor.getState().primeMaterialPaintFromSelection()
|
|
105
|
+
useEditor.getState().setPhase('structure')
|
|
106
|
+
useEditor.getState().setStructureLayer('elements')
|
|
107
|
+
useEditor.getState().setMode('material-paint')
|
|
92
108
|
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
|
93
109
|
if (isVersionPreviewMode) return
|
|
94
110
|
e.preventDefault()
|
|
@@ -131,10 +147,31 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
|
|
|
131
147
|
}
|
|
132
148
|
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
|
|
133
149
|
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
|
|
150
|
+
// Operable doors/windows use R to toggle their open/closed state.
|
|
134
151
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
135
152
|
if (selectedNodeIds.length === 1) {
|
|
136
153
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
137
|
-
if (node
|
|
154
|
+
if (node?.type === 'door') {
|
|
155
|
+
e.preventDefault()
|
|
156
|
+
if (node.openingKind !== 'opening') {
|
|
157
|
+
toggleDoorOpenState(node.id)
|
|
158
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
159
|
+
}
|
|
160
|
+
} else if (
|
|
161
|
+
node?.type === 'window' &&
|
|
162
|
+
node.openingKind !== 'opening' &&
|
|
163
|
+
(node.windowType === 'sliding' ||
|
|
164
|
+
node.windowType === 'casement' ||
|
|
165
|
+
node.windowType === 'awning' ||
|
|
166
|
+
node.windowType === 'hopper' ||
|
|
167
|
+
node.windowType === 'single-hung' ||
|
|
168
|
+
node.windowType === 'double-hung' ||
|
|
169
|
+
node.windowType === 'louvered')
|
|
170
|
+
) {
|
|
171
|
+
e.preventDefault()
|
|
172
|
+
toggleWindowOpenState(node.id)
|
|
173
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
174
|
+
} else if (node && 'rotation' in node) {
|
|
138
175
|
e.preventDefault()
|
|
139
176
|
const ROTATION_STEP = Math.PI / 4
|
|
140
177
|
|
|
@@ -154,7 +191,27 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
|
|
|
154
191
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
155
192
|
if (selectedNodeIds.length === 1) {
|
|
156
193
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
157
|
-
if (node
|
|
194
|
+
if (node?.type === 'door') {
|
|
195
|
+
e.preventDefault()
|
|
196
|
+
if (node.openingKind !== 'opening') {
|
|
197
|
+
closeDoorOpenState(node.id)
|
|
198
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
199
|
+
}
|
|
200
|
+
} else if (
|
|
201
|
+
node?.type === 'window' &&
|
|
202
|
+
node.openingKind !== 'opening' &&
|
|
203
|
+
(node.windowType === 'sliding' ||
|
|
204
|
+
node.windowType === 'casement' ||
|
|
205
|
+
node.windowType === 'awning' ||
|
|
206
|
+
node.windowType === 'hopper' ||
|
|
207
|
+
node.windowType === 'single-hung' ||
|
|
208
|
+
node.windowType === 'double-hung' ||
|
|
209
|
+
node.windowType === 'louvered')
|
|
210
|
+
) {
|
|
211
|
+
e.preventDefault()
|
|
212
|
+
closeWindowOpenState(node.id)
|
|
213
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
214
|
+
} else if (node && 'rotation' in node) {
|
|
158
215
|
e.preventDefault()
|
|
159
216
|
const ROTATION_STEP = Math.PI / 4
|
|
160
217
|
|
|
@@ -213,7 +270,7 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
|
|
|
213
270
|
}
|
|
214
271
|
window.addEventListener('keydown', handleKeyDown)
|
|
215
272
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
216
|
-
}, [isVersionPreviewMode])
|
|
273
|
+
}, [disabled, isVersionPreviewMode])
|
|
217
274
|
|
|
218
275
|
return null
|
|
219
276
|
}
|
package/src/hooks/use-mobile.ts
CHANGED
|
@@ -2,18 +2,18 @@ import * as React from 'react'
|
|
|
2
2
|
|
|
3
3
|
const MOBILE_BREAKPOINT = 768
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
const
|
|
5
|
+
const subscribe = (callback: () => void): (() => void) => {
|
|
6
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
|
7
|
+
mql.addEventListener('change', callback)
|
|
8
|
+
return () => mql.removeEventListener('change', callback)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const getClientSnapshot = (): boolean => window.innerWidth < MOBILE_BREAKPOINT
|
|
7
12
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
12
|
-
}
|
|
13
|
-
mql.addEventListener('change', onChange)
|
|
14
|
-
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
15
|
-
return () => mql.removeEventListener('change', onChange)
|
|
16
|
-
}, [])
|
|
13
|
+
// Server can't know the viewport — assume desktop. React's useSyncExternalStore
|
|
14
|
+
// reconciles the SSR / client snapshots without a hydration mismatch warning.
|
|
15
|
+
const getServerSnapshot = (): boolean => false
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
export function useIsMobile(): boolean {
|
|
18
|
+
return React.useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot)
|
|
19
19
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
type DoorInteractiveState,
|
|
4
|
+
isOperationDoorType,
|
|
5
|
+
useInteractive,
|
|
6
|
+
useScene,
|
|
7
|
+
} from '@pascal-app/core'
|
|
8
|
+
|
|
9
|
+
export const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
|
|
10
|
+
export const DOOR_TOGGLE_ANIMATION_MS = 520
|
|
11
|
+
|
|
12
|
+
export { isOperationDoorType }
|
|
13
|
+
|
|
14
|
+
type DoorOpenAnimationOptions = {
|
|
15
|
+
persist?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getDisplayedDoorValue(
|
|
19
|
+
doorId: AnyNodeId,
|
|
20
|
+
field: keyof DoorInteractiveState,
|
|
21
|
+
nodeValue: number | undefined,
|
|
22
|
+
) {
|
|
23
|
+
const interactive = useInteractive.getState()
|
|
24
|
+
const runtimeValue = interactive.doors[doorId]?.[field]
|
|
25
|
+
if (runtimeValue !== undefined) return runtimeValue
|
|
26
|
+
|
|
27
|
+
const queuedValue = interactive.doorAnimations[doorId]?.from
|
|
28
|
+
if (queuedValue !== undefined) return queuedValue
|
|
29
|
+
|
|
30
|
+
return nodeValue ?? 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function startDoorOpenAnimation(
|
|
34
|
+
doorId: AnyNodeId,
|
|
35
|
+
field: keyof DoorInteractiveState,
|
|
36
|
+
from: number,
|
|
37
|
+
to: number,
|
|
38
|
+
options?: DoorOpenAnimationOptions,
|
|
39
|
+
) {
|
|
40
|
+
useInteractive.getState().startDoorAnimation(doorId, {
|
|
41
|
+
field,
|
|
42
|
+
from,
|
|
43
|
+
to,
|
|
44
|
+
startedAt: null,
|
|
45
|
+
durationMs: DOOR_TOGGLE_ANIMATION_MS,
|
|
46
|
+
persist: options?.persist ?? true,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function toggleDoorOpenState(doorId: AnyNodeId, options?: DoorOpenAnimationOptions) {
|
|
51
|
+
const node = useScene.getState().nodes[doorId]
|
|
52
|
+
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
|
53
|
+
|
|
54
|
+
if (isOperationDoorType(node.doorType)) {
|
|
55
|
+
const currentOpenAmount = getDisplayedDoorValue(doorId, 'operationState', node.operationState)
|
|
56
|
+
startDoorOpenAnimation(
|
|
57
|
+
doorId,
|
|
58
|
+
'operationState',
|
|
59
|
+
currentOpenAmount,
|
|
60
|
+
currentOpenAmount >= 0.5 ? 0 : 1,
|
|
61
|
+
options,
|
|
62
|
+
)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const currentSwingAngle = getDisplayedDoorValue(doorId, 'swingAngle', node.swingAngle)
|
|
67
|
+
startDoorOpenAnimation(
|
|
68
|
+
doorId,
|
|
69
|
+
'swingAngle',
|
|
70
|
+
currentSwingAngle,
|
|
71
|
+
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
|
|
72
|
+
options,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function closeDoorOpenState(doorId: AnyNodeId, options?: DoorOpenAnimationOptions) {
|
|
77
|
+
const node = useScene.getState().nodes[doorId]
|
|
78
|
+
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
|
79
|
+
|
|
80
|
+
if (isOperationDoorType(node.doorType)) {
|
|
81
|
+
const currentOpenAmount = getDisplayedDoorValue(doorId, 'operationState', node.operationState)
|
|
82
|
+
startDoorOpenAnimation(doorId, 'operationState', currentOpenAmount, 0, options)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const currentSwingAngle = getDisplayedDoorValue(doorId, 'swingAngle', node.swingAngle)
|
|
87
|
+
startDoorOpenAnimation(doorId, 'swingAngle', currentSwingAngle, 0, options)
|
|
88
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { Point2D } from '@pascal-app/core'
|
|
2
|
+
import type { FloorplanLineSegment, FloorplanSelectionBounds } from './types'
|
|
3
|
+
|
|
4
|
+
export function clampPlanValue(value: number, min: number, max: number) {
|
|
5
|
+
return Math.min(Math.max(value, min), max)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function rotatePlanVector(x: number, y: number, rotation: number): [number, number] {
|
|
9
|
+
const cos = Math.cos(rotation)
|
|
10
|
+
const sin = Math.sin(rotation)
|
|
11
|
+
return [x * cos + y * sin, -x * sin + y * cos]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getRotatedRectanglePolygon(
|
|
15
|
+
center: Point2D,
|
|
16
|
+
width: number,
|
|
17
|
+
depth: number,
|
|
18
|
+
rotation: number,
|
|
19
|
+
): Point2D[] {
|
|
20
|
+
const halfWidth = width / 2
|
|
21
|
+
const halfDepth = depth / 2
|
|
22
|
+
const corners: Array<[number, number]> = [
|
|
23
|
+
[-halfWidth, -halfDepth],
|
|
24
|
+
[halfWidth, -halfDepth],
|
|
25
|
+
[halfWidth, halfDepth],
|
|
26
|
+
[-halfWidth, halfDepth],
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
return corners.map(([localX, localY]) => {
|
|
30
|
+
const [offsetX, offsetY] = rotatePlanVector(localX, localY, rotation)
|
|
31
|
+
return {
|
|
32
|
+
x: center.x + offsetX,
|
|
33
|
+
y: center.y + offsetY,
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function interpolatePlanPoint(start: Point2D, end: Point2D, t: number): Point2D {
|
|
39
|
+
return {
|
|
40
|
+
x: start.x + (end.x - start.x) * t,
|
|
41
|
+
y: start.y + (end.y - start.y) * t,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getPlanPointDistance(start: Point2D, end: Point2D): number {
|
|
46
|
+
return Math.hypot(end.x - start.x, end.y - start.y)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function movePlanPointTowards(start: Point2D, end: Point2D, distance: number): Point2D {
|
|
50
|
+
const totalDistance = getPlanPointDistance(start, end)
|
|
51
|
+
if (totalDistance <= Number.EPSILON || distance <= 0) {
|
|
52
|
+
return start
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return interpolatePlanPoint(start, end, Math.min(1, distance / totalDistance))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getThickPlanLinePolygon(line: FloorplanLineSegment, thickness: number): Point2D[] {
|
|
59
|
+
const dx = line.end.x - line.start.x
|
|
60
|
+
const dy = line.end.y - line.start.y
|
|
61
|
+
const length = Math.hypot(dx, dy)
|
|
62
|
+
|
|
63
|
+
if (length <= Number.EPSILON || thickness <= 0) {
|
|
64
|
+
return [line.start, line.end, line.end, line.start]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const halfThickness = thickness / 2
|
|
68
|
+
const normalX = (-dy / length) * halfThickness
|
|
69
|
+
const normalY = (dx / length) * halfThickness
|
|
70
|
+
|
|
71
|
+
return [
|
|
72
|
+
{ x: line.start.x + normalX, y: line.start.y + normalY },
|
|
73
|
+
{ x: line.end.x + normalX, y: line.end.y + normalY },
|
|
74
|
+
{ x: line.end.x - normalX, y: line.end.y - normalY },
|
|
75
|
+
{ x: line.start.x - normalX, y: line.start.y - normalY },
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getFloorplanSelectionBounds(
|
|
80
|
+
start: [number, number],
|
|
81
|
+
end: [number, number],
|
|
82
|
+
): FloorplanSelectionBounds {
|
|
83
|
+
return {
|
|
84
|
+
minX: Math.min(start[0], end[0]),
|
|
85
|
+
maxX: Math.max(start[0], end[0]),
|
|
86
|
+
minY: Math.min(start[1], end[1]),
|
|
87
|
+
maxY: Math.max(start[1], end[1]),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function isPointInsideSelectionBounds(point: Point2D, bounds: FloorplanSelectionBounds) {
|
|
92
|
+
return (
|
|
93
|
+
point.x >= bounds.minX &&
|
|
94
|
+
point.x <= bounds.maxX &&
|
|
95
|
+
point.y >= bounds.minY &&
|
|
96
|
+
point.y <= bounds.maxY
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isPointInsidePolygon(point: Point2D, polygon: Point2D[]) {
|
|
101
|
+
let isInside = false
|
|
102
|
+
|
|
103
|
+
for (
|
|
104
|
+
let currentIndex = 0, previousIndex = polygon.length - 1;
|
|
105
|
+
currentIndex < polygon.length;
|
|
106
|
+
previousIndex = currentIndex, currentIndex += 1
|
|
107
|
+
) {
|
|
108
|
+
const current = polygon[currentIndex]
|
|
109
|
+
const previous = polygon[previousIndex]
|
|
110
|
+
|
|
111
|
+
if (!(current && previous)) {
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const intersects =
|
|
116
|
+
current.y > point.y !== previous.y > point.y &&
|
|
117
|
+
point.x <
|
|
118
|
+
((previous.x - current.x) * (point.y - current.y)) / (previous.y - current.y) + current.x
|
|
119
|
+
|
|
120
|
+
if (intersects) {
|
|
121
|
+
isInside = !isInside
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return isInside
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function isPointInsidePolygonWithHoles(
|
|
129
|
+
point: Point2D,
|
|
130
|
+
polygon: Point2D[],
|
|
131
|
+
holes: Point2D[][] = [],
|
|
132
|
+
) {
|
|
133
|
+
return (
|
|
134
|
+
isPointInsidePolygon(point, polygon) && !holes.some((hole) => isPointInsidePolygon(point, hole))
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getLineOrientation(start: Point2D, end: Point2D, point: Point2D) {
|
|
139
|
+
return (end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (point.x - start.x)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isPointOnSegment(point: Point2D, start: Point2D, end: Point2D) {
|
|
143
|
+
const epsilon = 1e-9
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
Math.abs(getLineOrientation(start, end, point)) <= epsilon &&
|
|
147
|
+
point.x >= Math.min(start.x, end.x) - epsilon &&
|
|
148
|
+
point.x <= Math.max(start.x, end.x) + epsilon &&
|
|
149
|
+
point.y >= Math.min(start.y, end.y) - epsilon &&
|
|
150
|
+
point.y <= Math.max(start.y, end.y) + epsilon
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function doSegmentsIntersect(
|
|
155
|
+
firstStart: Point2D,
|
|
156
|
+
firstEnd: Point2D,
|
|
157
|
+
secondStart: Point2D,
|
|
158
|
+
secondEnd: Point2D,
|
|
159
|
+
) {
|
|
160
|
+
const orientation1 = getLineOrientation(firstStart, firstEnd, secondStart)
|
|
161
|
+
const orientation2 = getLineOrientation(firstStart, firstEnd, secondEnd)
|
|
162
|
+
const orientation3 = getLineOrientation(secondStart, secondEnd, firstStart)
|
|
163
|
+
const orientation4 = getLineOrientation(secondStart, secondEnd, firstEnd)
|
|
164
|
+
|
|
165
|
+
const hasProperIntersection =
|
|
166
|
+
((orientation1 > 0 && orientation2 < 0) || (orientation1 < 0 && orientation2 > 0)) &&
|
|
167
|
+
((orientation3 > 0 && orientation4 < 0) || (orientation3 < 0 && orientation4 > 0))
|
|
168
|
+
|
|
169
|
+
if (hasProperIntersection) {
|
|
170
|
+
return true
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
isPointOnSegment(secondStart, firstStart, firstEnd) ||
|
|
175
|
+
isPointOnSegment(secondEnd, firstStart, firstEnd) ||
|
|
176
|
+
isPointOnSegment(firstStart, secondStart, secondEnd) ||
|
|
177
|
+
isPointOnSegment(firstEnd, secondStart, secondEnd)
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function doesPolygonIntersectSelectionBounds(
|
|
182
|
+
polygon: Point2D[],
|
|
183
|
+
bounds: FloorplanSelectionBounds,
|
|
184
|
+
) {
|
|
185
|
+
if (polygon.length === 0) {
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (polygon.some((point) => isPointInsideSelectionBounds(point, bounds))) {
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const boundsCorners: [Point2D, Point2D, Point2D, Point2D] = [
|
|
194
|
+
{ x: bounds.minX, y: bounds.minY },
|
|
195
|
+
{ x: bounds.maxX, y: bounds.minY },
|
|
196
|
+
{ x: bounds.maxX, y: bounds.maxY },
|
|
197
|
+
{ x: bounds.minX, y: bounds.maxY },
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
if (boundsCorners.some((corner) => isPointInsidePolygon(corner, polygon))) {
|
|
201
|
+
return true
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const boundsEdges = [
|
|
205
|
+
[boundsCorners[0], boundsCorners[1]],
|
|
206
|
+
[boundsCorners[1], boundsCorners[2]],
|
|
207
|
+
[boundsCorners[2], boundsCorners[3]],
|
|
208
|
+
[boundsCorners[3], boundsCorners[0]],
|
|
209
|
+
] as const
|
|
210
|
+
|
|
211
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
212
|
+
const start = polygon[index]
|
|
213
|
+
const end = polygon[(index + 1) % polygon.length]
|
|
214
|
+
|
|
215
|
+
if (!(start && end)) {
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const [edgeStart, edgeEnd] of boundsEdges) {
|
|
220
|
+
if (doSegmentsIntersect(start, end, edgeStart, edgeEnd)) {
|
|
221
|
+
return true
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function getDistanceToWallSegment(
|
|
230
|
+
point: Point2D,
|
|
231
|
+
start: [number, number],
|
|
232
|
+
end: [number, number],
|
|
233
|
+
) {
|
|
234
|
+
const dx = end[0] - start[0]
|
|
235
|
+
const dy = end[1] - start[1]
|
|
236
|
+
const lengthSquared = dx * dx + dy * dy
|
|
237
|
+
|
|
238
|
+
if (lengthSquared <= Number.EPSILON) {
|
|
239
|
+
return Math.hypot(point.x - start[0], point.y - start[1])
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const projection = clampPlanValue(
|
|
243
|
+
((point.x - start[0]) * dx + (point.y - start[1]) * dy) / lengthSquared,
|
|
244
|
+
0,
|
|
245
|
+
1,
|
|
246
|
+
)
|
|
247
|
+
const projectedX = start[0] + dx * projection
|
|
248
|
+
const projectedY = start[1] + dy * projection
|
|
249
|
+
|
|
250
|
+
return Math.hypot(point.x - projectedX, point.y - projectedY)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function pointMatchesWallPlanPoint(
|
|
254
|
+
point: Point2D | undefined,
|
|
255
|
+
planPoint: [number, number],
|
|
256
|
+
epsilon = 1e-6,
|
|
257
|
+
): boolean {
|
|
258
|
+
if (!point) {
|
|
259
|
+
return false
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return Math.abs(point.x - planPoint[0]) <= epsilon && Math.abs(point.y - planPoint[1]) <= epsilon
|
|
263
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export {
|
|
2
|
+
clampPlanValue,
|
|
3
|
+
doesPolygonIntersectSelectionBounds,
|
|
4
|
+
getDistanceToWallSegment,
|
|
5
|
+
getFloorplanSelectionBounds,
|
|
6
|
+
getPlanPointDistance,
|
|
7
|
+
getRotatedRectanglePolygon,
|
|
8
|
+
getThickPlanLinePolygon,
|
|
9
|
+
interpolatePlanPoint,
|
|
10
|
+
isPointInsidePolygon,
|
|
11
|
+
isPointInsidePolygonWithHoles,
|
|
12
|
+
isPointInsideSelectionBounds,
|
|
13
|
+
movePlanPointTowards,
|
|
14
|
+
pointMatchesWallPlanPoint,
|
|
15
|
+
rotatePlanVector,
|
|
16
|
+
} from './geometry'
|
|
17
|
+
export {
|
|
18
|
+
buildFloorplanItemEntry,
|
|
19
|
+
collectLevelDescendants,
|
|
20
|
+
getItemFloorplanTransform,
|
|
21
|
+
} from './items'
|
|
22
|
+
export {
|
|
23
|
+
buildFloorplanStairEntry,
|
|
24
|
+
computeFloorplanStairSegmentTransforms,
|
|
25
|
+
getFloorplanStairSegmentPolygon,
|
|
26
|
+
} from './stairs'
|
|
27
|
+
export type {
|
|
28
|
+
FloorplanItemEntry,
|
|
29
|
+
FloorplanLineSegment,
|
|
30
|
+
FloorplanNodeTransform,
|
|
31
|
+
FloorplanSelectionBounds,
|
|
32
|
+
FloorplanStairArrowEntry,
|
|
33
|
+
FloorplanStairEntry,
|
|
34
|
+
FloorplanStairSegmentEntry,
|
|
35
|
+
LevelDescendantMap,
|
|
36
|
+
StairSegmentTransform,
|
|
37
|
+
} from './types'
|
|
38
|
+
export { getFloorplanWall, getFloorplanWallThickness } from './walls'
|