@pascal-app/editor 0.4.0 → 0.6.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 (97) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +341 -48
  4. package/src/components/editor/floating-building-action-menu.tsx +70 -0
  5. package/src/components/editor/floorplan-panel.tsx +1350 -722
  6. package/src/components/editor/index.tsx +221 -167
  7. package/src/components/editor/node-action-menu.tsx +40 -11
  8. package/src/components/editor/selection-manager.tsx +238 -10
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +422 -79
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  15. package/src/components/tools/building/move-building-tool.tsx +157 -0
  16. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  17. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  18. package/src/components/tools/door/door-math.ts +1 -1
  19. package/src/components/tools/door/door-tool.tsx +31 -7
  20. package/src/components/tools/door/move-door-tool.tsx +27 -8
  21. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  22. package/src/components/tools/fence/fence-drafting.ts +137 -0
  23. package/src/components/tools/fence/fence-tool.tsx +190 -0
  24. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  25. package/src/components/tools/fence/move-fence-tool.tsx +231 -0
  26. package/src/components/tools/item/item-tool.tsx +3 -3
  27. package/src/components/tools/item/move-tool.tsx +16 -0
  28. package/src/components/tools/item/placement-math.ts +14 -6
  29. package/src/components/tools/item/placement-strategies.ts +17 -9
  30. package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
  31. package/src/components/tools/roof/move-roof-tool.tsx +90 -26
  32. package/src/components/tools/roof/roof-tool.tsx +6 -6
  33. package/src/components/tools/select/box-select-tool.tsx +2 -2
  34. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  35. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  36. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  37. package/src/components/tools/slab/slab-tool.tsx +4 -4
  38. package/src/components/tools/stair/stair-defaults.ts +10 -0
  39. package/src/components/tools/stair/stair-tool.tsx +39 -8
  40. package/src/components/tools/tool-manager.tsx +54 -14
  41. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  42. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  43. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  44. package/src/components/tools/wall/wall-drafting.ts +331 -9
  45. package/src/components/tools/wall/wall-tool.tsx +19 -29
  46. package/src/components/tools/window/move-window-tool.tsx +27 -8
  47. package/src/components/tools/window/window-math.ts +1 -1
  48. package/src/components/tools/window/window-tool.tsx +31 -7
  49. package/src/components/tools/zone/zone-tool.tsx +7 -7
  50. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  51. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  52. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  53. package/src/components/ui/command-palette/index.tsx +0 -1
  54. package/src/components/ui/controls/material-picker.tsx +127 -94
  55. package/src/components/ui/controls/slider-control.tsx +28 -14
  56. package/src/components/ui/helpers/building-helper.tsx +32 -0
  57. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  58. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  59. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  60. package/src/components/ui/panels/door-panel.tsx +5 -5
  61. package/src/components/ui/panels/fence-panel.tsx +269 -0
  62. package/src/components/ui/panels/item-panel.tsx +5 -5
  63. package/src/components/ui/panels/panel-manager.tsx +32 -27
  64. package/src/components/ui/panels/reference-panel.tsx +5 -4
  65. package/src/components/ui/panels/roof-panel.tsx +91 -22
  66. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  67. package/src/components/ui/panels/slab-panel.tsx +63 -15
  68. package/src/components/ui/panels/stair-panel.tsx +377 -50
  69. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  70. package/src/components/ui/panels/wall-panel.tsx +159 -11
  71. package/src/components/ui/panels/window-panel.tsx +5 -7
  72. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
  74. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
  75. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
  76. package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
  77. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  78. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
  79. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
  80. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
  81. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
  82. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
  83. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
  84. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
  85. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
  86. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
  87. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
  88. package/src/components/ui/viewer-toolbar.tsx +55 -2
  89. package/src/components/viewer-overlay.tsx +26 -19
  90. package/src/hooks/use-auto-save.ts +3 -6
  91. package/src/hooks/use-contextual-tools.ts +25 -16
  92. package/src/hooks/use-grid-events.ts +13 -1
  93. package/src/hooks/use-keyboard.ts +7 -2
  94. package/src/index.tsx +2 -1
  95. package/src/lib/history.ts +20 -0
  96. package/src/lib/sfx-player.ts +96 -13
  97. package/src/store/use-editor.tsx +125 -10
