@pascal-app/editor 0.6.0 → 0.8.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 (157) hide show
  1. package/package.json +13 -9
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +74 -5
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +24 -3
  6. package/src/components/editor/first-person/build-collider-world.ts +363 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -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 +9861 -3297
  12. package/src/components/editor/index.tsx +295 -32
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  15. package/src/components/editor/thumbnail-generator.tsx +56 -68
  16. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  17. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  18. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  19. package/src/components/editor/wall-measurement-label.tsx +267 -36
  20. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  21. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  22. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  23. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
  24. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  25. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
  26. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  27. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  28. package/src/components/editor-2d/svg-paths.ts +119 -0
  29. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  30. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  31. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  32. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  33. package/src/components/systems/zone/zone-system.tsx +0 -0
  34. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  35. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  36. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  37. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  38. package/src/components/tools/column/column-tool.tsx +97 -0
  39. package/src/components/tools/column/move-column-tool.tsx +105 -0
  40. package/src/components/tools/door/door-tool.tsx +7 -0
  41. package/src/components/tools/door/move-door-tool.tsx +28 -8
  42. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  43. package/src/components/tools/fence/fence-drafting.ts +10 -3
  44. package/src/components/tools/fence/fence-tool.tsx +160 -4
  45. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
  46. package/src/components/tools/fence/move-fence-tool.tsx +111 -40
  47. package/src/components/tools/item/move-tool.tsx +7 -1
  48. package/src/components/tools/item/placement-math.ts +32 -5
  49. package/src/components/tools/item/placement-strategies.ts +110 -31
  50. package/src/components/tools/item/placement-types.ts +7 -0
  51. package/src/components/tools/item/use-draft-node.ts +1 -0
  52. package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
  53. package/src/components/tools/roof/move-roof-tool.tsx +29 -17
  54. package/src/components/tools/select/box-select-tool.tsx +12 -17
  55. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  56. package/src/components/tools/shared/segment-angle.ts +156 -0
  57. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  58. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  59. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  60. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  61. package/src/components/tools/tool-manager.tsx +20 -5
  62. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
  64. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  65. package/src/components/tools/wall/wall-drafting.ts +18 -9
  66. package/src/components/tools/wall/wall-tool.tsx +136 -4
  67. package/src/components/tools/window/move-window-tool.tsx +18 -0
  68. package/src/components/tools/window/window-tool.tsx +5 -0
  69. package/src/components/tools/zone/zone-tool.tsx +20 -5
  70. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  71. package/src/components/ui/action-menu/control-modes.tsx +34 -1
  72. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  73. package/src/components/ui/action-menu/index.tsx +98 -59
  74. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  75. package/src/components/ui/action-menu/view-toggles.tsx +418 -41
  76. package/src/components/ui/command-palette/editor-commands.tsx +24 -5
  77. package/src/components/ui/command-palette/index.tsx +4 -255
  78. package/src/components/ui/controls/material-picker.tsx +154 -164
  79. package/src/components/ui/controls/slider-control.tsx +66 -18
  80. package/src/components/ui/floating-level-selector.tsx +286 -55
  81. package/src/components/ui/helpers/helper-manager.tsx +10 -0
  82. package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
  83. package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
  84. package/src/components/ui/level-duplicate-dialog.tsx +113 -0
  85. package/src/components/ui/panels/ceiling-panel.tsx +3 -28
  86. package/src/components/ui/panels/column-panel.tsx +759 -0
  87. package/src/components/ui/panels/door-panel.tsx +989 -290
  88. package/src/components/ui/panels/fence-panel.tsx +2 -49
  89. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  90. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  91. package/src/components/ui/panels/node-display.ts +39 -0
  92. package/src/components/ui/panels/paint-panel.tsx +163 -0
  93. package/src/components/ui/panels/panel-manager.tsx +208 -28
  94. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  95. package/src/components/ui/panels/reference-panel.tsx +253 -5
  96. package/src/components/ui/panels/roof-panel.tsx +13 -64
  97. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  98. package/src/components/ui/panels/slab-panel.tsx +4 -30
  99. package/src/components/ui/panels/spawn-panel.tsx +161 -0
  100. package/src/components/ui/panels/stair-panel.tsx +20 -74
  101. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  102. package/src/components/ui/panels/wall-panel.tsx +10 -8
  103. package/src/components/ui/panels/window-panel.tsx +668 -139
  104. package/src/components/ui/primitives/number-input.tsx +1 -1
  105. package/src/components/ui/primitives/sidebar.tsx +0 -0
  106. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  107. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  108. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  109. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  110. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  112. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  114. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  115. package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
  116. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  117. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  118. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
  119. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
  120. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
  121. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/slider.tsx +1 -1
  124. package/src/components/viewer-overlay.tsx +0 -0
  125. package/src/components/viewer-zone-system.tsx +0 -0
  126. package/src/hooks/use-auto-frame.ts +45 -0
  127. package/src/hooks/use-auto-save.ts +14 -0
  128. package/src/hooks/use-keyboard.ts +74 -7
  129. package/src/hooks/use-mobile.ts +12 -12
  130. package/src/index.tsx +8 -1
  131. package/src/lib/door-interaction.ts +88 -0
  132. package/src/lib/floorplan/geometry.ts +263 -0
  133. package/src/lib/floorplan/index.ts +38 -0
  134. package/src/lib/floorplan/items.ts +179 -0
  135. package/src/lib/floorplan/selection-tool.ts +231 -0
  136. package/src/lib/floorplan/stairs.ts +478 -0
  137. package/src/lib/floorplan/types.ts +57 -0
  138. package/src/lib/floorplan/walls.ts +23 -0
  139. package/src/lib/guide-events.ts +10 -0
  140. package/src/lib/level-duplication.test.ts +70 -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/scene.ts +0 -0
  148. package/src/lib/sfx-bus.ts +2 -0
  149. package/src/lib/sfx-player.ts +5 -5
  150. package/src/lib/stair-duplication.ts +126 -0
  151. package/src/lib/window-interaction.ts +86 -0
  152. package/src/store/use-editor.tsx +186 -62
  153. package/tsconfig.json +2 -1
  154. package/src/components/feedback-dialog.tsx +0 -265
  155. package/src/components/pascal-radio.tsx +0 -280
  156. package/src/components/preview-button.tsx +0 -16
  157. package/src/components/ui/viewer-toolbar.tsx +0 -395
