@pascal-app/editor 0.4.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 (165) hide show
  1. package/package.json +62 -0
  2. package/src/components/editor/custom-camera-controls.tsx +387 -0
  3. package/src/components/editor/editor-layout-v2.tsx +220 -0
  4. package/src/components/editor/export-manager.tsx +78 -0
  5. package/src/components/editor/first-person-controls.tsx +249 -0
  6. package/src/components/editor/floating-action-menu.tsx +231 -0
  7. package/src/components/editor/floorplan-panel.tsx +9609 -0
  8. package/src/components/editor/grid.tsx +161 -0
  9. package/src/components/editor/index.tsx +928 -0
  10. package/src/components/editor/node-action-menu.tsx +66 -0
  11. package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
  12. package/src/components/editor/selection-manager.tsx +897 -0
  13. package/src/components/editor/site-edge-labels.tsx +90 -0
  14. package/src/components/editor/thumbnail-generator.tsx +166 -0
  15. package/src/components/editor/wall-measurement-label.tsx +258 -0
  16. package/src/components/feedback-dialog.tsx +265 -0
  17. package/src/components/pascal-radio.tsx +280 -0
  18. package/src/components/preview-button.tsx +16 -0
  19. package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
  20. package/src/components/systems/roof/roof-edit-system.tsx +69 -0
  21. package/src/components/systems/stair/stair-edit-system.tsx +69 -0
  22. package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
  23. package/src/components/systems/zone/zone-system.tsx +87 -0
  24. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
  25. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
  26. package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
  27. package/src/components/tools/door/door-math.ts +110 -0
  28. package/src/components/tools/door/door-tool.tsx +293 -0
  29. package/src/components/tools/door/move-door-tool.tsx +373 -0
  30. package/src/components/tools/item/item-tool.tsx +26 -0
  31. package/src/components/tools/item/move-tool.tsx +90 -0
  32. package/src/components/tools/item/placement-math.ts +85 -0
  33. package/src/components/tools/item/placement-strategies.ts +556 -0
  34. package/src/components/tools/item/placement-types.ts +117 -0
  35. package/src/components/tools/item/use-draft-node.ts +227 -0
  36. package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
  37. package/src/components/tools/roof/move-roof-tool.tsx +288 -0
  38. package/src/components/tools/roof/roof-tool.tsx +318 -0
  39. package/src/components/tools/select/box-select-tool.tsx +626 -0
  40. package/src/components/tools/shared/cursor-sphere.tsx +119 -0
  41. package/src/components/tools/shared/polygon-editor.tsx +361 -0
  42. package/src/components/tools/site/site-boundary-editor.tsx +42 -0
  43. package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
  44. package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
  45. package/src/components/tools/slab/slab-tool.tsx +322 -0
  46. package/src/components/tools/stair/stair-defaults.ts +7 -0
  47. package/src/components/tools/stair/stair-tool.tsx +194 -0
  48. package/src/components/tools/tool-manager.tsx +120 -0
  49. package/src/components/tools/wall/wall-drafting.ts +140 -0
  50. package/src/components/tools/wall/wall-tool.tsx +210 -0
  51. package/src/components/tools/window/move-window-tool.tsx +410 -0
  52. package/src/components/tools/window/window-math.ts +117 -0
  53. package/src/components/tools/window/window-tool.tsx +303 -0
  54. package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
  55. package/src/components/tools/zone/zone-tool.tsx +364 -0
  56. package/src/components/ui/action-menu/action-button.tsx +59 -0
  57. package/src/components/ui/action-menu/camera-actions.tsx +74 -0
  58. package/src/components/ui/action-menu/control-modes.tsx +240 -0
  59. package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
  60. package/src/components/ui/action-menu/index.tsx +152 -0
  61. package/src/components/ui/action-menu/structure-tools.tsx +100 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +397 -0
  63. package/src/components/ui/command-palette/editor-commands.tsx +396 -0
  64. package/src/components/ui/command-palette/index.tsx +730 -0
  65. package/src/components/ui/controls/action-button.tsx +33 -0
  66. package/src/components/ui/controls/material-picker.tsx +194 -0
  67. package/src/components/ui/controls/metric-control.tsx +262 -0
  68. package/src/components/ui/controls/panel-section.tsx +65 -0
  69. package/src/components/ui/controls/segmented-control.tsx +45 -0
  70. package/src/components/ui/controls/slider-control.tsx +245 -0
  71. package/src/components/ui/controls/toggle-control.tsx +38 -0
  72. package/src/components/ui/floating-level-selector.tsx +355 -0
  73. package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
  74. package/src/components/ui/helpers/helper-manager.tsx +33 -0
  75. package/src/components/ui/helpers/item-helper.tsx +40 -0
  76. package/src/components/ui/helpers/roof-helper.tsx +16 -0
  77. package/src/components/ui/helpers/slab-helper.tsx +20 -0
  78. package/src/components/ui/helpers/wall-helper.tsx +20 -0
  79. package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
  80. package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
  81. package/src/components/ui/panels/ceiling-panel.tsx +230 -0
  82. package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
  83. package/src/components/ui/panels/door-panel.tsx +600 -0
  84. package/src/components/ui/panels/item-panel.tsx +306 -0
  85. package/src/components/ui/panels/panel-manager.tsx +59 -0
  86. package/src/components/ui/panels/panel-wrapper.tsx +80 -0
  87. package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
  88. package/src/components/ui/panels/reference-panel.tsx +177 -0
  89. package/src/components/ui/panels/roof-panel.tsx +262 -0
  90. package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
  91. package/src/components/ui/panels/slab-panel.tsx +228 -0
  92. package/src/components/ui/panels/stair-panel.tsx +304 -0
  93. package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
  94. package/src/components/ui/panels/wall-panel.tsx +123 -0
  95. package/src/components/ui/panels/window-panel.tsx +441 -0
  96. package/src/components/ui/primitives/button.tsx +69 -0
  97. package/src/components/ui/primitives/card.tsx +75 -0
  98. package/src/components/ui/primitives/color-dot.tsx +61 -0
  99. package/src/components/ui/primitives/context-menu.tsx +227 -0
  100. package/src/components/ui/primitives/dialog.tsx +129 -0
  101. package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
  102. package/src/components/ui/primitives/error-boundary.tsx +52 -0
  103. package/src/components/ui/primitives/input.tsx +21 -0
  104. package/src/components/ui/primitives/number-input.tsx +187 -0
  105. package/src/components/ui/primitives/opacity-control.tsx +79 -0
  106. package/src/components/ui/primitives/popover.tsx +42 -0
  107. package/src/components/ui/primitives/separator.tsx +28 -0
  108. package/src/components/ui/primitives/sheet.tsx +130 -0
  109. package/src/components/ui/primitives/shortcut-token.tsx +64 -0
  110. package/src/components/ui/primitives/sidebar.tsx +855 -0
  111. package/src/components/ui/primitives/skeleton.tsx +13 -0
  112. package/src/components/ui/primitives/slider.tsx +58 -0
  113. package/src/components/ui/primitives/switch.tsx +29 -0
  114. package/src/components/ui/primitives/tooltip.tsx +57 -0
  115. package/src/components/ui/scene-loader.tsx +40 -0
  116. package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
  117. package/src/components/ui/sidebar/icon-rail.tsx +147 -0
  118. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
  119. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
  120. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
  121. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
  122. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
  123. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
  125. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
  126. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
  127. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
  128. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
  130. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
  131. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
  134. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
  135. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
  136. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
  137. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
  138. package/src/components/ui/sidebar/tab-bar.tsx +39 -0
  139. package/src/components/ui/slider-demo.tsx +36 -0
  140. package/src/components/ui/slider.tsx +81 -0
  141. package/src/components/ui/viewer-toolbar.tsx +342 -0
  142. package/src/components/viewer-overlay.tsx +499 -0
  143. package/src/components/viewer-zone-system.tsx +48 -0
  144. package/src/contexts/presets-context.tsx +121 -0
  145. package/src/hooks/use-auto-save.ts +194 -0
  146. package/src/hooks/use-contextual-tools.ts +52 -0
  147. package/src/hooks/use-grid-events.ts +106 -0
  148. package/src/hooks/use-keyboard.ts +214 -0
  149. package/src/hooks/use-mobile.ts +19 -0
  150. package/src/hooks/use-reduced-motion.ts +20 -0
  151. package/src/index.tsx +33 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/level-selection.ts +31 -0
  154. package/src/lib/scene.ts +394 -0
  155. package/src/lib/sfx/index.ts +2 -0
  156. package/src/lib/sfx-bus.ts +49 -0
  157. package/src/lib/sfx-player.ts +60 -0
  158. package/src/lib/utils.ts +43 -0
  159. package/src/store/use-audio.tsx +45 -0
  160. package/src/store/use-command-registry.ts +36 -0
  161. package/src/store/use-editor.tsx +522 -0
  162. package/src/store/use-palette-view-registry.ts +45 -0
  163. package/src/store/use-upload.ts +90 -0
  164. package/src/three-types.ts +3 -0
  165. package/tsconfig.json +9 -0