@@ -4,9 +4,14 @@ import {
4
4
  type AnyNodeId,
5
5
  calculateLevelMiters,
6
6
  DEFAULT_WALL_HEIGHT,
7
+ getWallCurveLength,
8
+ getWallMiterBoundaryPoints,
7
9
  getWallPlanFootprint,
10
+ getWallSurfacePolygon,
11
+ isCurvedWall,
8
12
  type Point2D,
9
13
  pointToKey,
14
+ sampleWallCenterline,
10
15
  sceneRegistry,
11
16
  useScene,
12
17
  type WallMiterData,
@@ -15,7 +20,7 @@ import {
15
20
  import { useViewer } from '@pascal-app/viewer'
16
21
  import { Html } from '@react-three/drei'
17
22
  import { createPortal, useFrame } from '@react-three/fiber'
18
- import { useEffect, useMemo, useState } from 'react'
23
+ import { useMemo, useState } from 'react'
19
24
  import * as THREE from 'three'
20
25
 
21
26
  const GUIDE_Y_OFFSET = 0.08
@@ -28,8 +33,7 @@ const BAR_AXIS = new THREE.Vector3(0, 1, 0)
28
33
  type Vec3 = [number, number, number]
29
34
 
30
35
  type MeasurementGuide = {
31
- guideStart: Vec3
32
- guideEnd: Vec3
36
+ guidePath: Vec3[]
33
37
  extStartStart: Vec3
34
38
  extStartEnd: Vec3
35
39
  extEndStart: Vec3
@@ -56,18 +60,19 @@ export function WallMeasurementLabel() {
56
60
  const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null
57
61
  const wall = selectedNode?.type === 'wall' ? selectedNode : null
58
62
 
59
- const [wallObject, setWallObject] = useState<THREE.Object3D | null>(null)
60
-
61
- useEffect(() => {
62
- setWallObject(null)
63
- }, [selectedId])
63
+ const [wallObjectState, setWallObjectState] = useState<{
64
+ id: WallNode['id']
65
+ object: THREE.Object3D
66
+ } | null>(null)
67
+ const wallObject =
68
+ selectedId && wallObjectState?.id === selectedId ? wallObjectState.object : null
64
69
 
65
70
  useFrame(() => {
66
71
  if (!selectedId || wallObject) return
67
72
 
68
73
  const nextWallObject = sceneRegistry.nodes.get(selectedId)
69
74
  if (nextWallObject) {
70
- setWallObject(nextWallObject)
75
+ setWallObjectState({ id: selectedId as WallNode['id'], object: nextWallObject })
71
76
  }
72
77
  })
73
78
 
@@ -131,6 +136,34 @@ function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {
131
136
  return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
132
137
  }
133
138
 
139
+ function getWallExteriorOffsetSign(wall: Pick<WallNode, 'frontSide' | 'backSide'>) {
140
+ if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
141
+ return 1
142
+ }
143
+
144
+ if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') {
145
+ return -1
146
+ }
147
+
148
+ return 1
149
+ }
150
+
151
+ function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData): Point2D[] | null {
152
+ const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData)
153
+ if (!boundaryPoints) return null
154
+
155
+ const surface = getWallSurfacePolygon(wall, 24, boundaryPoints)
156
+ const sidePointCount = 25
157
+ if (surface.length < sidePointCount * 2) return null
158
+
159
+ const offsetSign = getWallExteriorOffsetSign(wall)
160
+ if (offsetSign >= 0) {
161
+ return surface.slice(sidePointCount).reverse()
162
+ }
163
+
164
+ return surface.slice(0, sidePointCount)
165
+ }
166
+
134
167
  function buildMeasurementGuide(
135
168
  wall: WallNode,
136
169
  nodes: Record<string, WallNode | { type: string; children?: string[] }>,
@@ -143,31 +176,66 @@ function buildMeasurementGuide(
143
176
  const height = wall.height ?? DEFAULT_WALL_HEIGHT
144
177
  const startLocal = worldPointToWallLocal(wall, middlePoints.start)
145
178
  const endLocal = worldPointToWallLocal(wall, middlePoints.end)
179
+ const curvedMeasurementPath = isCurvedWall(wall)
180
+ ? getCurvedWallMeasurementPath(wall, miterData)
181
+ : null
182
+ const guidePath: Vec3[] = curvedMeasurementPath
183
+ ? curvedMeasurementPath.map((point) => {
184
+ const localPoint = worldPointToWallLocal(wall, point)
185
+ return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]]
186
+ })
187
+ : isCurvedWall(wall)
188
+ ? sampleWallCenterline(wall, 24).map((point, index, points) => {
189
+ const localPoint =
190
+ index === 0
191
+ ? startLocal
192
+ : index === points.length - 1
193
+ ? endLocal
194
+ : worldPointToWallLocal(wall, point)
195
+
196
+ return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]]
197
+ })
198
+ : [
199
+ [startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]],
200
+ [endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]],
201
+ ]
202
+
203
+ if (guidePath.length < 2) return null
204
+
205
+ let guideLength = 0
206
+ for (let index = 1; index < guidePath.length; index += 1) {
207
+ const prev = guidePath[index - 1]!
208
+ const next = guidePath[index]!
209
+ guideLength += Math.hypot(next[0] - prev[0], next[2] - prev[2])
210
+ }
146
211
 
