@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,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
+ }