@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.
- package/package.json +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- 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
|
+
}
|