147
- const guideStart: Vec3 = [startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]]
148
- const guideEnd: Vec3 = [endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]]
149
-
150
- const dirX = guideEnd[0] - guideStart[0]
151
- const dirZ = guideEnd[2] - guideStart[2]
152
- const dirLength = Math.hypot(dirX, dirZ)
153
-
154
- if (!Number.isFinite(dirLength) || dirLength < 0.001) return null
212
+ if (!Number.isFinite(guideLength) || guideLength < 0.001) return null
155
213
 
156
214
  // Extension lines coming out of the extremity markers of the wall
157
215
  const extOvershoot = 0.04
216
+ const guideStart = guidePath[0]!
217
+ const guideEnd = guidePath[guidePath.length - 1]!
218
+ const extensionStartBase = curvedMeasurementPath ? guideStart : startLocal
219
+ const extensionEndBase = curvedMeasurementPath ? guideEnd : endLocal
220
+ const midpoint = curvedMeasurementPath
221
+ ? guidePath[Math.floor(guidePath.length / 2)]!
222
+ : ([
223
+ (guideStart[0] + guideEnd[0]) / 2,
224
+ guideStart[1],
225
+ (guideStart[2] + guideEnd[2]) / 2,
226
+ ] as Vec3)
158
227
 
159
228
  return {
160
- guideStart,
161
- guideEnd,
162
- extStartStart: [startLocal[0], height, startLocal[2]],
163
- extStartEnd: [startLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, startLocal[2]],
164
- extEndStart: [endLocal[0], height, endLocal[2]],
165
- extEndEnd: [endLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, endLocal[2]],
166
- labelPosition: [
167
- (guideStart[0] + guideEnd[0]) / 2,
168
- guideStart[1] + LABEL_LIFT,
169
- (guideStart[2] + guideEnd[2]) / 2,
229
+ guidePath,
230
+ extStartStart: [extensionStartBase[0], height, extensionStartBase[2]],
231
+ extStartEnd: [
232
+ extensionStartBase[0],
233
+ height + GUIDE_Y_OFFSET + extOvershoot,
234
+ extensionStartBase[2],
170
235
  ],
236
+ extEndStart: [extensionEndBase[0], height, extensionEndBase[2]],
237
+ extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]],
238
+ labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]],
171
239
  }