@@ -0,0 +1,189 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type BuildingNode,
6
+ type CeilingNode,
7
+ type DoorNode,
8
+ type FenceNode,
9
+ type GuideNode,
10
+ type LevelNode,
11
+ type RoofNode,
12
+ type SiteNode,
13
+ type SlabNode,
14
+ type SpawnNode,
15
+ useScene,
16
+ type WallNode,
17
+ type WindowNode,
18
+ type ZoneNode as ZoneNodeType,
19
+ } from '@pascal-app/core'
20
+ import { useShallow } from 'zustand/react/shallow'
21
+ import { collectLevelDescendants } from '../../lib/floorplan'
22
+
23
+ type OpeningNode = WindowNode | DoorNode
24
+
25
+ const DEFAULT_BUILDING_POSITION = [0, 0, 0] as const satisfies [number, number, number]
26
+
27
+ function useLevelChildren<TNode extends AnyNode>(
28
+ levelId: LevelNode['id'] | null,
29
+ typeGuard: (node: AnyNode | undefined) => node is TNode,
30
+ ) {
31
+ return useScene(
32
+ useShallow((state) => {
33
+ if (!levelId) {
34
+ return [] as TNode[]
35
+ }
36
+
37
+ const levelNode = state.nodes[levelId]
38
+ if (!levelNode || levelNode.type !== 'level') {
39
+ return [] as TNode[]
40
+ }
41
+
42
+ return levelNode.children.map((childId) => state.nodes[childId]).filter(typeGuard)
43
+ }),
44
+ )
45
+ }
46
+
47
+ export function useFloorplanSceneData({
48
+ buildingId,
49
+ levelId,
50
+ }: {
51
+ buildingId: BuildingNode['id'] | null
52
+ levelId: LevelNode['id'] | null
53
+ }) {
54
+ const levelNode = useScene((state) =>
55
+ levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
56
+ )
57
+ const currentBuildingId =
58
+ levelNode?.type === 'level' && levelNode.parentId
59
+ ? (levelNode.parentId as BuildingNode['id'])
60
+ : buildingId
61
+
62
+ const buildingRotationY = useScene((state) => {
63
+ if (!currentBuildingId) return 0
64
+ const node = state.nodes[currentBuildingId]
65
+ return node?.type === 'building' ? (node.rotation[1] ?? 0) : 0
66
+ })
67
+
68
+ const buildingPosition = useScene((state) => {
69
+ if (!currentBuildingId) {
70
+ return DEFAULT_BUILDING_POSITION
71
+ }
72
+
73
+ const node = state.nodes[currentBuildingId]
74
+ return node?.type === 'building'
75
+ ? (node.position as [number, number, number])
76
+ : DEFAULT_BUILDING_POSITION
77
+ })
78
+
79
+ const site = useScene((state) => {
80
+ for (const rootNodeId of state.rootNodeIds) {
81
+ const node = state.nodes[rootNodeId]
82
+ if (node?.type === 'site') {
83
+ return node as SiteNode
84
+ }
85
+ }
86
+
87
+ return null
88
+ })
89
+
90
+ const floorplanLevels = useScene(
91
+ useShallow((state) => {
92
+ if (!currentBuildingId) {
93
+ return [] as LevelNode[]
94
+ }
95
+
96
+ const buildingNode = state.nodes[currentBuildingId]
97
+ if (!buildingNode || buildingNode.type !== 'building') {
98
+ return [] as LevelNode[]
99
+ }
100
+
101
+ return buildingNode.children
102
+ .map((childId) => state.nodes[childId])
103
+ .filter((node): node is LevelNode => node?.type === 'level')
104
+ .sort((a, b) => a.level - b.level)
105
+ }),
106
+ )
107
+
108
+ const walls = useLevelChildren(levelId, (node): node is WallNode => node?.type === 'wall')
109
+ const fences = useLevelChildren(levelId, (node): node is FenceNode => node?.type === 'fence')
110
+ const slabs = useLevelChildren(levelId, (node): node is SlabNode => node?.type === 'slab')
111
+ const ceilings = useLevelChildren(
112
+ levelId,
113
+ (node): node is CeilingNode => node?.type === 'ceiling',
114
+ )
115
+ const levelGuides = useLevelChildren(levelId, (node): node is GuideNode => node?.type === 'guide')
116
+ const zones = useLevelChildren(levelId, (node): node is ZoneNodeType => node?.type === 'zone')
117
+ const spawns = useLevelChildren(levelId, (node): node is SpawnNode => node?.type === 'spawn')
118
+ const roofs = useScene(
119
+ useShallow((state) => {
120
+ if (!levelId) {
121
+ return [] as RoofNode[]
122
+ }
123
+
124
+ const nextLevelNode = state.nodes[levelId]
125
+ if (!nextLevelNode || nextLevelNode.type !== 'level') {
126
+ return [] as RoofNode[]
127
+ }
128
+
129
+ return nextLevelNode.children
130
+ .map((childId) => state.nodes[childId])
131
+ .filter((node): node is RoofNode => node?.type === 'roof' && node.visible !== false)
132
+ }),
133
+ )
134
+ const openings = useScene(
135
+ useShallow((state) => {
136
+ if (!levelId) {
137
+ return [] as OpeningNode[]
138
+ }
139
+
140
+ const nextLevelNode = state.nodes[levelId]
141
+ if (!nextLevelNode || nextLevelNode.type !== 'level') {
142
+ return [] as OpeningNode[]
143
+ }
144
+
145
+ const nextWalls = nextLevelNode.children
146
+ .map((childId) => state.nodes[childId])
147
+ .filter((node): node is WallNode => node?.type === 'wall')
148
+
149
+ return nextWalls.flatMap((wall) =>
150
+ wall.children
151
+ .map((childId) => state.nodes[childId])
152
+ .filter((node): node is OpeningNode => node?.type === 'window' || node?.type === 'door'),
153
+ )
154
+ }),
155
+ )
156
+ const levelDescendantNodes = useScene(
157
+ useShallow((state) => {
158
+ if (!levelId) {
159
+ return [] as AnyNode[]
160
+ }
161
+
162
+ const nextLevelNode = state.nodes[levelId]
163
+ if (!nextLevelNode || nextLevelNode.type !== 'level') {
164
+ return [] as AnyNode[]
165
+ }
166
+
167
+ return collectLevelDescendants(nextLevelNode, state.nodes as Record<string, AnyNode>)
168
+ }),
169
+ )
170
+
171
+ return {
172
+ buildingPosition,
173
+ buildingRotationY,
174
+ currentBuildingId,
175
+ ceilings,
176
+ fences,
177
+ floorplanLevels,
178
+ levelDescendantNodes,
179
+ levelGuides,
180
+ levelNode,
181
+ openings,
182
+ roofs,
183
+ site,
184
+ slabs,
185
+ spawns,
186
+ walls,
187
+ zones,
188
+ }
189
+ }
@@ -8,6 +8,7 @@ import {
8
8
  getWallMiterBoundaryPoints,
9
9
  getWallPlanFootprint,
10
10
  getWallSurfacePolygon,
11
+ type ItemNode,
11
12
  isCurvedWall,
12
13
  type Point2D,
13
14
  pointToKey,
@@ -27,6 +28,8 @@ const GUIDE_Y_OFFSET = 0.08
27
28
  const LABEL_LIFT = 0.08
28
29
  const BAR_THICKNESS = 0.012
29
30
  const LINE_OPACITY = 0.95
31
+ const HEIGHT_TICK_HALF_LENGTH = 0.14
32
+ const HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16
30
33
 
31
34
  const BAR_AXIS = new THREE.Vector3(0, 1, 0)
32
35
 
@@ -39,6 +42,18 @@ type MeasurementGuide = {
39
42
  extEndStart: Vec3
40
43
  extEndEnd: Vec3
41
44
  labelPosition: Vec3
45
+ heightStart: Vec3
46
+ heightEnd: Vec3
47
+ heightBottomTickStart: Vec3
48
+ heightBottomTickEnd: Vec3
49
+ heightTopTickStart: Vec3
50
+ heightTopTickEnd: Vec3
51
+ heightLabelPosition: Vec3
52
+ }
53
+
54
+ type WallFaceLine = {
55
+ start: Point2D
56
+ end: Point2D
42
57
  }
43
58
 
44
59
  function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
@@ -57,28 +72,28 @@ export function WallMeasurementLabel() {
57
72
  const nodes = useScene((state) => state.nodes)
58
73
 
59
74
  const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
60
- const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null
61
- const wall = selectedNode?.type === 'wall' ? selectedNode : null
75
+ const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
76
+ const measurableNode =
77
+ selectedNode?.type === 'wall' || selectedNode?.type === 'item' ? selectedNode : null
62
78
 
63
- const [wallObjectState, setWallObjectState] = useState<{
64
- id: WallNode['id']
79
+ const [objectState, setObjectState] = useState<{
80
+ id: AnyNodeId
65
81
  object: THREE.Object3D
66
82
  } | null>(null)
67
- const wallObject =
68
- selectedId && wallObjectState?.id === selectedId ? wallObjectState.object : null
83
+ const selectedObject = selectedId && objectState?.id === selectedId ? objectState.object : null
69
84
 
70
85
  useFrame(() => {
71
- if (!selectedId || wallObject) return
86
+ if (!selectedId || selectedObject) return
72
87
 
73
- const nextWallObject = sceneRegistry.nodes.get(selectedId)
74
- if (nextWallObject) {
75
- setWallObjectState({ id: selectedId as WallNode['id'], object: nextWallObject })
88
+ const nextObject = sceneRegistry.nodes.get(selectedId)
89
+ if (nextObject) {
90
+ setObjectState({ id: selectedId as AnyNodeId, object: nextObject })
76
91
  }
77
92
  })
78
93
 
79
- if (!(wall && wallObject)) return null
94
+ if (!(measurableNode && selectedObject)) return null
80
95
 
81
- return createPortal(<WallMeasurementAnnotation wall={wall} />, wallObject)
96
+ return createPortal(<SelectedMeasurementAnnotation node={measurableNode} />, selectedObject)
82
97
  }
83
98
 
84
99
  function getLevelWalls(
@@ -97,6 +112,114 @@ function getLevelWalls(
97
112
  .filter((node): node is WallNode => Boolean(node && node.type === 'wall'))
98
113
  }
99
114
 
115
+ function pointMatchesWallPlanPoint(point: Point2D | undefined, planPoint: [number, number]) {
116
+ if (!point) return false
117
+
118
+ return Math.abs(point.x - planPoint[0]) < 1e-6 && Math.abs(point.y - planPoint[1]) < 1e-6
119
+ }
120
+
121
+ function getWallFaceLines(
122
+ wall: WallNode,
123
+ miterData: WallMiterData,
124
+ ): { left: WallFaceLine; right: WallFaceLine } | null {
125
+ if (isCurvedWall(wall)) return null
126
+
127
+ const footprint = getWallPlanFootprint(wall, miterData)
128
+ if (footprint.length < 4) return null
129
+
130
+ const startRight = footprint[0]
131
+ const endRight = footprint[1]
132
+ const hasEndCenterPoint = pointMatchesWallPlanPoint(footprint[2], wall.end)
133
+ const endLeft = footprint[hasEndCenterPoint ? 3 : 2]
134
+ const lastPoint = footprint[footprint.length - 1]
135
+ const hasStartCenterPoint = pointMatchesWallPlanPoint(lastPoint, wall.start)
136
+ const startLeft = footprint[hasStartCenterPoint ? footprint.length - 2 : footprint.length - 1]
137
+
138
+ if (!(startRight && endRight && endLeft && startLeft)) return null
139
+
140
+ return {
141
+ left: {
142
+ start: startLeft,
143
+ end: endLeft,
144
+ },
145
+ right: {
146
+ start: startRight,
147
+ end: endRight,
148
+ },
149
+ }
150
+ }
151
+
152
+ function getLineMidpoint(line: WallFaceLine): Point2D {
153
+ return {
154
+ x: (line.start.x + line.end.x) / 2,
155
+ y: (line.start.y + line.end.y) / 2,
156
+ }
157
+ }
158
+
159
+ function getLevelWallsCenter(levelWalls: WallNode[]): Point2D {
160
+ let minX = Number.POSITIVE_INFINITY
161
+ let maxX = Number.NEGATIVE_INFINITY
162
+ let minY = Number.POSITIVE_INFINITY
163
+ let maxY = Number.NEGATIVE_INFINITY
164
+
165
+ for (const candidateWall of levelWalls) {
166
+ minX = Math.min(minX, candidateWall.start[0], candidateWall.end[0])
167
+ maxX = Math.max(maxX, candidateWall.start[0], candidateWall.end[0])
168
+ minY = Math.min(minY, candidateWall.start[1], candidateWall.end[1])
169
+ maxY = Math.max(maxY, candidateWall.start[1], candidateWall.end[1])
170
+ }
171
+
172
+ return {
173
+ x: minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2,
174
+ y: minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2,
175
+ }
176
+ }
177
+
178
+ function getWallOuterFaceLine(
179
+ wall: WallNode,
180
+ miterData: WallMiterData,
181
+ levelWalls: WallNode[],
182
+ ): WallFaceLine | null {
183
+ const faceLines = getWallFaceLines(wall, miterData)
184
+ if (!faceLines) return null
185
+
186
+ if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
187
+ return faceLines.left
188
+ }
189
+
190
+ if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') {
191
+ return faceLines.right
192
+ }
193
+
194
+ const dx = wall.end[0] - wall.start[0]
195
+ const dy = wall.end[1] - wall.start[1]
196
+ const length = Math.hypot(dx, dy)
197
+ if (length < 1e-6) return null
198
+
199
+ const wallMidpoint = {
200
+ x: (wall.start[0] + wall.end[0]) / 2,
201
+ y: (wall.start[1] + wall.end[1]) / 2,
202
+ }
203
+ const levelCenter = getLevelWallsCenter(levelWalls)
204
+ const normal = { x: -dy / length, y: dx / length }
205
+ const fromCenter = {
206
+ x: wallMidpoint.x - levelCenter.x,
207
+ y: wallMidpoint.y - levelCenter.y,
208
+ }
209
+ const outwardNormal =
210
+ fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? normal : { x: -normal.x, y: -normal.y }
211
+ const rightMidpoint = getLineMidpoint(faceLines.right)
212
+ const leftMidpoint = getLineMidpoint(faceLines.left)
213
+ const rightScore =
214
+ (rightMidpoint.x - wallMidpoint.x) * outwardNormal.x +
215
+ (rightMidpoint.y - wallMidpoint.y) * outwardNormal.y
216
+ const leftScore =
217
+ (leftMidpoint.x - wallMidpoint.x) * outwardNormal.x +
218
+ (leftMidpoint.y - wallMidpoint.y) * outwardNormal.y
219
+
220
+ return rightScore >= leftScore ? faceLines.right : faceLines.left
221
+ }
222
+
100
223
  function getWallMiddlePoints(
101
224
  wall: WallNode,
102
225
  miterData: WallMiterData,
@@ -136,7 +259,10 @@ function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {
136
259
  return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
137
260
  }
138
261
 
139
- function getWallExteriorOffsetSign(wall: Pick<WallNode, 'frontSide' | 'backSide'>) {
262
+ function getWallExteriorOffsetSign(
263
+ wall: Pick<WallNode, 'start' | 'end' | 'frontSide' | 'backSide'>,
264
+ levelWalls: WallNode[],
265
+ ) {
140
266
  if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
141
267
  return 1
142
268
  }
@@ -145,10 +271,31 @@ function getWallExteriorOffsetSign(wall: Pick<WallNode, 'frontSide' | 'backSide'
145
271
  return -1
146
272
  }
147
273
 
148
- return 1
274
+ const dx = wall.end[0] - wall.start[0]
275
+ const dy = wall.end[1] - wall.start[1]
276
+ const length = Math.hypot(dx, dy)
277
+
278
+ if (length < 1e-6) return 1
279
+
280
+ const wallMidpoint = {
281
+ x: (wall.start[0] + wall.end[0]) / 2,
282
+ y: (wall.start[1] + wall.end[1]) / 2,
283
+ }
284
+ const levelCenter = getLevelWallsCenter(levelWalls)
285
+ const normal = { x: -dy / length, y: dx / length }
286
+ const fromCenter = {
287
+ x: wallMidpoint.x - levelCenter.x,
288
+ y: wallMidpoint.y - levelCenter.y,
289
+ }
290
+
291
+ return fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? 1 : -1
149
292
  }
150
293
 
151
- function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData): Point2D[] | null {
294
+ function getCurvedWallMeasurementPath(
295
+ wall: WallNode,
296
+ miterData: WallMiterData,
297
+ levelWalls: WallNode[],
298
+ ): Point2D[] | null {
152
299
  const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData)
153
300
  if (!boundaryPoints) return null
154
301
 
@@ -156,7 +303,7 @@ function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData):
156
303
  const sidePointCount = 25
157
304
  if (surface.length < sidePointCount * 2) return null
158
305
 
159
- const offsetSign = getWallExteriorOffsetSign(wall)
306
+ const offsetSign = getWallExteriorOffsetSign(wall, levelWalls)
160
307
  if (offsetSign >= 0) {
161
308
  return surface.slice(sidePointCount).reverse()
162
309
  }
@@ -170,14 +317,16 @@ function buildMeasurementGuide(
170
317
  ): MeasurementGuide | null {
171
318
  const levelWalls = getLevelWalls(wall, nodes)
172
319
  const miterData = calculateLevelMiters(levelWalls)
173
- const middlePoints = getWallMiddlePoints(wall, miterData)
174
- if (!middlePoints) return null
320
+ const measurementLine = getWallOuterFaceLine(wall, miterData, levelWalls)
321
+ const fallbackMiddlePoints = measurementLine ? null : getWallMiddlePoints(wall, miterData)
322
+ const measurementPoints = measurementLine ?? fallbackMiddlePoints
323
+ if (!measurementPoints) return null
175
324
 
176
325
  const height = wall.height ?? DEFAULT_WALL_HEIGHT
177
- const startLocal = worldPointToWallLocal(wall, middlePoints.start)
178
- const endLocal = worldPointToWallLocal(wall, middlePoints.end)
326
+ const startLocal = worldPointToWallLocal(wall, measurementPoints.start)
327
+ const endLocal = worldPointToWallLocal(wall, measurementPoints.end)
179
328
  const curvedMeasurementPath = isCurvedWall(wall)
180
- ? getCurvedWallMeasurementPath(wall, miterData)
329
+ ? getCurvedWallMeasurementPath(wall, miterData, levelWalls)
181
330
  : null
182
331
  const guidePath: Vec3[] = curvedMeasurementPath
183
332
  ? curvedMeasurementPath.map((point) => {
@@ -224,6 +373,38 @@ function buildMeasurementGuide(
224
373
  guideStart[1],
225
374
  (guideStart[2] + guideEnd[2]) / 2,
226
375
  ] as Vec3)
376
+ const rawHeightGuidePosition = [guideEnd[0], 0, guideEnd[2]] as Vec3
377
+ const beforeGuideEnd = guidePath[guidePath.length - 2] ?? guideStart
378
+ const tickDx = guideEnd[0] - beforeGuideEnd[0]
379
+ const tickDz = guideEnd[2] - beforeGuideEnd[2]
380
+ const tickLength = Math.hypot(tickDx, tickDz)
381
+ const tangentX = tickLength > 1e-6 ? tickDx / tickLength : 1
382
+ const tangentZ = tickLength > 1e-6 ? tickDz / tickLength : 0
383
+ const tickUnitX = -tangentZ
384
+ const tickUnitZ = tangentX
385
+ const wallEndLocal = worldPointToWallLocal(wall, { x: wall.end[0], y: wall.end[1] })
386
+ const endOutwardX = rawHeightGuidePosition[0] - wallEndLocal[0]
387
+ const endOutwardZ = rawHeightGuidePosition[2] - wallEndLocal[2]
388
+ const outsideSign = endOutwardX * tickUnitX + endOutwardZ * tickUnitZ >= 0 ? 1 : -1
389
+ const heightGuidePosition = [
390
+ rawHeightGuidePosition[0] + tickUnitX * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET,
391
+ 0,
392
+ rawHeightGuidePosition[2] + tickUnitZ * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET,
393
+ ] as Vec3
394
+ const getHorizontalHeightTick = (y: number): { start: Vec3; end: Vec3 } => ({
395
+ start: [
396
+ heightGuidePosition[0] - tickUnitX * HEIGHT_TICK_HALF_LENGTH,
397
+ y,
398
+ heightGuidePosition[2] - tickUnitZ * HEIGHT_TICK_HALF_LENGTH,
399
+ ],
400
+ end: [
401
+ heightGuidePosition[0] + tickUnitX * HEIGHT_TICK_HALF_LENGTH,
402
+ y,
403
+ heightGuidePosition[2] + tickUnitZ * HEIGHT_TICK_HALF_LENGTH,
404
+ ],
405
+ })
406
+ const bottomHeightTick = getHorizontalHeightTick(0)
407
+ const topHeightTick = getHorizontalHeightTick(height)
227
408
 
228
409
  return {
229
410
  guidePath,
@@ -236,6 +417,13 @@ function buildMeasurementGuide(
236
417
  extEndStart: [extensionEndBase[0], height, extensionEndBase[2]],
237
418
  extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]],
238
419
  labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]],
420
+ heightStart: [heightGuidePosition[0], 0, heightGuidePosition[2]],
421
+ heightEnd: [heightGuidePosition[0], height, heightGuidePosition[2]],
422
+ heightBottomTickStart: bottomHeightTick.start,
423
+ heightBottomTickEnd: bottomHeightTick.end,
424
+ heightTopTickStart: topHeightTick.start,
425
+ heightTopTickEnd: topHeightTick.end,
426
+ heightLabelPosition: [heightGuidePosition[0], height / 2, heightGuidePosition[2]],
239
427
  }
240
428
  }