@@ -0,0 +1,90 @@
1
+ 'use client'
2
+
3
+ import type { SiteNode } from '@pascal-app/core'
4
+ import { sceneRegistry, useScene } from '@pascal-app/core'
5
+ import { useViewer } from '@pascal-app/viewer'
6
+ import { Html } from '@react-three/drei'
7
+ import { createPortal, useFrame } from '@react-three/fiber'
8
+ import { useMemo, useRef, useState } from 'react'
9
+ import type { Object3D } from 'three'
10
+
11
+ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
12
+ if (unit === 'imperial') {
13
+ const feet = value * 3.280_84
14
+ const wholeFeet = Math.floor(feet)
15
+ const inches = Math.round((feet - wholeFeet) * 12)
16
+ if (inches === 12) return `${wholeFeet + 1}'0"`
17
+ return `${wholeFeet}'${inches}"`
18
+ }
19
+ return `${Number.parseFloat(value.toFixed(2))}m`
20
+ }
21
+
22
+ export function SiteEdgeLabels() {
23
+ const rootNodeIds = useScene((state) => state.rootNodeIds)
24
+ const nodes = useScene((state) => state.nodes)
25
+ const unit = useViewer((state) => state.unit)
26
+ const theme = useViewer((state) => state.theme)
27
+
28
+ const siteNode = rootNodeIds[0] ? (nodes[rootNodeIds[0]] as SiteNode) : null
29
+ const siteNodeId = siteNode?.id
30
+
31
+ const isNight = theme === 'dark'
32
+ const color = isNight ? '#ffffff' : '#111111'
33
+ const shadowColor = isNight ? '#111111' : '#ffffff'
34
+
35
+ const [siteObj, setSiteObj] = useState<Object3D | null>(null)
36
+ const prevSiteNodeIdRef = useRef<string | undefined>(undefined)
37
+
38
+ // Poll each frame until the site group is registered.
39
+ // Also resets when the site node ID changes (new project loaded).
40
+ useFrame(() => {
41
+ if (siteNodeId !== prevSiteNodeIdRef.current) {
42
+ prevSiteNodeIdRef.current = siteNodeId
43
+ setSiteObj(null)
44
+ return
45
+ }
46
+ if (siteObj || !siteNodeId) return
47
+ const obj = sceneRegistry.nodes.get(siteNodeId)
48
+ if (obj) setSiteObj(obj)
49
+ })
50
+
51
+ const edges = useMemo(() => {
52
+ const polygon = siteNode?.polygon?.points ?? []
53
+ if (polygon.length < 2) return []
54
+ return polygon.map(([x1, z1], i) => {
55
+ const [x2, z2] = polygon[(i + 1) % polygon.length]!
56
+ const midX = (x1! + x2) / 2
57
+ const midZ = (z1! + z2) / 2
58
+ const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)
59
+ return { midX, midZ, dist }
60
+ })
61
+ }, [siteNode?.polygon?.points])
62
+
63
+ if (!siteObj || edges.length === 0) return null
64
+
65
+ return createPortal(
66
+ <>
67
+ {edges.map((edge, i) => (
68
+ <Html
69
+ center
70
+ key={`edge-${i}`}
71
+ occlude
72
+ position={[edge.midX, 0.5, edge.midZ]}
73
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
74
+ zIndexRange={[10, 0]}
75
+ >
76
+ <div
77
+ className="whitespace-nowrap font-bold font-mono text-[15px]"
78
+ style={{
79
+ color,
80
+ 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}`,
81
+ }}
82
+ >
83
+ {formatMeasurement(edge.dist, unit)}
84
+ </div>
85
+ </Html>
86
+ ))}
87
+ </>,
88
+ siteObj,
89
+ )
90
+ }
@@ -0,0 +1,166 @@
1
+ 'use client'
2
+
3
+ import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
4
+ import { snapLevelsToTruePositions } from '@pascal-app/viewer'
5
+ import { useThree } from '@react-three/fiber'
6
+ import { useCallback, useEffect, useRef } from 'react'
7
+ import * as THREE from 'three'
8
+ import { EDITOR_LAYER } from '../../lib/constants'
9
+
10
+ const THUMBNAIL_WIDTH = 1920
11
+ const THUMBNAIL_HEIGHT = 1080
12
+ const AUTO_SAVE_DELAY = 10_000
13
+
14
+ interface ThumbnailGeneratorProps {
15
+ onThumbnailCapture?: (blob: Blob) => void
16
+ }
17
+
18
+ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
19
+ const gl = useThree((state) => state.gl)
20
+ const scene = useThree((state) => state.scene)
21
+ const isGenerating = useRef(false)
22
+ const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
23
+ const pendingAutoRef = useRef(false)
24
+ const onThumbnailCaptureRef = useRef(onThumbnailCapture)
25
+
26
+ useEffect(() => {
27
+ onThumbnailCaptureRef.current = onThumbnailCapture
28
+ }, [onThumbnailCapture])
29
+
30
+ const generate = useCallback(async () => {
31
+ if (isGenerating.current) return
32
+ if (!onThumbnailCaptureRef.current) return
33
+
34
+ isGenerating.current = true
35
+
36
+ try {
37
+ const thumbnailCamera = new THREE.PerspectiveCamera(
38
+ 60,
39
+ THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT,
40
+ 0.1,
41
+ 1000,
42
+ )
43
+
44
+ const nodes = useScene.getState().nodes
45
+ const siteNode = Object.values(nodes).find((n) => n.type === 'site')
46
+
47
+ if (siteNode?.camera) {
48
+ const { position, target } = siteNode.camera
49
+ thumbnailCamera.position.set(position[0], position[1], position[2])
50
+ thumbnailCamera.lookAt(target[0], target[1], target[2])
51
+ } else {
52
+ thumbnailCamera.position.set(8, 8, 8)
53
+ thumbnailCamera.lookAt(0, 0, 0)
54
+ }
55
+ thumbnailCamera.layers.disable(EDITOR_LAYER)
56
+
57
+ const { width, height } = gl.domElement
58
+ thumbnailCamera.aspect = width / height
59
+ thumbnailCamera.updateProjectionMatrix()
60
+
61
+ const restoreLevels = snapLevelsToTruePositions()
62
+
63
+ const visibilitySnapshot = new Map<string, boolean>()
64
+ for (const type of ['scan', 'guide'] as const) {
65
+ sceneRegistry.byType[type].forEach((id) => {
66
+ const obj = sceneRegistry.nodes.get(id)
67
+ if (obj) {
68
+ visibilitySnapshot.set(id, obj.visible)
69
+ obj.visible = false
70
+ }
71
+ })
72
+ }
73
+
74
+ gl.render(scene, thumbnailCamera)
75
+
76
+ restoreLevels()
77
+ visibilitySnapshot.forEach((wasVisible, id) => {
78
+ const obj = sceneRegistry.nodes.get(id)
79
+ if (obj) obj.visible = wasVisible
80
+ })
81
+
82
+ const srcAspect = width / height
83
+ const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
84
+ let sx = 0,
85
+ sy = 0,
86
+ sWidth = width,
87
+ sHeight = height
88
+ if (srcAspect > dstAspect) {
89
+ sWidth = Math.round(height * dstAspect)
90
+ sx = Math.round((width - sWidth) / 2)
91
+ } else if (srcAspect < dstAspect) {
92
+ sHeight = Math.round(width / dstAspect)
93
+ sy = Math.round((height - sHeight) / 2)
94
+ }
95
+
96
+ const offscreen = document.createElement('canvas')
97
+ offscreen.width = THUMBNAIL_WIDTH
98
+ offscreen.height = THUMBNAIL_HEIGHT
99
+ const ctx = offscreen.getContext('2d')!
100
+ ctx.drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
101
+
102
+ offscreen.toBlob((blob) => {
103
+ if (blob) {
104
+ onThumbnailCaptureRef.current?.(blob)
105
+ } else {
106
+ console.error('❌ Failed to create blob from canvas')
107
+ }
108
+ isGenerating.current = false
109
+ }, 'image/png')
110
+ } catch (error) {
111
+ console.error('❌ Failed to generate thumbnail:', error)
112
+ isGenerating.current = false
113
+ }
114
+ }, [gl, scene])
115
+
116
+ // Manual trigger via emitter
117
+ useEffect(() => {
118
+ const handleGenerateThumbnail = async () => {
119
+ await generate()
120
+ }
121
+
122
+ emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
123
+ return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
124
+ }, [generate])
125
+
126
+ // Auto-trigger: debounced on scene changes, deferred if tab is hidden
127
+ useEffect(() => {
128
+ if (!onThumbnailCapture) return
129
+
130
+ const triggerNow = () => generate()
131
+
132
+ const scheduleOrDefer = () => {
133
+ if (document.visibilityState === 'visible') {
134
+ triggerNow()
135
+ } else {
136
+ pendingAutoRef.current = true
137
+ }
138
+ }
139
+
140
+ const onSceneChange = () => {
141
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
142
+ debounceTimerRef.current = setTimeout(scheduleOrDefer, AUTO_SAVE_DELAY)
143
+ }
144
+
145
+ const onVisibilityChange = () => {
146
+ if (document.visibilityState === 'visible' && pendingAutoRef.current) {
147
+ pendingAutoRef.current = false
148
+ triggerNow()
149
+ }
150
+ }
151
+
152
+ const unsubscribe = useScene.subscribe((state, prevState) => {
153
+ if (state.nodes !== prevState.nodes) onSceneChange()
154
+ })
155
+
156
+ document.addEventListener('visibilitychange', onVisibilityChange)
157
+
158
+ return () => {
159
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
160
+ unsubscribe()
161
+ document.removeEventListener('visibilitychange', onVisibilityChange)
162
+ }
163
+ }, [onThumbnailCapture, generate])
164
+
165
+ return null
166
+ }
@@ -0,0 +1,258 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ calculateLevelMiters,
6
+ DEFAULT_WALL_HEIGHT,
7
+ getWallPlanFootprint,
8
+ type Point2D,
9
+ pointToKey,
10
+ sceneRegistry,
11
+ useScene,
12
+ type WallMiterData,
13
+ type WallNode,
14
+ } from '@pascal-app/core'
15
+ import { useViewer } from '@pascal-app/viewer'
16
+ import { Html } from '@react-three/drei'
17
+ import { createPortal, useFrame } from '@react-three/fiber'
18
+ import { useEffect, useMemo, useState } from 'react'
19
+ import * as THREE from 'three'
20
+
21
+ const GUIDE_Y_OFFSET = 0.08
22
+ const LABEL_LIFT = 0.08
23
+ const BAR_THICKNESS = 0.012
24
+ const LINE_OPACITY = 0.95
25
+
26
+ const BAR_AXIS = new THREE.Vector3(0, 1, 0)
27
+
28
+ type Vec3 = [number, number, number]
29
+
30
+ type MeasurementGuide = {
31
+ guideStart: Vec3
32
+ guideEnd: Vec3
33
+ extStartStart: Vec3
34
+ extStartEnd: Vec3
35
+ extEndStart: Vec3
36
+ extEndEnd: Vec3
37
+ labelPosition: Vec3
38
+ }
39
+
40
+ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
41
+ if (unit === 'imperial') {
42
+ const feet = value * 3.280_84
43
+ const wholeFeet = Math.floor(feet)
44
+ const inches = Math.round((feet - wholeFeet) * 12)
45
+ if (inches === 12) return `${wholeFeet + 1}'0"`
46
+ return `${wholeFeet}'${inches}"`
47
+ }
48
+ return `${Number.parseFloat(value.toFixed(2))}m`
49
+ }
50
+
51
+ export function WallMeasurementLabel() {
52
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
53
+ const nodes = useScene((state) => state.nodes)
54
+
55
+ const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
56
+ const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null
57
+ const wall = selectedNode?.type === 'wall' ? selectedNode : null
58
+
59
+ const [wallObject, setWallObject] = useState<THREE.Object3D | null>(null)
60
+
61
+ useEffect(() => {
62
+ setWallObject(null)
63
+ }, [selectedId])
64
+
65
+ useFrame(() => {
66
+ if (!selectedId || wallObject) return
67
+
68
+ const nextWallObject = sceneRegistry.nodes.get(selectedId)
69
+ if (nextWallObject) {
70
+ setWallObject(nextWallObject)
71
+ }
72
+ })
73
+
74
+ if (!(wall && wallObject)) return null
75
+
76
+ return createPortal(<WallMeasurementAnnotation wall={wall} />, wallObject)
77
+ }
78
+
79
+ function getLevelWalls(
80
+ wall: WallNode,
81
+ nodes: Record<string, WallNode | { type: string; children?: string[] }>,
82
+ ): WallNode[] {
83
+ if (!wall.parentId) return [wall]
84
+
85
+ const levelNode = nodes[wall.parentId as AnyNodeId]
86
+ if (!(levelNode && levelNode.type === 'level' && Array.isArray(levelNode.children))) {
87
+ return [wall]
88
+ }
89
+
90
+ return levelNode.children
91
+ .map((childId) => nodes[childId as AnyNodeId])
92
+ .filter((node): node is WallNode => Boolean(node && node.type === 'wall'))
93
+ }
94
+
95
+ function getWallMiddlePoints(
96
+ wall: WallNode,
97
+ miterData: WallMiterData,
98
+ ): { start: Point2D; end: Point2D } | null {
99
+ const footprint = getWallPlanFootprint(wall, miterData)
100
+ if (footprint.length < 4) return null
101
+
102
+ const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] })
103
+ const startJunction = miterData.junctionData.get(startKey)?.get(wall.id)
104
+
105
+ const rightStart = footprint[0]
106
+ const rightEnd = footprint[1]
107
+ const leftEnd = footprint[startJunction ? footprint.length - 3 : footprint.length - 2]
108
+ const leftStart = footprint[startJunction ? footprint.length - 2 : footprint.length - 1]
109
+
110
+ if (!(leftStart && leftEnd && rightStart && rightEnd)) return null
111
+
112
+ return {
113
+ start: {
114
+ x: (leftStart.x + rightStart.x) / 2,
115
+ y: (leftStart.y + rightStart.y) / 2,
116
+ },
117
+ end: {
118
+ x: (leftEnd.x + rightEnd.x) / 2,
119
+ y: (leftEnd.y + rightEnd.y) / 2,
120
+ },
121
+ }
122
+ }
123
+
124
+ function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {
125
+ const dx = point.x - wall.start[0]
126
+ const dz = point.y - wall.start[1]
127
+ const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
128
+ const cosA = Math.cos(-angle)
129
+ const sinA = Math.sin(-angle)
130
+
131
+ return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
132
+ }
133
+
134
+ function buildMeasurementGuide(
135
+ wall: WallNode,
136
+ nodes: Record<string, WallNode | { type: string; children?: string[] }>,
137
+ ): MeasurementGuide | null {
138
+ const levelWalls = getLevelWalls(wall, nodes)
139
+ const miterData = calculateLevelMiters(levelWalls)
140
+ const middlePoints = getWallMiddlePoints(wall, miterData)
141
+ if (!middlePoints) return null
142
+
143
+ const height = wall.height ?? DEFAULT_WALL_HEIGHT
144
+ const startLocal = worldPointToWallLocal(wall, middlePoints.start)
145
+ const endLocal = worldPointToWallLocal(wall, middlePoints.end)
146
+
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
155
+
156
+ // Extension lines coming out of the extremity markers of the wall
157
+ const extOvershoot = 0.04
158
+
159
+ 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,
170
+ ],
171
+ }
172
+ }
173
+
174
+ function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color: string }) {
175
+ const segment = useMemo(() => {
176
+ const startVector = new THREE.Vector3(...start)
177
+ const endVector = new THREE.Vector3(...end)
178
+ const direction = endVector.clone().sub(startVector)
179
+ const length = direction.length()
180
+
181
+ if (!Number.isFinite(length) || length < 0.0001) return null
182
+
183
+ return {
184
+ length,
185
+ position: startVector.clone().add(endVector).multiplyScalar(0.5),
186
+ quaternion: new THREE.Quaternion().setFromUnitVectors(BAR_AXIS, direction.normalize()),
187
+ }
188
+ }, [end, start])
189
+
190
+ if (!segment) return null
191
+
192
+ return (
193
+ <mesh
194
+ position={[segment.position.x, segment.position.y, segment.position.z]}
195
+ quaternion={segment.quaternion}
196
+ renderOrder={1000}
197
+ >
198
+ <boxGeometry args={[BAR_THICKNESS, segment.length, BAR_THICKNESS]} />
199
+ <meshBasicMaterial
200
+ color={color}
201
+ depthTest={false}
202
+ depthWrite={false}
203
+ opacity={LINE_OPACITY}
204
+ toneMapped={false}
205
+ transparent
206
+ />
207
+ </mesh>
208
+ )
209
+ }
210
+
211
+ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
212
+ const nodes = useScene((state) => state.nodes)
213
+ const theme = useViewer((state) => state.theme)
214
+ const unit = useViewer((state) => state.unit)
215
+ const isNight = theme === 'dark'
216
+ const color = isNight ? '#ffffff' : '#111111'
217
+ const shadowColor = isNight ? '#111111' : '#ffffff'
218
+
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
+ const guide = useMemo(
224
+ () =>
225
+ buildMeasurementGuide(
226
+ wall,
227
+ nodes as Record<string, WallNode | { type: string; children?: string[] }>,
228
+ ),
229
+ [nodes, wall],
230
+ )
231
+
232
+ if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
233
+
234
+ return (
235
+ <group>
236
+ <MeasurementBar color={color} end={guide.guideEnd} start={guide.guideStart} />
237
+ <MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} />
238
+ <MeasurementBar color={color} end={guide.extEndEnd} start={guide.extEndStart} />
239
+
240
+ <Html
241
+ center
242
+ position={guide.labelPosition}
243
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
244
+ zIndexRange={[20, 0]}
245
+ >
246
+ <div
247
+ className="whitespace-nowrap font-bold font-mono text-[15px]"
248
+ style={{
249
+ color,
250
+ 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}`,
251
+ }}
252
+ >
253
+ {label}
254
+ </div>
255
+ </Html>
256
+ </group>
257
+ )
258
+ }