172
240
  }
173
241
 
@@ -208,6 +276,16 @@ function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color:
208
276
  )
209
277
  }
210
278
 
279
+ function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) {
280
+ return (
281
+ <>
282
+ {path.slice(1).map((point, index) => (
283
+ <MeasurementBar color={color} end={point} key={index} start={path[index]!} />
284
+ ))}
285
+ </>
286
+ )
287
+ }
288
+
211
289
  function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
212
290
  const nodes = useScene((state) => state.nodes)
213
291
  const theme = useViewer((state) => state.theme)
@@ -216,10 +294,6 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
216
294
  const color = isNight ? '#ffffff' : '#111111'
217
295
  const shadowColor = isNight ? '#111111' : '#ffffff'
218
296
 
219
- const dx = wall.end[0] - wall.start[0]
220
- const dz = wall.end[1] - wall.start[1]
221
- const length = Math.hypot(dx, dz)
222
- const label = formatMeasurement(length, unit)
223
297
  const guide = useMemo(
224
298
  () =>
225
299
  buildMeasurementGuide(
@@ -228,12 +302,26 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
228
302
  ),
229
303
  [nodes, wall],
230
304
  )
305
+ const length = useMemo(() => {
306
+ if (!guide?.guidePath?.length || guide.guidePath.length < 2) {
307
+ return getWallCurveLength(wall)
308
+ }
309
+
310
+ let total = 0
311
+ for (let index = 1; index < guide.guidePath.length; index += 1) {
312
+ const prev = guide.guidePath[index - 1]!
313
+ const next = guide.guidePath[index]!
314
+ total += Math.hypot(next[0] - prev[0], next[2] - prev[2])
315
+ }
316
+ return total
317
+ }, [guide, wall])
318
+ const label = formatMeasurement(length, unit)
231
319
 
232
320
  if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
233
321
 
234
322
  return (
235
323
  <group>
236
- <MeasurementBar color={color} end={guide.guideEnd} start={guide.guideStart} />
324
+ <MeasurementPath color={color} path={guide.guidePath} />
237
325
  <MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} />
238
326
  <MeasurementBar color={color} end={guide.extEndEnd} start={guide.extEndStart} />
239
327
 
