@pascal-app/editor 0.5.1 → 0.6.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 (79) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +255 -34
  4. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  5. package/src/components/editor/floorplan-panel.tsx +1323 -713
  6. package/src/components/editor/index.tsx +2 -0
  7. package/src/components/editor/node-action-menu.tsx +14 -1
  8. package/src/components/editor/selection-manager.tsx +200 -8
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +319 -157
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  15. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  16. package/src/components/tools/door/door-tool.tsx +12 -0
  17. package/src/components/tools/door/move-door-tool.tsx +10 -0
  18. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  19. package/src/components/tools/fence/fence-drafting.ts +19 -7
  20. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  21. package/src/components/tools/fence/move-fence-tool.tsx +8 -0
  22. package/src/components/tools/item/move-tool.tsx +9 -0
  23. package/src/components/tools/item/placement-math.ts +14 -6
  24. package/src/components/tools/item/placement-strategies.ts +2 -2
  25. package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
  26. package/src/components/tools/roof/move-roof-tool.tsx +89 -28
  27. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  28. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  29. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  30. package/src/components/tools/stair/stair-tool.tsx +11 -3
  31. package/src/components/tools/tool-manager.tsx +12 -0
  32. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  33. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  34. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  35. package/src/components/tools/wall/wall-drafting.ts +331 -9
  36. package/src/components/tools/window/move-window-tool.tsx +10 -0
  37. package/src/components/tools/window/window-tool.tsx +12 -0
  38. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  39. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  40. package/src/components/ui/command-palette/index.tsx +0 -1
  41. package/src/components/ui/controls/material-picker.tsx +127 -94
  42. package/src/components/ui/controls/slider-control.tsx +28 -14
  43. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  44. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  45. package/src/components/ui/panels/door-panel.tsx +5 -5
  46. package/src/components/ui/panels/fence-panel.tsx +97 -12
  47. package/src/components/ui/panels/item-panel.tsx +5 -5
  48. package/src/components/ui/panels/panel-manager.tsx +31 -29
  49. package/src/components/ui/panels/reference-panel.tsx +5 -4
  50. package/src/components/ui/panels/roof-panel.tsx +91 -22
  51. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  52. package/src/components/ui/panels/slab-panel.tsx +63 -15
  53. package/src/components/ui/panels/stair-panel.tsx +173 -19
  54. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  55. package/src/components/ui/panels/wall-panel.tsx +159 -11
  56. package/src/components/ui/panels/window-panel.tsx +5 -7
  57. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
  58. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  59. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  60. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  61. package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
  62. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  63. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
  64. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  65. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  66. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  67. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  68. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
  69. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  70. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  71. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
  72. package/src/components/ui/viewer-toolbar.tsx +55 -2
  73. package/src/components/viewer-overlay.tsx +25 -19
  74. package/src/hooks/use-contextual-tools.ts +14 -13
  75. package/src/hooks/use-keyboard.ts +3 -2
  76. package/src/index.tsx +2 -1
  77. package/src/lib/history.ts +20 -0
  78. package/src/lib/sfx-player.ts +96 -13
  79. package/src/store/use-editor.tsx +118 -10
