@pascal-app/editor 0.5.1 → 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 (150) hide show
  1. package/package.json +12 -7
  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 +29 -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 +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -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/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -0,0 +1,113 @@
1
+ 'use client'
2
+
3
+ import type { Point2D, RoofNode, RoofSegmentNode } from '@pascal-app/core'
4
+ import { memo } from 'react'
5
+ import { toSvgX, toSvgY } from '../svg-paths'
6
+
7
+ type FloorplanLineSegment = {
8
+ start: Point2D
9
+ end: Point2D
10
+ }
11
+
12
+ type FloorplanRoofSegmentEntry = {
13
+ segment: RoofSegmentNode
14
+ points: string
15
+ ridgeLine: FloorplanLineSegment | null
16
+ }
17
+
18
+ type FloorplanRoofEntry = {
19
+ roof: RoofNode
20
+ segments: FloorplanRoofSegmentEntry[]
21
+ }
22
+
23
+ type FloorplanRoofPalette = {
24
+ roofFill: string
25
+ roofActiveFill: string
26
+ roofSelectedFill: string
27
+ roofStroke: string
28
+ roofActiveStroke: string
29
+ roofSelectedStroke: string
30
+ roofRidgeStroke: string
31
+ roofSelectedRidgeStroke: string
32
+ }
33
+
34
+ type FloorplanRoofLayerProps = {
35
+ highlightedIdSet: ReadonlySet<string>
36
+ palette: FloorplanRoofPalette
37
+ roofEntries: FloorplanRoofEntry[]
38
+ selectedIdSet: ReadonlySet<string>
39
+ }
40
+
41
+ export const FloorplanRoofLayer = memo(function FloorplanRoofLayer({
42
+ highlightedIdSet,
43
+ palette,
44
+ roofEntries,
45
+ selectedIdSet,
46
+ }: FloorplanRoofLayerProps) {
47
+ if (roofEntries.length === 0) {
48
+ return null
49
+ }
50
+
51
+ return (
52
+ <>
53
+ {roofEntries.map(({ roof, segments }) => {
54
+ const roofSelected = selectedIdSet.has(roof.id)
55
+ const roofHighlighted = highlightedIdSet.has(roof.id)
56
+ const hasSelectedSegment = segments.some(({ segment }) => selectedIdSet.has(segment.id))
57
+ const hasHighlightedSegment = segments.some(({ segment }) =>
58
+ highlightedIdSet.has(segment.id),
59
+ )
60
+ const isRoofActive =
61
+ roofSelected || roofHighlighted || hasSelectedSegment || hasHighlightedSegment
62
+
63
+ return (
64
+ <g key={roof.id} pointerEvents="none">
65
+ {segments.map(({ points, ridgeLine, segment }) => {
66
+ const isSegmentSelected = selectedIdSet.has(segment.id)
67
+ const isSegmentHighlighted = highlightedIdSet.has(segment.id)
68
+ const isSegmentActive = isSegmentSelected || isSegmentHighlighted
69
+
70
+ return (
71
+ <g key={segment.id}>
72
+ <polygon
73
+ fill={
74
+ isSegmentActive
75
+ ? palette.roofSelectedFill
76
+ : isRoofActive
77
+ ? palette.roofActiveFill
78
+ : palette.roofFill
79
+ }
80
+ points={points}
81
+ stroke={
82
+ isSegmentActive
83
+ ? palette.roofSelectedStroke
84
+ : isRoofActive
85
+ ? palette.roofActiveStroke
86
+ : palette.roofStroke
87
+ }
88
+ strokeWidth={isSegmentActive ? '2.25' : isRoofActive ? '1.75' : '1.1'}
89
+ vectorEffect="non-scaling-stroke"
90
+ />
91
+ {ridgeLine ? (
92
+ <line
93
+ fill="none"
94
+ stroke={
95
+ isSegmentActive ? palette.roofSelectedRidgeStroke : palette.roofRidgeStroke
96
+ }
97
+ strokeWidth={isSegmentActive ? '2' : '1.4'}
98
+ vectorEffect="non-scaling-stroke"
99
+ x1={toSvgX(ridgeLine.start.x)}
100
+ x2={toSvgX(ridgeLine.end.x)}
101
+ y1={toSvgY(ridgeLine.start.y)}
102
+ y2={toSvgY(ridgeLine.end.y)}
103
+ />
104
+ ) : null}
105
+ </g>
106
+ )
107
+ })}
108
+ </g>
109
+ )
110
+ })}
111
+ </>
112
+ )
113
+ })
@@ -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 }