@@ -0,0 +1,272 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type CeilingNode,
5
+ emitter,
6
+ resolveLevelId,
7
+ sceneRegistry,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { createPortal, type ThreeEvent } from '@react-three/fiber'
12
+ import { useEffect, useMemo, useState } from 'react'
13
+ import type { Object3D } from 'three'
14
+ import { useShallow } from 'zustand/react/shallow'
15
+ import useEditor from '../../../store/use-editor'
16
+
17
+ const BRACKET_THICKNESS = 0.04
18
+ const BRACKET_HEIGHT = 0.04
19
+ const BRACKET_Y_OFFSET = 0.035
20
+ const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28]
21
+
22
+ type CornerBracketData = {
23
+ corner: [number, number]
24
+ incomingDirection: [number, number]
25
+ outgoingDirection: [number, number]
26
+ incomingLength: number
27
+ outgoingLength: number
28
+ cornerStrength: number
29
+ }
30
+
31
+ export const CeilingSelectionAffordanceSystem = () => {
32
+ const phase = useEditor((state) => state.phase)
33
+ const mode = useEditor((state) => state.mode)
34
+ const structureLayer = useEditor((state) => state.structureLayer)
35
+ const movingNode = useEditor((state) => state.movingNode)
36
+ const curvingWall = useEditor((state) => state.curvingWall)
37
+ const currentLevelId = useViewer((state) => state.selection.levelId)
38
+
39
+ const ceilings = useScene(
40
+ useShallow((state) =>
41
+ Object.values(state.nodes).filter((node): node is CeilingNode => {
42
+ return (
43
+ node.type === 'ceiling' &&
44
+ node.visible !== false &&
45
+ currentLevelId !== null &&
46
+ resolveLevelId(node, state.nodes) === currentLevelId
47
+ )
48
+ }),
49
+ ),
50
+ )
51
+
52
+ const shouldRender =
53
+ phase === 'structure' &&
54
+ mode === 'select' &&
55
+ structureLayer === 'elements' &&
56
+ !movingNode &&
57
+ !curvingWall &&
58
+ currentLevelId !== null
59
+
60
+ if (!shouldRender) return null
61
+
62
+ return (
63
+ <>
64
+ {ceilings.map((ceiling) => (
65
+ <CeilingSelectionAffordance ceiling={ceiling} key={ceiling.id} levelId={currentLevelId} />
66
+ ))}
67
+ </>
68
+ )
69
+ }
70
+
71
+ const CeilingSelectionAffordance = ({
72
+ ceiling,
73
+ levelId,
74
+ }: {
75
+ ceiling: CeilingNode
76
+ levelId: string
77
+ }) => {
78
+ const [levelObject, setLevelObject] = useState<Object3D | null>(() => sceneRegistry.nodes.get(levelId) ?? null)
79
+
80
+ const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
81
+
82
+ useEffect(() => {
83
+ let frameId = 0
84
+
85
+ const resolveLevelObject = () => {
86
+ const nextLevelObject = sceneRegistry.nodes.get(levelId) ?? null
87
+ setLevelObject((currentLevelObject) => {
88
+ if (currentLevelObject === nextLevelObject) {
89
+ return currentLevelObject
90
+ }
91
+ return nextLevelObject
92
+ })
93
+
94
+ if (!nextLevelObject) {
95
+ frameId = window.requestAnimationFrame(resolveLevelObject)
96
+ }
97
+ }
98
+
99
+ resolveLevelObject()
100
+
101
+ return () => {
102
+ if (frameId) {
103
+ window.cancelAnimationFrame(frameId)
104
+ }
105
+ }
106
+ }, [levelId])
107
+
108
+ if (!levelObject || corners.length === 0) return null
109
+
110
+ return createPortal(
111
+ <group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
112
+ {corners.map((corner, index) => (
113
+ <CornerBracket
114
+ ceiling={ceiling}
115
+ corner={corner}
116
+ key={`${ceiling.id}-corner-${index}`}
117
+ />
118
+ ))}
119
+ </group>,
120
+ levelObject,
121
+ )
122
+ }
123
+
124
+ const CornerBracket = ({
125
+ ceiling,
126
+ corner,
127
+ }: {
128
+ ceiling: CeilingNode
129
+ corner: CornerBracketData
130
+ }) => {
131
+ const [isHovered, setIsHovered] = useState(false)
132
+ const color = '#d4d4d4'
133
+ const opacity = 0.72
134
+ const cubeColor = isHovered ? '#818cf8' : '#d4d4d4'
135
+ const cubeOpacity = isHovered ? 0.92 : 0.72
136
+
137
+ const handleClick = (e: ThreeEvent<MouseEvent>) => {
138
+ e.stopPropagation()
139
+
140
+ const nodes = useScene.getState().nodes
141
+
142
+ useEditor.getState().setMovingNode(null)
143
+ useEditor.getState().setMovingWallEndpoint(null)
144
+ useEditor.getState().setCurvingWall(null)
145
+ useEditor.getState().setEditingHole(null)
146
+ useEditor.getState().setMode('select')
147
+
148
+ emitter.emit('ceiling:click' as any, {
149
+ node: ceiling,
150
+ nativeEvent: e.nativeEvent,
151
+ localPosition: [0, 0, 0],
152
+ position: [corner.corner[0], ceiling.height ?? 2.5, corner.corner[1]],
153
+ stopPropagation: () => e.stopPropagation(),
154
+ })
155
+ }
156
+
157
+ return (
158
+ <group position={[corner.corner[0], 0, corner.corner[1]]}>
159
+ <BracketLeg
160
+ color={color}
161
+ direction={corner.incomingDirection}
162
+ length={corner.incomingLength}
163
+ onClick={handleClick}
164
+ opacity={opacity}
165
+ />
166
+ <BracketLeg
167
+ color={color}
168
+ direction={corner.outgoingDirection}
169
+ length={corner.outgoingLength}
170
+ onClick={handleClick}
171
+ opacity={opacity}
172
+ />
173
+
174
+ <mesh
175
+ onClick={handleClick}
176
+ onPointerEnter={(e) => {
177
+ e.stopPropagation()
178
+ setIsHovered(true)
179
+ }}
180
+ onPointerLeave={(e) => {
181
+ e.stopPropagation()
182
+ setIsHovered(false)
183
+ }}
184
+ >
185
+ <boxGeometry args={HIT_BOX_SIZE} />
186
+ <meshBasicMaterial color={cubeColor} depthWrite={false} opacity={cubeOpacity} transparent />
187
+ </mesh>
188
+ </group>
189
+ )
190
+ }
191
+
192
+ const BracketLeg = ({
193
+ direction,
194
+ length,
195
+ color,
196
+ onClick,
197
+ opacity,
198
+ }: {
199
+ direction: [number, number]
200
+ length: number
201
+ color: string
202
+ onClick: (e: ThreeEvent<MouseEvent>) => void
203
+ opacity: number
204
+ }) => {
205
+ const angle = Math.atan2(direction[1], direction[0])
206
+ const position: [number, number, number] = [
207
+ direction[0] * (length / 2),
208
+ 0,
209
+ direction[1] * (length / 2),
210
+ ]
211
+
212
+ return (
213
+ <mesh
214
+ onClick={onClick}
215
+ position={position}
216
+ rotation={[0, angle, 0]}
217
+ >
218
+ <boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
219
+ <meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
220
+ </mesh>
221
+ )
222
+ }
223
+
224
+ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] {
225
+ if (polygon.length < 3) return []
226
+
227
+ const allCorners = polygon.map((corner, index) => {
228
+ const previous = polygon[(index - 1 + polygon.length) % polygon.length]!
229
+ const next = polygon[(index + 1) % polygon.length]!
230
+ const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number]
231
+ const outgoingVector = [next[0] - corner[0], next[1] - corner[1]] as [number, number]
232
+ const incomingDirection = normalize2D(incomingVector)
233
+ const outgoingDirection = normalize2D(outgoingVector)
234
+
235
+ const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
236
+ const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
237
+ const cornerStrength = 1 - Math.abs(incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1])
238
+
239
+ return {
240
+ corner,
241
+ incomingDirection,
242
+ outgoingDirection,
243
+ incomingLength: getBracketLength(incomingLength),
244
+ outgoingLength: getBracketLength(outgoingLength),
245
+ cornerStrength,
246
+ }
247
+ })
248
+
249
+ if (allCorners.length <= 4) {
250
+ return allCorners
251
+ }
252
+
253
+ const selectedIndices = new Set(
254
+ allCorners
255
+ .map((corner, index) => ({ index, strength: corner.cornerStrength }))
256
+ .sort((a, b) => b.strength - a.strength)
257
+ .slice(0, 4)
258
+ .map(({ index }) => index),
259
+ )
260
+
261
+ return allCorners.filter((_, index) => selectedIndices.has(index))
262
+ }
263
+
264
+ function normalize2D(vector: [number, number]): [number, number] {
265
+ const length = Math.hypot(vector[0], vector[1])
266
+ if (length < 1e-6) return [1, 0]
267
+ return [vector[0] / length, vector[1] / length]
268
+ }
269
+
270
+ function getBracketLength(edgeLength: number): number {
271
+ return Math.max(0.14, Math.min(0.38, edgeLength * 0.22))
272
+ }
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
6
6
  * Imperatively toggles the Three.js visibility of roof objects based on the
