@pascal-app/editor 0.6.0 → 0.8.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.
Files changed (157) hide show
  1. package/package.json +13 -9
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +74 -5
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +24 -3
  6. package/src/components/editor/first-person/build-collider-world.ts +363 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9861 -3297
  12. package/src/components/editor/index.tsx +295 -32
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  15. package/src/components/editor/thumbnail-generator.tsx +56 -68
  16. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  17. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  18. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  19. package/src/components/editor/wall-measurement-label.tsx +267 -36
  20. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  21. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  22. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  23. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
  24. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  25. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
  26. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  27. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  28. package/src/components/editor-2d/svg-paths.ts +119 -0
  29. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  30. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  31. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  32. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  33. package/src/components/systems/zone/zone-system.tsx +0 -0
  34. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  35. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  36. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  37. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  38. package/src/components/tools/column/column-tool.tsx +97 -0
  39. package/src/components/tools/column/move-column-tool.tsx +105 -0
  40. package/src/components/tools/door/door-tool.tsx +7 -0
  41. package/src/components/tools/door/move-door-tool.tsx +28 -8
  42. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  43. package/src/components/tools/fence/fence-drafting.ts +10 -3
  44. package/src/components/tools/fence/fence-tool.tsx +160 -4
  45. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
  46. package/src/components/tools/fence/move-fence-tool.tsx +111 -40
  47. package/src/components/tools/item/move-tool.tsx +7 -1
  48. package/src/components/tools/item/placement-math.ts +32 -5
  49. package/src/components/tools/item/placement-strategies.ts +110 -31
  50. package/src/components/tools/item/placement-types.ts +7 -0
  51. package/src/components/tools/item/use-draft-node.ts +1 -0
  52. package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
  53. package/src/components/tools/roof/move-roof-tool.tsx +29 -17
  54. package/src/components/tools/select/box-select-tool.tsx +12 -17
  55. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  56. package/src/components/tools/shared/segment-angle.ts +156 -0
  57. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  58. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  59. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  60. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  61. package/src/components/tools/tool-manager.tsx +20 -5
  62. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
  64. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  65. package/src/components/tools/wall/wall-drafting.ts +18 -9
  66. package/src/components/tools/wall/wall-tool.tsx +136 -4
  67. package/src/components/tools/window/move-window-tool.tsx +18 -0
  68. package/src/components/tools/window/window-tool.tsx +5 -0
  69. package/src/components/tools/zone/zone-tool.tsx +20 -5
  70. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  71. package/src/components/ui/action-menu/control-modes.tsx +34 -1
  72. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  73. package/src/components/ui/action-menu/index.tsx +98 -59
  74. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  75. package/src/components/ui/action-menu/view-toggles.tsx +418 -41
  76. package/src/components/ui/command-palette/editor-commands.tsx +24 -5
  77. package/src/components/ui/command-palette/index.tsx +4 -255
  78. package/src/components/ui/controls/material-picker.tsx +154 -164
  79. package/src/components/ui/controls/slider-control.tsx +66 -18
  80. package/src/components/ui/floating-level-selector.tsx +286 -55
  81. package/src/components/ui/helpers/helper-manager.tsx +10 -0
  82. package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
  83. package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
  84. package/src/components/ui/level-duplicate-dialog.tsx +113 -0
  85. package/src/components/ui/panels/ceiling-panel.tsx +3 -28
  86. package/src/components/ui/panels/column-panel.tsx +759 -0
  87. package/src/components/ui/panels/door-panel.tsx +989 -290
  88. package/src/components/ui/panels/fence-panel.tsx +2 -49
  89. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  90. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  91. package/src/components/ui/panels/node-display.ts +39 -0
  92. package/src/components/ui/panels/paint-panel.tsx +163 -0
  93. package/src/components/ui/panels/panel-manager.tsx +208 -28
  94. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  95. package/src/components/ui/panels/reference-panel.tsx +253 -5
  96. package/src/components/ui/panels/roof-panel.tsx +13 -64
  97. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  98. package/src/components/ui/panels/slab-panel.tsx +4 -30
  99. package/src/components/ui/panels/spawn-panel.tsx +161 -0
  100. package/src/components/ui/panels/stair-panel.tsx +20 -74
  101. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  102. package/src/components/ui/panels/wall-panel.tsx +10 -8
  103. package/src/components/ui/panels/window-panel.tsx +668 -139
  104. package/src/components/ui/primitives/number-input.tsx +1 -1
  105. package/src/components/ui/primitives/sidebar.tsx +0 -0
  106. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  107. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  108. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  109. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  110. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  112. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  114. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  115. package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
  116. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  117. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  118. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
  119. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
  120. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
  121. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/slider.tsx +1 -1
  124. package/src/components/viewer-overlay.tsx +0 -0
  125. package/src/components/viewer-zone-system.tsx +0 -0
  126. package/src/hooks/use-auto-frame.ts +45 -0
  127. package/src/hooks/use-auto-save.ts +14 -0
  128. package/src/hooks/use-keyboard.ts +74 -7
  129. package/src/hooks/use-mobile.ts +12 -12
  130. package/src/index.tsx +8 -1
  131. package/src/lib/door-interaction.ts +88 -0
  132. package/src/lib/floorplan/geometry.ts +263 -0
  133. package/src/lib/floorplan/index.ts +38 -0
  134. package/src/lib/floorplan/items.ts +179 -0
  135. package/src/lib/floorplan/selection-tool.ts +231 -0
  136. package/src/lib/floorplan/stairs.ts +478 -0
  137. package/src/lib/floorplan/types.ts +57 -0
  138. package/src/lib/floorplan/walls.ts +23 -0
  139. package/src/lib/guide-events.ts +10 -0
  140. package/src/lib/level-duplication.test.ts +70 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/scene.ts +0 -0
  148. package/src/lib/sfx-bus.ts +2 -0
  149. package/src/lib/sfx-player.ts +5 -5
  150. package/src/lib/stair-duplication.ts +126 -0
  151. package/src/lib/window-interaction.ts +86 -0
  152. package/src/store/use-editor.tsx +186 -62
  153. package/tsconfig.json +2 -1
  154. package/src/components/feedback-dialog.tsx +0 -265
  155. package/src/components/pascal-radio.tsx +0 -280
  156. package/src/components/preview-button.tsx +0 -16
  157. package/src/components/ui/viewer-toolbar.tsx +0 -395