241
429
 
@@ -286,6 +474,45 @@ function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) {
286
474
  )
287
475
  }
288
476
 
477
+ function MeasurementLabel({
478
+ label,
479
+ position,
480
+ color,
481
+ shadowColor,
482
+ }: {
483
+ label: string
484
+ position: Vec3
485
+ color: string
486
+ shadowColor: string
487
+ }) {
488
+ return (
489
+ <Html
490
+ center
491
+ position={position}
492
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
493
+ zIndexRange={[20, 0]}
494
+ >
495
+ <div
496
+ className="whitespace-nowrap font-bold font-mono text-[15px]"
497
+ style={{
498
+ color,
499
+ textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
500
+ }}
501
+ >
502
+ {label}
503
+ </div>
504
+ </Html>
505
+ )
506
+ }
507
+
508
+ function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode }) {
509
+ if (node.type === 'wall') {
510
+ return <WallMeasurementAnnotation wall={node} />
511
+ }
512
+
513
+ return null
514
+ }
515
+
289
516
  function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
290
517
  const nodes = useScene((state) => state.nodes)
291
518
  const theme = useViewer((state) => state.theme)
@@ -316,6 +543,7 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
316
543
  return total
317
544
  }, [guide, wall])
318
545
  const label = formatMeasurement(length, unit)
