@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
@@ -5,17 +5,23 @@ import {
5
5
  type AnyNode,
6
6
  type AnyNodeId,
7
7
  type BuildingNode,
8
+ type CeilingNode,
8
9
  calculateLevelMiters,
9
10
  DoorNode,
10
11
  emitter,
11
12
  type GridEvent,
12
13
  type GuideNode,
13
14
  getScaledDimensions,
15
+ getWallChordFrame,
16
+ getWallCurveLength,
17
+ getWallMidpointHandlePoint,
14
18
  getWallPlanFootprint,
15
19
  type ItemNode,
16
20
  ItemNode as ItemNodeSchema,
21
+ isCurvedWall,
17
22
  type LevelNode,
18
23
  loadAssetUrl,
24
+ normalizeWallCurveOffset,
19
25
  type Point2D,
20
26
  type SiteNode,
21
27
  SlabNode,
@@ -46,7 +52,7 @@ import { createPortal } from 'react-dom'
46
52
  import { useShallow } from 'zustand/react/shallow'
47
53
  import { sfxEmitter } from '../../lib/sfx-bus'
48
54
  import { cn } from '../../lib/utils'
49
- import useEditor from '../../store/use-editor'
55
+ import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor'
50
56
  import { snapToHalf } from '../tools/item/placement-math'
51
57
  import {
52
58
  DEFAULT_STAIR_ATTACHMENT_SIDE,
@@ -153,6 +159,12 @@ type FloorplanViewport = {
153
159
  width: number
154
160
  }
155
161
 
162
+ function floorplanViewportEquals(a: FloorplanViewport | null, b: FloorplanViewport | null) {
163
+ if (a === b) return true
164
+ if (!(a && b)) return false
165
+ return a.centerX === b.centerX && a.centerY === b.centerY && a.width === b.width
166
+ }
167
+
156
168
  type SvgPoint = {
157
169
  x: number
158
170
  y: number
@@ -235,6 +247,12 @@ type WallEndpointDragState = {
235
247
  currentPoint: WallPlanPoint
236
248
  }
237
249
 
250
+ type WallCurveDragState = {
251
+ pointerId: number
252
+ wallId: WallNode['id']
253
+ currentCurveOffset: number
254
+ }
255
+
238
256
  const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const
239
257
 
240
258
  type GuideCorner = (typeof GUIDE_CORNERS)[number]
@@ -276,6 +294,11 @@ type WallEndpointDraft = {
276
294
  end: WallPlanPoint
277
295
  }
278
296
 
297
+ type WallCurveDraft = {
298
+ wallId: WallNode['id']
299
+ curveOffset: number
300
+ }
301
+
279
302
  type SlabBoundaryDraft = {
280
303
  slabId: SlabNode['id']
281
304
  polygon: WallPlanPoint[]
@@ -2176,7 +2199,7 @@ function getWallMeasurementOverlay(
2176
2199
  ): WallMeasurementOverlay | null {
2177
2200
  const dx = wall.end[0] - wall.start[0]
2178
2201
  const dz = wall.end[1] - wall.start[1]
2179
- const length = Math.hypot(dx, dz)
2202
+ const length = getWallCurveLength(wall)
2180
2203
 
2181
2204
  if (length < 0.1) {
2182
2205
  return null
@@ -2425,13 +2448,19 @@ function buildGridPath(
2425
2448
  function findClosestWallPoint(
2426
2449
  point: WallPlanPoint,
2427
2450
  walls: WallNode[],
2428
- maxDistance = 0.5,
2451
+ options?: {
2452
+ maxDistance?: number
2453
+ canUseWall?: (wall: WallNode) => boolean
2454
+ },
2429
2455
  ): {
2430
2456
  wall: WallNode
2431
2457
  point: WallPlanPoint
2432
2458
  t: number
2433
2459
  normal: [number, number, number]
2434
2460
  } | null {
2461
+ const maxDistance = options?.maxDistance ?? 0.5
2462
+ const canUseWall = options?.canUseWall
2463
+
2435
2464
  let best: {
2436
2465
  wall: WallNode
2437
2466
  point: WallPlanPoint
@@ -2441,6 +2470,10 @@ function findClosestWallPoint(
2441
2470
  let bestDistSq = maxDistance * maxDistance
2442
2471
 
2443
2472
  for (const wall of walls) {
2473
+ if (canUseWall && !canUseWall(wall)) {
2474
+ continue
2475
+ }
2476
+
2444
2477
  const [x1, z1] = wall.start
2445
2478
  const [x2, z2] = wall.end
2446
2479
  const dx = x2 - x1
@@ -4351,6 +4384,97 @@ const FloorplanWallEndpointLayer = memo(function FloorplanWallEndpointLayer({
4351
4384
  )
4352
4385
  })
4353
4386
 
4387
+ const FloorplanWallCurveHandleLayer = memo(function FloorplanWallCurveHandleLayer({
4388
+ curveHandles,
4389
+ hoveredHandleId,
4390
+ onHandleHoverChange,
4391
+ onWallCurvePointerDown,
4392
+ palette,
4393
+ }: {
4394
+ curveHandles: Array<{
4395
+ wall: WallNode
4396
+ point: WallPlanPoint
4397
+ isActive: boolean
4398
+ }>
4399
+ hoveredHandleId: string | null
4400
+ onHandleHoverChange: (handleId: string | null) => void
4401
+ onWallCurvePointerDown: (wall: WallNode, event: ReactPointerEvent<SVGCircleElement>) => void
4402
+ palette: FloorplanPalette
4403
+ }) {
4404
+ return (
4405
+ <>
4406
+ {curveHandles.map(({ wall, point, isActive }) => {
4407
+ const handleId = `curve:${wall.id}`
4408
+ const isHovered = hoveredHandleId === handleId
4409
+ const stroke = isActive ? palette.endpointHandleActiveStroke : palette.endpointHandleStroke
4410
+ const hoverStroke = isActive
4411
+ ? palette.endpointHandleActiveStroke
4412
+ : palette.endpointHandleHoverStroke
4413
+ const svgPoint = toSvgPlanPoint(point)
4414
+ const radius = isActive ? 0.16 : 0.14
4415
+
4416
+ return (
4417
+ <g
4418
+ key={handleId}
4419
+ onClick={(event) => {
4420
+ event.stopPropagation()
4421
+ }}
4422
+ onPointerEnter={() => onHandleHoverChange(handleId)}
4423
+ onPointerLeave={() => onHandleHoverChange(null)}
4424
+ >
4425
+ <circle
4426
+ cx={svgPoint.x}
4427
+ cy={svgPoint.y}
4428
+ fill="none"
4429
+ pointerEvents="none"
4430
+ r={radius}
4431
+ stroke={hoverStroke}
4432
+ strokeOpacity={isActive ? 0.24 : 0.16}
4433
+ strokeWidth={FLOORPLAN_ENDPOINT_HOVER_GLOW_STROKE_WIDTH}
4434
+ style={{
4435
+ opacity: isHovered ? 1 : 0,
4436
+ transition: FLOORPLAN_HOVER_TRANSITION,
4437
+ }}
4438
+ vectorEffect="non-scaling-stroke"
4439
+ />
4440
+ <circle
4441
+ cx={svgPoint.x}
4442
+ cy={svgPoint.y}
4443
+ fill={isActive ? palette.endpointHandleActiveFill : palette.endpointHandleFill}
4444
+ fillOpacity={0.96}
4445
+ pointerEvents="none"
4446
+ r={radius}
4447
+ stroke={stroke}
4448
+ strokeWidth="0.05"
4449
+ vectorEffect="non-scaling-stroke"
4450
+ />
4451
+ <circle
4452
+ cx={svgPoint.x}
4453
+ cy={svgPoint.y}
4454
+ fill={stroke}
4455
+ pointerEvents="none"
4456
+ r={0.045}
4457
+ vectorEffect="non-scaling-stroke"
4458
+ />
4459
+ <circle
4460
+ cx={svgPoint.x}
4461
+ cy={svgPoint.y}
4462
+ fill="transparent"
4463
+ onPointerDown={(event) => onWallCurvePointerDown(wall, event)}
4464
+ pointerEvents="all"
4465
+ r={radius}
4466
+ stroke="transparent"
4467
+ strokeWidth={FLOORPLAN_ENDPOINT_HIT_STROKE_WIDTH}
4468
+ style={{ cursor: EDITOR_CURSOR }}
4469
+ vectorEffect="non-scaling-stroke"
4470
+ />
4471
+ </g>
4472
+ )
4473
+ })}
4474
+ </>
4475
+ )
4476
+ })
4477
+
4354
4478
  const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
4355
4479
  hoveredHandleId,
4356
4480
  midpointHandles,
@@ -4537,149 +4661,449 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
4537
4661
  )
4538
4662
  })
4539
4663
 
4540
- export function FloorplanPanel() {
4541
- const viewportHostRef = useRef<HTMLDivElement>(null)
4542
- const svgRef = useRef<SVGSVGElement>(null)
4543
- const panStateRef = useRef<PanState | null>(null)
4544
- const guideInteractionRef = useRef<GuideInteractionState | null>(null)
4545
- const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null)
4546
- const wallEndpointDragRef = useRef<WallEndpointDragState | null>(null)
4547
- const siteBoundaryDraftRef = useRef<SiteBoundaryDraft | null>(null)
4548
- const slabBoundaryDraftRef = useRef<SlabBoundaryDraft | null>(null)
4549
- const zoneBoundaryDraftRef = useRef<ZoneBoundaryDraft | null>(null)
4550
- const gestureScaleRef = useRef(1)
4551
- const panelInteractionRef = useRef<PanelInteractionState | null>(null)
4552
- const panelBoundsRef = useRef<ViewportBounds | null>(null)
4553
- const containerRef = useRef<HTMLDivElement>(null)
4554
- const hasUserAdjustedViewportRef = useRef(false)
4555
- const previousLevelIdRef = useRef<string | null>(null)
4556
- const floorplanMarqueeSnapPointRef = useRef<WallPlanPoint | null>(null)
4557
- const levelId = useViewer((state) => state.selection.levelId)
4558
- const buildingId = useViewer((state) => state.selection.buildingId)
4559
- const selectedZoneId = useViewer((state) => state.selection.zoneId)
4560
- const selectedIds = useViewer((state) => state.selection.selectedIds)
4561
- const previewSelectedIds = useViewer((state) => state.previewSelectedIds)
4562
- const setSelection = useViewer((state) => state.setSelection)
4563
- const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
4564
- const theme = useViewer((state) => state.theme)
4565
- const unit = useViewer((state) => state.unit)
4566
- const showGrid = useViewer((state) => state.showGrid)
4567
- const showGuides = useViewer((state) => state.showGuides)
4568
- const setShowGuides = useViewer((state) => state.setShowGuides)
4569
- const catalogCategory = useEditor((state) => state.catalogCategory)
4570
- const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
4571
- const selectedItem = useEditor((state) => state.selectedItem)
4664
+ type FloorplanSiteKeyHandlerProps = {
4665
+ onRestoreGroundLevel: () => void
4666
+ }
4572
4667
 
4668
+ const FloorplanSiteKeyHandler = memo(function FloorplanSiteKeyHandler({
4669
+ onRestoreGroundLevel,
4670
+ }: FloorplanSiteKeyHandlerProps) {
4573
4671
  const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
4574
- const setFloorplanHovered = useEditor((state) => state.setFloorplanHovered)
4575
- const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
4576
- const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
4577
- const setMode = useEditor((state) => state.setMode)
4578
- const movingNode = useEditor((state) => state.movingNode)
4579
4672
  const phase = useEditor((state) => state.phase)
4580
- const mode = useEditor((state) => state.mode)
4581
- const setPhase = useEditor((state) => state.setPhase)
4582
- const setMovingNode = useEditor((state) => state.setMovingNode)
4583
- const structureLayer = useEditor((state) => state.structureLayer)
4584
- const setStructureLayer = useEditor((state) => state.setStructureLayer)
4585
- const setTool = useEditor((state) => state.setTool)
4586
- const tool = useEditor((state) => state.tool)
4587
- const deleteNode = useScene((state) => state.deleteNode)
4588
- const updateNode = useScene((state) => state.updateNode)
4589
- const levelNode = useScene((state) =>
4590
- levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
4591
- )
4592
- const currentBuildingId =
4593
- levelNode?.type === 'level' && levelNode.parentId
4594
- ? (levelNode.parentId as BuildingNode['id'])
4595
- : (buildingId as BuildingNode['id'] | null)
4596
- const buildingRotationY = useScene((state) => {
4597
- if (!currentBuildingId) return 0
4598
- const node = state.nodes[currentBuildingId]
4599
- return node?.type === 'building' ? (node.rotation[1] ?? 0) : 0
4600
- })
4601
- const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
4602
- const site = useScene((state) => {
4603
- for (const rootNodeId of state.rootNodeIds) {
4604
- const node = state.nodes[rootNodeId]
4605
- if (node?.type === 'site') {
4606
- return node as SiteNode
4673
+ const setFloorplanSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
4674
+
4675
+ useEffect(() => {
4676
+ const handleKeyDown = (event: KeyboardEvent) => {
4677
+ const target = event.target as HTMLElement | null
4678
+ const isEditableTarget =
4679
+ target instanceof HTMLInputElement ||
4680
+ target instanceof HTMLTextAreaElement ||
4681
+ Boolean(target?.isContentEditable)
4682
+
4683
+ if (
4684
+ isEditableTarget ||
4685
+ !isFloorplanHovered ||
4686
+ phase !== 'site' ||
4687
+ event.metaKey ||
4688
+ event.ctrlKey ||
4689
+ event.altKey ||
4690
+ event.key.toLowerCase() !== 'v'
4691
+ ) {
4692
+ return
4607
4693
  }
4694
+
4695
+ setFloorplanSelectionTool('click')
4696
+ onRestoreGroundLevel()
4608
4697
  }
4609
4698
 
4610
- return null
4611
- })
4612
- const floorplanLevels = useScene(
4613
- useShallow((state) => {
4614
- if (!currentBuildingId) {
4615
- return [] as LevelNode[]
4616
- }
4699
+ window.addEventListener('keydown', handleKeyDown, true)
4700
+ return () => {
4701
+ window.removeEventListener('keydown', handleKeyDown, true)
4702
+ }
4703
+ }, [isFloorplanHovered, onRestoreGroundLevel, phase, setFloorplanSelectionTool])
4617
4704
 
4618
- const buildingNode = state.nodes[currentBuildingId]
4619
- if (!buildingNode || buildingNode.type !== 'building') {
4620
- return [] as LevelNode[]
4621
- }
4705
+ return null
4706
+ })
4622
4707
 
4623
- return buildingNode.children
4624
- .map((childId) => state.nodes[childId])
4625
- .filter((node): node is LevelNode => node?.type === 'level')
4626
- .sort((a, b) => a.level - b.level)
4627
- }),
4628
- )
4629
- const walls = useScene(
4630
- useShallow((state) => {
4631
- if (!levelId) {
4632
- return [] as WallNode[]
4633
- }
4708
+ type FloorplanDuplicateHotkeyProps = {
4709
+ hasDuplicatable: boolean
4710
+ onDuplicateSelected: () => void
4711
+ }
4634
4712
 
4635
- const nextLevelNode = state.nodes[levelId]
4636
- if (!nextLevelNode || nextLevelNode.type !== 'level') {
4637
- return [] as WallNode[]
4638
- }
4713
+ const FloorplanDuplicateHotkey = memo(function FloorplanDuplicateHotkey({
4714
+ hasDuplicatable,
4715
+ onDuplicateSelected,
4716
+ }: FloorplanDuplicateHotkeyProps) {
4717
+ const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
4639
4718
 
4640
- return nextLevelNode.children
4641
- .map((childId) => state.nodes[childId])
4642
- .filter((node): node is WallNode => node?.type === 'wall')
4643
- }),
4644
- )
4645
- const openings = useScene(
4646
- useShallow((state) => {
4647
- if (!levelId) {
4648
- return [] as OpeningNode[]
4719
+ useEffect(() => {
4720
+ const handleKeyDown = (event: KeyboardEvent) => {
4721
+ if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'c') {
4722
+ return
4649
4723
  }
4650
4724
 
4651
- const nextLevelNode = state.nodes[levelId]
4652
- if (!nextLevelNode || nextLevelNode.type !== 'level') {
4653
- return [] as OpeningNode[]
4725
+ if (!(isFloorplanHovered && hasDuplicatable)) {
4726
+ return
4654
4727
  }
4655
4728
 
4656
- const nextWalls = nextLevelNode.children
4657
- .map((childId) => state.nodes[childId])
4658
- .filter((node): node is WallNode => node?.type === 'wall')
4729
+ const target = event.target as HTMLElement | null
4730
+ const isEditableTarget =
4731
+ target instanceof HTMLInputElement ||
4732
+ target instanceof HTMLTextAreaElement ||
4733
+ Boolean(target?.isContentEditable)
4659
4734
 
4660
- return nextWalls.flatMap((wall) =>
4661
- wall.children
4662
- .map((childId) => state.nodes[childId])
4663
- .filter((node): node is OpeningNode => node?.type === 'window' || node?.type === 'door'),
4664
- )
4665
- }),
4666
- )
4667
- const slabs = useScene(
4668
- useShallow((state) => {
4669
- if (!levelId) {
4670
- return [] as SlabNode[]
4735
+ if (isEditableTarget) {
4736
+ return
4671
4737
  }
4672
4738
 
4673
- const nextLevelNode = state.nodes[levelId]
4674
- if (!nextLevelNode || nextLevelNode.type !== 'level') {
4675
- return [] as SlabNode[]
4676
- }
4739
+ event.preventDefault()
4740
+ onDuplicateSelected()
4741
+ }
4677
4742
 
4678
- return nextLevelNode.children
4743
+ window.addEventListener('keydown', handleKeyDown, true)
4744
+ return () => {
4745
+ window.removeEventListener('keydown', handleKeyDown, true)
4746
+ }
4747
+ }, [hasDuplicatable, isFloorplanHovered, onDuplicateSelected])
4748
+
4749
+ return null
4750
+ })
4751
+
4752
+ type FloorplanActionMenuHandler = (event: ReactMouseEvent<HTMLButtonElement>) => void
4753
+
4754
+ type FloorplanActionMenuEntry = {
4755
+ position: SvgPoint | null
4756
+ onDelete: FloorplanActionMenuHandler
4757
+ onMove: FloorplanActionMenuHandler
4758
+ onDuplicate?: FloorplanActionMenuHandler
4759
+ }
4760
+
4761
+ type FloorplanActionMenuLayerProps = {
4762
+ item: FloorplanActionMenuEntry
4763
+ wall: FloorplanActionMenuEntry
4764
+ slab: FloorplanActionMenuEntry
4765
+ ceiling: FloorplanActionMenuEntry
4766
+ opening: FloorplanActionMenuEntry
4767
+ stair: FloorplanActionMenuEntry
4768
+ }
4769
+
4770
+ const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
4771
+ item,
4772
+ wall,
4773
+ slab,
4774
+ ceiling,
4775
+ opening,
4776
+ stair,
4777
+ }: FloorplanActionMenuLayerProps) {
4778
+ const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
4779
+ const movingNode = useEditor((state) => state.movingNode)
4780
+ const curvingWall = useEditor((state) => state.curvingWall)
4781
+ const curvingFence = useEditor((state) => state.curvingFence)
4782
+
4783
+ if (!isFloorplanHovered || movingNode || curvingWall || curvingFence) {
4784
+ return null
4785
+ }
4786
+
4787
+ const entries: FloorplanActionMenuEntry[] = [item, wall, slab, ceiling, opening, stair]
4788
+
4789
+ return (
4790
+ <>
4791
+ {entries.map((entry, index) =>
4792
+ entry.position ? (
4793
+ <div
4794
+ className="absolute z-30"
4795
+ key={index}
4796
+ style={{
4797
+ left: entry.position.x,
4798
+ top: entry.position.y,
4799
+ transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,
4800
+ }}
4801
+ >
4802
+ <NodeActionMenu
4803
+ onDelete={entry.onDelete}
4804
+ onDuplicate={entry.onDuplicate}
4805
+ onMove={entry.onMove}
4806
+ onPointerDown={(event) => event.stopPropagation()}
4807
+ onPointerUp={(event) => event.stopPropagation()}
4808
+ />
4809
+ </div>
4810
+ ) : null,
4811
+ )}
4812
+ </>
4813
+ )
4814
+ })
4815
+
4816
+ type FloorplanCursorIndicatorOverlayProps = {
4817
+ cursorPosition: SvgPoint | null
4818
+ cursorAnchorPosition: SvgPoint | null
4819
+ floorplanSelectionTool: FloorplanSelectionTool
4820
+ movingOpeningType: 'door' | 'window' | null
4821
+ isPanning: boolean
4822
+ cursorColor: string
4823
+ }
4824
+
4825
+ const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndicatorOverlay({
4826
+ cursorPosition,
4827
+ cursorAnchorPosition,
4828
+ floorplanSelectionTool,
4829
+ movingOpeningType,
4830
+ isPanning,
4831
+ cursorColor,
4832
+ }: FloorplanCursorIndicatorOverlayProps) {
4833
+ const mode = useEditor((state) => state.mode)
4834
+ const tool = useEditor((state) => state.tool)
4835
+ const structureLayer = useEditor((state) => state.structureLayer)
4836
+ const catalogCategory = useEditor((state) => state.catalogCategory)
4837
+
4838
+ const activeFloorplanToolConfig = useMemo(() => {
4839
+ if (movingOpeningType) {
4840
+ return structureTools.find((entry) => entry.id === movingOpeningType) ?? null
4841
+ }
4842
+
4843
+ if (mode !== 'build' || !tool) {
4844
+ return null
4845
+ }
4846
+
4847
+ if (tool === 'item' && catalogCategory) {
4848
+ return furnishTools.find((entry) => entry.catalogCategory === catalogCategory) ?? null
4849
+ }
4850
+
4851
+ return structureTools.find((entry) => entry.id === tool) ?? null
4852
+ }, [catalogCategory, mode, movingOpeningType, tool])
4853
+
4854
+ const indicator = useMemo<FloorplanCursorIndicator | null>(() => {
4855
+ if (activeFloorplanToolConfig) {
4856
+ return { kind: 'asset', iconSrc: activeFloorplanToolConfig.iconSrc }
4857
+ }
4858
+
4859
+ if (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') {
4860
+ return { kind: 'icon', icon: 'mdi:select-drag' }
4861
+ }
4862
+
4863
+ if (mode === 'delete') {
4864
+ return { kind: 'icon', icon: 'mdi:trash-can-outline' }
4865
+ }
4866
+
4867
+ return null
4868
+ }, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
4869
+
4870
+ const position = mode === 'delete' ? cursorPosition : cursorAnchorPosition
4871
+
4872
+ if (!(indicator && position) || isPanning) {
4873
+ return null
4874
+ }
4875
+
4876
+ return (
4877
+ <div
4878
+ aria-hidden="true"
4879
+ className="pointer-events-none absolute z-20"
4880
+ style={{ left: position.x, top: position.y }}
4881
+ >
4882
+ {mode === 'delete' ? (
4883
+ <div
4884
+ className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
4885
+ style={{
4886
+ boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${cursorColor}22`,
4887
+ transform: `translate(${FLOORPLAN_CURSOR_BADGE_OFFSET_X}px, ${FLOORPLAN_CURSOR_BADGE_OFFSET_Y}px)`,
4888
+ }}
4889
+ >
4890
+ {indicator.kind === 'asset' ? (
4891
+ <img
4892
+ alt=""
4893
+ aria-hidden="true"
4894
+ className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
4895
+ src={indicator.iconSrc}
4896
+ />
4897
+ ) : (
4898
+ <Icon
4899
+ aria-hidden="true"
4900
+ className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
4901
+ color={cursorColor}
4902
+ height={18}
4903
+ icon={indicator.icon}
4904
+ width={18}
4905
+ />
4906
+ )}
4907
+ </div>
4908
+ ) : (
4909
+ <>
4910
+ <div
4911
+ className="absolute top-0 left-1/2 w-px -translate-x-1/2 -translate-y-full"
4912
+ style={{
4913
+ backgroundColor: cursorColor,
4914
+ boxShadow: `0 0 12px ${cursorColor}55`,
4915
+ height: FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT,
4916
+ }}
4917
+ />
4918
+ <div
4919
+ className="absolute top-0 left-1/2 flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
4920
+ style={{
4921
+ transform: `translate(-50%, calc(-100% - ${FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT}px))`,
4922
+ }}
4923
+ >
4924
+ {indicator.kind === 'asset' ? (
4925
+ <img
4926
+ alt=""
4927
+ aria-hidden="true"
4928
+ className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
4929
+ src={indicator.iconSrc}
4930
+ />
4931
+ ) : (
4932
+ <Icon
4933
+ aria-hidden="true"
4934
+ className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
4935
+ color="white"
4936
+ height={18}
4937
+ icon={indicator.icon}
4938
+ width={18}
4939
+ />
4940
+ )}
4941
+ </div>
4942
+ </>
4943
+ )}
4944
+ </div>
4945
+ )
4946
+ })
4947
+
4948
+ export function FloorplanPanel() {
4949
+ const viewportHostRef = useRef<HTMLDivElement>(null)
4950
+ const svgRef = useRef<SVGSVGElement>(null)
4951
+ const panStateRef = useRef<PanState | null>(null)
4952
+ const guideInteractionRef = useRef<GuideInteractionState | null>(null)
4953
+ const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null)
4954
+ const wallEndpointDragRef = useRef<WallEndpointDragState | null>(null)
4955
+ const wallCurveDragRef = useRef<WallCurveDragState | null>(null)
4956
+ const siteBoundaryDraftRef = useRef<SiteBoundaryDraft | null>(null)
4957
+ const slabBoundaryDraftRef = useRef<SlabBoundaryDraft | null>(null)
4958
+ const zoneBoundaryDraftRef = useRef<ZoneBoundaryDraft | null>(null)
4959
+ const gestureScaleRef = useRef(1)
4960
+ const panelInteractionRef = useRef<PanelInteractionState | null>(null)
4961
+ const panelBoundsRef = useRef<ViewportBounds | null>(null)
4962
+ const containerRef = useRef<HTMLDivElement>(null)
4963
+ const hasUserAdjustedViewportRef = useRef(false)
4964
+ const previousLevelIdRef = useRef<string | null>(null)
4965
+ const floorplanMarqueeSnapPointRef = useRef<WallPlanPoint | null>(null)
4966
+ const levelId = useViewer((state) => state.selection.levelId)
4967
+ const buildingId = useViewer((state) => state.selection.buildingId)
4968
+ const selectedZoneId = useViewer((state) => state.selection.zoneId)
4969
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
4970
+ const previewSelectedIds = useViewer((state) => state.previewSelectedIds)
4971
+ const setSelection = useViewer((state) => state.setSelection)
4972
+ const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
4973
+ const theme = useViewer((state) => state.theme)
4974
+ const unit = useViewer((state) => state.unit)
4975
+ const showGrid = useViewer((state) => state.showGrid)
4976
+ const showGuides = useViewer((state) => state.showGuides)
4977
+ const setShowGuides = useViewer((state) => state.setShowGuides)
4978
+ const selectedItem = useEditor((state) => state.selectedItem)
4979
+
4980
+ const setFloorplanHovered = useEditor((state) => state.setFloorplanHovered)
4981
+ const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
4982
+ const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
4983
+ const setMode = useEditor((state) => state.setMode)
4984
+ const movingNode = useEditor((state) => state.movingNode)
4985
+ const curvingWall = useEditor((state) => state.curvingWall)
4986
+ const curvingFence = useEditor((state) => state.curvingFence)
4987
+ const phase = useEditor((state) => state.phase)
4988
+ const mode = useEditor((state) => state.mode)
4989
+ const setPhase = useEditor((state) => state.setPhase)
4990
+ const setMovingNode = useEditor((state) => state.setMovingNode)
4991
+ const structureLayer = useEditor((state) => state.structureLayer)
4992
+ const setStructureLayer = useEditor((state) => state.setStructureLayer)
4993
+ const setTool = useEditor((state) => state.setTool)
4994
+ const tool = useEditor((state) => state.tool)
4995
+ const deleteNode = useScene((state) => state.deleteNode)
4996
+ const updateNode = useScene((state) => state.updateNode)
4997
+ const levelNode = useScene((state) =>
4998
+ levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
4999
+ )
5000
+ const currentBuildingId =
5001
+ levelNode?.type === 'level' && levelNode.parentId
5002
+ ? (levelNode.parentId as BuildingNode['id'])
5003
+ : (buildingId as BuildingNode['id'] | null)
5004
+ const buildingRotationY = useScene((state) => {
5005
+ if (!currentBuildingId) return 0
5006
+ const node = state.nodes[currentBuildingId]
5007
+ return node?.type === 'building' ? (node.rotation[1] ?? 0) : 0
5008
+ })
5009
+ const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
5010
+ const site = useScene((state) => {
5011
+ for (const rootNodeId of state.rootNodeIds) {
5012
+ const node = state.nodes[rootNodeId]
5013
+ if (node?.type === 'site') {
5014
+ return node as SiteNode
5015
+ }
5016
+ }
5017
+
5018
+ return null
5019
+ })
5020
+ const floorplanLevels = useScene(
5021
+ useShallow((state) => {
5022
+ if (!currentBuildingId) {
5023
+ return [] as LevelNode[]
5024
+ }
5025
+
5026
+ const buildingNode = state.nodes[currentBuildingId]
5027
+ if (!buildingNode || buildingNode.type !== 'building') {
5028
+ return [] as LevelNode[]
5029
+ }
5030
+
5031
+ return buildingNode.children
5032
+ .map((childId) => state.nodes[childId])
5033
+ .filter((node): node is LevelNode => node?.type === 'level')
5034
+ .sort((a, b) => a.level - b.level)
5035
+ }),
5036
+ )
5037
+ const walls = useScene(
5038
+ useShallow((state) => {
5039
+ if (!levelId) {
5040
+ return [] as WallNode[]
5041
+ }
5042
+
5043
+ const nextLevelNode = state.nodes[levelId]
5044
+ if (!nextLevelNode || nextLevelNode.type !== 'level') {
5045
+ return [] as WallNode[]
5046
+ }
5047
+
5048
+ return nextLevelNode.children
5049
+ .map((childId) => state.nodes[childId])
5050
+ .filter((node): node is WallNode => node?.type === 'wall')
5051
+ }),
5052
+ )
5053
+ const openings = useScene(
5054
+ useShallow((state) => {
5055
+ if (!levelId) {
5056
+ return [] as OpeningNode[]
5057
+ }
5058
+
5059
+ const nextLevelNode = state.nodes[levelId]
5060
+ if (!nextLevelNode || nextLevelNode.type !== 'level') {
5061
+ return [] as OpeningNode[]
5062
+ }
5063
+
5064
+ const nextWalls = nextLevelNode.children
5065
+ .map((childId) => state.nodes[childId])
5066
+ .filter((node): node is WallNode => node?.type === 'wall')
5067
+
5068
+ return nextWalls.flatMap((wall) =>
5069
+ wall.children
5070
+ .map((childId) => state.nodes[childId])
5071
+ .filter((node): node is OpeningNode => node?.type === 'window' || node?.type === 'door'),
5072
+ )
5073
+ }),
5074
+ )
5075
+ const slabs = useScene(
5076
+ useShallow((state) => {
5077
+ if (!levelId) {
5078
+ return [] as SlabNode[]
5079
+ }
5080
+
5081
+ const nextLevelNode = state.nodes[levelId]
5082
+ if (!nextLevelNode || nextLevelNode.type !== 'level') {
5083
+ return [] as SlabNode[]
5084
+ }
5085
+
5086
+ return nextLevelNode.children
4679
5087
  .map((childId) => state.nodes[childId])
4680
5088
  .filter((node): node is SlabNode => node?.type === 'slab')
4681
5089
  }),
4682
5090
  )
5091
+ const ceilings = useScene(
5092
+ useShallow((state) => {
5093
+ if (!levelId) {
5094
+ return [] as CeilingNode[]
5095
+ }
5096
+
5097
+ const nextLevelNode = state.nodes[levelId]
5098
+ if (!nextLevelNode || nextLevelNode.type !== 'level') {
5099
+ return [] as CeilingNode[]
5100
+ }
5101
+
5102
+ return nextLevelNode.children
5103
+ .map((childId) => state.nodes[childId])
5104
+ .filter((node): node is CeilingNode => node?.type === 'ceiling')
5105
+ }),
5106
+ )
4683
5107
  const levelGuides = useScene(
4684
5108
  useShallow((state) => {
4685
5109
  if (!levelId) {
@@ -4741,6 +5165,7 @@ export function FloorplanPanel() {
4741
5165
  const [cursorPoint, setCursorPoint] = useState<WallPlanPoint | null>(null)
4742
5166
  const [floorplanCursorPosition, setFloorplanCursorPosition] = useState<SvgPoint | null>(null)
4743
5167
  const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null)
5168
+ const [wallCurveDraft, setWallCurveDraft] = useState<WallCurveDraft | null>(null)
4744
5169
  const [hoveredOpeningId, setHoveredOpeningId] = useState<OpeningNode['id'] | null>(null)
4745
5170
  const [hoveredWallId, setHoveredWallId] = useState<WallNode['id'] | null>(null)
4746
5171
  const [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null)
@@ -4748,6 +5173,7 @@ export function FloorplanPanel() {
4748
5173
  const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null)
4749
5174
  const [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null)
4750
5175
  const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)
5176
+ const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState<string | null>(null)
4751
5177
  const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState<string | null>(null)
4752
5178
  const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState<string | null>(null)
4753
5179
  const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState<string | null>(null)
@@ -4810,59 +5236,20 @@ export function FloorplanPanel() {
4810
5236
  }
4811
5237
 
4812
5238
  if (!(siteBoundaryDraft && siteBoundaryDraft.siteId === sitePolygonEntry.site.id)) {
4813
- return sitePolygonEntry
4814
- }
4815
-
4816
- const polygon = siteBoundaryDraft.polygon.map(toPoint2D)
4817
-
4818
- return {
4819
- ...sitePolygonEntry,
4820
- polygon,
4821
- points: formatPolygonPoints(polygon),
4822
- }
4823
- }, [siteBoundaryDraft, sitePolygonEntry])
4824
- const movingOpeningType =
4825
- movingNode?.type === 'door' || movingNode?.type === 'window' ? movingNode.type : null
4826
-
4827
- const activeFloorplanToolConfig = useMemo(() => {
4828
- if (movingOpeningType) {
4829
- return structureTools.find((entry) => entry.id === movingOpeningType) ?? null
4830
- }
4831
-
4832
- if (mode !== 'build' || !tool) {
4833
- return null
4834
- }
4835
-
4836
- if (tool === 'item' && catalogCategory) {
4837
- return furnishTools.find((entry) => entry.catalogCategory === catalogCategory) ?? null
4838
- }
4839
-
4840
- return structureTools.find((entry) => entry.id === tool) ?? null
4841
- }, [catalogCategory, mode, movingOpeningType, tool])
4842
- const activeFloorplanCursorIndicator = useMemo<FloorplanCursorIndicator | null>(() => {
4843
- if (activeFloorplanToolConfig) {
4844
- return {
4845
- kind: 'asset',
4846
- iconSrc: activeFloorplanToolConfig.iconSrc,
4847
- }
5239
+ return sitePolygonEntry
4848
5240
  }
4849
5241
 
4850
- if (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') {
4851
- return {
4852
- kind: 'icon',
4853
- icon: 'mdi:select-drag',
4854
- }
4855
- }
5242
+ const polygon = siteBoundaryDraft.polygon.map(toPoint2D)
4856
5243
 
4857
- if (mode === 'delete') {
4858
- return {
4859
- kind: 'icon',
4860
- icon: 'mdi:trash-can-outline',
4861
- }
5244
+ return {
5245
+ ...sitePolygonEntry,
5246
+ polygon,
5247
+ points: formatPolygonPoints(polygon),
4862
5248
  }
5249
+ }, [siteBoundaryDraft, sitePolygonEntry])
5250
+ const movingOpeningType =
5251
+ movingNode?.type === 'door' || movingNode?.type === 'window' ? movingNode.type : null
4863
5252
 
4864
- return null
4865
- }, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
4866
5253
  const visibleGuides = useMemo<GuideNode[]>(() => {
4867
5254
  if (!showGuides) {
4868
5255
  return []
@@ -4922,29 +5309,42 @@ export function FloorplanPanel() {
4922
5309
  [floorplanWalls],
4923
5310
  )
4924
5311
  const displayWallById = useMemo(() => {
4925
- if (!wallEndpointDraft) {
5312
+ if (!(wallEndpointDraft || wallCurveDraft)) {
4926
5313
  return wallById
4927
5314
  }
4928
5315
 
4929
- const wall = wallById.get(wallEndpointDraft.wallId)
4930
- if (!wall) {
4931
- return wallById
5316
+ const nextWallById = new Map(wallById)
5317
+
5318
+ if (wallEndpointDraft) {
5319
+ const wall = nextWallById.get(wallEndpointDraft.wallId)
5320
+ if (wall) {
5321
+ nextWallById.set(
5322
+ wall.id,
5323
+ buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end),
5324
+ )
5325
+ }
4932
5326
  }
4933
5327
 
4934
- const nextWallById = new Map(wallById)
4935
- nextWallById.set(
4936
- wall.id,
4937
- buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end),
4938
- )
5328
+ if (wallCurveDraft) {
5329
+ const wall = nextWallById.get(wallCurveDraft.wallId)
5330
+ if (wall) {
5331
+ nextWallById.set(wall.id, { ...wall, curveOffset: wallCurveDraft.curveOffset })
5332
+ }
5333
+ }
4939
5334
 
4940
5335
  return nextWallById
4941
- }, [wallById, wallEndpointDraft])
5336
+ }, [wallById, wallCurveDraft, wallEndpointDraft])
4942
5337
  const displayFloorplanWallById = useMemo(() => {
4943
- if (!wallEndpointDraft) {
5338
+ if (!(wallEndpointDraft || wallCurveDraft)) {
5339
+ return floorplanWallById
5340
+ }
5341
+
5342
+ const previewWallId = wallEndpointDraft?.wallId ?? wallCurveDraft?.wallId
5343
+ if (!previewWallId) {
4944
5344
  return floorplanWallById
4945
5345
  }
4946
5346
 
4947
- const previewWall = displayWallById.get(wallEndpointDraft.wallId)
5347
+ const previewWall = displayWallById.get(previewWallId)
4948
5348
  if (!previewWall) {
4949
5349
  return floorplanWallById
4950
5350
  }
@@ -4952,7 +5352,7 @@ export function FloorplanPanel() {
4952
5352
  const nextFloorplanWallById = new Map(floorplanWallById)
4953
5353
  nextFloorplanWallById.set(previewWall.id, getFloorplanWall(previewWall))
4954
5354
  return nextFloorplanWallById
4955
- }, [displayWallById, floorplanWallById, wallEndpointDraft])
5355
+ }, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft])
4956
5356
  const wallPolygons = useMemo(
4957
5357
  () =>
4958
5358
  walls.map((wall) => {
@@ -4967,11 +5367,16 @@ export function FloorplanPanel() {
4967
5367
  [floorplanWallById, wallMiterData, walls],
4968
5368
  )
4969
5369
  const displayWallPolygons = useMemo(() => {
4970
- if (!wallEndpointDraft) {
5370
+ if (!(wallEndpointDraft || wallCurveDraft)) {
4971
5371
  return wallPolygons
4972
5372
  }
4973
5373
 
4974
- const previewWall = displayWallById.get(wallEndpointDraft.wallId)
5374
+ const previewWallId = wallEndpointDraft?.wallId ?? wallCurveDraft?.wallId
5375
+ if (!previewWallId) {
5376
+ return wallPolygons
5377
+ }
5378
+
5379
+ const previewWall = displayWallById.get(previewWallId)
4975
5380
  if (!previewWall) {
4976
5381
  return wallPolygons
4977
5382
  }
@@ -4990,7 +5395,7 @@ export function FloorplanPanel() {
4990
5395
  }
4991
5396
  : entry,
4992
5397
  )
4993
- }, [displayWallById, wallEndpointDraft, wallPolygons])
5398
+ }, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons])
4994
5399
 
4995
5400
  const openingsPolygons = useMemo(
4996
5401
  () =>
@@ -5046,6 +5451,29 @@ export function FloorplanPanel() {
5046
5451
  : entry,
5047
5452
  )
5048
5453
  }, [slabBoundaryDraft, slabPolygons])
5454
+ const ceilingPolygons = useMemo(
5455
+ () =>
5456
+ ceilings.flatMap((ceiling) => {
5457
+ const polygon = toFloorplanPolygon(ceiling.polygon)
5458
+ if (polygon.length < 3) {
5459
+ return []
5460
+ }
5461
+
5462
+ const holes = (ceiling.holes ?? [])
5463
+ .map((hole) => toFloorplanPolygon(hole))
5464
+ .filter((hole) => hole.length >= 3)
5465
+
5466
+ return [
5467
+ {
5468
+ ceiling,
5469
+ polygon,
5470
+ holes,
5471
+ path: formatPolygonPath(polygon, holes),
5472
+ },
5473
+ ]
5474
+ }),
5475
+ [ceilings],
5476
+ )
5049
5477
  const zonePolygons = useMemo(
5050
5478
  () =>
5051
5479
  zones.flatMap((zone) => {
@@ -5176,6 +5604,13 @@ export function FloorplanPanel() {
5176
5604
 
5177
5605
  return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null
5178
5606
  }, [floorplanItemEntries, selectedIds])
5607
+ const selectedWallEntry = useMemo(() => {
5608
+ if (selectedIds.length !== 1) {
5609
+ return null
5610
+ }
5611
+
5612
+ return displayWallPolygons.find(({ wall }) => wall.id === selectedIds[0]) ?? null
5613
+ }, [displayWallPolygons, selectedIds])
5179
5614
  const selectedStairEntry = useMemo(() => {
5180
5615
  if (selectedIds.length !== 1) {
5181
5616
  return null
@@ -5192,6 +5627,13 @@ export function FloorplanPanel() {
5192
5627
 
5193
5628
  return displaySlabPolygons.find(({ slab }) => slab.id === selectedIds[0]) ?? null
5194
5629
  }, [displaySlabPolygons, selectedIds])
5630
+ const selectedCeilingEntry = useMemo(() => {
5631
+ if (selectedIds.length !== 1) {
5632
+ return null
5633
+ }
5634
+
5635
+ return ceilingPolygons.find(({ ceiling }) => ceiling.id === selectedIds[0]) ?? null
5636
+ }, [ceilingPolygons, selectedIds])
5195
5637
  const selectedZoneEntry = useMemo(() => {
5196
5638
  if (!selectedZoneId) {
5197
5639
  return null
@@ -5212,12 +5654,25 @@ export function FloorplanPanel() {
5212
5654
  const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive
5213
5655
  const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair'
5214
5656
  const isStairMoveActive = movingNode?.type === 'stair'
5657
+ const isSlabMoveActive = movingNode?.type === 'slab'
5658
+ const isCeilingMoveActive = movingNode?.type === 'ceiling'
5659
+ const isWallMoveActive = movingNode?.type === 'wall'
5660
+ const isWallCurveActive = curvingWall?.type === 'wall'
5661
+ const isFenceCurveActive = curvingFence?.type === 'fence'
5215
5662
  const isItemPlacementPreviewActive =
5216
5663
  (mode === 'build' && tool === 'item') || movingNode?.type === 'item'
5217
5664
  const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
5218
5665
  const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo
5219
5666
  const isFloorplanGridInteractionActive =
5220
- isStairBuildActive || isStairMoveActive || isFloorItemBuildActive || isFloorItemMoveActive
5667
+ isStairBuildActive ||
5668
+ isStairMoveActive ||
5669
+ isSlabMoveActive ||
5670
+ isCeilingMoveActive ||
5671
+ isWallMoveActive ||
5672
+ isWallCurveActive ||
5673
+ isFenceCurveActive ||
5674
+ isFloorItemBuildActive ||
5675
+ isFloorItemMoveActive
5221
5676
  const floorplanPreviewStairSegment = useMemo(
5222
5677
  () =>
5223
5678
  StairSegmentNodeSchema.parse({
@@ -5399,6 +5854,56 @@ export function FloorplanPanel() {
5399
5854
  shouldShowPersistentWallEndpointHandles,
5400
5855
  wallEndpointDraft,
5401
5856
  ])
5857
+ const wallCurveHandles = useMemo(() => {
5858
+ if (
5859
+ isOpeningPlacementActive ||
5860
+ movingNode ||
5861
+ mode !== 'select' ||
5862
+ floorplanSelectionTool !== 'click' ||
5863
+ !selectedWallEntry
5864
+ ) {
5865
+ return []
5866
+ }
5867
+
5868
+ const hasWallChildrenBlockingCurve = (selectedWallEntry.wall.children ?? []).some((childId) => {
5869
+ const childNode = levelDescendantNodeById.get(childId as AnyNodeId)
5870
+ if (!childNode) {
5871
+ return false
5872
+ }
5873
+
5874
+ if (childNode.type === 'door' || childNode.type === 'window') {
5875
+ return true
5876
+ }
5877
+
5878
+ if (childNode.type === 'item') {
5879
+ const attachTo = childNode.asset?.attachTo
5880
+ return attachTo === 'wall' || attachTo === 'wall-side'
5881
+ }
5882
+
5883
+ return false
5884
+ })
5885
+ if (hasWallChildrenBlockingCurve) {
5886
+ return []
5887
+ }
5888
+
5889
+ const centerPoint = getWallMidpointHandlePoint(selectedWallEntry.wall)
5890
+
5891
+ return [
5892
+ {
5893
+ wall: selectedWallEntry.wall,
5894
+ point: [centerPoint.x, centerPoint.y] as WallPlanPoint,
5895
+ isActive: wallCurveDraft?.wallId === selectedWallEntry.wall.id,
5896
+ },
5897
+ ]
5898
+ }, [
5899
+ floorplanSelectionTool,
5900
+ isOpeningPlacementActive,
5901
+ mode,
5902
+ movingNode,
5903
+ levelDescendantNodeById,
5904
+ selectedWallEntry,
5905
+ wallCurveDraft,
5906
+ ])
5402
5907
  const slabVertexHandles = useMemo(() => {
5403
5908
  if (!shouldShowSlabBoundaryHandles) {
5404
5909
  return []
@@ -5660,23 +6165,15 @@ export function FloorplanPanel() {
5660
6165
  if (levelChanged) {
5661
6166
  previousLevelIdRef.current = levelId ?? null
5662
6167
  hasUserAdjustedViewportRef.current = false
5663
- setViewport(fittedViewport)
6168
+ setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
5664
6169
  return
5665
6170
  }
5666
6171
 
5667
6172
  if (!hasUserAdjustedViewportRef.current) {
5668
- setViewport(fittedViewport)
6173
+ setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
5669
6174
  }
5670
6175
  }, [fittedViewport, levelId])
5671
6176
 
5672
- useEffect(() => {
5673
- if (!(phase === 'site' && levelNode?.type === 'level')) {
5674
- return
5675
- }
5676
-
5677
- setPhase('structure')
5678
- }, [levelNode, phase, setPhase])
5679
-
5680
6177
  const viewBox = useMemo(() => {
5681
6178
  const currentViewport = viewport ?? fittedViewport
5682
6179
  const width = currentViewport.width
@@ -5717,6 +6214,27 @@ export function FloorplanPanel() {
5717
6214
  : null,
5718
6215
  [selectedItemEntry, surfaceSize, viewBox],
5719
6216
  )
6217
+ const selectedSlabActionMenuPosition = useMemo(
6218
+ () =>
6219
+ selectedSlabEntry
6220
+ ? getFloorplanActionMenuPosition(selectedSlabEntry.polygon, viewBox, surfaceSize)
6221
+ : null,
6222
+ [selectedSlabEntry, surfaceSize, viewBox],
6223
+ )
6224
+ const selectedCeilingActionMenuPosition = useMemo(
6225
+ () =>
6226
+ selectedCeilingEntry
6227
+ ? getFloorplanActionMenuPosition(selectedCeilingEntry.polygon, viewBox, surfaceSize)
6228
+ : null,
6229
+ [selectedCeilingEntry, surfaceSize, viewBox],
6230
+ )
6231
+ const selectedWallActionMenuPosition = useMemo(
6232
+ () =>
6233
+ selectedWallEntry
6234
+ ? getFloorplanActionMenuPosition(selectedWallEntry.polygon, viewBox, surfaceSize)
6235
+ : null,
6236
+ [selectedWallEntry, surfaceSize, viewBox],
6237
+ )
5720
6238
  const selectedStairActionMenuPosition = useMemo(
5721
6239
  () =>
5722
6240
  selectedStairEntry
@@ -6198,6 +6716,11 @@ export function FloorplanPanel() {
6198
6716
  setWallEndpointDraft(null)
6199
6717
  setHoveredEndpointId(null)
6200
6718
  }, [])
6719
+ const clearWallCurveDrag = useCallback(() => {
6720
+ wallCurveDragRef.current = null
6721
+ setWallCurveDraft(null)
6722
+ setHoveredWallCurveHandleId(null)
6723
+ }, [])
6201
6724
  const clearSiteBoundaryInteraction = useCallback(() => {
6202
6725
  setSiteVertexDragState(null)
6203
6726
  setSiteBoundaryDraft(null)
@@ -6219,11 +6742,13 @@ export function FloorplanPanel() {
6219
6742
  clearSlabPlacementDraft()
6220
6743
  clearZonePlacementDraft()
6221
6744
  clearWallEndpointDrag()
6745
+ clearWallCurveDrag()
6222
6746
  clearSiteBoundaryInteraction()
6223
6747
  clearSlabBoundaryInteraction()
6224
6748
  clearZoneBoundaryInteraction()
6225
6749
  setCursorPoint(null)
6226
6750
  }, [
6751
+ clearWallCurveDrag,
6227
6752
  clearSiteBoundaryInteraction,
6228
6753
  clearSlabBoundaryInteraction,
6229
6754
  clearSlabPlacementDraft,
@@ -6430,51 +6955,85 @@ export function FloorplanPanel() {
6430
6955
  }
6431
6956
 
6432
6957
  const dragState = wallEndpointDragRef.current
6433
- if (!dragState || event.pointerId !== dragState.pointerId) {
6958
+ if (dragState && event.pointerId === dragState.pointerId) {
6959
+ event.preventDefault()
6960
+
6961
+ const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
6962
+ if (!planPoint) {
6963
+ return
6964
+ }
6965
+
6966
+ const snappedPoint = snapWallDraftPoint({
6967
+ point: planPoint,
6968
+ walls,
6969
+ start: dragState.fixedPoint,
6970
+ angleSnap: !shiftPressed,
6971
+ ignoreWallIds: [dragState.wallId],
6972
+ })
6973
+
6974
+ if (pointsEqual(dragState.currentPoint, snappedPoint)) {
6975
+ return
6976
+ }
6977
+
6978
+ dragState.currentPoint = snappedPoint
6979
+ setCursorPoint(snappedPoint)
6980
+ setWallEndpointDraft((previousDraft) => {
6981
+ const nextDraft = buildWallEndpointDraft(
6982
+ dragState.wallId,
6983
+ dragState.endpoint,
6984
+ dragState.fixedPoint,
6985
+ snappedPoint,
6986
+ )
6987
+
6988
+ if (
6989
+ !(
6990
+ previousDraft &&
6991
+ pointsEqual(previousDraft.start, nextDraft.start) &&
6992
+ pointsEqual(previousDraft.end, nextDraft.end)
6993
+ )
6994
+ ) {
6995
+ sfxEmitter.emit('sfx:grid-snap')
6996
+ }
6997
+
6998
+ return nextDraft
6999
+ })
7000
+ return
7001
+ }
7002
+
7003
+ const curveDragState = wallCurveDragRef.current
7004
+ if (!curveDragState || event.pointerId !== curveDragState.pointerId) {
6434
7005
  return
6435
7006
  }
6436
7007
 
6437
7008
  event.preventDefault()
6438
7009
 
6439
7010
  const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
6440
- if (!planPoint) {
7011
+ const wall = wallById.get(curveDragState.wallId)
7012
+ if (!(planPoint && wall)) {
6441
7013
  return
6442
7014
  }
6443
7015
 
6444
- const snappedPoint = snapWallDraftPoint({
6445
- point: planPoint,
6446
- walls,
6447
- start: dragState.fixedPoint,
6448
- angleSnap: !shiftPressed,
6449
- ignoreWallIds: [dragState.wallId],
6450
- })
7016
+ const chord = getWallChordFrame(wall)
7017
+ const snappedPoint: WallPlanPoint = shiftPressed
7018
+ ? planPoint
7019
+ : [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
7020
+ const rawCurveOffset = -(
7021
+ (snappedPoint[0] - chord.midpoint.x) * chord.normal.x +
7022
+ (snappedPoint[1] - chord.midpoint.y) * chord.normal.y
7023
+ )
7024
+ const nextCurveOffset = normalizeWallCurveOffset(
7025
+ wall,
7026
+ shiftPressed ? rawCurveOffset : snapToHalf(rawCurveOffset),
7027
+ )
6451
7028
 
6452
- if (pointsEqual(dragState.currentPoint, snappedPoint)) {
7029
+ if (curveDragState.currentCurveOffset === nextCurveOffset) {
6453
7030
  return
6454
7031
  }
6455
7032
 
6456
- dragState.currentPoint = snappedPoint
7033
+ curveDragState.currentCurveOffset = nextCurveOffset
7034
+ setWallCurveDraft({ wallId: wall.id, curveOffset: nextCurveOffset })
6457
7035
  setCursorPoint(snappedPoint)
6458
- setWallEndpointDraft((previousDraft) => {
6459
- const nextDraft = buildWallEndpointDraft(
6460
- dragState.wallId,
6461
- dragState.endpoint,
6462
- dragState.fixedPoint,
6463
- snappedPoint,
6464
- )
6465
-
6466
- if (
6467
- !(
6468
- previousDraft &&
6469
- pointsEqual(previousDraft.start, nextDraft.start) &&
6470
- pointsEqual(previousDraft.end, nextDraft.end)
6471
- )
6472
- ) {
6473
- sfxEmitter.emit('sfx:grid-snap')
6474
- }
6475
-
6476
- return nextDraft
6477
- })
7036
+ sfxEmitter.emit('sfx:grid-snap')
6478
7037
  }
6479
7038
 
6480
7039
  const commitGuideInteraction = (event: PointerEvent) => {
@@ -6559,6 +7118,26 @@ export function FloorplanPanel() {
6559
7118
  setCursorPoint(null)
6560
7119
  }
6561
7120
 
7121
+ const commitWallCurveDrag = (event: PointerEvent) => {
7122
+ const dragState = wallCurveDragRef.current
7123
+ if (!dragState || event.pointerId !== dragState.pointerId) {
7124
+ return
7125
+ }
7126
+
7127
+ const wall = wallById.get(dragState.wallId)
7128
+ if (wall) {
7129
+ const nextCurveOffset = normalizeWallCurveOffset(wall, dragState.currentCurveOffset)
7130
+ const currentCurveOffset = normalizeWallCurveOffset(wall, wall.curveOffset ?? 0)
7131
+ if (nextCurveOffset !== currentCurveOffset) {
7132
+ updateNode(wall.id, { curveOffset: nextCurveOffset })
7133
+ sfxEmitter.emit('sfx:structure-build')
7134
+ }
7135
+ }
7136
+
7137
+ clearWallCurveDrag()
7138
+ setCursorPoint(null)
7139
+ }
7140
+
6562
7141
  const cancelWallEndpointDrag = (event: PointerEvent) => {
6563
7142
  const dragState = wallEndpointDragRef.current
6564
7143
  if (!dragState || event.pointerId !== dragState.pointerId) {
@@ -6569,11 +7148,23 @@ export function FloorplanPanel() {
6569
7148
  setCursorPoint(null)
6570
7149
  }
6571
7150
 
7151
+ const cancelWallCurveDrag = (event: PointerEvent) => {
7152
+ const dragState = wallCurveDragRef.current
7153
+ if (!dragState || event.pointerId !== dragState.pointerId) {
7154
+ return
7155
+ }
7156
+
7157
+ clearWallCurveDrag()
7158
+ setCursorPoint(null)
7159
+ }
7160
+
6572
7161
  window.addEventListener('pointermove', handleWindowPointerMove)
6573
7162
  window.addEventListener('pointerup', commitGuideInteraction)
6574
7163
  window.addEventListener('pointercancel', cancelGuideInteraction)
6575
7164
  window.addEventListener('pointerup', commitWallEndpointDrag)
6576
7165
  window.addEventListener('pointercancel', cancelWallEndpointDrag)
7166
+ window.addEventListener('pointerup', commitWallCurveDrag)
7167
+ window.addEventListener('pointercancel', cancelWallCurveDrag)
6577
7168
 
6578
7169
  return () => {
6579
7170
  window.removeEventListener('pointermove', handleWindowPointerMove)
@@ -6581,8 +7172,11 @@ export function FloorplanPanel() {
6581
7172
  window.removeEventListener('pointercancel', cancelGuideInteraction)
6582
7173
  window.removeEventListener('pointerup', commitWallEndpointDrag)
6583
7174
  window.removeEventListener('pointercancel', cancelWallEndpointDrag)
7175
+ window.removeEventListener('pointerup', commitWallCurveDrag)
7176
+ window.removeEventListener('pointercancel', cancelWallCurveDrag)
6584
7177
  }
6585
7178
  }, [
7179
+ clearWallCurveDrag,
6586
7180
  clearGuideInteraction,
6587
7181
  clearWallEndpointDrag,
6588
7182
  getSvgPointFromClientPoint,
@@ -6596,7 +7190,8 @@ export function FloorplanPanel() {
6596
7190
 
6597
7191
  useEffect(() => {
6598
7192
  clearWallEndpointDrag()
6599
- }, [clearWallEndpointDrag, levelId])
7193
+ clearWallCurveDrag()
7194
+ }, [clearWallCurveDrag, clearWallEndpointDrag, levelId])
6600
7195
 
6601
7196
  useEffect(() => {
6602
7197
  if (shouldShowSiteBoundaryHandles) {
@@ -7066,7 +7661,9 @@ export function FloorplanPanel() {
7066
7661
  }
7067
7662
 
7068
7663
  if (isOpeningPlacementActive) {
7069
- const closest = findClosestWallPoint(planPoint, walls)
7664
+ const closest = findClosestWallPoint(planPoint, walls, {
7665
+ canUseWall: (wall) => !isCurvedWall(wall),
7666
+ })
7070
7667
  if (closest) {
7071
7668
  const dx = closest.wall.end[0] - closest.wall.start[0]
7072
7669
  const dz = closest.wall.end[1] - closest.wall.start[1]
@@ -7286,7 +7883,9 @@ export function FloorplanPanel() {
7286
7883
  }
7287
7884
 
7288
7885
  if (isOpeningPlacementActive) {
7289
- const closest = findClosestWallPoint(planPoint, walls)
7886
+ const closest = findClosestWallPoint(planPoint, walls, {
7887
+ canUseWall: (wall) => !isCurvedWall(wall),
7888
+ })
7290
7889
  if (closest) {
7291
7890
  const dx = closest.wall.end[0] - closest.wall.start[0]
7292
7891
  const dz = closest.wall.end[1] - closest.wall.start[1]
@@ -7373,7 +7972,9 @@ export function FloorplanPanel() {
7373
7972
  isOpeningPlacementActive,
7374
7973
  isPolygonBuildActive,
7375
7974
  isWallBuildActive,
7975
+ isWindowBuildActive,
7376
7976
  isZoneBuildActive,
7977
+ movingOpeningType,
7377
7978
  setSelectedReferenceId,
7378
7979
  setSelection,
7379
7980
  shiftPressed,
@@ -8095,6 +8696,96 @@ export function FloorplanPanel() {
8095
8696
  },
8096
8697
  [deleteNode, selectedItemEntry, setSelection],
8097
8698
  )
8699
+ const handleSelectedWallMove = useCallback(
8700
+ (event: ReactMouseEvent<HTMLButtonElement>) => {
8701
+ event.stopPropagation()
8702
+
8703
+ const wall = selectedWallEntry?.wall
8704
+ if (!wall) {
8705
+ return
8706
+ }
8707
+
8708
+ sfxEmitter.emit('sfx:item-pick')
8709
+ setMovingNode(wall)
8710
+ setSelection({ selectedIds: [] })
8711
+ },
8712
+ [selectedWallEntry, setMovingNode, setSelection],
8713
+ )
8714
+ const handleSelectedWallDelete = useCallback(
8715
+ (event: ReactMouseEvent<HTMLButtonElement>) => {
8716
+ event.stopPropagation()
8717
+
8718
+ const wall = selectedWallEntry?.wall
8719
+ if (!wall) {
8720
+ return
8721
+ }
8722
+
8723
+ sfxEmitter.emit('sfx:item-delete')
8724
+ deleteNode(wall.id as AnyNodeId)
8725
+ setSelection({ selectedIds: [] })
8726
+ },
8727
+ [deleteNode, selectedWallEntry, setSelection],
8728
+ )
8729
+ const handleSelectedSlabMove = useCallback(
8730
+ (event: ReactMouseEvent<HTMLButtonElement>) => {
8731
+ event.stopPropagation()
8732
+
8733
+ const slab = selectedSlabEntry?.slab
8734
+ if (!slab) {
8735
+ return
8736
+ }
8737
+
8738
+ sfxEmitter.emit('sfx:item-pick')
8739
+ setMovingNode(slab)
8740
+ setSelection({ selectedIds: [] })
8741
+ },
8742
+ [selectedSlabEntry, setMovingNode, setSelection],
8743
+ )
8744
+ const handleSelectedSlabDelete = useCallback(
8745
+ (event: ReactMouseEvent<HTMLButtonElement>) => {
8746
+ event.stopPropagation()
8747
+
8748
+ const slab = selectedSlabEntry?.slab
8749
+ if (!slab) {
8750
+ return
8751
+ }
8752
+
8753
+ sfxEmitter.emit('sfx:item-delete')
8754
+ deleteNode(slab.id as AnyNodeId)
8755
+ setSelection({ selectedIds: [] })
8756
+ },
8757
+ [deleteNode, selectedSlabEntry, setSelection],
8758
+ )
8759
+ const handleSelectedCeilingMove = useCallback(
8760
+ (event: ReactMouseEvent<HTMLButtonElement>) => {
8761
+ event.stopPropagation()
8762
+
8763
+ const ceiling = selectedCeilingEntry?.ceiling
8764
+ if (!ceiling) {
8765
+ return
8766
+ }
8767
+
8768
+ sfxEmitter.emit('sfx:item-pick')
8769
+ setMovingNode(ceiling)
8770
+ setSelection({ selectedIds: [] })
8771
+ },
8772
+ [selectedCeilingEntry, setMovingNode, setSelection],
8773
+ )
8774
+ const handleSelectedCeilingDelete = useCallback(
8775
+ (event: ReactMouseEvent<HTMLButtonElement>) => {
8776
+ event.stopPropagation()
8777
+
8778
+ const ceiling = selectedCeilingEntry?.ceiling
8779
+ if (!ceiling) {
8780
+ return
8781
+ }
8782
+
8783
+ sfxEmitter.emit('sfx:item-delete')
8784
+ deleteNode(ceiling.id as AnyNodeId)
8785
+ setSelection({ selectedIds: [] })
8786
+ },
8787
+ [deleteNode, selectedCeilingEntry, setSelection],
8788
+ )
8098
8789
  const handleStairDoubleClick = useCallback(
8099
8790
  (stair: StairNode, event: ReactMouseEvent<SVGElement>) => {
8100
8791
  emitFloorplanNodeClick(stair.id, 'double-click', event)
@@ -8304,6 +8995,39 @@ export function FloorplanPanel() {
8304
8995
  },
8305
8996
  [clearWallPlacementDraft, handleWallPlacementPoint, handleWallSelect, isWallBuildActive, mode],
8306
8997
  )
8998
+ const handleWallCurvePointerDown = useCallback(
8999
+ (wall: WallNode, event: ReactPointerEvent<SVGCircleElement>) => {
9000
+ if (event.button !== 0) {
9001
+ return
9002
+ }
9003
+
9004
+ event.preventDefault()
9005
+ event.stopPropagation()
9006
+ setHoveredWallCurveHandleId(null)
9007
+
9008
+ if (isWallBuildActive || mode !== 'select') {
9009
+ return
9010
+ }
9011
+
9012
+ clearWallPlacementDraft()
9013
+ handleWallSelect(wall)
9014
+ clearWallEndpointDrag()
9015
+
9016
+ const currentCurveOffset = normalizeWallCurveOffset(wall, wall.curveOffset ?? 0)
9017
+ wallCurveDragRef.current = {
9018
+ pointerId: event.pointerId,
9019
+ wallId: wall.id,
9020
+ currentCurveOffset,
9021
+ }
9022
+ setWallCurveDraft({
9023
+ wallId: wall.id,
9024
+ curveOffset: currentCurveOffset,
9025
+ })
9026
+ const center = getWallMidpointHandlePoint(wall)
9027
+ setCursorPoint([center.x, center.y])
9028
+ },
9029
+ [clearWallEndpointDrag, clearWallPlacementDraft, handleWallSelect, isWallBuildActive, mode],
9030
+ )
8307
9031
  const handleSlabVertexPointerDown = useCallback(
8308
9032
  (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent<SVGCircleElement>) => {
8309
9033
  if (event.button !== 0) {
@@ -8650,10 +9374,20 @@ export function FloorplanPanel() {
8650
9374
  zoneVertexDragState,
8651
9375
  ])
8652
9376
 
9377
+ // Lightweight flag that mirrors the conditions under which
9378
+ // FloorplanCursorIndicatorOverlay renders — used to gate cursor-position
9379
+ // tracking. Derived locally here (rather than duplicating the overlay's full
9380
+ // useMemos) so this handler doesn't need to know about catalogCategory.
9381
+ const hasFloorplanCursorIndicator =
9382
+ Boolean(movingOpeningType) ||
9383
+ (mode === 'build' && tool !== null) ||
9384
+ (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') ||
9385
+ mode === 'delete'
9386
+
8653
9387
  const handleSvgPointerMove = useCallback(
8654
9388
  (event: ReactPointerEvent<SVGSVGElement>) => {
8655
9389
  if (
8656
- activeFloorplanCursorIndicator &&
9390
+ hasFloorplanCursorIndicator &&
8657
9391
  !panStateRef.current &&
8658
9392
  !guideInteractionRef.current &&
8659
9393
  !wallEndpointDragRef.current &&
@@ -8662,19 +9396,28 @@ export function FloorplanPanel() {
8662
9396
  !zoneVertexDragState
8663
9397
  ) {
8664
9398
  const rect = event.currentTarget.getBoundingClientRect()
8665
- setFloorplanCursorPosition({
9399
+ const nextPosition = {
8666
9400
  x: event.clientX - rect.left,
8667
9401
  y: event.clientY - rect.top,
8668
- })
9402
+ }
9403
+ setFloorplanCursorPosition((currentPosition) =>
9404
+ currentPosition &&
9405
+ currentPosition.x === nextPosition.x &&
9406
+ currentPosition.y === nextPosition.y
9407
+ ? currentPosition
9408
+ : nextPosition,
9409
+ )
8669
9410
  } else {
8670
- setFloorplanCursorPosition(null)
9411
+ setFloorplanCursorPosition((currentPosition) =>
9412
+ currentPosition === null ? currentPosition : null,
9413
+ )
8671
9414
  }
8672
9415
 
8673
9416
  handlePointerMove(event)
8674
9417
  },
8675
9418
  [
8676
- activeFloorplanCursorIndicator,
8677
9419
  handlePointerMove,
9420
+ hasFloorplanCursorIndicator,
8678
9421
  siteVertexDragState,
8679
9422
  slabVertexDragState,
8680
9423
  zoneVertexDragState,
@@ -9044,84 +9787,25 @@ export function FloorplanPanel() {
9044
9787
  setStructureLayer,
9045
9788
  site,
9046
9789
  ])
9047
- useEffect(() => {
9048
- const handleKeyDown = (event: KeyboardEvent) => {
9049
- const target = event.target as HTMLElement | null
9050
- const isEditableTarget =
9051
- target instanceof HTMLInputElement ||
9052
- target instanceof HTMLTextAreaElement ||
9053
- Boolean(target?.isContentEditable)
9054
-
9055
- if (
9056
- isEditableTarget ||
9057
- !isFloorplanHovered ||
9058
- phase !== 'site' ||
9059
- event.metaKey ||
9060
- event.ctrlKey ||
9061
- event.altKey ||
9062
- event.key.toLowerCase() !== 'v'
9063
- ) {
9064
- return
9065
- }
9066
-
9067
- setFloorplanSelectionTool('click')
9068
- restoreGroundLevelStructureSelection()
9069
- }
9070
-
9071
- window.addEventListener('keydown', handleKeyDown, true)
9072
-
9073
- return () => {
9074
- window.removeEventListener('keydown', handleKeyDown, true)
9790
+ const hasDuplicatableFloorplanSelection = Boolean(
9791
+ selectedItemEntry || selectedOpeningEntry || selectedStairEntry,
9792
+ )
9793
+ const handleDuplicateFloorplanSelection = useCallback(() => {
9794
+ if (selectedOpeningEntry) {
9795
+ duplicateSelectedOpening()
9796
+ return
9075
9797
  }
9076
- }, [isFloorplanHovered, phase, restoreGroundLevelStructureSelection])
9077
- useEffect(() => {
9078
- const handleKeyDown = (event: KeyboardEvent) => {
9079
- if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'c') {
9080
- return
9081
- }
9082
-
9083
- if (
9084
- !(isFloorplanHovered && (selectedItemEntry || selectedOpeningEntry || selectedStairEntry))
9085
- ) {
9086
- return
9087
- }
9088
-
9089
- const target = event.target as HTMLElement | null
9090
- const isEditableTarget =
9091
- target instanceof HTMLInputElement ||
9092
- target instanceof HTMLTextAreaElement ||
9093
- Boolean(target?.isContentEditable)
9094
-
9095
- if (isEditableTarget) {
9096
- return
9097
- }
9098
-
9099
- event.preventDefault()
9100
- if (selectedOpeningEntry) {
9101
- duplicateSelectedOpening()
9102
- return
9103
- }
9104
-
9105
- if (selectedItemEntry) {
9106
- duplicateSelectedItem()
9107
- return
9108
- }
9109
-
9110
- if (selectedStairEntry) {
9111
- duplicateSelectedStair()
9112
- }
9798
+ if (selectedItemEntry) {
9799
+ duplicateSelectedItem()
9800
+ return
9113
9801
  }
9114
-
9115
- window.addEventListener('keydown', handleKeyDown, true)
9116
-
9117
- return () => {
9118
- window.removeEventListener('keydown', handleKeyDown, true)
9802
+ if (selectedStairEntry) {
9803
+ duplicateSelectedStair()
9119
9804
  }
9120
9805
  }, [
9121
9806
  duplicateSelectedItem,
9122
9807
  duplicateSelectedOpening,
9123
9808
  duplicateSelectedStair,
9124
- isFloorplanHovered,
9125
9809
  selectedItemEntry,
9126
9810
  selectedOpeningEntry,
9127
9811
  selectedStairEntry,
@@ -9135,9 +9819,6 @@ export function FloorplanPanel() {
9135
9819
  : activeDraftAnchorPoint
9136
9820
  ? palette.draftStroke
9137
9821
  : palette.cursor
9138
- const activeCursorIndicatorPosition =
9139
- mode === 'delete' ? floorplanCursorPosition : floorplanCursorAnchorPosition
9140
-
9141
9822
  return (
9142
9823
  <div
9143
9824
  className="pointer-events-auto flex h-full w-full flex-col overflow-hidden bg-background/95"
@@ -9148,80 +9829,20 @@ export function FloorplanPanel() {
9148
9829
  }}
9149
9830
  ref={containerRef}
9150
9831
  >
9832
+ <FloorplanSiteKeyHandler onRestoreGroundLevel={restoreGroundLevelStructureSelection} />
9833
+ <FloorplanDuplicateHotkey
9834
+ hasDuplicatable={hasDuplicatableFloorplanSelection}
9835
+ onDuplicateSelected={handleDuplicateFloorplanSelection}
9836
+ />
9151
9837
  <div className="relative min-h-0 flex-1" ref={viewportHostRef}>
9152
- {activeFloorplanCursorIndicator && activeCursorIndicatorPosition && !isPanning && (
9153
- <div
9154
- aria-hidden="true"
9155
- className="pointer-events-none absolute z-20"
9156
- style={{
9157
- left: activeCursorIndicatorPosition.x,
9158
- top: activeCursorIndicatorPosition.y,
9159
- }}
9160
- >
9161
- {mode === 'delete' ? (
9162
- <div
9163
- className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
9164
- style={{
9165
- boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${floorplanCursorColor}22`,
9166
- transform: `translate(${FLOORPLAN_CURSOR_BADGE_OFFSET_X}px, ${FLOORPLAN_CURSOR_BADGE_OFFSET_Y}px)`,
9167
- }}
9168
- >
9169
- {activeFloorplanCursorIndicator.kind === 'asset' ? (
9170
- <img
9171
- alt=""
9172
- aria-hidden="true"
9173
- className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
9174
- src={activeFloorplanCursorIndicator.iconSrc}
9175
- />
9176
- ) : (
9177
- <Icon
9178
- aria-hidden="true"
9179
- className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
9180
- color={floorplanCursorColor}
9181
- height={18}
9182
- icon={activeFloorplanCursorIndicator.icon}
9183
- width={18}
9184
- />
9185
- )}
9186
- </div>
9187
- ) : (
9188
- <>
9189
- <div
9190
- className="absolute top-0 left-1/2 w-px -translate-x-1/2 -translate-y-full"
9191
- style={{
9192
- backgroundColor: floorplanCursorColor,
9193
- boxShadow: `0 0 12px ${floorplanCursorColor}55`,
9194
- height: FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT,
9195
- }}
9196
- />
9197
- <div
9198
- className="absolute top-0 left-1/2 flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
9199
- style={{
9200
- transform: `translate(-50%, calc(-100% - ${FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT}px))`,
9201
- }}
9202
- >
9203
- {activeFloorplanCursorIndicator.kind === 'asset' ? (
9204
- <img
9205
- alt=""
9206
- aria-hidden="true"
9207
- className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
9208
- src={activeFloorplanCursorIndicator.iconSrc}
9209
- />
9210
- ) : (
9211
- <Icon
9212
- aria-hidden="true"
9213
- className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
9214
- color="white"
9215
- height={18}
9216
- icon={activeFloorplanCursorIndicator.icon}
9217
- width={18}
9218
- />
9219
- )}
9220
- </div>
9221
- </>
9222
- )}
9223
- </div>
9224
- )}
9838
+ <FloorplanCursorIndicatorOverlay
9839
+ cursorAnchorPosition={floorplanCursorAnchorPosition}
9840
+ cursorColor={floorplanCursorColor}
9841
+ cursorPosition={floorplanCursorPosition}
9842
+ floorplanSelectionTool={floorplanSelectionTool}
9843
+ isPanning={isPanning}
9844
+ movingOpeningType={movingOpeningType}
9845
+ />
9225
9846
  {showGuides && canInteractWithGuides && selectedGuide && (
9226
9847
  <FloorplanGuideHandleHint
9227
9848
  anchor={guideHandleHintAnchor}
@@ -9230,60 +9851,41 @@ export function FloorplanPanel() {
9230
9851
  rotationModifierPressed={rotationModifierPressed}
9231
9852
  />
9232
9853
  )}
9233
- {selectedItemActionMenuPosition && isFloorplanHovered && !movingNode && (
9234
- <div
9235
- className="absolute z-30"
9236
- style={{
9237
- left: selectedItemActionMenuPosition.x,
9238
- top: selectedItemActionMenuPosition.y,
9239
- transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,
9240
- }}
9241
- >
9242
- <NodeActionMenu
9243
- onDelete={handleSelectedItemDelete}
9244
- onDuplicate={handleSelectedItemDuplicate}
9245
- onMove={handleSelectedItemMove}
9246
- onPointerDown={(event) => event.stopPropagation()}
9247
- onPointerUp={(event) => event.stopPropagation()}
9248
- />
9249
- </div>
9250
- )}
9251
- {selectedOpeningActionMenuPosition && isFloorplanHovered && !movingNode && (
9252
- <div
9253
- className="absolute z-30"
9254
- style={{
9255
- left: selectedOpeningActionMenuPosition.x,
9256
- top: selectedOpeningActionMenuPosition.y,
9257
- transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,
9258
- }}
9259
- >
9260
- <NodeActionMenu
9261
- onDelete={handleSelectedOpeningDelete}
9262
- onDuplicate={handleSelectedOpeningDuplicate}
9263
- onMove={handleSelectedOpeningMove}
9264
- onPointerDown={(event) => event.stopPropagation()}
9265
- onPointerUp={(event) => event.stopPropagation()}
9266
- />
9267
- </div>
9268
- )}
9269
- {selectedStairActionMenuPosition && isFloorplanHovered && !movingNode && (
9270
- <div
9271
- className="absolute z-30"
9272
- style={{
9273
- left: selectedStairActionMenuPosition.x,
9274
- top: selectedStairActionMenuPosition.y,
9275
- transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,
9276
- }}
9277
- >
9278
- <NodeActionMenu
9279
- onDelete={handleSelectedStairDelete}
9280
- onDuplicate={handleSelectedStairDuplicate}
9281
- onMove={handleSelectedStairMove}
9282
- onPointerDown={(event) => event.stopPropagation()}
9283
- onPointerUp={(event) => event.stopPropagation()}
9284
- />
9285
- </div>
9286
- )}
9854
+ <FloorplanActionMenuLayer
9855
+ ceiling={{
9856
+ position: selectedCeilingActionMenuPosition,
9857
+ onDelete: handleSelectedCeilingDelete,
9858
+ onMove: handleSelectedCeilingMove,
9859
+ }}
9860
+ item={{
9861
+ position: selectedItemActionMenuPosition,
9862
+ onDelete: handleSelectedItemDelete,
9863
+ onDuplicate: handleSelectedItemDuplicate,
9864
+ onMove: handleSelectedItemMove,
9865
+ }}
9866
+ opening={{
9867
+ position: selectedOpeningActionMenuPosition,
9868
+ onDelete: handleSelectedOpeningDelete,
9869
+ onDuplicate: handleSelectedOpeningDuplicate,
9870
+ onMove: handleSelectedOpeningMove,
9871
+ }}
9872
+ slab={{
9873
+ position: selectedSlabActionMenuPosition,
9874
+ onDelete: handleSelectedSlabDelete,
9875
+ onMove: handleSelectedSlabMove,
9876
+ }}
9877
+ stair={{
9878
+ position: selectedStairActionMenuPosition,
9879
+ onDelete: handleSelectedStairDelete,
9880
+ onDuplicate: handleSelectedStairDuplicate,
9881
+ onMove: handleSelectedStairMove,
9882
+ }}
9883
+ wall={{
9884
+ position: selectedWallActionMenuPosition,
9885
+ onDelete: handleSelectedWallDelete,
9886
+ onMove: handleSelectedWallMove,
9887
+ }}
9888
+ />
9287
9889
 
9288
9890
  {!levelNode || levelNode.type !== 'level' ? (
9289
9891
  <div className="flex h-full items-center justify-center px-6 text-center text-muted-foreground text-sm">
@@ -9316,308 +9918,316 @@ export function FloorplanPanel() {
9316
9918
  <FloorplanGridLayer
9317
9919
  majorGridPath={majorGridPath}
9318
9920
  minorGridPath={minorGridPath}
9319
- palette={palette}
9320
- showGrid={showGrid}
9321
- />
9322
-
9323
- <FloorplanGuideLayer
9324
- activeGuideInteractionGuideId={activeGuideInteractionGuideId}
9325
- activeGuideInteractionMode={activeGuideInteractionMode}
9326
- guides={displayGuides}
9327
- isInteractive={canInteractWithGuides}
9328
- onGuideSelect={handleGuideSelect}
9329
- onGuideTranslateStart={handleGuideTranslateStart}
9330
- selectedGuideId={selectedGuideId}
9331
- />
9921
+ palette={palette}
9922
+ showGrid={showGrid}
9923
+ />
9332
9924
 
9333
- <FloorplanSiteLayer isEditing={isSiteEditActive} sitePolygon={visibleSitePolygon} />
9334
-
9335
- <FloorplanGeometryLayer
9336
- canFocusGeometry={canSelectElementFloorplanGeometry}
9337
- canSelectGeometry={canInteractElementFloorplanGeometry}
9338
- canSelectSlabs={canInteractFloorplanSlabs}
9339
- highlightedIdSet={highlightedFloorplanIdSet}
9340
- hoveredOpeningId={hoveredOpeningId}
9341
- hoveredSlabId={hoveredSlabId}
9342
- hoveredWallId={hoveredWallId}
9343
- isDeleteMode={isDeleteMode}
9344
- onOpeningDoubleClick={handleOpeningDoubleClick}
9345
- onOpeningHoverChange={handleOpeningHoverChange}
9346
- onOpeningPointerDown={handleOpeningPointerDown}
9347
- onOpeningSelect={handleOpeningSelect}
9348
- onSlabDoubleClick={handleSlabDoubleClick}
9349
- onSlabHoverChange={handleSlabHoverChange}
9350
- onSlabSelect={handleSlabSelect}
9351
- onWallClick={handleWallClick}
9352
- onWallDoubleClick={handleWallDoubleClick}
9353
- onWallHoverChange={handleWallHoverChange}
9354
- openingsPolygons={openingsPolygons}
9355
- palette={palette}
9356
- selectedIdSet={selectedIdSet}
9357
- slabPolygons={displaySlabPolygons}
9358
- unit={unit}
9359
- wallPolygons={displayWallPolygons}
9360
- />
9925
+ <FloorplanGuideLayer
9926
+ activeGuideInteractionGuideId={activeGuideInteractionGuideId}
9927
+ activeGuideInteractionMode={activeGuideInteractionMode}
9928
+ guides={displayGuides}
9929
+ isInteractive={canInteractWithGuides}
9930
+ onGuideSelect={handleGuideSelect}
9931
+ onGuideTranslateStart={handleGuideTranslateStart}
9932
+ selectedGuideId={selectedGuideId}
9933
+ />
9361
9934
 
9362
- <FloorplanZoneLayer
9363
- canSelectZones={canInteractFloorplanZones}
9364
- hoveredZoneId={hoveredZoneId}
9365
- isDeleteMode={isDeleteMode}
9366
- onZoneHoverChange={handleZoneHoverChange}
9367
- onZoneSelect={handleZoneSelect}
9368
- palette={palette}
9369
- selectedZoneId={selectedZoneId}
9370
- zonePolygons={visibleZonePolygons}
9371
- />
9935
+ <FloorplanSiteLayer isEditing={isSiteEditActive} sitePolygon={visibleSitePolygon} />
9936
+
9937
+ <FloorplanGeometryLayer
9938
+ canFocusGeometry={canSelectElementFloorplanGeometry}
9939
+ canSelectGeometry={canInteractElementFloorplanGeometry}
9940
+ canSelectSlabs={canInteractFloorplanSlabs}
9941
+ highlightedIdSet={highlightedFloorplanIdSet}
9942
+ hoveredOpeningId={hoveredOpeningId}
9943
+ hoveredSlabId={hoveredSlabId}
9944
+ hoveredWallId={hoveredWallId}
9945
+ isDeleteMode={isDeleteMode}
9946
+ onOpeningDoubleClick={handleOpeningDoubleClick}
9947
+ onOpeningHoverChange={handleOpeningHoverChange}
9948
+ onOpeningPointerDown={handleOpeningPointerDown}
9949
+ onOpeningSelect={handleOpeningSelect}
9950
+ onSlabDoubleClick={handleSlabDoubleClick}
9951
+ onSlabHoverChange={handleSlabHoverChange}
9952
+ onSlabSelect={handleSlabSelect}
9953
+ onWallClick={handleWallClick}
9954
+ onWallDoubleClick={handleWallDoubleClick}
9955
+ onWallHoverChange={handleWallHoverChange}
9956
+ openingsPolygons={openingsPolygons}
9957
+ palette={palette}
9958
+ selectedIdSet={selectedIdSet}
9959
+ slabPolygons={displaySlabPolygons}
9960
+ unit={unit}
9961
+ wallPolygons={displayWallPolygons}
9962
+ />
9372
9963
 
9373
- <FloorplanNodeLayer
9374
- canFocusItems={canFocusFloorplanItems}
9375
- canFocusStairs={canFocusFloorplanStairs}
9376
- canSelectItems={canSelectFloorplanItems}
9377
- canSelectStairs={canSelectFloorplanStairs}
9378
- highlightedIdSet={highlightedFloorplanIdSet}
9379
- hoveredItemId={hoveredItemId}
9380
- hoveredStairId={hoveredStairId}
9381
- isDeleteMode={isDeleteMode}
9382
- isFurnishContextActive={isFloorplanFurnishContextActive}
9383
- itemEntries={floorplanItemEntries}
9384
- onItemDoubleClick={handleItemDoubleClick}
9385
- onItemHoverChange={handleItemHoverChange}
9386
- onItemHoverEnter={handleFloorplanItemHoverEnter}
9387
- onItemPointerDown={handleItemPointerDown}
9388
- onItemSelect={handleItemSelect}
9389
- onStairDoubleClick={handleStairDoubleClick}
9390
- onStairHoverChange={handleStairHoverChange}
9391
- onStairHoverEnter={handleFloorplanStairHoverEnter}
9392
- onStairSelect={handleStairSelect}
9393
- palette={palette}
9394
- selectedIdSet={selectedIdSet}
9395
- stairEntries={renderedFloorplanStairEntries}
9396
- />
9964
+ <FloorplanZoneLayer
9965
+ canSelectZones={canInteractFloorplanZones}
9966
+ hoveredZoneId={hoveredZoneId}
9967
+ isDeleteMode={isDeleteMode}
9968
+ onZoneHoverChange={handleZoneHoverChange}
9969
+ onZoneSelect={handleZoneSelect}
9970
+ palette={palette}
9971
+ selectedZoneId={selectedZoneId}
9972
+ zonePolygons={visibleZonePolygons}
9973
+ />
9397
9974
 
9398
- {/* Zone labels: always visible so users can click to select zones from any mode */}
9399
- <FloorplanZoneLabelLayer
9400
- onLabelHoverChange={handleZoneHoverChange}
9401
- onZoneLabelClick={handleZoneLabelClick}
9402
- selectedZoneId={selectedZoneId}
9403
- svgRef={svgRef}
9404
- viewBox={viewBox}
9405
- zonePolygons={displayZonePolygons}
9406
- />
9975
+ <FloorplanNodeLayer
9976
+ canFocusItems={canFocusFloorplanItems}
9977
+ canFocusStairs={canFocusFloorplanStairs}
9978
+ canSelectItems={canSelectFloorplanItems}
9979
+ canSelectStairs={canSelectFloorplanStairs}
9980
+ highlightedIdSet={highlightedFloorplanIdSet}
9981
+ hoveredItemId={hoveredItemId}
9982
+ hoveredStairId={hoveredStairId}
9983
+ isDeleteMode={isDeleteMode}
9984
+ isFurnishContextActive={isFloorplanFurnishContextActive}
9985
+ itemEntries={floorplanItemEntries}
9986
+ onItemDoubleClick={handleItemDoubleClick}
9987
+ onItemHoverChange={handleItemHoverChange}
9988
+ onItemHoverEnter={handleFloorplanItemHoverEnter}
9989
+ onItemPointerDown={handleItemPointerDown}
9990
+ onItemSelect={handleItemSelect}
9991
+ onStairDoubleClick={handleStairDoubleClick}
9992
+ onStairHoverChange={handleStairHoverChange}
9993
+ onStairHoverEnter={handleFloorplanStairHoverEnter}
9994
+ onStairSelect={handleStairSelect}
9995
+ palette={palette}
9996
+ selectedIdSet={selectedIdSet}
9997
+ stairEntries={renderedFloorplanStairEntries}
9998
+ />
9407
9999
 
9408
- <FloorplanPolygonHandleLayer
9409
- hoveredHandleId={hoveredSiteHandleId}
9410
- midpointHandles={siteMidpointHandles}
9411
- onHandleHoverChange={setHoveredSiteHandleId}
9412
- onMidpointPointerDown={(nodeId, edgeIndex, event) =>
9413
- handleSiteMidpointPointerDown(nodeId as SiteNode['id'], edgeIndex, event)
9414
- }
9415
- onVertexDoubleClick={(nodeId, vertexIndex, event) =>
9416
- handleSiteVertexDoubleClick(nodeId as SiteNode['id'], vertexIndex, event)
9417
- }
9418
- onVertexPointerDown={(nodeId, vertexIndex, event) =>
9419
- handleSiteVertexPointerDown(nodeId as SiteNode['id'], vertexIndex, event)
9420
- }
9421
- palette={palette}
9422
- vertexHandles={siteVertexHandles}
9423
- />
10000
+ {/* Zone labels: always visible so users can click to select zones from any mode */}
10001
+ <FloorplanZoneLabelLayer
10002
+ onLabelHoverChange={handleZoneHoverChange}
10003
+ onZoneLabelClick={handleZoneLabelClick}
10004
+ selectedZoneId={selectedZoneId}
10005
+ svgRef={svgRef}
10006
+ viewBox={viewBox}
10007
+ zonePolygons={displayZonePolygons}
10008
+ />
9424
10009
 
9425
- {isMarqueeSelectionToolActive && (
9426
- <rect
9427
- fill="transparent"
9428
- height={viewBox.height}
9429
- onClick={(event) => {
9430
- event.preventDefault()
9431
- event.stopPropagation()
9432
- }}
9433
- onDoubleClick={(event) => {
9434
- event.preventDefault()
9435
- event.stopPropagation()
9436
- }}
9437
- onPointerCancel={handleMarqueePointerCancel}
9438
- onPointerDown={handleMarqueePointerDown}
9439
- onPointerMove={handleMarqueePointerMove}
9440
- onPointerUp={handleMarqueePointerUp}
9441
- style={{ cursor: EDITOR_CURSOR }}
9442
- width={viewBox.width}
9443
- x={viewBox.minX}
9444
- y={viewBox.minY}
10010
+ <FloorplanPolygonHandleLayer
10011
+ hoveredHandleId={hoveredSiteHandleId}
10012
+ midpointHandles={siteMidpointHandles}
10013
+ onHandleHoverChange={setHoveredSiteHandleId}
10014
+ onMidpointPointerDown={(nodeId, edgeIndex, event) =>
10015
+ handleSiteMidpointPointerDown(nodeId as SiteNode['id'], edgeIndex, event)
10016
+ }
10017
+ onVertexDoubleClick={(nodeId, vertexIndex, event) =>
10018
+ handleSiteVertexDoubleClick(nodeId as SiteNode['id'], vertexIndex, event)
10019
+ }
10020
+ onVertexPointerDown={(nodeId, vertexIndex, event) =>
10021
+ handleSiteVertexPointerDown(nodeId as SiteNode['id'], vertexIndex, event)
10022
+ }
10023
+ palette={palette}
10024
+ vertexHandles={siteVertexHandles}
9445
10025
  />
9446
- )}
9447
10026
 
9448
- {visibleSvgMarqueeBounds && (
9449
- <>
10027
+ {isMarqueeSelectionToolActive && (
9450
10028
  <rect
9451
- fill={palette.cursor}
9452
- fillOpacity={0.12}
9453
- height={visibleSvgMarqueeBounds.height}
9454
- pointerEvents="none"
9455
- stroke={palette.cursor}
9456
- strokeOpacity={0.26}
9457
- strokeWidth={FLOORPLAN_MARQUEE_GLOW_WIDTH}
10029
+ fill="transparent"
10030
+ height={viewBox.height}
10031
+ onClick={(event) => {
10032
+ event.preventDefault()
10033
+ event.stopPropagation()
10034
+ }}
10035
+ onDoubleClick={(event) => {
10036
+ event.preventDefault()
10037
+ event.stopPropagation()
10038
+ }}
10039
+ onPointerCancel={handleMarqueePointerCancel}
10040
+ onPointerDown={handleMarqueePointerDown}
10041
+ onPointerMove={handleMarqueePointerMove}
10042
+ onPointerUp={handleMarqueePointerUp}
10043
+ style={{ cursor: EDITOR_CURSOR }}
10044
+ width={viewBox.width}
10045
+ x={viewBox.minX}
10046
+ y={viewBox.minY}
10047
+ />
10048
+ )}
10049
+
10050
+ {visibleSvgMarqueeBounds && (
10051
+ <>
10052
+ <rect
10053
+ fill={palette.cursor}
10054
+ fillOpacity={0.12}
10055
+ height={visibleSvgMarqueeBounds.height}
10056
+ pointerEvents="none"
10057
+ stroke={palette.cursor}
10058
+ strokeOpacity={0.26}
10059
+ strokeWidth={FLOORPLAN_MARQUEE_GLOW_WIDTH}
10060
+ vectorEffect="non-scaling-stroke"
10061
+ width={visibleSvgMarqueeBounds.width}
10062
+ x={visibleSvgMarqueeBounds.x}
10063
+ y={visibleSvgMarqueeBounds.y}
10064
+ />
10065
+ <rect
10066
+ fill="none"
10067
+ height={visibleSvgMarqueeBounds.height}
10068
+ pointerEvents="none"
10069
+ stroke={palette.cursor}
10070
+ strokeOpacity={0.96}
10071
+ strokeWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH}
10072
+ vectorEffect="non-scaling-stroke"
10073
+ width={visibleSvgMarqueeBounds.width}
10074
+ x={visibleSvgMarqueeBounds.x}
10075
+ y={visibleSvgMarqueeBounds.y}
10076
+ />
10077
+ </>
10078
+ )}
10079
+
10080
+ {draftPolygon && (
10081
+ <polygon
10082
+ fill={palette.draftFill}
10083
+ fillOpacity={0.35}
10084
+ points={draftPolygonPoints ?? undefined}
10085
+ stroke={palette.draftStroke}
10086
+ strokeDasharray="0.24 0.12"
10087
+ strokeWidth="0.07"
9458
10088
  vectorEffect="non-scaling-stroke"
9459
- width={visibleSvgMarqueeBounds.width}
9460
- x={visibleSvgMarqueeBounds.x}
9461
- y={visibleSvgMarqueeBounds.y}
9462
10089
  />
9463
- <rect
10090
+ )}
10091
+
10092
+ {polygonDraftPolygonPoints && (
10093
+ <polygon
10094
+ fill={palette.draftFill}
10095
+ fillOpacity={0.2}
10096
+ points={polygonDraftPolygonPoints}
10097
+ stroke="none"
10098
+ />
10099
+ )}
10100
+
10101
+ {polygonDraftPolylinePoints && (
10102
+ <polyline
9464
10103
  fill="none"
9465
- height={visibleSvgMarqueeBounds.height}
9466
- pointerEvents="none"
9467
- stroke={palette.cursor}
9468
- strokeOpacity={0.96}
9469
- strokeWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH}
10104
+ points={polygonDraftPolylinePoints}
10105
+ stroke={palette.draftStroke}
10106
+ strokeLinecap="round"
10107
+ strokeLinejoin="round"
10108
+ strokeWidth="0.08"
9470
10109
  vectorEffect="non-scaling-stroke"
9471
- width={visibleSvgMarqueeBounds.width}
9472
- x={visibleSvgMarqueeBounds.x}
9473
- y={visibleSvgMarqueeBounds.y}
9474
10110
  />
9475
- </>
9476
- )}
10111
+ )}
9477
10112
 
9478
- {draftPolygon && (
9479
- <polygon
9480
- fill={palette.draftFill}
9481
- fillOpacity={0.35}
9482
- points={draftPolygonPoints ?? undefined}
9483
- stroke={palette.draftStroke}
9484
- strokeDasharray="0.24 0.12"
9485
- strokeWidth="0.07"
9486
- vectorEffect="non-scaling-stroke"
9487
- />
9488
- )}
10113
+ {polygonDraftClosingSegment && (
10114
+ <line
10115
+ stroke={palette.draftStroke}
10116
+ strokeDasharray="0.16 0.1"
10117
+ strokeLinecap="round"
10118
+ strokeOpacity={0.75}
10119
+ strokeWidth="0.05"
10120
+ vectorEffect="non-scaling-stroke"
10121
+ x1={polygonDraftClosingSegment.x1}
10122
+ x2={polygonDraftClosingSegment.x2}
10123
+ y1={polygonDraftClosingSegment.y1}
10124
+ y2={polygonDraftClosingSegment.y2}
10125
+ />
10126
+ )}
9489
10127
 
9490
- {polygonDraftPolygonPoints && (
9491
- <polygon
9492
- fill={palette.draftFill}
9493
- fillOpacity={0.2}
9494
- points={polygonDraftPolygonPoints}
9495
- stroke="none"
9496
- />
9497
- )}
10128
+ {activePolygonDraftPoints.map((point, index) => (
10129
+ <circle
10130
+ cx={toSvgX(point[0])}
10131
+ cy={toSvgY(point[1])}
10132
+ fill={index === 0 ? palette.anchor : palette.draftStroke}
10133
+ fillOpacity={0.95}
10134
+ key={`polygon-draft-${index}`}
10135
+ pointerEvents="none"
10136
+ r={index === 0 ? 0.12 : 0.1}
10137
+ vectorEffect="non-scaling-stroke"
10138
+ />
10139
+ ))}
9498
10140
 
9499
- {polygonDraftPolylinePoints && (
9500
- <polyline
9501
- fill="none"
9502
- points={polygonDraftPolylinePoints}
9503
- stroke={palette.draftStroke}
9504
- strokeLinecap="round"
9505
- strokeLinejoin="round"
9506
- strokeWidth="0.08"
9507
- vectorEffect="non-scaling-stroke"
10141
+ <FloorplanWallEndpointLayer
10142
+ endpointHandles={wallEndpointHandles}
10143
+ hoveredEndpointId={hoveredEndpointId}
10144
+ onEndpointHoverChange={setHoveredEndpointId}
10145
+ onWallEndpointPointerDown={handleWallEndpointPointerDown}
10146
+ palette={palette}
9508
10147
  />
9509
- )}
9510
10148
 
9511
- {polygonDraftClosingSegment && (
9512
- <line
9513
- stroke={palette.draftStroke}
9514
- strokeDasharray="0.16 0.1"
9515
- strokeLinecap="round"
9516
- strokeOpacity={0.75}
9517
- strokeWidth="0.05"
9518
- vectorEffect="non-scaling-stroke"
9519
- x1={polygonDraftClosingSegment.x1}
9520
- x2={polygonDraftClosingSegment.x2}
9521
- y1={polygonDraftClosingSegment.y1}
9522
- y2={polygonDraftClosingSegment.y2}
10149
+ <FloorplanWallCurveHandleLayer
10150
+ curveHandles={wallCurveHandles}
10151
+ hoveredHandleId={hoveredWallCurveHandleId}
10152
+ onHandleHoverChange={setHoveredWallCurveHandleId}
10153
+ onWallCurvePointerDown={handleWallCurvePointerDown}
10154
+ palette={palette}
9523
10155
  />
9524
- )}
9525
10156
 
9526
- {activePolygonDraftPoints.map((point, index) => (
9527
- <circle
9528
- cx={toSvgX(point[0])}
9529
- cy={toSvgY(point[1])}
9530
- fill={index === 0 ? palette.anchor : palette.draftStroke}
9531
- fillOpacity={0.95}
9532
- key={`polygon-draft-${index}`}
9533
- pointerEvents="none"
9534
- r={index === 0 ? 0.12 : 0.1}
9535
- vectorEffect="non-scaling-stroke"
10157
+ <FloorplanPolygonHandleLayer
10158
+ hoveredHandleId={hoveredSlabHandleId}
10159
+ midpointHandles={slabMidpointHandles}
10160
+ onHandleHoverChange={setHoveredSlabHandleId}
10161
+ onMidpointPointerDown={(nodeId, edgeIndex, event) =>
10162
+ handleSlabMidpointPointerDown(nodeId as SlabNode['id'], edgeIndex, event)
10163
+ }
10164
+ onVertexDoubleClick={(nodeId, vertexIndex, event) =>
10165
+ handleSlabVertexDoubleClick(nodeId as SlabNode['id'], vertexIndex, event)
10166
+ }
10167
+ onVertexPointerDown={(nodeId, vertexIndex, event) =>
10168
+ handleSlabVertexPointerDown(nodeId as SlabNode['id'], vertexIndex, event)
10169
+ }
10170
+ palette={palette}
10171
+ vertexHandles={slabVertexHandles}
9536
10172
  />
9537
- ))}
9538
-
9539
- <FloorplanWallEndpointLayer
9540
- endpointHandles={wallEndpointHandles}
9541
- hoveredEndpointId={hoveredEndpointId}
9542
- onEndpointHoverChange={setHoveredEndpointId}
9543
- onWallEndpointPointerDown={handleWallEndpointPointerDown}
9544
- palette={palette}
9545
- />
9546
-
9547
- <FloorplanPolygonHandleLayer
9548
- hoveredHandleId={hoveredSlabHandleId}
9549
- midpointHandles={slabMidpointHandles}
9550
- onHandleHoverChange={setHoveredSlabHandleId}
9551
- onMidpointPointerDown={(nodeId, edgeIndex, event) =>
9552
- handleSlabMidpointPointerDown(nodeId as SlabNode['id'], edgeIndex, event)
9553
- }
9554
- onVertexDoubleClick={(nodeId, vertexIndex, event) =>
9555
- handleSlabVertexDoubleClick(nodeId as SlabNode['id'], vertexIndex, event)
9556
- }
9557
- onVertexPointerDown={(nodeId, vertexIndex, event) =>
9558
- handleSlabVertexPointerDown(nodeId as SlabNode['id'], vertexIndex, event)
9559
- }
9560
- palette={palette}
9561
- vertexHandles={slabVertexHandles}
9562
- />
9563
10173
 
9564
- <FloorplanPolygonHandleLayer
9565
- hoveredHandleId={hoveredZoneHandleId}
9566
- midpointHandles={zoneMidpointHandles}
9567
- onHandleHoverChange={setHoveredZoneHandleId}
9568
- onMidpointPointerDown={(nodeId, edgeIndex, event) =>
9569
- handleZoneMidpointPointerDown(nodeId as ZoneNodeType['id'], edgeIndex, event)
9570
- }
9571
- onVertexDoubleClick={(nodeId, vertexIndex, event) =>
9572
- handleZoneVertexDoubleClick(nodeId as ZoneNodeType['id'], vertexIndex, event)
9573
- }
9574
- onVertexPointerDown={(nodeId, vertexIndex, event) =>
9575
- handleZoneVertexPointerDown(nodeId as ZoneNodeType['id'], vertexIndex, event)
9576
- }
9577
- palette={palette}
9578
- vertexHandles={zoneVertexHandles}
9579
- />
9580
-
9581
- {selectedGuide && showGuides && (
9582
- <FloorplanGuideSelectionOverlay
9583
- guide={selectedGuide}
9584
- isDarkMode={theme === 'dark'}
9585
- onCornerHoverChange={setHoveredGuideCorner}
9586
- onCornerPointerDown={handleGuideCornerPointerDown}
9587
- rotationModifierPressed={rotationModifierPressed}
9588
- showHandles={canInteractWithGuides}
10174
+ <FloorplanPolygonHandleLayer
10175
+ hoveredHandleId={hoveredZoneHandleId}
10176
+ midpointHandles={zoneMidpointHandles}
10177
+ onHandleHoverChange={setHoveredZoneHandleId}
10178
+ onMidpointPointerDown={(nodeId, edgeIndex, event) =>
10179
+ handleZoneMidpointPointerDown(nodeId as ZoneNodeType['id'], edgeIndex, event)
10180
+ }
10181
+ onVertexDoubleClick={(nodeId, vertexIndex, event) =>
10182
+ handleZoneVertexDoubleClick(nodeId as ZoneNodeType['id'], vertexIndex, event)
10183
+ }
10184
+ onVertexPointerDown={(nodeId, vertexIndex, event) =>
10185
+ handleZoneVertexPointerDown(nodeId as ZoneNodeType['id'], vertexIndex, event)
10186
+ }
10187
+ palette={palette}
10188
+ vertexHandles={zoneVertexHandles}
9589
10189
  />
9590
- )}
9591
10190
 
9592
- {cursorPoint && (
9593
- <g>
9594
- <circle
9595
- cx={toSvgX(cursorPoint[0])}
9596
- cy={toSvgY(cursorPoint[1])}
9597
- fill={floorplanCursorColor}
9598
- fillOpacity={0.25}
9599
- r={FLOORPLAN_CURSOR_MARKER_GLOW_RADIUS}
10191
+ {selectedGuide && showGuides && (
10192
+ <FloorplanGuideSelectionOverlay
10193
+ guide={selectedGuide}
10194
+ isDarkMode={theme === 'dark'}
10195
+ onCornerHoverChange={setHoveredGuideCorner}
10196
+ onCornerPointerDown={handleGuideCornerPointerDown}
10197
+ rotationModifierPressed={rotationModifierPressed}
10198
+ showHandles={canInteractWithGuides}
9600
10199
  />
10200
+ )}
10201
+
10202
+ {cursorPoint && (
10203
+ <g>
10204
+ <circle
10205
+ cx={toSvgX(cursorPoint[0])}
10206
+ cy={toSvgY(cursorPoint[1])}
10207
+ fill={floorplanCursorColor}
10208
+ fillOpacity={0.25}
10209
+ r={FLOORPLAN_CURSOR_MARKER_GLOW_RADIUS}
10210
+ />
10211
+ <circle
10212
+ cx={toSvgX(cursorPoint[0])}
10213
+ cy={toSvgY(cursorPoint[1])}
10214
+ fill={floorplanCursorColor}
10215
+ fillOpacity={0.9}
10216
+ r={FLOORPLAN_CURSOR_MARKER_CORE_RADIUS}
10217
+ />
10218
+ </g>
10219
+ )}
10220
+
10221
+ {activeDraftAnchorPoint && (
9601
10222
  <circle
9602
- cx={toSvgX(cursorPoint[0])}
9603
- cy={toSvgY(cursorPoint[1])}
9604
- fill={floorplanCursorColor}
9605
- fillOpacity={0.9}
9606
- r={FLOORPLAN_CURSOR_MARKER_CORE_RADIUS}
10223
+ cx={toSvgX(activeDraftAnchorPoint[0])}
10224
+ cy={toSvgY(activeDraftAnchorPoint[1])}
10225
+ fill={palette.anchor}
10226
+ fillOpacity={0.95}
10227
+ r="0.14"
10228
+ vectorEffect="non-scaling-stroke"
9607
10229
  />
9608
- </g>
9609
- )}
9610
-
9611
- {activeDraftAnchorPoint && (
9612
- <circle
9613
- cx={toSvgX(activeDraftAnchorPoint[0])}
9614
- cy={toSvgY(activeDraftAnchorPoint[1])}
9615
- fill={palette.anchor}
9616
- fillOpacity={0.95}
9617
- r="0.14"
9618
- vectorEffect="non-scaling-stroke"
9619
- />
9620
- )}
10230
+ )}
9621
10231
  </g>
9622
10232
  </svg>
9623
10233
  )}