@@ -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 = ({ isVersionPreviewMode = false } = {}) => {
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')
@@ -68,6 +77,7 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
68
77
  e.preventDefault()
69
78
  useEditor.getState().setPhase('furnish')
70
79
  useEditor.getState().setMode('build')
80
+ useEditor.getState().setActiveSidebarPanel('items')
71
81
  } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
72
82
  if (isVersionPreviewMode) return
73
83
  e.preventDefault()
@@ -89,6 +99,13 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
89
99
  if (isVersionPreviewMode) return
90
100
  e.preventDefault()
91
101
  useEditor.getState().setMode('delete')
102
+ } else if (e.key === 'p' && !e.metaKey && !e.ctrlKey) {
103
+ if (isVersionPreviewMode) return
104
+ e.preventDefault()
105
+ useEditor.getState().primeMaterialPaintFromSelection()
106
+ useEditor.getState().setPhase('structure')
107
+ useEditor.getState().setStructureLayer('elements')
108
+ useEditor.getState().setMode('material-paint')
92
109
  } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
93
110
  if (isVersionPreviewMode) return
94
111
  e.preventDefault()
@@ -131,10 +148,31 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
131
148
  }
132
149
  } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
133
150
  // Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
151
+ // Operable doors/windows use R to toggle their open/closed state.
134
152
  const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
