@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.
Files changed (122) hide show
  1. package/package.json +9 -5
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +20 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -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 +9855 -3298
  12. package/src/components/editor/index.tsx +269 -21
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/thumbnail-generator.tsx +38 -7
  15. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  16. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  17. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  18. package/src/components/editor/wall-measurement-label.tsx +267 -36
  19. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  20. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  21. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  22. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  23. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  24. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  25. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  26. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  27. package/src/components/editor-2d/svg-paths.ts +119 -0
  28. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  29. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  30. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  31. package/src/components/tools/column/column-tool.tsx +97 -0
  32. package/src/components/tools/column/move-column-tool.tsx +105 -0
  33. package/src/components/tools/door/door-tool.tsx +7 -0
  34. package/src/components/tools/door/move-door-tool.tsx +28 -8
  35. package/src/components/tools/fence/fence-drafting.ts +10 -3
  36. package/src/components/tools/fence/fence-tool.tsx +159 -3
  37. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
  38. package/src/components/tools/fence/move-fence-tool.tsx +101 -34
  39. package/src/components/tools/item/move-tool.tsx +10 -1
  40. package/src/components/tools/item/placement-math.ts +30 -1
  41. package/src/components/tools/item/placement-strategies.ts +109 -31
  42. package/src/components/tools/item/placement-types.ts +7 -0
  43. package/src/components/tools/item/use-draft-node.ts +2 -0
  44. package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
  45. package/src/components/tools/roof/move-roof-tool.tsx +22 -15
  46. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  47. package/src/components/tools/shared/segment-angle.ts +156 -0
  48. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  49. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  50. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  51. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  52. package/src/components/tools/tool-manager.tsx +18 -3
  53. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
  54. package/src/components/tools/wall/wall-drafting.ts +18 -9
  55. package/src/components/tools/wall/wall-tool.tsx +134 -2
  56. package/src/components/tools/window/move-window-tool.tsx +18 -0
  57. package/src/components/tools/window/window-tool.tsx +5 -0
  58. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  59. package/src/components/ui/action-menu/control-modes.tsx +28 -1
  60. package/src/components/ui/action-menu/index.tsx +91 -1
  61. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  63. package/src/components/ui/command-palette/editor-commands.tsx +18 -1
  64. package/src/components/ui/controls/material-picker.tsx +152 -165
  65. package/src/components/ui/controls/slider-control.tsx +66 -18
  66. package/src/components/ui/floating-level-selector.tsx +286 -55
  67. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  68. package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
  69. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  70. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  71. package/src/components/ui/panels/ceiling-panel.tsx +1 -25
  72. package/src/components/ui/panels/column-panel.tsx +715 -0
  73. package/src/components/ui/panels/door-panel.tsx +981 -289
  74. package/src/components/ui/panels/fence-panel.tsx +3 -45
  75. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  76. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  77. package/src/components/ui/panels/node-display.ts +39 -0
  78. package/src/components/ui/panels/paint-panel.tsx +138 -0
  79. package/src/components/ui/panels/panel-manager.tsx +210 -1
  80. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  81. package/src/components/ui/panels/reference-panel.tsx +238 -5
  82. package/src/components/ui/panels/roof-panel.tsx +4 -105
  83. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  84. package/src/components/ui/panels/slab-panel.tsx +4 -30
  85. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  86. package/src/components/ui/panels/stair-panel.tsx +11 -117
  87. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  88. package/src/components/ui/panels/wall-panel.tsx +1 -95
  89. package/src/components/ui/panels/window-panel.tsx +660 -139
  90. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  91. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  92. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  93. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  94. package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
  95. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  96. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  97. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
  98. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
  99. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  100. package/src/components/ui/viewer-toolbar.tsx +42 -1
  101. package/src/hooks/use-auto-frame.ts +45 -0
  102. package/src/hooks/use-keyboard.ts +64 -7
  103. package/src/hooks/use-mobile.ts +12 -12
  104. package/src/lib/door-interaction.ts +88 -0
  105. package/src/lib/floorplan/geometry.ts +263 -0
  106. package/src/lib/floorplan/index.ts +38 -0
  107. package/src/lib/floorplan/items.ts +179 -0
  108. package/src/lib/floorplan/selection-tool.ts +231 -0
  109. package/src/lib/floorplan/stairs.ts +478 -0
  110. package/src/lib/floorplan/types.ts +57 -0
  111. package/src/lib/floorplan/walls.ts +23 -0
  112. package/src/lib/guide-events.ts +10 -0
  113. package/src/lib/level-duplication.test.ts +72 -0
  114. package/src/lib/level-duplication.ts +153 -0
  115. package/src/lib/local-guide-image.ts +42 -0
  116. package/src/lib/material-paint.ts +284 -0
  117. package/src/lib/roof-duplication.ts +214 -0
  118. package/src/lib/scene-bounds.test.ts +183 -0
  119. package/src/lib/scene-bounds.ts +169 -0
  120. package/src/lib/stair-duplication.ts +126 -0
  121. package/src/lib/window-interaction.ts +86 -0
  122. package/src/store/use-editor.tsx +164 -8
