@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,474 @@
1
+ 'use client'
2
+
3
+ import type { Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
4
+ import {
5
+ memo,
6
+ type MouseEvent as ReactMouseEvent,
7
+ type PointerEvent as ReactPointerEvent,
8
+ } from 'react'
9
+ import {
10
+ buildSvgAnnularSectorPath,
11
+ buildSvgArcPath,
12
+ buildSvgArrowHeadPoints,
13
+ formatSvgPolygonPoints,
14
+ getArcPlanPoint,
15
+ toSvgX,
16
+ toSvgY,
17
+ } from '../svg-paths'
18
+
19
+ type FloorplanPolygonEntry = {
20
+ points: string
21
+ polygon: Point2D[]
22
+ }
23
+
24
+ type FloorplanStairSegmentEntry = {
25
+ segment: StairSegmentNode
26
+ points: string
27
+ treadBars: FloorplanPolygonEntry[]
28
+ }
29
+
30
+ type FloorplanStairArrowEntry = {
31
+ head: Point2D[]
32
+ polyline: Point2D[]
33
+ }
34
+
35
+ type FloorplanStairEntry = {
36
+ arrow: FloorplanStairArrowEntry | null
37
+ hitPolygons: Point2D[][]
38
+ stair: StairNode
39
+ segments: FloorplanStairSegmentEntry[]
40
+ }
41
+
42
+ type FloorplanPalette = {
43
+ deleteFill: string
44
+ deleteStroke: string
45
+ stairFill: string
46
+ stairSelectedFill: string
47
+ stairStroke: string
48
+ stairAccent: string
49
+ stairTread: string
50
+ stairSelectedTread: string
51
+ }
52
+
53
+ type FloorplanStairLayerProps = {
54
+ canFocusStairs: boolean
55
+ canSelectStairs: boolean
56
+ cursor: string
57
+ highlightedIdSet: ReadonlySet<string>
58
+ hitStrokeWidth: number
59
+ hoveredStairId: StairNode['id'] | null
60
+ isDeleteMode: boolean
61
+ onStairDoubleClick: (stair: StairNode, event: ReactMouseEvent<SVGElement>) => void
62
+ onStairHoverChange: (stairId: StairNode['id'] | null) => void
63
+ onStairHoverEnter: (stairId: StairNode['id']) => void
64
+ onStairPointerDown: (stairId: StairNode['id'], event: ReactPointerEvent<SVGElement>) => void
65
+ onStairSelect: (stairId: StairNode['id'], event: ReactMouseEvent<SVGElement>) => void
66
+ palette: FloorplanPalette
67
+ selectedIdSet: ReadonlySet<string>
68
+ stairEntries: FloorplanStairEntry[]
69
+ }
70
+
71
+ function clamp(value: number, min: number, max: number) {
72
+ return Math.min(max, Math.max(min, value))
73
+ }
74
+
75
+ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
76
+ const stairType = stair.stairType ?? 'straight'
77
+ const baseSweepAngle = stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
78
+
79
+ if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
80
+ return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
81
+ }
82
+
83
+ return baseSweepAngle
84
+ }
85
+
86
+ function getFloorplanStairStepCount(stair: StairNode, minimum: number) {
87
+ return Math.max(minimum, Math.round(stair.stepCount ?? 10))
88
+ }
89
+
90
+ function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
91
+ if (stair.stairType !== 'spiral' || (stair.topLandingMode ?? 'none') !== 'integrated') {
92
+ return 0
93
+ }
94
+
95
+ const innerRadius = Math.max(0.05, stair.innerRadius ?? 0.9)
96
+ const width = Math.max(stair.width ?? 1, 0.4)
97
+ const landingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max(width * 0.9, 0.8))
98
+
99
+ return (
100
+ Math.min(Math.PI * 0.75, landingDepth / Math.max(innerRadius + width / 2, 0.1)) *
101
+ Math.sign(sweepAngle || 1)
102
+ )
103
+ }
104
+
105
+ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
106
+ canFocusStairs,
107
+ canSelectStairs,
108
+ cursor,
109
+ highlightedIdSet,
110
+ hitStrokeWidth,
111
+ hoveredStairId,
112
+ isDeleteMode,
113
+ onStairDoubleClick,
114
+ onStairHoverChange,
115
+ onStairHoverEnter,
116
+ onStairPointerDown,
117
+ onStairSelect,
118
+ palette,
119
+ selectedIdSet,
120
+ stairEntries,
121
+ }: FloorplanStairLayerProps) {
122
+ if (stairEntries.length === 0) {
123
+ return null
124
+ }
125
+
126
+ return (
127
+ <>
128
+ {stairEntries.map(({ arrow, hitPolygons, stair, segments }) => {
129
+ const stairSelected = selectedIdSet.has(stair.id)
130
+ const stairHighlighted = highlightedIdSet.has(stair.id)
131
+ const segmentSelected = segments.some(({ segment }) => selectedIdSet.has(segment.id))
132
+ const segmentHighlighted = segments.some(({ segment }) => highlightedIdSet.has(segment.id))
133
+ const isHovered = hoveredStairId === stair.id
134
+ const isDeleteHovered = isDeleteMode && isHovered
135
+ const isSelectionActive =
136
+ stairSelected || stairHighlighted || segmentSelected || segmentHighlighted
137
+ const stairType = stair.stairType ?? 'straight'
138
+ const normalizedSweepAngle = getNormalizedFloorplanStairSweepAngle(stair)
139
+ const sectorStartAngle = -stair.rotation - normalizedSweepAngle / 2
140
+ const sectorEndAngle = sectorStartAngle + normalizedSweepAngle
141
+ const spiralLandingSweep = getFloorplanSpiralLandingSweep(stair, normalizedSweepAngle)
142
+ const visualSectorEndAngle = sectorEndAngle + spiralLandingSweep
143
+ const stairCenter = {
144
+ x: stair.position[0],
145
+ y: stair.position[2],
146
+ }
147
+ const innerRadius = Math.max(
148
+ stairType === 'spiral' ? 0.05 : 0.2,
149
+ stair.innerRadius ?? (stairType === 'spiral' ? 0.2 : 0.9),
150
+ )
151
+ const outerRadius = innerRadius + stair.width
152
+ const centerlineRadius = innerRadius + stair.width / 2
153
+ const curvedStroke = isDeleteHovered
154
+ ? palette.deleteStroke
155
+ : isSelectionActive
156
+ ? '#2563eb'
157
+ : palette.stairStroke
158
+ const curvedAccent = isDeleteHovered
159
+ ? palette.deleteStroke
160
+ : isSelectionActive
161
+ ? '#1d4ed8'
162
+ : palette.stairAccent
163
+ const curvedFill = isDeleteHovered
164
+ ? palette.deleteFill
165
+ : isSelectionActive
166
+ ? palette.stairSelectedFill
167
+ : palette.stairFill
168
+ const straightAccent = isDeleteHovered
169
+ ? palette.deleteStroke
170
+ : isSelectionActive
171
+ ? '#1d4ed8'
172
+ : palette.stairAccent
173
+ const straightStroke = isDeleteHovered
174
+ ? palette.deleteStroke
175
+ : isSelectionActive
176
+ ? '#1d4ed8'
177
+ : palette.stairStroke
178
+ const straightTread = isDeleteHovered
179
+ ? palette.deleteStroke
180
+ : isSelectionActive
181
+ ? palette.stairSelectedTread
182
+ : palette.stairTread
183
+ const straightFill = isDeleteHovered
184
+ ? palette.deleteFill
185
+ : isSelectionActive
186
+ ? palette.stairSelectedFill
187
+ : palette.stairFill
188
+ const curvedOuterLineWidth = isSelectionActive ? '2' : '1.4'
189
+ const curvedInnerLineWidth = isSelectionActive ? '1.7' : '1.2'
190
+ const stairSymbol =
191
+ stairType === 'spiral' ? (
192
+ <>
193
+ <path
194
+ d={buildSvgAnnularSectorPath(
195
+ stairCenter,
196
+ innerRadius,
197
+ outerRadius,
198
+ sectorStartAngle,
199
+ visualSectorEndAngle,
200
+ )}
201
+ fill={curvedFill}
202
+ pointerEvents="none"
203
+ />
204
+ <path
205
+ d={buildSvgArcPath(
206
+ stairCenter,
207
+ outerRadius,
208
+ sectorStartAngle,
209
+ visualSectorEndAngle,
210
+ )}
211
+ fill="none"
212
+ pointerEvents="none"
213
+ stroke={curvedStroke}
214
+ strokeWidth={curvedOuterLineWidth}
215
+ vectorEffect="non-scaling-stroke"
216
+ />
217
+ <path
218
+ d={buildSvgArcPath(
219
+ stairCenter,
220
+ innerRadius,
221
+ sectorStartAngle,
222
+ visualSectorEndAngle,
223
+ )}
224
+ fill="none"
225
+ pointerEvents="none"
226
+ stroke={curvedStroke}
227
+ strokeWidth={curvedInnerLineWidth}
228
+ vectorEffect="non-scaling-stroke"
229
+ />
230
+ {Array.from({ length: getFloorplanStairStepCount(stair, 6) + 1 }, (_, index) => {
231
+ const stepCount = getFloorplanStairStepCount(stair, 6)
232
+ const stepSweep = normalizedSweepAngle / stepCount
233
+ const angle = sectorStartAngle + stepSweep * index
234
+ const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
235
+ const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
236
+ const dashedFromIndex = Math.floor(stepCount * 0.68)
237
+
238
+ return (
239
+ <line
240
+ key={`${stair.id}:spiral-step:${index}`}
241
+ pointerEvents="none"
242
+ stroke={index === stepCount ? curvedAccent : curvedStroke}
243
+ strokeDasharray={index >= dashedFromIndex ? '0.1 0.08' : undefined}
244
+ strokeWidth={index === stepCount ? '1.8' : '1.15'}
245
+ vectorEffect="non-scaling-stroke"
246
+ x1={toSvgX(innerPoint.x)}
247
+ x2={toSvgX(outerPoint.x)}
248
+ y1={toSvgY(innerPoint.y)}
249
+ y2={toSvgY(outerPoint.y)}
250
+ />
251
+ )
252
+ })}
253
+ <circle
254
+ cx={toSvgX(stairCenter.x)}
255
+ cy={toSvgY(stairCenter.y)}
256
+ fill={curvedFill}
257
+ pointerEvents="none"
258
+ r={Math.max(innerRadius * 0.18, 0.06)}
259
+ stroke={curvedAccent}
260
+ strokeWidth="1.2"
261
+ vectorEffect="non-scaling-stroke"
262
+ />
263
+ {(() => {
264
+ const directionAngle =
265
+ visualSectorEndAngle -
266
+ (normalizedSweepAngle / getFloorplanStairStepCount(stair, 6)) * 0.8
267
+ const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, directionAngle)
268
+ const tangentAngle =
269
+ directionAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2)
270
+
271
+ return (
272
+ <polygon
273
+ fill={curvedAccent}
274
+ key={`${stair.id}:spiral-arrow`}
275
+ pointerEvents="none"
276
+ points={buildSvgArrowHeadPoints(
277
+ arrowPoint,
278
+ tangentAngle,
279
+ clamp(stair.width * 0.18, 0.12, 0.18),
280
+ )}
281
+ />
282
+ )
283
+ })()}
284
+ </>
285
+ ) : stairType === 'curved' ? (
286
+ <>
287
+ <path
288
+ d={buildSvgAnnularSectorPath(
289
+ stairCenter,
290
+ innerRadius,
291
+ outerRadius,
292
+ sectorStartAngle,
293
+ sectorEndAngle,
294
+ )}
295
+ fill={curvedFill}
296
+ pointerEvents="none"
297
+ />
298
+ <path
299
+ d={buildSvgArcPath(stairCenter, outerRadius, sectorStartAngle, sectorEndAngle)}
300
+ fill="none"
301
+ pointerEvents="none"
302
+ stroke={curvedStroke}
303
+ strokeWidth={curvedOuterLineWidth}
304
+ vectorEffect="non-scaling-stroke"
305
+ />
306
+ <path
307
+ d={buildSvgArcPath(stairCenter, innerRadius, sectorStartAngle, sectorEndAngle)}
308
+ fill="none"
309
+ pointerEvents="none"
310
+ stroke={curvedStroke}
311
+ strokeWidth={curvedInnerLineWidth}
312
+ vectorEffect="non-scaling-stroke"
313
+ />
314
+ {Array.from({ length: getFloorplanStairStepCount(stair, 4) + 1 }, (_, index) => {
315
+ const stepCount = getFloorplanStairStepCount(stair, 4)
316
+ const stepSweep = normalizedSweepAngle / stepCount
317
+ const angle = sectorStartAngle + stepSweep * index
318
+ const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
319
+ const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
320
+
321
+ return (
322
+ <line
323
+ key={`${stair.id}:curved-step:${index}`}
324
+ pointerEvents="none"
325
+ stroke={curvedStroke}
326
+ strokeWidth={index === 0 || index === stepCount ? '1.5' : '1.1'}
327
+ vectorEffect="non-scaling-stroke"
328
+ x1={toSvgX(innerPoint.x)}
329
+ x2={toSvgX(outerPoint.x)}
330
+ y1={toSvgY(innerPoint.y)}
331
+ y2={toSvgY(outerPoint.y)}
332
+ />
333
+ )
334
+ })}
335
+ <path
336
+ d={buildSvgArcPath(
337
+ stairCenter,
338
+ centerlineRadius,
339
+ sectorStartAngle +
340
+ (normalizedSweepAngle / getFloorplanStairStepCount(stair, 4)) * 0.55,
341
+ sectorEndAngle -
342
+ (normalizedSweepAngle / getFloorplanStairStepCount(stair, 4)) * 0.55,
343
+ )}
344
+ fill="none"
345
+ pointerEvents="none"
346
+ stroke={curvedAccent}
347
+ strokeDasharray="0.08 0.11"
348
+ strokeWidth="1.1"
349
+ vectorEffect="non-scaling-stroke"
350
+ />
351
+ {(() => {
352
+ const stepCount = getFloorplanStairStepCount(stair, 4)
353
+ const stepSweep = normalizedSweepAngle / stepCount
354
+ const arrowAngle = sectorEndAngle - stepSweep * 0.8
355
+ const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, arrowAngle)
356
+ const tangentAngle =
357
+ arrowAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2)
358
+
359
+ return (
360
+ <polygon
361
+ fill={curvedAccent}
362
+ key={`${stair.id}:curved-arrow`}
363
+ pointerEvents="none"
364
+ points={buildSvgArrowHeadPoints(
365
+ arrowPoint,
366
+ tangentAngle,
367
+ clamp(stair.width * 0.16, 0.1, 0.16),
368
+ )}
369
+ />
370
+ )
371
+ })()}
372
+ </>
373
+ ) : (
374
+ <>
375
+ {segments.map(({ points, segment, treadBars }) => (
376
+ <g key={segment.id}>
377
+ <polygon
378
+ fill={straightFill}
379
+ pointerEvents="none"
380
+ points={points}
381
+ stroke={straightStroke}
382
+ strokeWidth={isSelectionActive ? '2' : '1.35'}
383
+ vectorEffect="non-scaling-stroke"
384
+ />
385
+ {treadBars.map((treadBar, treadIndex) => (
386
+ <polygon
387
+ fill={straightTread}
388
+ key={`${segment.id}:tread:${treadIndex}`}
389
+ pointerEvents="none"
390
+ points={segment.segmentType === 'landing' ? '' : treadBar.points}
391
+ />
392
+ ))}
393
+ </g>
394
+ ))}
395
+ {arrow?.polyline && arrow.polyline.length >= 2 ? (
396
+ <>
397
+ <polyline
398
+ fill="none"
399
+ points={formatSvgPolygonPoints(arrow.polyline)}
400
+ pointerEvents="none"
401
+ stroke={straightAccent}
402
+ strokeWidth="1.15"
403
+ vectorEffect="non-scaling-stroke"
404
+ />
405
+ <circle
406
+ cx={toSvgX(arrow.polyline[0]!.x)}
407
+ cy={toSvgY(arrow.polyline[0]!.y)}
408
+ fill={straightAccent}
409
+ pointerEvents="none"
410
+ r="0.045"
411
+ />
412
+ <polygon
413
+ fill={straightAccent}
414
+ points={formatSvgPolygonPoints(arrow.head)}
415
+ pointerEvents="none"
416
+ />
417
+ </>
418
+ ) : null}
419
+ </>
420
+ )
421
+
422
+ return (
423
+ <g
424
+ key={stair.id}
425
+ onClick={
426
+ canSelectStairs
427
+ ? (event) => {
428
+ event.stopPropagation()
429
+ onStairSelect(stair.id, event)
430
+ }
431
+ : undefined
432
+ }
433
+ onDoubleClick={
434
+ canFocusStairs
435
+ ? (event) => {
436
+ event.stopPropagation()
437
+ onStairDoubleClick(stair, event)
438
+ }
439
+ : undefined
440
+ }
441
+ onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
442
+ onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
443
+ onPointerDown={
444
+ canFocusStairs && stairSelected
445
+ ? (event) => {
446
+ if (event.button === 0) {
447
+ onStairPointerDown(stair.id, event)
448
+ }
449
+ }
450
+ : undefined
451
+ }
452
+ pointerEvents={canSelectStairs ? undefined : 'none'}
453
+ style={canSelectStairs ? { cursor } : undefined}
454
+ >
455
+ {hitPolygons.map((polygon, polygonIndex) => (
456
+ <polygon
457
+ fill="transparent"
458
+ key={`${stair.id}:hit:${polygonIndex}`}
459
+ points={formatSvgPolygonPoints(polygon)}
460
+ pointerEvents={canSelectStairs ? 'all' : 'none'}
461
+ stroke="transparent"
462
+ strokeLinejoin="round"
463
+ strokeWidth={hitStrokeWidth}
464
+ vectorEffect="non-scaling-stroke"
465
+ />
466
+ ))}
467
+ <title>{stair.name || 'Staircase'}</title>
468
+ {stairSymbol}
469
+ </g>
470
+ )
471
+ })}
472
+ </>
473
+ )
474
+ })
@@ -0,0 +1,119 @@
1
+ 'use client'
2
+
3
+ import type { Point2D } from '@pascal-app/core'
4
+
5
+ function toSvgX(value: number) {
6
+ return value
7
+ }
8
+
9
+ function toSvgY(value: number) {
10
+ return value
11
+ }
12
+
13
+ function toSvgPoint(point: Point2D) {
14
+ return {
15
+ x: toSvgX(point.x),
16
+ y: toSvgY(point.y),
17
+ }
18
+ }
19
+
20
+ export function formatPolygonPath(points: Point2D[], holes: Point2D[][] = []) {
21
+ const formatSubpath = (subpathPoints: Point2D[]) => {
22
+ const [firstPoint, ...restPoints] = subpathPoints
23
+ if (!firstPoint) {
24
+ return null
25
+ }
26
+
27
+ const firstSvgPoint = toSvgPoint(firstPoint)
28
+
29
+ return [
30
+ `M ${firstSvgPoint.x} ${firstSvgPoint.y}`,
31
+ ...restPoints.map((point) => {
32
+ const svgPoint = toSvgPoint(point)
33
+ return `L ${svgPoint.x} ${svgPoint.y}`
34
+ }),
35
+ 'Z',
36
+ ].join(' ')
37
+ }
38
+
39
+ return [points, ...holes].map(formatSubpath).filter(Boolean).join(' ')
40
+ }
41
+
42
+ export function buildSvgPolylinePath(points: Point2D[]) {
43
+ if (points.length < 2) {
44
+ return null
45
+ }
46
+
47
+ return points
48
+ .map((point, index) => {
49
+ const svgPoint = toSvgPoint(point)
50
+ return `${index === 0 ? 'M' : 'L'} ${svgPoint.x} ${svgPoint.y}`
51
+ })
52
+ .join(' ')
53
+ }
54
+
55
+ export function getArcPlanPoint(center: Point2D, radius: number, angle: number): Point2D {
56
+ return {
57
+ x: center.x + Math.cos(angle) * radius,
58
+ y: center.y + Math.sin(angle) * radius,
59
+ }
60
+ }
61
+
62
+ export function buildSvgArcPath(
63
+ center: Point2D,
64
+ radius: number,
65
+ startAngle: number,
66
+ endAngle: number,
67
+ ) {
68
+ const start = getArcPlanPoint(center, radius, startAngle)
69
+ const end = getArcPlanPoint(center, radius, endAngle)
70
+ const delta = endAngle - startAngle
71
+ const largeArcFlag = Math.abs(delta) > Math.PI ? 1 : 0
72
+ const sweepFlag = delta >= 0 ? 1 : 0
73
+
74
+ return `M ${toSvgX(start.x)} ${toSvgY(start.y)} A ${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${toSvgX(end.x)} ${toSvgY(end.y)}`
75
+ }
76
+
77
+ export function buildSvgAnnularSectorPath(
78
+ center: Point2D,
79
+ innerRadius: number,
80
+ outerRadius: number,
81
+ startAngle: number,
82
+ endAngle: number,
83
+ ) {
84
+ const outerStart = getArcPlanPoint(center, outerRadius, startAngle)
85
+ const outerEnd = getArcPlanPoint(center, outerRadius, endAngle)
86
+ const innerEnd = getArcPlanPoint(center, innerRadius, endAngle)
87
+ const innerStart = getArcPlanPoint(center, innerRadius, startAngle)
88
+ const delta = endAngle - startAngle
89
+ const largeArcFlag = Math.abs(delta) > Math.PI ? 1 : 0
90
+ const sweepFlag = delta >= 0 ? 1 : 0
91
+ const reverseSweepFlag = sweepFlag ? 0 : 1
92
+
93
+ return [
94
+ `M ${toSvgX(outerStart.x)} ${toSvgY(outerStart.y)}`,
95
+ `A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} ${sweepFlag} ${toSvgX(outerEnd.x)} ${toSvgY(outerEnd.y)}`,
96
+ `L ${toSvgX(innerEnd.x)} ${toSvgY(innerEnd.y)}`,
97
+ `A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} ${reverseSweepFlag} ${toSvgX(innerStart.x)} ${toSvgY(innerStart.y)}`,
98
+ 'Z',
99
+ ].join(' ')
100
+ }
101
+
102
+ export function formatSvgPolygonPoints(points: Point2D[]) {
103
+ return points.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`).join(' ')
104
+ }
105
+
106
+ export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) {
107
+ const left = {
108
+ x: point.x - size * Math.cos(angle - Math.PI / 6),
109
+ y: point.y - size * Math.sin(angle - Math.PI / 6),
110
+ }
111
+ const right = {
112
+ x: point.x - size * Math.cos(angle + Math.PI / 6),
113
+ y: point.y - size * Math.sin(angle + Math.PI / 6),
114
+ }
115
+
116
+ return formatSvgPolygonPoints([point, left, right])
117
+ }
118
+
119
+ export { toSvgPoint, toSvgX, toSvgY }
@@ -31,6 +31,7 @@ export const CeilingBoundaryEditor: React.FC<CeilingBoundaryEditorProps> = ({ ce
31
31
 
32
32
  return (
33
33
  <PolygonEditor
34
+ allowEdgeMove
34
35
  color="#d4d4d4"
35
36
  levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
36
37
  minVertices={3}
@@ -36,6 +36,7 @@ export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId,
36
36
 
37
37
  return (
38
38
  <PolygonEditor
39
+ allowEdgeMove
39
40
  allowPolygonMove
40
41
  color="#ef4444"
41
42
  levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes
@@ -111,15 +111,15 @@ export const CeilingTool: React.FC = () => {
111
111
  const onGridMove = (event: GridEvent) => {
112
112
  if (!(cursorRef.current && gridCursorRef.current)) return
113
113
 
114
- const gridX = Math.round(event.position[0] * 2) / 2
115
- const gridZ = Math.round(event.position[2] * 2) / 2
114
+ const gridX = Math.round(event.localPosition[0] * 2) / 2
115
+ const gridZ = Math.round(event.localPosition[2] * 2) / 2
116
116
  const gridPosition: [number, number] = [gridX, gridZ]
117
117
 
118
118
  setCursorPosition(gridPosition)
119
- setLevelY(event.position[1])
119
+ setLevelY(event.localPosition[1])
120
120
 
121
- const ceilingY = event.position[1] + CEILING_HEIGHT
122
- const gridY = event.position[1] + GRID_OFFSET
121
+ const ceilingY = event.localPosition[1] + CEILING_HEIGHT
122
+ const gridY = event.localPosition[1] + GRID_OFFSET
123
123
 
124
124
  // Calculate snapped display position (bypass snap when Shift is held)
125
125
  const lastPoint = points[points.length - 1]