135
153
  if (selectedNodeIds.length === 1) {
136
154
  const node = useScene.getState().nodes[selectedNodeIds[0]!]
137
- if (node && 'rotation' in node) {
155
+ if (node?.type === 'door') {
156
+ e.preventDefault()
157
+ if (node.openingKind !== 'opening') {
158
+ toggleDoorOpenState(node.id)
159
+ sfxEmitter.emit('sfx:item-rotate')
160
+ }
161
+ } else if (
162
+ node?.type === 'window' &&
163
+ node.openingKind !== 'opening' &&
164
+ (node.windowType === 'sliding' ||
165
+ node.windowType === 'casement' ||
166
+ node.windowType === 'awning' ||
167
+ node.windowType === 'hopper' ||
168
+ node.windowType === 'single-hung' ||
169
+ node.windowType === 'double-hung' ||
170
+ node.windowType === 'louvered')
171
+ ) {
172
+ e.preventDefault()
173
+ toggleWindowOpenState(node.id)
174
+ sfxEmitter.emit('sfx:item-rotate')
175
+ } else if (node && 'rotation' in node) {
138
176
  e.preventDefault()
139
177
  const ROTATION_STEP = Math.PI / 4
140
178
 
@@ -154,7 +192,27 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
154
192
  const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
155
193
  if (selectedNodeIds.length === 1) {
156
194
  const node = useScene.getState().nodes[selectedNodeIds[0]!]
157
- if (node && 'rotation' in node) {
195
+ if (node?.type === 'door') {
196
+ e.preventDefault()
197
+ if (node.openingKind !== 'opening') {
198
+ closeDoorOpenState(node.id)
199
+ sfxEmitter.emit('sfx:item-rotate')
200
+ }
201
+ } else if (
202
+ node?.type === 'window' &&
203
+ node.openingKind !== 'opening' &&
204
+ (node.windowType === 'sliding' ||
205
+ node.windowType === 'casement' ||
206
+ node.windowType === 'awning' ||
207
+ node.windowType === 'hopper' ||
208
+ node.windowType === 'single-hung' ||
209
+ node.windowType === 'double-hung' ||
210
+ node.windowType === 'louvered')
211
+ ) {
212
+ e.preventDefault()
213
+ closeWindowOpenState(node.id)
214
+ sfxEmitter.emit('sfx:item-rotate')
215
+ } else if (node && 'rotation' in node) {
158
216
  e.preventDefault()
159
217
  const ROTATION_STEP = Math.PI / 4
160
218
 
@@ -195,6 +253,15 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
195
253
  const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
196
254
 
197
255
  if (selectedNodeIds.length > 0) {
256
+ // Guard against accidental bulk deletion (e.g. box-select all + Delete)
257
+ const BULK_DELETE_THRESHOLD = 10
258
+ if (selectedNodeIds.length >= BULK_DELETE_THRESHOLD) {
259
+ const confirmed = window.confirm(
260
+ `Delete ${selectedNodeIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
261
+ )
262
+ if (!confirmed) return
263
+ }
264
+
198
265
  // Play appropriate SFX based on what's being deleted
199
266
  if (selectedNodeIds.length === 1) {
200
267
  const node = useScene.getState().nodes[selectedNodeIds[0]!]
@@ -213,7 +280,7 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
213
280
  }
214
281
  window.addEventListener('keydown', handleKeyDown)
215
282
  return () => window.removeEventListener('keydown', handleKeyDown)
216
- }, [isVersionPreviewMode])
283
+ }, [disabled, isVersionPreviewMode])
217
284
 
218
285
  return null
219
286
  }
@@ -2,18 +2,18 @@ import * as React from 'react'
2
2
 
3
3
  const MOBILE_BREAKPOINT = 768
4
4
 
5
- export function useIsMobile() {
6
- const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
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
- React.useEffect(() => {
9
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10
- const onChange = () => {
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
- return !!isMobile
17
+ export function useIsMobile(): boolean {
18
+ return React.useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot)
19
19
  }
package/src/index.tsx CHANGED
@@ -1,13 +1,21 @@
1
1
  export type { EditorProps } from './components/editor'
2
2
  export { default as Editor } from './components/editor'
3
+ export {
4
+ type SnapshotCameraData,
5
+ ThumbnailGenerator,
6
+ } from './components/editor/thumbnail-generator'
7
+ export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions'
8
+ export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
3
9
  export { useCommandPalette } from './components/ui/command-palette'
4
10
  export { SliderControl } from './components/ui/controls/slider-control'
5
11
  export { FloatingLevelSelector } from './components/ui/floating-level-selector'
6
12
  export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items'
13
+ export { PALETTE_COLORS } from './components/ui/primitives/color-dot'
7
14
  export { useSidebarStore } from './components/ui/primitives/sidebar'
8
15
  export { Slider } from './components/ui/primitives/slider'
9
16
  export { SceneLoader } from './components/ui/scene-loader'
10
17
  export type { ExtraPanel } from './components/ui/sidebar/icon-rail'
18
+ export { ItemsPanel } from './components/ui/sidebar/panels/items-panel'
11
19
  export {
12
20
  type ProjectVisibility,
13
21
  SettingsPanel,
@@ -15,7 +23,6 @@ export {
15
23
  } from './components/ui/sidebar/panels/settings-panel'
16
24
  export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
17
25
  export type { SidebarTab } from './components/ui/sidebar/tab-bar'
18
- export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
19
26
  export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
20
27
  export { PresetsProvider } from './contexts/presets-context'
21
28
  export type { SaveStatus } from './hooks/use-auto-save'
@@ -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'