@@ -0,0 +1,365 @@
1
+ import {
2
+ getGarageVisibleOpeningRatio,
3
+ type AnyNodeId,
4
+ type DoorNode,
5
+ isOperationDoorType,
6
+ sceneRegistry,
7
+ useInteractive,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import * as THREE from 'three'
11
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
12
+ import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
13
+
14
+ const COLLIDER_NODE_TYPES = [
15
+ 'wall',
16
+ 'fence',
17
+ 'slab',
18
+ 'stair',
19
+ 'stair-segment',
20
+ 'roof',
21
+ 'roof-segment',
22
+ 'door',
23
+ 'window',
24
+ 'item',
25
+ ] as const
26
+
27
+ const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh'])
28
+ const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial()
29
+ const DOWN = new THREE.Vector3(0, -1, 0)
30
+ const UP = new THREE.Vector3(0, 1, 0)
31
+ const SPAWN_EYE_HEIGHT = 1.65
32
+ const RAYCAST_CLEARANCE = 25
33
+ const DOOR_LEAF_COLLIDER_DEPTH = 0.06
34
+ const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85
35
+
36
+ export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
37
+
38
+ export type FirstPersonColliderWorld = {
39
+ mesh: THREE.Mesh
40
+ bounds: THREE.Box3 | null
41
+ dispose: () => void
42
+ }
43
+
44
+ export type FirstPersonSpawn = {
45
+ position: [number, number, number]
46
+ yaw: number
47
+ }
48
+
49
+ type ColliderNodeType = (typeof COLLIDER_NODE_TYPES)[number]
50
+
51
+ function isMesh(object: THREE.Object3D): object is THREE.Mesh {
52
+ return 'isMesh' in object && (object as THREE.Mesh).isMesh
53
+ }
54
+
55
+ function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) {
56
+ return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
57
+ }
58
+
59
+ function cloneWorldGeometry(mesh: THREE.Mesh) {
60
+ const sourceGeometry = mesh.geometry
61
+ const position = sourceGeometry.getAttribute('position')
62
+ if (!position || position.count < 3) return null
63
+
64
+ const workingGeometry = sourceGeometry.index
65
+ ? sourceGeometry.toNonIndexed()
66
+ : sourceGeometry.clone()
67
+ const cleanGeometry = new THREE.BufferGeometry()
68
+ cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone())
69
+
70
+ const normal = workingGeometry.getAttribute('normal')
71
+ if (normal) {
72
+ cleanGeometry.setAttribute('normal', normal.clone())
73
+ } else {
74
+ cleanGeometry.computeVertexNormals()
75
+ }
76
+
77
+ cleanGeometry.applyMatrix4(mesh.matrixWorld)
78
+ workingGeometry.dispose()
79
+
80
+ const worldPosition = cleanGeometry.getAttribute('position')
81
+ if (!worldPosition || worldPosition.count < 3) {
82
+ cleanGeometry.dispose()
83
+ return null
84
+ }
85
+
86
+ return cleanGeometry
87
+ }
88
+
89
+ function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) {
90
+ if (type === 'window') {
91
+ const node = useScene.getState().nodes[nodeId as AnyNodeId]
92
+ return node?.type === 'window' && node.openingKind === 'opening'
93
+ }
94
+
95
+ if (type !== 'door') return false
96
+
97
+ const node = useScene.getState().nodes[nodeId as AnyNodeId]
98
+ if (!node || node.type !== 'door') return false
99
+
100
+ if (node.openingKind === 'opening') return true
101
+
102
+ if (!node.segments.length) return true
103
+
104
+ return node.segments.every((segment) => segment.type === 'empty')
105
+ }
106
+
107
+ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
108
+ const hasLeafContent = node.segments.some((segment) => segment.type !== 'empty')
109
+ if (!hasLeafContent) return null
110
+
111
+ const leafW = node.width - 2 * node.frameThickness
112
+ const leafH = node.height - node.frameThickness
113
+ if (leafW <= 0 || leafH <= 0) return null
114
+
115
+ const leafCenterY = -node.frameThickness / 2
116
+ const runtimeDoorState = useInteractive.getState().doors[node.id]
117
+ const operationState = runtimeDoorState?.operationState ?? node.operationState
118
+ const swingAngle = runtimeDoorState?.swingAngle ?? node.swingAngle
119
+
120
+ root.updateWorldMatrix(true, false)
121
+
122
+ if (node.doorType === 'garage-sectional' || node.doorType === 'garage-rollup') {
123
+ const openAmount = getGarageVisibleOpeningRatio(node.doorType, operationState)
124
+ const visibleHeight = leafH * (1 - openAmount)
125
+ if (visibleHeight <= 0.12) return null
126
+
127
+ const sourceGeometry = new THREE.BoxGeometry(
128
+ leafW,
129
+ visibleHeight,
130
+ DOOR_LEAF_COLLIDER_DEPTH,
131
+ ).toNonIndexed()
132
+ const geometry = new THREE.BufferGeometry()
133
+ geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
134
+ geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
135
+ sourceGeometry.dispose()
136
+ const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2
137
+ geometry.applyMatrix4(
138
+ root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)),
139
+ )
140
+ return geometry
141
+ }
142
+
143
+ if (
144
+ isOperationDoorType(node.doorType) &&
145
+ (operationState ?? 0) >= OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD
146
+ ) {
147
+ return null
148
+ }
149
+
150
+ const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
151
+ const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
152
+ const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
153
+ const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle ?? 0))
154
+ const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
155
+
156
+ const sourceGeometry = new THREE.BoxGeometry(
157
+ leafW,
158
+ leafH,
159
+ DOOR_LEAF_COLLIDER_DEPTH,
160
+ ).toNonIndexed()
161
+ const geometry = new THREE.BufferGeometry()
162
+ geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
163
+ geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
164
+ sourceGeometry.dispose()
165
+ const matrix = root.matrixWorld
166
+ .clone()
167
+ .multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0))
168
+ .multiply(new THREE.Matrix4().makeRotationY(leafSwingRotation))
169
+ .multiply(new THREE.Matrix4().makeTranslation(-hingeX, leafCenterY, 0))
170
+
171
+ geometry.applyMatrix4(matrix)
172
+ return geometry
173
+ }
174
+
175
+ function buildRegisteredNodeTypeLookup() {
176
+ const nodeTypes = new Map<string, ColliderNodeType>()
177
+
178
+ for (const type of COLLIDER_NODE_TYPES) {
179
+ for (const nodeId of sceneRegistry.byType[type]) {
180
+ nodeTypes.set(nodeId, type)
181
+ }
182
+ }
183
+
184
+ return nodeTypes
185
+ }
186
+
187
+ function collectColliderGeometriesFromNode(
188
+ root: THREE.Object3D,
189
+ rootNodeId: string,
190
+ visitedMeshes: WeakSet<THREE.Object3D>,
191
+ registeredObjectIds: Map<THREE.Object3D, string>,
192
+ registeredNodeTypes: Map<string, ColliderNodeType>,
193
+ ): THREE.BufferGeometry[] {
194
+ const geometries: THREE.BufferGeometry[] = []
195
+
196
+ const visit = (object: THREE.Object3D) => {
197
+ if (visitedMeshes.has(object)) return
198
+ visitedMeshes.add(object)
199
+
200
+ if (
201
+ isMesh(object) &&
202
+ object.visible &&
203
+ isColliderMaterialVisible(object.material) &&
204
+ !SKIPPED_MESH_NAMES.has(object.name)
205
+ ) {
206
+ const geometry = cloneWorldGeometry(object)
207
+ if (geometry) {
208
+ geometries.push(geometry)
209
+ }
210
+ }
211
+
212
+ for (const child of object.children) {
213
+ const childNodeId = registeredObjectIds.get(child)
214
+ if (childNodeId && childNodeId !== rootNodeId) {
215
+ const childType = registeredNodeTypes.get(childNodeId)
216
+ if (childType && COLLIDER_NODE_TYPES.includes(childType)) {
217
+ continue
218
+ }
219
+ }
220
+
221
+ visit(child)
222
+ }
223
+ }
224
+
225
+ visit(root)
226
+
227
+ return geometries
228
+ }
229
+
230
+ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null {
231
+ const geometries: THREE.BufferGeometry[] = []
232
+ const visitedMeshes = new WeakSet<THREE.Object3D>()
233
+ const registeredNodeTypes = buildRegisteredNodeTypeLookup()
234
+ const registeredObjectIds = new Map<THREE.Object3D, string>()
235
+
236
+ for (const [nodeId, object] of sceneRegistry.nodes) {
237
+ registeredObjectIds.set(object, nodeId)
238
+ }
239
+
240
+ for (const type of COLLIDER_NODE_TYPES) {
241
+ for (const nodeId of sceneRegistry.byType[type]) {
242
+ if (shouldSkipColliderNode(nodeId, type)) continue
243
+
244
+ const root = sceneRegistry.nodes.get(nodeId)
245
+ if (!root) continue
246
+
247
+ if (type === 'door') {
248
+ const node = useScene.getState().nodes[nodeId as AnyNodeId]
249
+ if (node?.type !== 'door') continue
250
+
251
+ const doorGeometry = createDoorLeafColliderGeometry(root, node)
252
+ if (doorGeometry) {
253
+ geometries.push(doorGeometry)
254
+ }
255
+ continue
256
+ }
257
+
258
+ root.updateMatrixWorld(true)
259
+ geometries.push(
260
+ ...collectColliderGeometriesFromNode(
261
+ root,
262
+ nodeId,
263
+ visitedMeshes,
264
+ registeredObjectIds,
265
+ registeredNodeTypes,
266
+ ),
267
+ )
268
+ }
269
+ }
270
+
271
+ if (geometries.length === 0) {
272
+ return null
273
+ }
274
+
275
+ const mergedGeometry = mergeGeometries(geometries, false)
276
+ geometries.forEach((geometry) => {
277
+ geometry.dispose()
278
+ })
279
+
280
+ if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
281
+ mergedGeometry?.dispose()
282
+ return null
283
+ }
284
+
285
+ const bvhGeometry = mergedGeometry as THREE.BufferGeometry & {
286
+ computeBoundsTree?: typeof computeBoundsTree
287
+ disposeBoundsTree?: typeof disposeBoundsTree
288
+ }
289
+
290
+ ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
291
+ ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
292
+ bvhGeometry.computeBoundsTree?.({
293
+ maxLeafTris: 12,
294
+ strategy: 0,
295
+ } as never)
296
+ bvhGeometry.computeBoundingBox()
297
+
298
+ const mesh = new THREE.Mesh(bvhGeometry, COLLIDER_MATERIAL)
299
+ mesh.raycast = acceleratedRaycast
300
+ mesh.visible = true
301
+ mesh.userData = {
302
+ type: 'STATIC',
303
+ friction: 0.8,
304
+ restitution: 0.05,
305
+ excludeFloatHit: false,
306
+ excludeCollisionCheck: false,
307
+ }
308
+ mesh.updateMatrixWorld(true)
309
+
310
+ return {
311
+ mesh,
312
+ bounds: bvhGeometry.boundingBox?.clone() ?? null,
313
+ dispose: () => {
314
+ bvhGeometry.disposeBoundsTree?.()
315
+ bvhGeometry.dispose()
316
+ },
317
+ }
318
+ }
319
+
320
+ export function deriveFirstPersonSpawn(
321
+ camera: THREE.Camera,
322
+ world: FirstPersonColliderWorld,
323
+ ): FirstPersonSpawn {
324
+ const direction = new THREE.Vector3()
325
+ camera.getWorldDirection(direction)
326
+ direction.y = 0
327
+ if (direction.lengthSq() < 1e-6) {
328
+ direction.set(0, 0, -1)
329
+ } else {
330
+ direction.normalize()
331
+ }
332
+
333
+ const yaw = Math.atan2(-direction.x, -direction.z)
334
+ const raycaster = new THREE.Raycaster()
335
+ const candidates: Array<[number, number]> = [[camera.position.x, camera.position.z]]
336
+
337
+ const boundsCenter = world.bounds?.getCenter(new THREE.Vector3())
338
+ if (boundsCenter) {
339
+ candidates.push([boundsCenter.x, boundsCenter.z])
340
+ }
341
+
342
+ for (const [x, z] of candidates) {
343
+ const topY =
344
+ Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE
345
+ raycaster.set(new THREE.Vector3(x, topY, z), DOWN)
346
+ const intersections = raycaster.intersectObject(world.mesh, false)
347
+ const hit = intersections.find((intersection) => {
348
+ if (!intersection.face) return true
349
+ const normal = intersection.face.normal.clone().transformDirection(world.mesh.matrixWorld)
350
+ return normal.dot(UP) > 0.2
351
+ })
352
+
353
+ if (hit) {
354
+ return {
355
+ position: [hit.point.x, hit.point.y + SPAWN_EYE_HEIGHT, hit.point.z],
356
+ yaw,
357
+ }
358
+ }
359
+ }
360
+
361
+ return {
362
+ position: [camera.position.x, Math.max(camera.position.y, SPAWN_EYE_HEIGHT), camera.position.z],
363
+ yaw,
364
+ }
365
+ }