546
+ const heightLabel = `H ${formatMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`
319
547
 
320
548
  if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
321
549
 
@@ -324,23 +552,26 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
324
552
  <MeasurementPath color={color} path={guide.guidePath} />
325
553
  <MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} />
326
554
  <MeasurementBar color={color} end={guide.extEndEnd} start={guide.extEndStart} />
555
+ <MeasurementBar color={color} end={guide.heightEnd} start={guide.heightStart} />
556
+ <MeasurementBar
557
+ color={color}
558
+ end={guide.heightBottomTickEnd}
559
+ start={guide.heightBottomTickStart}
560
+ />
561
+ <MeasurementBar color={color} end={guide.heightTopTickEnd} start={guide.heightTopTickStart} />
327
562
 
328
- <Html
329
- center
563
+ <MeasurementLabel
564
+ color={color}
565
+ label={label}
330
566
  position={guide.labelPosition}
331
- style={{ pointerEvents: 'none', userSelect: 'none' }}
332
- zIndexRange={[20, 0]}
333
- >
334
- <div
335
- className="whitespace-nowrap font-bold font-mono text-[15px]"
336
- style={{
337
- color,
338
- textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
339
- }}
340
- >
341
- {label}
342
- </div>
343
- </Html>
567
+ shadowColor={shadowColor}
568
+ />
569
+ <MeasurementLabel
570
+ color={color}
571
+ label={heightLabel}
572
+ position={guide.heightLabelPosition}
573
+ shadowColor={shadowColor}
574
+ />
344
575
  </group>
