@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,478 @@
1
+ import type { Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
2
+ import {
3
+ clampPlanValue,
4
+ getPlanPointDistance,
5
+ getThickPlanLinePolygon,
6
+ interpolatePlanPoint,
7
+ movePlanPointTowards,
8
+ rotatePlanVector,
9
+ } from './geometry'
10
+ import type {
11
+ FloorplanLineSegment,
12
+ FloorplanStairArrowEntry,
13
+ FloorplanStairEntry,
14
+ FloorplanStairSegmentEntry,
15
+ StairSegmentTransform,
16
+ } from './types'
17
+
18
+ const FLOORPLAN_STAIR_OUTLINE_BAND_THICKNESS = 0.05
19
+ const FLOORPLAN_STAIR_OUTLINE_MAX_FRACTION = 0.18
20
+ const FLOORPLAN_STAIR_TREAD_BAND_THICKNESS = 0.05 * 0.82
21
+ const FLOORPLAN_STAIR_TREAD_MIN_THICKNESS = 0.02 * 1.5
22
+ const FLOORPLAN_STAIR_ARROW_HEAD_MIN_SIZE = 0.14
23
+ const FLOORPLAN_STAIR_ARROW_HEAD_MAX_SIZE = 0.24
24
+
25
+ type FloorplanStairArrowSide = 'back' | 'front' | 'left' | 'right'
26
+
27
+ function getFloorplanStairSegmentCenterLine(polygon: Point2D[]): FloorplanLineSegment | null {
28
+ if (polygon.length < 4) {
29
+ return null
30
+ }
31
+
32
+ const [backLeft, backRight, frontRight, frontLeft] = polygon
33
+
34
+ return {
35
+ start: interpolatePlanPoint(backLeft!, backRight!, 0.5),
36
+ end: interpolatePlanPoint(frontLeft!, frontRight!, 0.5),
37
+ }
38
+ }
39
+
40
+ function getFloorplanStairInnerPolygon(polygon: Point2D[]): Point2D[] {
41
+ if (polygon.length < 4) {
42
+ return polygon
43
+ }
44
+
45
+ const [backLeft, backRight, frontRight, frontLeft] = polygon
46
+ const outerWidth = getPlanPointDistance(backLeft!, backRight!)
47
+ const outerLength = getPlanPointDistance(backLeft!, frontLeft!)
48
+ const widthInset = Math.min(
49
+ FLOORPLAN_STAIR_OUTLINE_BAND_THICKNESS,
50
+ outerWidth * FLOORPLAN_STAIR_OUTLINE_MAX_FRACTION,
51
+ )
52
+ const lengthInset = Math.min(
53
+ FLOORPLAN_STAIR_OUTLINE_BAND_THICKNESS,
54
+ outerLength * FLOORPLAN_STAIR_OUTLINE_MAX_FRACTION,
55
+ )
56
+
57
+ const insetBackLeft = movePlanPointTowards(backLeft!, frontLeft!, lengthInset)
58
+ const insetBackRight = movePlanPointTowards(backRight!, frontRight!, lengthInset)
59
+ const insetFrontLeft = movePlanPointTowards(frontLeft!, backLeft!, lengthInset)
60
+ const insetFrontRight = movePlanPointTowards(frontRight!, backRight!, lengthInset)
61
+
62
+ const innerPolygon = [
63
+ movePlanPointTowards(insetBackLeft, insetBackRight, widthInset),
64
+ movePlanPointTowards(insetBackRight, insetBackLeft, widthInset),
65
+ movePlanPointTowards(insetFrontRight, insetFrontLeft, widthInset),
66
+ movePlanPointTowards(insetFrontLeft, insetFrontRight, widthInset),
67
+ ]
68
+
69
+ const innerWidth = getPlanPointDistance(innerPolygon[0]!, innerPolygon[1]!)
70
+ const innerLength = getPlanPointDistance(innerPolygon[0]!, innerPolygon[3]!)
71
+
72
+ return innerWidth > 0.06 && innerLength > 0.06 ? innerPolygon : polygon
73
+ }
74
+
75
+ function getFloorplanStairTreadLines(
76
+ segment: StairSegmentNode,
77
+ innerPolygon: Point2D[],
78
+ ): FloorplanLineSegment[] {
79
+ if (segment.segmentType !== 'stair' || segment.stepCount <= 1 || innerPolygon.length < 4) {
80
+ return []
81
+ }
82
+
83
+ const [backLeft, backRight, frontRight, frontLeft] = innerPolygon
84
+ const treadLines: FloorplanLineSegment[] = []
85
+
86
+ for (let stepIndex = 1; stepIndex < segment.stepCount; stepIndex += 1) {
87
+ const t = stepIndex / segment.stepCount
88
+ treadLines.push({
89
+ start: interpolatePlanPoint(backLeft!, frontLeft!, t),
90
+ end: interpolatePlanPoint(backRight!, frontRight!, t),
91
+ })
92
+ }
93
+
94
+ return treadLines
95
+ }
96
+
97
+ function getFloorplanStairTreadThickness(segment: StairSegmentNode, innerPolygon: Point2D[]) {
98
+ if (segment.segmentType !== 'stair' || segment.stepCount <= 1 || innerPolygon.length < 4) {
99
+ return 0
100
+ }
101
+
102
+ const innerWidth = getPlanPointDistance(innerPolygon[0]!, innerPolygon[1]!)
103
+ const innerLength = getPlanPointDistance(innerPolygon[0]!, innerPolygon[3]!)
104
+ const treadRun = innerLength / Math.max(segment.stepCount, 1)
105
+ return clampPlanValue(
106
+ Math.min(FLOORPLAN_STAIR_TREAD_BAND_THICKNESS, innerWidth * 0.12, treadRun * 0.44),
107
+ FLOORPLAN_STAIR_TREAD_MIN_THICKNESS,
108
+ FLOORPLAN_STAIR_TREAD_BAND_THICKNESS,
109
+ )
110
+ }
111
+
112
+ function getFloorplanStairTreadBars(
113
+ segment: StairSegmentNode,
114
+ innerPolygon: Point2D[],
115
+ treadThickness = getFloorplanStairTreadThickness(segment, innerPolygon),
116
+ ): Point2D[][] {
117
+ const treadLines = getFloorplanStairTreadLines(segment, innerPolygon)
118
+ if (treadLines.length === 0 || treadThickness <= 0) {
119
+ return []
120
+ }
121
+
122
+ return treadLines.map((line) => getThickPlanLinePolygon(line, treadThickness))
123
+ }
124
+
125
+ function getFloorplanStairSegmentCenterPoint(segment: FloorplanStairSegmentEntry): Point2D | null {
126
+ if (segment.centerLine) {
127
+ return interpolatePlanPoint(segment.centerLine.start, segment.centerLine.end, 0.5)
128
+ }
129
+
130
+ if (segment.polygon.length < 4) {
131
+ return null
132
+ }
133
+
134
+ const [backLeft, backRight, frontRight, frontLeft] = segment.polygon
135
+
136
+ return {
137
+ x: (backLeft!.x + backRight!.x + frontRight!.x + frontLeft!.x) / 4,
138
+ y: (backLeft!.y + backRight!.y + frontRight!.y + frontLeft!.y) / 4,
139
+ }
140
+ }
141
+
142
+ function getFloorplanStairSegmentSidePoint(
143
+ segment: FloorplanStairSegmentEntry,
144
+ side: FloorplanStairArrowSide,
145
+ ): Point2D | null {
146
+ if (segment.polygon.length < 4) {
147
+ return null
148
+ }
149
+
150
+ const [backLeft, backRight, frontRight, frontLeft] = segment.polygon
151
+
152
+ switch (side) {
153
+ case 'back':
154
+ return interpolatePlanPoint(backLeft!, backRight!, 0.5)
155
+ case 'front':
156
+ return interpolatePlanPoint(frontLeft!, frontRight!, 0.5)
157
+ case 'left':
158
+ return interpolatePlanPoint(backLeft!, frontLeft!, 0.5)
159
+ case 'right':
160
+ return interpolatePlanPoint(backRight!, frontRight!, 0.5)
161
+ }
162
+ }
163
+
164
+ function getFloorplanStairExitSide(
165
+ nextSegment: StairSegmentNode | undefined,
166
+ ): FloorplanStairArrowSide {
167
+ if (!nextSegment) {
168
+ return 'front'
169
+ }
170
+
171
+ if (nextSegment.attachmentSide === 'left') {
172
+ return 'right'
173
+ }
174
+ if (nextSegment.attachmentSide === 'right') {
175
+ return 'left'
176
+ }
177
+
178
+ return 'front'
179
+ }
180
+
181
+ function appendUniquePlanPoint(points: Point2D[], point: Point2D | null) {
182
+ if (!point) {
183
+ return
184
+ }
185
+
186
+ const lastPoint = points[points.length - 1]
187
+ if (lastPoint && getPlanPointDistance(lastPoint, point) <= 0.001) {
188
+ return
189
+ }
190
+
191
+ points.push(point)
192
+ }
193
+
194
+ function getFloorplanArcPoint(center: Point2D, radius: number, angle: number): Point2D {
195
+ return {
196
+ x: center.x + Math.cos(angle) * radius,
197
+ y: center.y + Math.sin(angle) * radius,
198
+ }
199
+ }
200
+
201
+ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
202
+ const stairType = stair.stairType ?? 'straight'
203
+ const baseSweepAngle = stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
204
+
205
+ if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
206
+ return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
207
+ }
208
+
209
+ return baseSweepAngle
210
+ }
211
+
212
+ function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
213
+ if (
214
+ (stair.stairType ?? 'straight') !== 'spiral' ||
215
+ (stair.topLandingMode ?? 'none') !== 'integrated'
216
+ ) {
217
+ return 0
218
+ }
219
+
220
+ const innerRadius = Math.max(0.05, stair.innerRadius ?? 0.9)
221
+ const width = Math.max(stair.width ?? 1, 0.4)
222
+ const landingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max(width * 0.9, 0.8))
223
+
224
+ return (
225
+ Math.min(Math.PI * 0.75, landingDepth / Math.max(innerRadius + width / 2, 0.1)) *
226
+ Math.sign(sweepAngle || 1)
227
+ )
228
+ }
229
+
230
+ function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] {
231
+ const stairType = stair.stairType ?? 'straight'
232
+ const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair)
233
+ const startAngle = -stair.rotation - sweepAngle / 2
234
+ const endAngle = startAngle + sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle)
235
+ const center = {
236
+ x: stair.position[0],
237
+ y: stair.position[2],
238
+ }
239
+ const innerRadius = Math.max(
240
+ stairType === 'spiral' ? 0.05 : 0.2,
241
+ stair.innerRadius ?? (stairType === 'spiral' ? 0.2 : 0.9),
242
+ )
243
+ const outerRadius = innerRadius + stair.width
244
+ const outerArcLength = Math.abs(sweepAngle) * outerRadius
245
+ const segmentCount = Math.max(
246
+ 24,
247
+ Math.ceil(Math.abs(sweepAngle) / (Math.PI / 24)),
248
+ Math.ceil(outerArcLength / 0.14),
249
+ )
250
+ const outerPoints: Point2D[] = []
251
+ const innerPoints: Point2D[] = []
252
+
253
+ for (let index = 0; index <= segmentCount; index += 1) {
254
+ const t = index / segmentCount
255
+ const angle = startAngle + (endAngle - startAngle) * t
256
+ outerPoints.push(getFloorplanArcPoint(center, outerRadius, angle))
257
+ innerPoints.push(getFloorplanArcPoint(center, innerRadius, angle))
258
+ }
259
+
260
+ return [...outerPoints, ...innerPoints.reverse()]
261
+ }
262
+
263
+ function buildFloorplanStairArrow(
264
+ segments: FloorplanStairSegmentEntry[],
265
+ ): FloorplanStairArrowEntry | null {
266
+ const rawPoints: Point2D[] = []
267
+
268
+ for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) {
269
+ const segment = segments[segmentIndex]!
270
+ const nextSegment = segments[segmentIndex + 1]?.segment
271
+ const entryPoint = getFloorplanStairSegmentSidePoint(segment, 'back')
272
+ const exitPoint = getFloorplanStairSegmentSidePoint(
273
+ segment,
274
+ getFloorplanStairExitSide(nextSegment),
275
+ )
276
+
277
+ if (!(entryPoint && exitPoint)) {
278
+ continue
279
+ }
280
+
281
+ appendUniquePlanPoint(rawPoints, entryPoint)
282
+
283
+ const isStraightSegment = getPlanPointDistance(entryPoint, exitPoint) <= 0.001
284
+ if (isStraightSegment) {
285
+ continue
286
+ }
287
+
288
+ const exitSide = getFloorplanStairExitSide(nextSegment)
289
+ if (exitSide === 'front') {
290
+ appendUniquePlanPoint(rawPoints, exitPoint)
291
+ continue
292
+ }
293
+
294
+ appendUniquePlanPoint(rawPoints, getFloorplanStairSegmentCenterPoint(segment))
295
+ appendUniquePlanPoint(rawPoints, exitPoint)
296
+ }
297
+
298
+ if (rawPoints.length < 2) {
299
+ return null
300
+ }
301
+
302
+ const firstPoint = rawPoints[0]!
303
+ const secondPoint = rawPoints[1]!
304
+ const beforeLastPoint = rawPoints[rawPoints.length - 2]!
305
+ const lastPoint = rawPoints[rawPoints.length - 1]!
306
+ const firstLength = getPlanPointDistance(firstPoint, secondPoint)
307
+ const lastLength = getPlanPointDistance(beforeLastPoint, lastPoint)
308
+
309
+ if (firstLength <= Number.EPSILON || lastLength <= Number.EPSILON) {
310
+ return null
311
+ }
312
+
313
+ const polyline = [
314
+ movePlanPointTowards(firstPoint, secondPoint, Math.min(0.24, firstLength * 0.18)),
315
+ ...rawPoints.slice(1, -1),
316
+ movePlanPointTowards(lastPoint, beforeLastPoint, Math.min(0.3, lastLength * 0.22)),
317
+ ]
318
+ const arrowTailPoint = polyline[polyline.length - 2]
319
+ const arrowTip = polyline[polyline.length - 1]
320
+
321
+ if (!(arrowTailPoint && arrowTip)) {
322
+ return null
323
+ }
324
+
325
+ const arrowBodyLength = getPlanPointDistance(arrowTailPoint, arrowTip)
326
+ if (arrowBodyLength <= Number.EPSILON) {
327
+ return null
328
+ }
329
+
330
+ const arrowHeadLength = clampPlanValue(
331
+ arrowBodyLength * 0.72,
332
+ FLOORPLAN_STAIR_ARROW_HEAD_MIN_SIZE,
333
+ FLOORPLAN_STAIR_ARROW_HEAD_MAX_SIZE,
334
+ )
335
+ const arrowHeadBase = movePlanPointTowards(arrowTip, arrowTailPoint, arrowHeadLength)
336
+ const directionX = arrowTip.x - arrowHeadBase.x
337
+ const directionY = arrowTip.y - arrowHeadBase.y
338
+ const directionLength = Math.hypot(directionX, directionY)
339
+
340
+ if (directionLength <= Number.EPSILON) {
341
+ return null
342
+ }
343
+
344
+ const normalX = -directionY / directionLength
345
+ const normalY = directionX / directionLength
346
+ const arrowHeadHalfWidth = arrowHeadLength * 0.34
347
+
348
+ return {
349
+ head: [
350
+ arrowTip,
351
+ {
352
+ x: arrowHeadBase.x + normalX * arrowHeadHalfWidth,
353
+ y: arrowHeadBase.y + normalY * arrowHeadHalfWidth,
354
+ },
355
+ {
356
+ x: arrowHeadBase.x - normalX * arrowHeadHalfWidth,
357
+ y: arrowHeadBase.y - normalY * arrowHeadHalfWidth,
358
+ },
359
+ ],
360
+ polyline,
361
+ }
362
+ }
363
+
364
+ export function computeFloorplanStairSegmentTransforms(
365
+ segments: StairSegmentNode[],
366
+ ): StairSegmentTransform[] {
367
+ const transforms: StairSegmentTransform[] = []
368
+ let currentX = 0
369
+ let currentY = 0
370
+ let currentZ = 0
371
+ let currentRotation = 0
372
+
373
+ for (let index = 0; index < segments.length; index += 1) {
374
+ const segment = segments[index]!
375
+
376
+ if (index === 0) {
377
+ transforms.push({
378
+ position: [currentX, currentY, currentZ],
379
+ rotation: currentRotation,
380
+ })
381
+ continue
382
+ }
383
+
384
+ const previousSegment = segments[index - 1]!
385
+ let attachX = 0
386
+ let attachY = previousSegment.height
387
+ let attachZ = previousSegment.length
388
+ let rotationDelta = 0
389
+
390
+ if (segment.attachmentSide === 'left') {
391
+ attachX = previousSegment.width / 2
392
+ attachZ = previousSegment.length / 2
393
+ rotationDelta = Math.PI / 2
394
+ } else if (segment.attachmentSide === 'right') {
395
+ attachX = -previousSegment.width / 2
396
+ attachZ = previousSegment.length / 2
397
+ rotationDelta = -Math.PI / 2
398
+ }
399
+
400
+ const [rotatedAttachX, rotatedAttachZ] = rotatePlanVector(attachX, attachZ, currentRotation)
401
+ currentX += rotatedAttachX
402
+ currentY += attachY
403
+ currentZ += rotatedAttachZ
404
+ currentRotation += rotationDelta
405
+
406
+ transforms.push({
407
+ position: [currentX, currentY, currentZ],
408
+ rotation: currentRotation,
409
+ })
410
+ }
411
+
412
+ return transforms
413
+ }
414
+
415
+ export function getFloorplanStairSegmentPolygon(
416
+ stair: StairNode,
417
+ segment: StairSegmentNode,
418
+ transform: StairSegmentTransform,
419
+ ): Point2D[] {
420
+ const halfWidth = segment.width / 2
421
+ const localCorners: Array<[number, number]> = [
422
+ [-halfWidth, 0],
423
+ [halfWidth, 0],
424
+ [halfWidth, segment.length],
425
+ [-halfWidth, segment.length],
426
+ ]
427
+
428
+ return localCorners.map(([localX, localY]) => {
429
+ const [segmentX, segmentY] = rotatePlanVector(localX, localY, transform.rotation)
430
+ const groupX = transform.position[0] + segmentX
431
+ const groupY = transform.position[2] + segmentY
432
+ const [worldOffsetX, worldOffsetY] = rotatePlanVector(groupX, groupY, stair.rotation)
433
+
434
+ return {
435
+ x: stair.position[0] + worldOffsetX,
436
+ y: stair.position[2] + worldOffsetY,
437
+ }
438
+ })
439
+ }
440
+
441
+ export function buildFloorplanStairEntry(
442
+ stair: StairNode,
443
+ segments: StairSegmentNode[],
444
+ ): FloorplanStairEntry | null {
445
+ const stairType = stair.stairType ?? 'straight'
446
+
447
+ if (segments.length === 0 && stairType === 'straight') {
448
+ return null
449
+ }
450
+
451
+ const transforms = computeFloorplanStairSegmentTransforms(segments)
452
+ const segmentEntries = segments.map((segment, index) => {
453
+ const polygon = getFloorplanStairSegmentPolygon(stair, segment, transforms[index]!)
454
+ const centerLine = getFloorplanStairSegmentCenterLine(polygon)
455
+ const innerPolygon = getFloorplanStairInnerPolygon(polygon)
456
+ const treadThickness = getFloorplanStairTreadThickness(segment, innerPolygon)
457
+
458
+ return {
459
+ centerLine,
460
+ innerPolygon,
461
+ segment,
462
+ polygon,
463
+ treadBars: getFloorplanStairTreadBars(segment, innerPolygon, treadThickness),
464
+ treadThickness,
465
+ }
466
+ })
467
+ const hitPolygons =
468
+ stairType === 'straight'
469
+ ? segmentEntries.map(({ polygon }) => polygon)
470
+ : [getFloorplanCurvedStairHitPolygon(stair)]
471
+
472
+ return {
473
+ arrow: buildFloorplanStairArrow(segmentEntries),
474
+ hitPolygons,
475
+ stair,
476
+ segments: segmentEntries,
477
+ }
478
+ }
@@ -0,0 +1,57 @@
1
+ import type { AnyNode, ItemNode, Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
2
+
3
+ export type FloorplanNodeTransform = {
4
+ position: Point2D
5
+ rotation: number
6
+ }
7
+
8
+ export type FloorplanLineSegment = {
9
+ start: Point2D
10
+ end: Point2D
11
+ }
12
+
13
+ export type FloorplanItemEntry = {
14
+ dimensionPolygon: Point2D[]
15
+ item: ItemNode
16
+ polygon: Point2D[]
17
+ usesRealMesh: boolean
18
+ center: Point2D
19
+ rotation: number
20
+ width: number
21
+ depth: number
22
+ }
23
+
24
+ export type FloorplanStairSegmentEntry = {
25
+ centerLine: FloorplanLineSegment | null
26
+ innerPolygon: Point2D[]
27
+ segment: StairSegmentNode
28
+ polygon: Point2D[]
29
+ treadBars: Point2D[][]
30
+ treadThickness: number
31
+ }
32
+
33
+ export type FloorplanStairArrowEntry = {
34
+ head: Point2D[]
35
+ polyline: Point2D[]
36
+ }
37
+
38
+ export type FloorplanStairEntry = {
39
+ arrow: FloorplanStairArrowEntry | null
40
+ hitPolygons: Point2D[][]
41
+ stair: StairNode
42
+ segments: FloorplanStairSegmentEntry[]
43
+ }
44
+
45
+ export type FloorplanSelectionBounds = {
46
+ minX: number
47
+ maxX: number
48
+ minY: number
49
+ maxY: number
50
+ }
51
+
52
+ export type StairSegmentTransform = {
53
+ position: [number, number, number]
54
+ rotation: number
55
+ }
56
+
57
+ export type LevelDescendantMap = ReadonlyMap<string, AnyNode>
@@ -0,0 +1,23 @@
1
+ import type { WallNode } from '@pascal-app/core'
2
+
3
+ const FLOORPLAN_WALL_THICKNESS_SCALE = 1.18
4
+ const FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS = 0.13
5
+ const FLOORPLAN_MAX_EXTRA_THICKNESS = 0.035
6
+
7
+ export function getFloorplanWallThickness(wall: WallNode): number {
8
+ const baseThickness = wall.thickness ?? 0.1
9
+ const scaledThickness = baseThickness * FLOORPLAN_WALL_THICKNESS_SCALE
10
+
11
+ return Math.min(
12
+ baseThickness + FLOORPLAN_MAX_EXTRA_THICKNESS,
13
+ Math.max(baseThickness, scaledThickness, FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS),
14
+ )
15
+ }
16
+
17
+ export function getFloorplanWall(wall: WallNode): WallNode {
18
+ return {
19
+ ...wall,
20
+ // Slightly exaggerate thin walls so the 2D plan stays legible without drifting from BIM data.
21
+ thickness: getFloorplanWallThickness(wall),
22
+ }
23
+ }
@@ -0,0 +1,10 @@
1
+ import type { GuideNode } from '@pascal-app/core'
2
+ import mitt from 'mitt'
3
+
4
+ type GuideEditorEvents = {
5
+ 'guide:set-reference-scale': { guideId: GuideNode['id'] }
6
+ 'guide:cancel-reference-scale': undefined
7
+ 'guide:deleted': { guideId: GuideNode['id'] }
8
+ }
9
+
10
+ export const guideEmitter = mitt<GuideEditorEvents>()
@@ -0,0 +1,72 @@
1
+ // @ts-expect-error — bun:test is provided by the Bun runtime; editor does not
2
+ // depend on @types/bun so the import type is unresolved at compile time.
3
+ import { describe, expect, test } from 'bun:test'
4
+ import {
5
+ type AnyNode,
6
+ type AnyNodeId,
7
+ BuildingNode,
8
+ LevelNode,
9
+ SpawnNode,
10
+ WallNode,
11
+ } from '@pascal-app/core/schema'
12
+ import { buildLevelDuplicateCreateOps } from './level-duplication'
13
+
14
+ describe('buildLevelDuplicateCreateOps', () => {
15
+ test('parents a duplicated bootstrap level back to its building', () => {
16
+ const level = LevelNode.parse({ level: 0, children: [] })
17
+ const building = BuildingNode.parse({ children: [level.id] })
18
+ const wall = WallNode.parse({
19
+ parentId: level.id,
20
+ start: [0, 0],
21
+ end: [4, 0],
22
+ })
23
+ const sourceLevel = { ...level, children: [wall.id] } satisfies LevelNode
24
+ const nodes = {
25
+ [building.id]: building,
26
+ [sourceLevel.id]: sourceLevel,
27
+ [wall.id]: wall,
28
+ } as Record<AnyNodeId, AnyNode>
29
+
30
+ const { createOps, newLevelId } = buildLevelDuplicateCreateOps({
31
+ nodes,
32
+ level: sourceLevel,
33
+ levels: [sourceLevel],
34
+ preset: 'everything',
35
+ })
36
+
37
+ const levelCreateOp = createOps.find((op) => op.node.id === newLevelId)
38
+
39
+ expect(sourceLevel.parentId).toBeNull()
40
+ expect(levelCreateOp?.parentId).toBe(building.id)
41
+ })
42
+
43
+ test('does not copy spawn points from the source level', () => {
44
+ const building = BuildingNode.parse({})
45
+ const spawn = SpawnNode.parse({ parentId: 'level_source' })
46
+ const level = LevelNode.parse({
47
+ id: 'level_source',
48
+ level: 0,
49
+ parentId: building.id,
50
+ children: [spawn.id],
51
+ })
52
+ const nodes = {
53
+ [building.id]: { ...building, children: [level.id] },
54
+ [level.id]: level,
55
+ [spawn.id]: spawn,
56
+ } as Record<AnyNodeId, AnyNode>
57
+
58
+ const { createOps, newLevelId } = buildLevelDuplicateCreateOps({
59
+ nodes,
60
+ level,
61
+ levels: [level],
62
+ preset: 'everything',
63
+ })
64
+
65
+ const copiedLevel = createOps.find((op) => op.node.id === newLevelId)?.node as
66
+ | LevelNode
67
+ | undefined
68
+
69
+ expect(createOps.some((op) => op.node.type === 'spawn')).toBe(false)
70
+ expect(copiedLevel?.children).toEqual([])
71
+ })
72
+ })