7
7
  * editor selection — without causing React re-renders in RoofRenderer.
8
8
  *
9
- * When a roof (or one of its segments) is selected:
9
+ * When a roof-segment is selected:
10
10
  * - merged-roof mesh is hidden
11
11
  * - segments-wrapper group is shown (individual segments visible for editing)
12
12
  * - all children are marked dirty so RoofSystem rebuilds their geometry
@@ -22,14 +22,14 @@ export const RoofEditSystem = () => {
22
22
  useEffect(() => {
23
23
  const nodes = useScene.getState().nodes
24
24
 
25
- // Collect which roof nodes should be in "edit mode"
25
+ // Collect which roof nodes should be in "edit mode".
26
+ // Selecting the roof itself should keep the merged visual intact so
27
+ // material appearance does not jump between merged and per-segment meshes.
26
28
  const activeRoofIds = new Set<string>()
27
29
  for (const id of selectedIds) {
28
30
  const node = nodes[id as AnyNodeId]
29
31
  if (!node) continue
30
- if (node.type === 'roof') {
31
- activeRoofIds.add(id)
32
- } else if (node.type === 'roof-segment' && node.parentId) {
32
+ if (node.type === 'roof-segment' && node.parentId) {
33
33
  activeRoofIds.add(node.parentId)
34
34
  }
35
35
  }
@@ -1,6 +1,6 @@
1
1
  import { type AnyNodeId, type StairNode, sceneRegistry, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
- import { useEffect, useRef } from 'react'
3
+ import { useCallback, useEffect, useRef } from 'react'
4
4
 
5
5
  /**
6
6
  * Imperatively toggles the Three.js visibility of stair objects based on the
@@ -17,6 +17,27 @@ import { useEffect, useRef } from 'react'
17
17
  */
18
18
  export const StairEditSystem = () => {
19
19
  const selectedIds = useViewer((s) => s.selection.selectedIds)
20
+ const selectedStairSignature = useScene(
21
+ useCallback(
22
+ (state) =>
23
+ selectedIds
24
+ .map((id) => {
25
+ const node = state.nodes[id as AnyNodeId]
26
+ if (!node) return null
27
+ if (node.type === 'stair') {
28
+ return `${node.id}:${node.stairType}`
29
+ }
30
+ if (node.type === 'stair-segment' && node.parentId) {
31
+ const parent = state.nodes[node.parentId as AnyNodeId] as StairNode | undefined
32
+ return parent?.type === 'stair' ? `${parent.id}:${parent.stairType}` : null
33
+ }
34
+ return null
35
+ })
36
+ .filter(Boolean)
37
+ .join('|'),
38
+ [selectedIds],
39
+ ),
40
+ )
20
41
  const prevActiveStairIds = useRef(new Set<string>())
21
42
 
22
43
  useEffect(() => {
@@ -41,14 +62,15 @@ export const StairEditSystem = () => {
41
62
  const group = sceneRegistry.nodes.get(stairId)
42
63
  if (!group) continue
43
64
 
65
+ const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined
66
+ const isCurved = stairNode?.stairType === 'curved' || stairNode?.stairType === 'spiral'
44
67
  const mergedMesh = group.getObjectByName('merged-stair')
45
68
  const segmentsWrapper = group.getObjectByName('segments-wrapper')
46
69
  const isActive = activeStairIds.has(stairId)
47
70
 
48
- if (mergedMesh) mergedMesh.visible = !isActive
49
- if (segmentsWrapper) segmentsWrapper.visible = isActive
71
+ if (mergedMesh) mergedMesh.visible = !isActive && !isCurved
72
+ if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved
50
73
 
51
- const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined
52
74
  if (stairNode?.children?.length) {
53
75
  const wasActive = prevActiveStairIds.current.has(stairId)
54
76
  if (isActive !== wasActive) {
@@ -63,7 +85,7 @@ export const StairEditSystem = () => {
63
85
  }
64
86
 
65
87
  prevActiveStairIds.current = activeStairIds
66
- }, [selectedIds])
88
+ }, [selectedIds, selectedStairSignature])
67
89
 
68
90
  return null
69
91
  }