345
576
  )
346
577
  }
@@ -0,0 +1,95 @@
1
+ 'use client'
2
+
3
+ import { memo, type MouseEvent as ReactMouseEvent } from 'react'
4
+ import useEditor from '../../store/use-editor'
5
+ import { NodeActionMenu } from '../editor/node-action-menu'
6
+
7
+ type SvgPoint = {
8
+ x: number
9
+ y: number
10
+ }
11
+
12
+ export type FloorplanActionMenuHandler = (event: ReactMouseEvent<HTMLButtonElement>) => void
13
+
14
+ export type FloorplanActionMenuEntry = {
15
+ position: SvgPoint | null
16
+ onDelete: FloorplanActionMenuHandler
17
+ onMove: FloorplanActionMenuHandler
18
+ onAddHole?: FloorplanActionMenuHandler
19
+ onDuplicate?: FloorplanActionMenuHandler
20
+ }
21
+
22
+ type FloorplanActionMenuLayerProps = {
23
+ item: FloorplanActionMenuEntry
24
+ wall: FloorplanActionMenuEntry
25
+ fence: FloorplanActionMenuEntry
26
+ slab: FloorplanActionMenuEntry
27
+ ceiling: FloorplanActionMenuEntry
28
+ opening: FloorplanActionMenuEntry
29
+ spawn: FloorplanActionMenuEntry
30
+ stair: FloorplanActionMenuEntry
31
+ roof: FloorplanActionMenuEntry
32
+ offsetY?: number
33
+ }
34
+
35
+ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
36
+ item,
37
+ wall,
38
+ fence,
39
+ slab,
40
+ ceiling,
41
+ opening,
42
+ spawn,
43
+ stair,
44
+ roof,
45
+ offsetY = 10,
46
+ }: FloorplanActionMenuLayerProps) {
47
+ const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
48
+ const movingNode = useEditor((state) => state.movingNode)
49
+ const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
50
+ const curvingWall = useEditor((state) => state.curvingWall)
51
+ const curvingFence = useEditor((state) => state.curvingFence)
52
+
53
+ if (!isFloorplanHovered || movingNode || movingFenceEndpoint || curvingWall || curvingFence) {
54
+ return null
55
+ }
56
+
57
+ const entries: FloorplanActionMenuEntry[] = [
58
+ item,
59
+ wall,
60
+ fence,
61
+ slab,
62
+ ceiling,
63
+ opening,
64
+ spawn,
65
+ stair,
66
+ roof,
67
+ ]
68
+
69
+ return (
70
+ <>
71
+ {entries.map((entry, index) =>
72
+ entry.position ? (
73
+ <div
74
+ className="absolute z-30"
75
+ key={index}
76
+ style={{
77
+ left: entry.position.x,
78
+ top: entry.position.y,
79
+ transform: `translate(-50%, calc(-100% - ${offsetY}px))`,
80
+ }}
81
+ >
82
+ <NodeActionMenu
83
+ onAddHole={entry.onAddHole}
84
+ onDelete={entry.onDelete}
85
+ onDuplicate={entry.onDuplicate}
86
+ onMove={entry.onMove}
87
+ onPointerDown={(event) => event.stopPropagation()}
88
+ onPointerUp={(event) => event.stopPropagation()}
89
+ />
90
+ </div>
91
+ ) : null,
92
+ )}
93
+ </>
94
+ )
95
+ })