@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,69 @@
|
|
|
1
|
+
import { type AnyNodeId, type RoofNode, sceneRegistry, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Imperatively toggles the Three.js visibility of roof objects based on the
|
|
7
|
+
* editor selection — without causing React re-renders in RoofRenderer.
|
|
8
|
+
*
|
|
9
|
+
* When a roof (or one of its segments) is selected:
|
|
10
|
+
* - merged-roof mesh is hidden
|
|
11
|
+
* - segments-wrapper group is shown (individual segments visible for editing)
|
|
12
|
+
* - all children are marked dirty so RoofSystem rebuilds their geometry
|
|
13
|
+
*
|
|
14
|
+
* When deselected:
|
|
15
|
+
* - merged-roof mesh is shown
|
|
16
|
+
* - segments-wrapper group is hidden
|
|
17
|
+
*/
|
|
18
|
+
export const RoofEditSystem = () => {
|
|
19
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
20
|
+
const prevActiveRoofIds = useRef(new Set<string>())
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const nodes = useScene.getState().nodes
|
|
24
|
+
|
|
25
|
+
// Collect which roof nodes should be in "edit mode"
|
|
26
|
+
const activeRoofIds = new Set<string>()
|
|
27
|
+
for (const id of selectedIds) {
|
|
28
|
+
const node = nodes[id as AnyNodeId]
|
|
29
|
+
if (!node) continue
|
|
30
|
+
if (node.type === 'roof') {
|
|
31
|
+
activeRoofIds.add(id)
|
|
32
|
+
} else if (node.type === 'roof-segment' && node.parentId) {
|
|
33
|
+
activeRoofIds.add(node.parentId)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Update all roofs that are currently active OR were previously active
|
|
38
|
+
const roofIdsToUpdate = new Set([...activeRoofIds, ...prevActiveRoofIds.current])
|
|
39
|
+
|
|
40
|
+
for (const roofId of roofIdsToUpdate) {
|
|
41
|
+
const group = sceneRegistry.nodes.get(roofId)
|
|
42
|
+
if (!group) continue
|
|
43
|
+
|
|
44
|
+
const mergedMesh = group.getObjectByName('merged-roof')
|
|
45
|
+
const segmentsWrapper = group.getObjectByName('segments-wrapper')
|
|
46
|
+
const isActive = activeRoofIds.has(roofId)
|
|
47
|
+
|
|
48
|
+
if (mergedMesh) mergedMesh.visible = !isActive
|
|
49
|
+
if (segmentsWrapper) segmentsWrapper.visible = isActive
|
|
50
|
+
|
|
51
|
+
const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined
|
|
52
|
+
if (roofNode?.children?.length) {
|
|
53
|
+
const wasActive = prevActiveRoofIds.current.has(roofId)
|
|
54
|
+
if (isActive !== wasActive) {
|
|
55
|
+
// Entering edit mode: rebuild individual segment geometries
|
|
56
|
+
// Exiting edit mode: sync transforms + rebuild merged mesh
|
|
57
|
+
const { markDirty } = useScene.getState()
|
|
58
|
+
for (const childId of roofNode.children) {
|
|
59
|
+
markDirty(childId as AnyNodeId)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
prevActiveRoofIds.current = activeRoofIds
|
|
66
|
+
}, [selectedIds])
|
|
67
|
+
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type AnyNodeId, type StairNode, sceneRegistry, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Imperatively toggles the Three.js visibility of stair objects based on the
|
|
7
|
+
* editor selection — without causing React re-renders in StairRenderer.
|
|
8
|
+
*
|
|
9
|
+
* When a stair (or one of its segments) is selected:
|
|
10
|
+
* - merged-stair mesh is hidden
|
|
11
|
+
* - segments-wrapper group is shown (individual segments visible for editing)
|
|
12
|
+
* - all children are marked dirty so StairSystem rebuilds their geometry
|
|
13
|
+
*
|
|
14
|
+
* When deselected:
|
|
15
|
+
* - merged-stair mesh is shown
|
|
16
|
+
* - segments-wrapper group is hidden
|
|
17
|
+
*/
|
|
18
|
+
export const StairEditSystem = () => {
|
|
19
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
20
|
+
const prevActiveStairIds = useRef(new Set<string>())
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const nodes = useScene.getState().nodes
|
|
24
|
+
|
|
25
|
+
// Collect which stair nodes should be in "edit mode"
|
|
26
|
+
const activeStairIds = new Set<string>()
|
|
27
|
+
for (const id of selectedIds) {
|
|
28
|
+
const node = nodes[id as AnyNodeId]
|
|
29
|
+
if (!node) continue
|
|
30
|
+
if (node.type === 'stair') {
|
|
31
|
+
activeStairIds.add(id)
|
|
32
|
+
} else if (node.type === 'stair-segment' && node.parentId) {
|
|
33
|
+
activeStairIds.add(node.parentId)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Update all stairs that are currently active OR were previously active
|
|
38
|
+
const stairIdsToUpdate = new Set([...activeStairIds, ...prevActiveStairIds.current])
|
|
39
|
+
|
|
40
|
+
for (const stairId of stairIdsToUpdate) {
|
|
41
|
+
const group = sceneRegistry.nodes.get(stairId)
|
|
42
|
+
if (!group) continue
|
|
43
|
+
|
|
44
|
+
const mergedMesh = group.getObjectByName('merged-stair')
|
|
45
|
+
const segmentsWrapper = group.getObjectByName('segments-wrapper')
|
|
46
|
+
const isActive = activeStairIds.has(stairId)
|
|
47
|
+
|
|
48
|
+
if (mergedMesh) mergedMesh.visible = !isActive
|
|
49
|
+
if (segmentsWrapper) segmentsWrapper.visible = isActive
|
|
50
|
+
|
|
51
|
+
const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined
|
|
52
|
+
if (stairNode?.children?.length) {
|
|
53
|
+
const wasActive = prevActiveStairIds.current.has(stairId)
|
|
54
|
+
if (isActive !== wasActive) {
|
|
55
|
+
// Entering edit mode: rebuild individual segment geometries
|
|
56
|
+
// Exiting edit mode: sync transforms + rebuild merged mesh
|
|
57
|
+
const { markDirty } = useScene.getState()
|
|
58
|
+
for (const childId of stairNode.children) {
|
|
59
|
+
markDirty(childId as AnyNodeId)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
prevActiveStairIds.current = activeStairIds
|
|
66
|
+
}, [selectedIds])
|
|
67
|
+
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNodeId, emitter, useScene, type ZoneNode } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { Check, Pencil } from 'lucide-react'
|
|
6
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
7
|
+
import { createPortal } from 'react-dom'
|
|
8
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
9
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
10
|
+
import useEditor from '../../../store/use-editor'
|
|
11
|
+
|
|
12
|
+
// ─── Per-zone label editor ────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
|
15
|
+
const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined)
|
|
16
|
+
const updateNode = useScene((s) => s.updateNode)
|
|
17
|
+
const deleteNode = useScene((s) => s.deleteNode)
|
|
18
|
+
const setSelection = useViewer((s) => s.setSelection)
|
|
19
|
+
const selectedZoneId = useViewer((s) => s.selection.zoneId)
|
|
20
|
+
const hoveredId = useViewer((s) => s.hoveredId)
|
|
21
|
+
const mode = useEditor((s) => s.mode)
|
|
22
|
+
const isSelected = selectedZoneId === zoneId
|
|
23
|
+
const isDeleteHovered = mode === 'delete' && hoveredId === zoneId
|
|
24
|
+
const [editing, setEditing] = useState(false)
|
|
25
|
+
const [value, setValue] = useState('')
|
|
26
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
27
|
+
const [labelEl, setLabelEl] = useState<HTMLElement | null>(null)
|
|
28
|
+
|
|
29
|
+
// Keep a ref so the click handler never has a stale zone name
|
|
30
|
+
const zoneNameRef = useRef(zone?.name ?? '')
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
zoneNameRef.current = zone?.name ?? ''
|
|
33
|
+
}, [zone?.name])
|
|
34
|
+
|
|
35
|
+
// Setup: find the label element, enable pointer events, and hide the
|
|
36
|
+
// zone-renderer's own text node (children[0]) — we replace it via portal.
|
|
37
|
+
// Retries via rAF because the <Html> element from drei may not exist yet at mount time.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
let cancelled = false
|
|
40
|
+
let textEl: HTMLElement | undefined
|
|
41
|
+
|
|
42
|
+
const tryFind = () => {
|
|
43
|
+
const el = document.getElementById(`${zoneId}-label`)
|
|
44
|
+
if (!el) {
|
|
45
|
+
if (!cancelled) requestAnimationFrame(tryFind)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
setLabelEl(el)
|
|
49
|
+
textEl = el.children[0] as HTMLElement | undefined
|
|
50
|
+
if (textEl) textEl.style.display = 'none'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
tryFind()
|
|
54
|
+
|
|
55
|
+
return () => {
|
|
56
|
+
cancelled = true
|
|
57
|
+
if (textEl) textEl.style.display = ''
|
|
58
|
+
}
|
|
59
|
+
}, [zoneId])
|
|
60
|
+
|
|
61
|
+
// Focus + select-all when entering edit mode
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (editing) {
|
|
64
|
+
inputRef.current?.focus()
|
|
65
|
+
inputRef.current?.select()
|
|
66
|
+
}
|
|
67
|
+
}, [editing])
|
|
68
|
+
|
|
69
|
+
// Tint the label pin red when delete-hovered
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!labelEl) return
|
|
72
|
+
const pin = labelEl.querySelector('.label-pin') as HTMLElement | null
|
|
73
|
+
if (!pin) return
|
|
74
|
+
const line = pin.children[0] as HTMLElement | undefined
|
|
75
|
+
const circle = pin.children[1] as HTMLElement | undefined
|
|
76
|
+
const color = isDeleteHovered ? '#dc2626' : (zone?.color ?? '#6366f1')
|
|
77
|
+
if (line) line.style.backgroundColor = color
|
|
78
|
+
if (circle) {
|
|
79
|
+
circle.style.backgroundColor = color
|
|
80
|
+
}
|
|
81
|
+
if (isDeleteHovered) {
|
|
82
|
+
pin.style.opacity = '1'
|
|
83
|
+
}
|
|
84
|
+
return () => {
|
|
85
|
+
// Restore zone color
|
|
86
|
+
const originalColor = zone?.color ?? '#6366f1'
|
|
87
|
+
if (line) line.style.backgroundColor = originalColor
|
|
88
|
+
if (circle) circle.style.backgroundColor = originalColor
|
|
89
|
+
}
|
|
90
|
+
}, [isDeleteHovered, labelEl, zone?.color])
|
|
91
|
+
|
|
92
|
+
const save = useCallback(() => {
|
|
93
|
+
const trimmed = value.trim()
|
|
94
|
+
if (trimmed !== (zone?.name ?? '')) {
|
|
95
|
+
updateNode(zoneId, { name: trimmed || undefined })
|
|
96
|
+
}
|
|
97
|
+
setEditing(false)
|
|
98
|
+
}, [value, zone?.name, updateNode, zoneId])
|
|
99
|
+
|
|
100
|
+
const cancel = useCallback(() => {
|
|
101
|
+
setValue(zone?.name ?? '')
|
|
102
|
+
setEditing(false)
|
|
103
|
+
}, [zone?.name])
|
|
104
|
+
|
|
105
|
+
// Select zone + switch to zone mode from any mode
|
|
106
|
+
const selectZone = useCallback(() => {
|
|
107
|
+
useEditor.getState().setPhase('structure')
|
|
108
|
+
useEditor.getState().setStructureLayer('zones')
|
|
109
|
+
useEditor.getState().setMode('select')
|
|
110
|
+
setSelection({ zoneId })
|
|
111
|
+
}, [zoneId, setSelection])
|
|
112
|
+
|
|
113
|
+
// Enter text editing
|
|
114
|
+
const enterTextEditing = useCallback(() => {
|
|
115
|
+
selectZone()
|
|
116
|
+
setValue(zoneNameRef.current)
|
|
117
|
+
setEditing(true)
|
|
118
|
+
}, [selectZone])
|
|
119
|
+
|
|
120
|
+
// Listen for edit-label events from the 2D floorplan (double-click on zone label)
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const handler = (event: { zoneId: string }) => {
|
|
123
|
+
if (event.zoneId === zoneId) {
|
|
124
|
+
setValue(zoneNameRef.current)
|
|
125
|
+
setEditing(true)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
emitter.on('zone:edit-label' as any, handler as any)
|
|
129
|
+
return () => {
|
|
130
|
+
emitter.off('zone:edit-label' as any, handler as any)
|
|
131
|
+
}
|
|
132
|
+
}, [zoneId])
|
|
133
|
+
|
|
134
|
+
if (!labelEl) return null
|
|
135
|
+
|
|
136
|
+
const shadowColor = isDeleteHovered ? '#dc2626' : (zone?.color ?? '#6366f1')
|
|
137
|
+
const textShadow = [
|
|
138
|
+
`-1px -1px 0 ${shadowColor}`,
|
|
139
|
+
` 1px -1px 0 ${shadowColor}`,
|
|
140
|
+
`-1px 1px 0 ${shadowColor}`,
|
|
141
|
+
` 1px 1px 0 ${shadowColor}`,
|
|
142
|
+
].join(',')
|
|
143
|
+
|
|
144
|
+
// order: -1 puts this flex item before children[0] (hidden) and children[1] (pin)
|
|
145
|
+
const sharedStyle: React.CSSProperties = {
|
|
146
|
+
order: -1,
|
|
147
|
+
color: 'white',
|
|
148
|
+
textShadow,
|
|
149
|
+
fontSize: 14,
|
|
150
|
+
fontFamily: 'sans-serif',
|
|
151
|
+
userSelect: 'none',
|
|
152
|
+
pointerEvents: 'auto',
|
|
153
|
+
display: 'inline-flex',
|
|
154
|
+
alignItems: 'center',
|
|
155
|
+
gap: 4,
|
|
156
|
+
whiteSpace: 'nowrap',
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return createPortal(
|
|
160
|
+
editing ? (
|
|
161
|
+
<div
|
|
162
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
163
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
164
|
+
style={sharedStyle}
|
|
165
|
+
>
|
|
166
|
+
<input
|
|
167
|
+
onBlur={save}
|
|
168
|
+
onChange={(e) => setValue(e.target.value)}
|
|
169
|
+
onClick={(e) => e.stopPropagation()}
|
|
170
|
+
onKeyDown={(e) => {
|
|
171
|
+
e.stopPropagation()
|
|
172
|
+
if (e.key === 'Enter') {
|
|
173
|
+
e.preventDefault()
|
|
174
|
+
save()
|
|
175
|
+
}
|
|
176
|
+
if (e.key === 'Escape') {
|
|
177
|
+
e.preventDefault()
|
|
178
|
+
cancel()
|
|
179
|
+
}
|
|
180
|
+
}}
|
|
181
|
+
ref={inputRef}
|
|
182
|
+
style={{
|
|
183
|
+
width: `${Math.max((value || zone?.name || '').length + 1, 4)}ch`,
|
|
184
|
+
border: 'none',
|
|
185
|
+
borderBottom: `1px solid ${shadowColor}`,
|
|
186
|
+
background: 'transparent',
|
|
187
|
+
color: 'white',
|
|
188
|
+
textShadow,
|
|
189
|
+
outline: 'none',
|
|
190
|
+
padding: 0,
|
|
191
|
+
margin: 0,
|
|
192
|
+
fontSize: 'inherit',
|
|
193
|
+
lineHeight: 'inherit',
|
|
194
|
+
fontFamily: 'inherit',
|
|
195
|
+
textAlign: 'center',
|
|
196
|
+
}}
|
|
197
|
+
type="text"
|
|
198
|
+
value={value}
|
|
199
|
+
/>
|
|
200
|
+
<button
|
|
201
|
+
onClick={(e) => {
|
|
202
|
+
e.stopPropagation()
|
|
203
|
+
save()
|
|
204
|
+
}}
|
|
205
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
206
|
+
style={{
|
|
207
|
+
background: 'none',
|
|
208
|
+
border: 'none',
|
|
209
|
+
color: 'white',
|
|
210
|
+
cursor: 'pointer',
|
|
211
|
+
padding: 0,
|
|
212
|
+
display: 'inline-flex',
|
|
213
|
+
alignItems: 'center',
|
|
214
|
+
}}
|
|
215
|
+
type="button"
|
|
216
|
+
>
|
|
217
|
+
<Check size={12} />
|
|
218
|
+
</button>
|
|
219
|
+
</div>
|
|
220
|
+
) : (
|
|
221
|
+
<button
|
|
222
|
+
onClick={(e) => {
|
|
223
|
+
e.stopPropagation()
|
|
224
|
+
if (mode === 'delete') {
|
|
225
|
+
sfxEmitter.emit('sfx:structure-delete')
|
|
226
|
+
deleteNode(zoneId as AnyNodeId)
|
|
227
|
+
setSelection({ zoneId: null })
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
if (isSelected) {
|
|
231
|
+
// Already selected → enter text editing
|
|
232
|
+
enterTextEditing()
|
|
233
|
+
} else {
|
|
234
|
+
// Not selected → select zone + switch to zone mode
|
|
235
|
+
selectZone()
|
|
236
|
+
}
|
|
237
|
+
}}
|
|
238
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
239
|
+
onPointerEnter={(e) => {
|
|
240
|
+
if (mode === 'delete') {
|
|
241
|
+
useViewer.setState({ hoveredId: zoneId })
|
|
242
|
+
}
|
|
243
|
+
}}
|
|
244
|
+
onPointerLeave={() => {
|
|
245
|
+
if (mode === 'delete' && useViewer.getState().hoveredId === zoneId) {
|
|
246
|
+
useViewer.setState({ hoveredId: null })
|
|
247
|
+
}
|
|
248
|
+
}}
|
|
249
|
+
onPointerMove={
|
|
250
|
+
mode === 'delete'
|
|
251
|
+
? (e) => {
|
|
252
|
+
// Re-dispatch pointermove to the viewer container so DeleteCursorBadge tracks the cursor.
|
|
253
|
+
const viewerDiv = (e.currentTarget as HTMLElement).closest(
|
|
254
|
+
'.relative.overflow-hidden',
|
|
255
|
+
)
|
|
256
|
+
if (viewerDiv) {
|
|
257
|
+
viewerDiv.dispatchEvent(
|
|
258
|
+
new PointerEvent('pointermove', {
|
|
259
|
+
clientX: e.clientX,
|
|
260
|
+
clientY: e.clientY,
|
|
261
|
+
bubbles: true,
|
|
262
|
+
}),
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
: undefined
|
|
267
|
+
}
|
|
268
|
+
style={{
|
|
269
|
+
...sharedStyle,
|
|
270
|
+
background: 'none',
|
|
271
|
+
border: 'none',
|
|
272
|
+
cursor: 'pointer',
|
|
273
|
+
padding: 0,
|
|
274
|
+
}}
|
|
275
|
+
type="button"
|
|
276
|
+
>
|
|
277
|
+
<span>{zone?.name}</span>
|
|
278
|
+
{isSelected && (
|
|
279
|
+
<span
|
|
280
|
+
onClick={(e) => {
|
|
281
|
+
e.stopPropagation()
|
|
282
|
+
enterTextEditing()
|
|
283
|
+
}}
|
|
284
|
+
role="button"
|
|
285
|
+
style={{
|
|
286
|
+
display: 'inline-flex',
|
|
287
|
+
alignItems: 'center',
|
|
288
|
+
cursor: 'text',
|
|
289
|
+
filter: `drop-shadow(0 0 2px ${shadowColor})`,
|
|
290
|
+
}}
|
|
291
|
+
tabIndex={0}
|
|
292
|
+
>
|
|
293
|
+
<Pencil size={12} />
|
|
294
|
+
</span>
|
|
295
|
+
)}
|
|
296
|
+
</button>
|
|
297
|
+
),
|
|
298
|
+
labelEl,
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─── System: rendered in the main React tree (outside Canvas) ─────────────────
|
|
303
|
+
|
|
304
|
+
export function ZoneLabelEditorSystem() {
|
|
305
|
+
const zoneIds = useScene(
|
|
306
|
+
useShallow((s) =>
|
|
307
|
+
Object.values(s.nodes)
|
|
308
|
+
.filter((n) => n.type === 'zone')
|
|
309
|
+
.map((n) => n.id as ZoneNode['id']),
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<>
|
|
315
|
+
{zoneIds.map((id) => (
|
|
316
|
+
<ZoneLabelEditor key={id} zoneId={id} />
|
|
317
|
+
))}
|
|
318
|
+
</>
|
|
319
|
+
)
|
|
320
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { useFrame } from '@react-three/fiber'
|
|
4
|
+
import { type Group, MathUtils, type Mesh } from 'three'
|
|
5
|
+
import type { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
6
|
+
import useEditor from '../../../store/use-editor'
|
|
7
|
+
|
|
8
|
+
// Disable raycasting on zone geometry so clicks pass through to items underneath.
|
|
9
|
+
// Zone selection in the editor is handled exclusively via the HTML label overlay.
|
|
10
|
+
const noopRaycast = () => {}
|
|
11
|
+
|
|
12
|
+
export const ZoneSystem = () => {
|
|
13
|
+
useFrame((_, delta) => {
|
|
14
|
+
const structureLayer = useEditor.getState().structureLayer
|
|
15
|
+
const editorMode = useEditor.getState().mode
|
|
16
|
+
const selectedLevelId = useViewer.getState().selection.levelId
|
|
17
|
+
const selectedZoneId = useViewer.getState().selection.zoneId
|
|
18
|
+
const hoveredId = useViewer.getState().hoveredId
|
|
19
|
+
|
|
20
|
+
const zoneGeometryVisible = structureLayer === 'zones'
|
|
21
|
+
const zones = sceneRegistry.byType.zone || new Set()
|
|
22
|
+
const nodes = useScene.getState().nodes
|
|
23
|
+
const lerpSpeed = 10 * delta
|
|
24
|
+
|
|
25
|
+
zones.forEach((zoneId) => {
|
|
26
|
+
const obj = sceneRegistry.nodes.get(zoneId)
|
|
27
|
+
if (!obj) return
|
|
28
|
+
|
|
29
|
+
const zone = nodes[zoneId as ZoneNode['id']] as ZoneNode | undefined
|
|
30
|
+
|
|
31
|
+
const isOnSelectedLevel = zone?.parentId === selectedLevelId
|
|
32
|
+
const isSelected = zoneId === selectedZoneId
|
|
33
|
+
const isDeleteHovered = editorMode === 'delete' && hoveredId === zoneId
|
|
34
|
+
|
|
35
|
+
// Keep group visible (so <Html> labels stay active), hide/show meshes only.
|
|
36
|
+
// Show meshes when: in zone mode, selected, or delete-hovered.
|
|
37
|
+
if (!obj.visible) obj.visible = true
|
|
38
|
+
const meshVisible = zoneGeometryVisible || isSelected || isDeleteHovered
|
|
39
|
+
const targetOpacity = isSelected || isDeleteHovered ? 1 : zoneGeometryVisible ? 1 : 0
|
|
40
|
+
|
|
41
|
+
const walls = (obj as Group).getObjectByName('walls') as Mesh | undefined
|
|
42
|
+
if (walls) {
|
|
43
|
+
walls.visible = meshVisible
|
|
44
|
+
const material = walls.material as MeshBasicNodeMaterial
|
|
45
|
+
if (material?.userData?.uOpacity) {
|
|
46
|
+
material.userData.uOpacity.value = MathUtils.lerp(
|
|
47
|
+
material.userData.uOpacity.value,
|
|
48
|
+
targetOpacity,
|
|
49
|
+
lerpSpeed,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const floor = (obj as Group).getObjectByName('floor') as Mesh | undefined
|
|
55
|
+
if (floor) {
|
|
56
|
+
floor.visible = meshVisible
|
|
57
|
+
const material = floor.material as MeshBasicNodeMaterial
|
|
58
|
+
if (material?.userData?.uOpacity) {
|
|
59
|
+
material.userData.uOpacity.value = MathUtils.lerp(
|
|
60
|
+
material.userData.uOpacity.value,
|
|
61
|
+
targetOpacity,
|
|
62
|
+
lerpSpeed,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Disable raycasting once per zone object so geometry never intercepts clicks
|
|
68
|
+
if (!obj.userData.__raycastDisabled) {
|
|
69
|
+
obj.raycast = noopRaycast
|
|
70
|
+
obj.traverse((child) => {
|
|
71
|
+
child.raycast = noopRaycast
|
|
72
|
+
})
|
|
73
|
+
obj.userData.__raycastDisabled = true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Labels: always visible on the current level (regardless of mode)
|
|
77
|
+
const showLabel = !!selectedLevelId && isOnSelectedLevel
|
|
78
|
+
const labelOpacity = showLabel ? '1' : '0'
|
|
79
|
+
const labelEl = document.getElementById(`${zoneId}-label`)
|
|
80
|
+
if (labelEl && labelEl.style.opacity !== labelOpacity) {
|
|
81
|
+
labelEl.style.opacity = labelOpacity
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { useCallback } from 'react'
|
|
4
|
+
import { PolygonEditor } from '../shared/polygon-editor'
|
|
5
|
+
|
|
6
|
+
interface CeilingBoundaryEditorProps {
|
|
7
|
+
ceilingId: CeilingNode['id']
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Ceiling boundary editor - allows editing ceiling polygon vertices for a specific ceiling
|
|
12
|
+
* Uses the generic PolygonEditor component
|
|
13
|
+
*/
|
|
14
|
+
export const CeilingBoundaryEditor: React.FC<CeilingBoundaryEditorProps> = ({ ceilingId }) => {
|
|
15
|
+
const ceilingNode = useScene((state) => state.nodes[ceilingId])
|
|
16
|
+
const updateNode = useScene((state) => state.updateNode)
|
|
17
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
18
|
+
|
|
19
|
+
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
|
|
20
|
+
|
|
21
|
+
const handlePolygonChange = useCallback(
|
|
22
|
+
(newPolygon: Array<[number, number]>) => {
|
|
23
|
+
updateNode(ceilingId, { polygon: newPolygon })
|
|
24
|
+
// Re-assert selection so the ceiling stays selected after the edit
|
|
25
|
+
setSelection({ selectedIds: [ceilingId] })
|
|
26
|
+
},
|
|
27
|
+
[ceilingId, updateNode, setSelection],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if (!ceiling?.polygon || ceiling.polygon.length < 3) return null
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<PolygonEditor
|
|
34
|
+
color="#d4d4d4"
|
|
35
|
+
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
|
|
36
|
+
minVertices={3}
|
|
37
|
+
onPolygonChange={handlePolygonChange}
|
|
38
|
+
polygon={ceiling.polygon}
|
|
39
|
+
surfaceHeight={ceiling.height ?? 2.5}
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { useCallback } from 'react'
|
|
4
|
+
import { PolygonEditor } from '../shared/polygon-editor'
|
|
5
|
+
|
|
6
|
+
interface CeilingHoleEditorProps {
|
|
7
|
+
ceilingId: CeilingNode['id']
|
|
8
|
+
holeIndex: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Ceiling hole editor - allows editing a specific hole polygon within a ceiling
|
|
13
|
+
* Uses the generic PolygonEditor component
|
|
14
|
+
*/
|
|
15
|
+
export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId, holeIndex }) => {
|
|
16
|
+
const ceilingNode = useScene((state) => state.nodes[ceilingId])
|
|
17
|
+
const updateNode = useScene((state) => state.updateNode)
|
|
18
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
19
|
+
|
|
20
|
+
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
|
|
21
|
+
const holes = ceiling?.holes || []
|
|
22
|
+
const hole = holes[holeIndex]
|
|
23
|
+
|
|
24
|
+
const handlePolygonChange = useCallback(
|
|
25
|
+
(newPolygon: Array<[number, number]>) => {
|
|
26
|
+
const updatedHoles = [...holes]
|
|
27
|
+
updatedHoles[holeIndex] = newPolygon
|
|
28
|
+
updateNode(ceilingId, { holes: updatedHoles })
|
|
29
|
+
// Re-assert selection so the ceiling stays selected after the edit
|
|
30
|
+
setSelection({ selectedIds: [ceilingId] })
|
|
31
|
+
},
|
|
32
|
+
[ceilingId, holeIndex, holes, updateNode, setSelection],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if (!(ceiling && hole) || hole.length < 3) return null
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<PolygonEditor
|
|
39
|
+
color="#ef4444"
|
|
40
|
+
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes
|
|
41
|
+
minVertices={3}
|
|
42
|
+
onPolygonChange={handlePolygonChange}
|
|
43
|
+
polygon={hole}
|
|
44
|
+
surfaceHeight={ceiling.height ?? 2.5}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
}
|