@@ -23,6 +23,7 @@ import {
23
23
  import { initSFXBus } from '../../lib/sfx-bus'
24
24
  import useEditor from '../../store/use-editor'
25
25
  import { CeilingSystem } from '../systems/ceiling/ceiling-system'
26
+ import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
26
27
  import { RoofEditSystem } from '../systems/roof/roof-edit-system'
27
28
  import { StairEditSystem } from '../systems/stair/stair-edit-system'
28
29
  import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
@@ -523,6 +524,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
523
524
  <ExportManager />
524
525
  {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
525
526
  <CeilingSystem />
527
+ <CeilingSelectionAffordanceSystem />
526
528
  <RoofEditSystem />
527
529
  <StairEditSystem />
528
530
  {!isLoading && !isFirstPersonMode && (
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { Icon } from '@iconify/react'
4
- import { Copy, Move, Trash2 } from 'lucide-react'
4
+ import { Copy, Move, Spline, Trash2 } from 'lucide-react'
5
5
  import type { MouseEventHandler, PointerEventHandler } from 'react'
6
6
 
7
7
  type NodeActionMenuProps = {
@@ -9,6 +9,7 @@ type NodeActionMenuProps = {
9
9
  onDelete?: MouseEventHandler<HTMLButtonElement>
10
10
  onDuplicate?: MouseEventHandler<HTMLButtonElement>
11
11
  onMove?: MouseEventHandler<HTMLButtonElement>
12
+ onCurve?: MouseEventHandler<HTMLButtonElement>
12
13
  onPointerDown?: PointerEventHandler<HTMLDivElement>
13
14
  onPointerUp?: PointerEventHandler<HTMLDivElement>
14
15
  onPointerEnter?: PointerEventHandler<HTMLDivElement>
@@ -20,6 +21,7 @@ export function NodeActionMenu({
20
21
  onDelete,
21
22
  onDuplicate,
22
23
  onMove,
24
+ onCurve,
23
25
  onPointerDown,
24
26
  onPointerUp,
25
27
  onPointerEnter,
@@ -44,6 +46,17 @@ export function NodeActionMenu({
44
46
  <Move className="h-4 w-4" />
45
47
  </button>
46
48
  )}
49
+ {onCurve && (
50
+ <button
51
+ aria-label="Curve"
52
+ className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
53
+ onClick={onCurve}
54
+ title="Curve"
55
+ type="button"
56
+ >
57
+ <Spline className="h-4 w-4" />
58
+ </button>
59
+ )}
47
60
  {onDuplicate && (
48
61
  <button
49
62
  aria-label="Duplicate"
@@ -5,16 +5,24 @@ import {
5
5
  emitter,
6
6
  type ItemNode,
7
7
  type NodeEvent,
8
+ type RoofEvent,
9
+ type RoofSegmentEvent,
8
10
  resolveLevelId,
9
11
  sceneRegistry,
12
+ type StairEvent,
13
+ type StairNode,
14
+ type StairSurfaceMaterialRole,
15
+ type StairSegmentEvent,
10
16
  useScene,
17
+ type WallEvent,
18
+ type WallSurfaceSide,
11
19
  } from '@pascal-app/core'
12
20
 
13
21
  import { useViewer } from '@pascal-app/viewer'
14
22
  import { useCallback, useEffect, useRef } from 'react'
15
- import { Color, type Material, type Mesh, type Object3D } from 'three'
23
+ import { Color, type BufferGeometry, type Material, type Mesh, type Object3D } from 'three'
16
24
  import { sfxEmitter } from '../../lib/sfx-bus'
17
- import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
25
+ import useEditor, { type MaterialTargetRole, type Phase, type StructureLayer } from './../../store/use-editor'
18
26
  import { boxSelectHandled } from '../tools/select/box-select-tool'
19
27
 
20
28
  const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -68,6 +76,123 @@ export const resolveBuildingId = (
68
76
  return null
69
77
  }
70
78
 
79
+ function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
80
+ const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
81
+ if (materialIndex === 1) return 'interior'
82
+ if (materialIndex === 2) return 'exterior'
83
+
84
+ const normalZ = event.normal?.[2]
85
+ const localZ = event.localPosition[2]
86
+ const thickness = event.node.thickness ?? 0.1
87
+
88
+ if (
89
+ normalZ === undefined ||
90
+ Math.abs(normalZ) < 0.65 ||
91
+ Math.abs(localZ) < Math.max(thickness * 0.2, 0.01)
92
+ ) {
93
+ return null
94
+ }
95
+
96
+ const hitFace = localZ >= 0 ? 'front' : 'back'
97
+ const semantic = hitFace === 'front' ? event.node.frontSide : event.node.backSide
98
+
99
+ if (semantic === 'interior' || semantic === 'exterior') {
100
+ return semantic
101
+ }
102
+
103
+ return hitFace === 'front' ? 'interior' : 'exterior'
104
+ }
105
+
106
+ function resolveStairMaterialTarget(
107
+ event: StairEvent | StairSegmentEvent,
108
+ ): StairSurfaceMaterialRole | null {
109
+ const hitObjectName = event.nativeEvent.object?.name ?? ''
110
+ const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
111
+
112
+ if (hitObjectName.startsWith('stair-railing')) {
113
+ return 'railing'
114
+ }
115
+
116
+ if (hitObjectName.startsWith('stair-side')) {
117
+ return 'side'
118
+ }
119
+
120
+ if (materialIndex === 0) {
121
+ return 'tread'
122
+ }
123
+
124
+ if (materialIndex === 1) {
125
+ return 'side'
126
+ }
127
+
128
+ const normalY = event.normal?.[1]
129
+ if (normalY !== undefined && normalY > 0.75) {
130
+ return 'tread'
131
+ }
132
+
133
+ if (normalY !== undefined && Math.abs(normalY) <= 0.75) {
134
+ return 'side'
135
+ }
136
+
137
+ return null
138
+ }
139
+
140
+ function resolveRoofMaterialTarget(
141
+ event: RoofEvent | RoofSegmentEvent,
142
+ ): 'top' | 'edge' | 'wall' | null {
143
+ const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
144
+ if (materialIndex === 3) return 'top'
145
+ if (materialIndex === 0) return 'edge'
146
+ if (materialIndex === 1 || materialIndex === 2) return 'wall'
147
+
148
+ const normalY = event.normal?.[1]
149
+ if (normalY !== undefined && normalY > 0.35) return 'top'
150
+ if (normalY !== undefined && Math.abs(normalY) <= 0.35) return 'edge'
151
+ if (normalY !== undefined && normalY < -0.35) return 'wall'
152
+
153
+ return null
154
+ }
155
+
156
+ function getEventObject(event: NodeEvent): Object3D {
157
+ const eventWithObject = event as NodeEvent & { object?: Object3D }
158
+ return eventWithObject.object ?? event.nativeEvent.object
159
+ }
160
+
161
+ function getIntersectionMaterialIndex(
162
+ object: Object3D,
163
+ faceIndex: number | undefined,
164
+ ): number | undefined {
165
+ if (faceIndex === undefined) return undefined
166
+
167
+ const geometry = (object as Mesh).geometry as BufferGeometry | undefined
168
+ if (!geometry || geometry.groups.length === 0) return undefined
169
+
170
+ const triangleStart = faceIndex * 3
171
+ const group = geometry.groups.find(
172
+ (entry) => triangleStart >= entry.start && triangleStart < entry.start + entry.count,
173
+ )
174
+
175
+ return group?.materialIndex
176
+ }
177
+
178
+ function setSelectedMaterialTargetForNode(
179
+ node: AnyNode,
180
+ role: MaterialTargetRole | null,
181
+ ) {
182
+ if (!role) {
183
+ const currentTarget = useEditor.getState().selectedMaterialTarget
184
+ if (currentTarget?.nodeId !== node.id) {
185
+ useEditor.getState().setSelectedMaterialTarget(null)
186
+ }
187
+ return
188
+ }
189
+
190
+ useEditor.getState().setSelectedMaterialTarget({
191
+ nodeId: node.id as AnyNodeId,
192
+ role,
193
+ })
194
+ }
195
+
71
196
  const HIGHLIGHT_PROFILES = {
72
197
  delete: {
73
198
  color: new Color('#dc2626'),
@@ -346,6 +471,8 @@ export const SelectionManager = () => {
346
471
  const clickHandledRef = useRef(false)
347
472
 
348
473
  const movingNode = useEditor((s) => s.movingNode)
474
+ const curvingWall = useEditor((s) => s.curvingWall)
475
+ const curvingFence = useEditor((s) => s.curvingFence)
349
476
 
350
477
  useEffect(() => {
351
478
  setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
@@ -384,7 +511,7 @@ export const SelectionManager = () => {
384
511
 
385
512
  useEffect(() => {
386
513
  if (mode !== 'select') return
387
- if (movingNode) return
514
+ if (movingNode || curvingWall || curvingFence) return
388
515
 
389
516
  const onClick = (event: NodeEvent) => {
390
517
  // Skip if box-select just completed (drag ended over a node)
@@ -438,6 +565,42 @@ export const SelectionManager = () => {
438
565
 
439
566
  activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
440
567
 
568
+ let nextMaterialTargetHandled = false
569
+
570
+ if (node.type === 'wall' && nodeToSelect.type === 'wall') {
571
+ setSelectedMaterialTargetForNode(
572
+ nodeToSelect,
573
+ resolveWallMaterialTarget(event as WallEvent),
574
+ )
575
+ nextMaterialTargetHandled = true
576
+ }
577
+
578
+ if (
579
+ (node.type === 'stair' || node.type === 'stair-segment') &&
580
+ nodeToSelect.type === 'stair'
581
+ ) {
582
+ setSelectedMaterialTargetForNode(
583
+ nodeToSelect,
584
+ resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent),
585
+ )
586
+ nextMaterialTargetHandled = true
587
+ }
588
+
589
+ if (
590
+ (node.type === 'roof' || node.type === 'roof-segment') &&
591
+ nodeToSelect.type === 'roof'
592
+ ) {
593
+ setSelectedMaterialTargetForNode(
594
+ nodeToSelect,
595
+ resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent),
596
+ )
597
+ nextMaterialTargetHandled = true
598
+ }
599
+
600
+ if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
601
+ useEditor.getState().setSelectedMaterialTarget(null)
602
+ }
603
+
441
604
  // Reset the handled flag after a short delay to allow grid:click to be ignored
442
605
  setTimeout(() => {
443
606
  clickHandledRef.current = false
@@ -470,6 +633,7 @@ export const SelectionManager = () => {
470
633
  const { phase, structureLayer } = useEditor.getState()
471
634
  const activeStrategy = SELECTION_STRATEGIES[phase]
472
635
  if (activeStrategy) activeStrategy.handleDeselect()
636
+ useEditor.getState().setSelectedMaterialTarget(null)
473
637
 
474
638
  // When deselecting from zone mode, return to structure select
475
639
  if (phase === 'structure' && structureLayer === 'zones') {
@@ -485,12 +649,12 @@ export const SelectionManager = () => {
485
649
  })
486
650
  emitter.off('grid:click', onGridClick)
487
651
  }
488
- }, [mode, movingNode])
652
+ }, [curvingFence, curvingWall, mode, movingNode])
489
653
 
490
654
  // Global double-click handler for auto-switching phases and cross-phase hover
491
655
  useEffect(() => {
492
656
  if (mode !== 'select') return
493
- if (movingNode) return
657
+ if (movingNode || curvingWall || curvingFence) return
494
658
 
495
659
  const onEnter = (event: NodeEvent) => {
496
660
  const node = event.node
@@ -619,7 +783,7 @@ export const SelectionManager = () => {
619
783
  emitter.off(`${type}:double-click` as any, onDoubleClick as any)
620
784
  })
621
785
  }
622
- }, [mode, movingNode])
786
+ }, [curvingFence, curvingWall, mode, movingNode])
623
787
 
624
788
  // Delete mode: click-to-delete (sledgehammer tool)
625
789
  useEffect(() => {
@@ -703,6 +867,12 @@ export const SelectionManager = () => {
703
867
  }
704
868
 
705
869
  const SelectionStateSync = () => {
870
+ const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
871
+ const setSelectedMaterialTarget = useEditor((s) => s.setSelectedMaterialTarget)
872
+ const singleSelectedId = useViewer((s) =>
873
+ s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
874
+ )
875
+
706
876
  useEffect(() => {
707
877
  return useScene.subscribe((state) => {
708
878
  const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection
@@ -731,6 +901,28 @@ const SelectionStateSync = () => {
731
901
  })
732
902
  }, [])
733
903
 
904
+ useEffect(() => {
905
+ if (!selectedMaterialTarget) return
906
+
907
+ if (!singleSelectedId) {
908
+ setSelectedMaterialTarget(null)
909
+ return
910
+ }
911
+
912
+ const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
913
+ if (
914
+ !selectedNode ||
915
+ (selectedNode.type !== 'wall' && selectedNode.type !== 'stair' && selectedNode.type !== 'roof')
916
+ ) {
917
+ setSelectedMaterialTarget(null)
918
+ return
919
+ }
920
+
921
+ if (selectedMaterialTarget.nodeId !== selectedNode.id) {
922
+ setSelectedMaterialTarget(null)
923
+ }
924
+ }, [selectedMaterialTarget, setSelectedMaterialTarget, singleSelectedId])
925
+
734
926
  return null
735
927
  }
736
928
 
@@ -919,13 +1111,13 @@ const EditorOutlinerSync = () => {
919
1111
  outliner.selectedObjects.length = 0
920
1112
  for (const id of idsToHighlight) {
921
1113
  const obj = sceneRegistry.nodes.get(id)
922
- if (obj) outliner.selectedObjects.push(obj)
1114
+ if (obj?.parent) outliner.selectedObjects.push(obj)
923
1115
  }
924
1116
 
925
1117
  outliner.hoveredObjects.length = 0
926
1118
  if (hoveredId) {
927
1119
  const obj = sceneRegistry.nodes.get(hoveredId)
928
- if (obj) outliner.hoveredObjects.push(obj)
1120
+ if (obj?.parent) outliner.hoveredObjects.push(obj)
929
1121
  }
930
1122
  }, [phase, previewSelectedIds, selection, hoveredId, outliner])
931
1123
 
@@ -20,12 +20,18 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
20
20
  }
21
21
 
22
22
  export function SiteEdgeLabels() {
23
- const rootNodeIds = useScene((state) => state.rootNodeIds)
24
- const nodes = useScene((state) => state.nodes)
23
+ // Narrow subscription to just the site node — subscribing to the full
24
+ // s.nodes dict re-rendered this on every wall/level mutation even though
25
+ // the site itself rarely changes.
26
+ const siteNode = useScene((state) => {
27
+ const firstRoot = state.rootNodeIds[0]
28
+ if (!firstRoot) return null
29
+ const node = state.nodes[firstRoot]
30
+ return node?.type === 'site' ? (node as SiteNode) : null
31
+ })
25
32
  const unit = useViewer((state) => state.unit)
26
33
  const theme = useViewer((state) => state.theme)
27
34
 
28
- const siteNode = rootNodeIds[0] ? (nodes[rootNodeIds[0]] as SiteNode) : null
29
35
  const siteNodeId = siteNode?.id
30
36
 
31
37
  const isNight = theme === 'dark'