@pascal-app/editor 0.7.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +755 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +62 -54
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +203 -87
- package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
- package/src/components/editor/first-person-controls.tsx +836 -31
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5837 -12006
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +234 -190
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +435 -158
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
- package/src/components/editor/thumbnail-generator.tsx +27 -64
- package/src/components/editor/use-floorplan-background-placement.ts +122 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +2 -2
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +40 -107
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +33 -9
- package/src/components/tools/item/placement-strategies.ts +201 -25
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -543
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +57 -1
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +36 -11
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +5 -76
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +33 -196
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +262 -37
- package/src/components/ui/command-palette/editor-commands.tsx +24 -17
- package/src/components/ui/command-palette/index.tsx +6 -256
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +17 -35
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +48 -11
- package/src/components/ui/helpers/helper-manager.tsx +21 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
- package/src/components/ui/level-duplicate-dialog.tsx +5 -6
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +33 -74
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +24 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +125 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +243 -4
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +55 -43
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +212 -20
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +2 -2
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +25 -1
- package/src/lib/sfx-player.ts +75 -27
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-duplication.ts +2 -2
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +342 -91
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/tsconfig.json +2 -1
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
- package/src/components/tools/fence/move-fence-tool.tsx +0 -298
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -359
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
- package/src/components/tools/wall/move-wall-tool.tsx +0 -356
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -250
- package/src/components/ui/panels/column-panel.tsx +0 -715
- package/src/components/ui/panels/door-panel.tsx +0 -1292
- package/src/components/ui/panels/fence-panel.tsx +0 -227
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -230
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -155
- package/src/components/ui/panels/stair-panel.tsx +0 -525
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -177
- package/src/components/ui/panels/window-panel.tsx +0 -960
- package/src/components/ui/viewer-toolbar.tsx +0 -436
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNodeId, sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { createPortal, useFrame, useThree } from '@react-three/fiber'
|
|
6
|
+
import { useEffect, useMemo, useRef } from 'react'
|
|
7
|
+
import { BoxGeometry, type BufferGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
|
|
8
|
+
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
|
9
|
+
import { EDITOR_LAYER } from '../../lib/constants'
|
|
10
|
+
|
|
11
|
+
// How far the outline sits outside the opening's own extents, so it reads as
|
|
12
|
+
// a frame *around* the opening rather than coinciding with its edges.
|
|
13
|
+
const PAD = 0.05
|
|
14
|
+
|
|
15
|
+
const ACCENT = 0x83_81_ed
|
|
16
|
+
|
|
17
|
+
const NO_RAYCAST = () => null
|
|
18
|
+
const scratchScale = new Vector3()
|
|
19
|
+
|
|
20
|
+
// Indigo accent matching the resize-arrow handles — deliberately distinct
|
|
21
|
+
// from the white selection outline so the highlight reads as "editable child
|
|
22
|
+
// here", not "this is selected". `depthTest: false` keeps both layers drawn
|
|
23
|
+
// on top of the wall so frameless openings (which have no visible geometry)
|
|
24
|
+
// are still located. Shared across every box; never disposed.
|
|
25
|
+
const outlineMaterial = new LineBasicNodeMaterial({
|
|
26
|
+
color: ACCENT,
|
|
27
|
+
depthTest: false,
|
|
28
|
+
depthWrite: false,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Translucent block that fills the opening volume so the cutout reads as an
|
|
32
|
+
// occupied slot from every angle — the front face shows it head-on, the top
|
|
33
|
+
// face shows it from a top-down floorplan view (a single vertical pane would
|
|
34
|
+
// be edge-on, hence invisible, when looking straight down). Front-side culling
|
|
35
|
+
// keeps the translucency even instead of doubling up on overlapping back faces.
|
|
36
|
+
const fillMaterial = new MeshBasicNodeMaterial({
|
|
37
|
+
color: ACCENT,
|
|
38
|
+
transparent: true,
|
|
39
|
+
opacity: 0.5,
|
|
40
|
+
depthTest: false,
|
|
41
|
+
depthWrite: false,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
function makeOutlineGeometry(width: number, height: number, depth: number): BufferGeometry {
|
|
45
|
+
const box = new BoxGeometry(width + PAD, height + PAD, depth + PAD)
|
|
46
|
+
const edges = new EdgesGeometry(box)
|
|
47
|
+
box.dispose()
|
|
48
|
+
return edges
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* When a wall is selected, draws a translucent indigo highlight (filled block
|
|
53
|
+
* + outline) over each door / window it hosts. Openings whose `openingKind`
|
|
54
|
+
* is `'opening'` have no visible geometry, so without this affordance the
|
|
55
|
+
* user can't tell an editable cutout lives there — the fill marks it (and
|
|
56
|
+
* stays out of the way of clicking the opening itself, which selects it).
|
|
57
|
+
*
|
|
58
|
+
* Highlights are portalled to the scene root so they sit outside the wall's
|
|
59
|
+
* selection-outline subtree and keep their own accent colour.
|
|
60
|
+
*/
|
|
61
|
+
export function WallOpeningHighlights() {
|
|
62
|
+
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
63
|
+
const { scene } = useThree()
|
|
64
|
+
|
|
65
|
+
if (selectedIds.length === 0) return null
|
|
66
|
+
|
|
67
|
+
return createPortal(
|
|
68
|
+
<>
|
|
69
|
+
{selectedIds.map((id) => (
|
|
70
|
+
<SelectionOpeningHighlights key={id} selectedId={id} />
|
|
71
|
+
))}
|
|
72
|
+
</>,
|
|
73
|
+
scene,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Resolves a selected node into the opening highlight(s) to draw:
|
|
78
|
+
// - a selected wall → a hint over each door / window it hosts ("editable
|
|
79
|
+
// child here").
|
|
80
|
+
// - a directly-selected frameless opening (a `door` whose `openingKind` is
|
|
81
|
+
// `'opening'`) → a fill over its own cutout, so the selection reads as
|
|
82
|
+
// occupied even though the opening renders no geometry of its own.
|
|
83
|
+
function SelectionOpeningHighlights({ selectedId }: { selectedId: string }) {
|
|
84
|
+
const node = useScene((state) => state.nodes[selectedId as AnyNodeId])
|
|
85
|
+
|
|
86
|
+
if (node?.type === 'wall') {
|
|
87
|
+
const depth = node.thickness ?? 0.1
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
{(node.children ?? []).map((childId) => (
|
|
91
|
+
<OpeningHighlight depth={depth} key={childId} openingId={childId} />
|
|
92
|
+
))}
|
|
93
|
+
</>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (node?.type === 'door' && node.openingKind === 'opening') {
|
|
98
|
+
return <SelectedOpeningHighlight openingId={selectedId} parentId={node.parentId} />
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A frameless opening selected on its own. Pulls the cutout depth from its
|
|
105
|
+
// host wall's thickness so the fill block matches the wall it sits in.
|
|
106
|
+
function SelectedOpeningHighlight({
|
|
107
|
+
openingId,
|
|
108
|
+
parentId,
|
|
109
|
+
}: {
|
|
110
|
+
openingId: string
|
|
111
|
+
parentId: string | null
|
|
112
|
+
}) {
|
|
113
|
+
const parent = useScene((state) => (parentId ? state.nodes[parentId as AnyNodeId] : undefined))
|
|
114
|
+
const depth = parent?.type === 'wall' ? (parent.thickness ?? 0.1) : 0.1
|
|
115
|
+
return <OpeningHighlight depth={depth} openingId={openingId} />
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function OpeningHighlight({ openingId, depth }: { openingId: string; depth: number }) {
|
|
119
|
+
const node = useScene((state) => state.nodes[openingId as AnyNodeId])
|
|
120
|
+
// Resize arrows publish width/height to the live-override store during the
|
|
121
|
+
// drag and only commit to the scene node on pointer-up, so read the
|
|
122
|
+
// override-merged dimensions to keep the highlight box tracking the resize.
|
|
123
|
+
const override = useLiveNodeOverrides((s) => s.overrides.get(openingId))
|
|
124
|
+
const groupRef = useRef<Group>(null)
|
|
125
|
+
|
|
126
|
+
const isOpening = node?.type === 'door' || node?.type === 'window'
|
|
127
|
+
const width = isOpening ? ((override?.width as number | undefined) ?? node.width) : 0
|
|
128
|
+
const height = isOpening ? ((override?.height as number | undefined) ?? node.height) : 0
|
|
129
|
+
|
|
130
|
+
const outlineGeometry = useMemo(
|
|
131
|
+
() => (isOpening ? makeOutlineGeometry(width, height, depth) : null),
|
|
132
|
+
[isOpening, width, height, depth],
|
|
133
|
+
)
|
|
134
|
+
const fillGeometry = useMemo(
|
|
135
|
+
() => (isOpening ? new BoxGeometry(width, height, depth) : null),
|
|
136
|
+
[isOpening, width, height, depth],
|
|
137
|
+
)
|
|
138
|
+
useEffect(() => () => outlineGeometry?.dispose(), [outlineGeometry])
|
|
139
|
+
useEffect(() => () => fillGeometry?.dispose(), [fillGeometry])
|
|
140
|
+
|
|
141
|
+
// The opening's mesh (registered by its renderer) already carries the wall
|
|
142
|
+
// transform + its own local pose baked into `matrixWorld`. Copy that
|
|
143
|
+
// straight onto the highlight each frame so it tracks moves, resizes, and
|
|
144
|
+
// wall rotation without any wall-local maths here.
|
|
145
|
+
useFrame(() => {
|
|
146
|
+
const group = groupRef.current
|
|
147
|
+
if (!group) return
|
|
148
|
+
const obj = sceneRegistry.nodes.get(openingId as AnyNodeId)
|
|
149
|
+
if (!obj) {
|
|
150
|
+
group.visible = false
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
group.visible = true
|
|
154
|
+
obj.matrixWorld.decompose(group.position, group.quaternion, scratchScale)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
if (!isOpening || !outlineGeometry || !fillGeometry) return null
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<group ref={groupRef}>
|
|
161
|
+
<mesh
|
|
162
|
+
frustumCulled={false}
|
|
163
|
+
geometry={fillGeometry}
|
|
164
|
+
layers={EDITOR_LAYER}
|
|
165
|
+
material={fillMaterial}
|
|
166
|
+
raycast={NO_RAYCAST}
|
|
167
|
+
renderOrder={1004}
|
|
168
|
+
/>
|
|
169
|
+
<lineSegments
|
|
170
|
+
frustumCulled={false}
|
|
171
|
+
geometry={outlineGeometry}
|
|
172
|
+
layers={EDITOR_LAYER}
|
|
173
|
+
material={outlineMaterial}
|
|
174
|
+
raycast={NO_RAYCAST}
|
|
175
|
+
renderOrder={1005}
|
|
176
|
+
/>
|
|
177
|
+
</group>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export default WallOpeningHighlights
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { sceneRegistry } from '@pascal-app/core'
|
|
4
|
+
import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor'
|
|
5
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
6
|
+
import { useFrame } from '@react-three/fiber'
|
|
7
|
+
import { memo, useRef } from 'react'
|
|
8
|
+
import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three'
|
|
9
|
+
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
10
|
+
import { EDITOR_LAYER } from '../../lib/constants'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* "Magnetic" wall-snap beacon for the 3D editor — a vertical marker that
|
|
14
|
+
* stands at the draft / move endpoint while it's locked onto existing wall
|
|
15
|
+
* geometry. It's the spatial cue from the design reference: a standing pillar
|
|
16
|
+
* so you can see *where* the snap caught even at an angle, plus a floor marker
|
|
17
|
+
* whose shape tells you *what* it caught (CAD-style osnap glyphs):
|
|
18
|
+
*
|
|
19
|
+
* endpoint (corner) → square midpoint → triangle
|
|
20
|
+
* intersection → ✕ cross wall body (edge) → circle
|
|
21
|
+
*
|
|
22
|
+
* Subscribes to the shared `useWallSnapIndicator` store (published by the wall
|
|
23
|
+
* draft + endpoint-move tools). Shares the indigo accent of
|
|
24
|
+
* `Alignment3DGuideLayer` for visual consistency.
|
|
25
|
+
*
|
|
26
|
+
* The point carries only XZ (building-local plan coords); like the alignment
|
|
27
|
+
* guides it's lifted to the active level's building-local Y each frame so it
|
|
28
|
+
* stands on the floor being edited when floors are stacked. Mounted inside
|
|
29
|
+
* ToolManager's building-local group.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const BEACON_COLOR = 0x81_8c_f8 // indigo-400 — matches the alignment guide accent
|
|
33
|
+
const BEACON_HEIGHT = 2.5 // world-meter height of the pillar
|
|
34
|
+
const BEACON_RADIUS = 0.018 // world-meter radius of the pillar
|
|
35
|
+
const MARKER = 0.13 // world-meter base size of the floor glyph
|
|
36
|
+
const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid
|
|
37
|
+
|
|
38
|
+
// Shared resources — one material + unit geometries, so snap churn during a
|
|
39
|
+
// drag doesn't rebuild GPU buffers (mirrors the alignment guide layer).
|
|
40
|
+
const beaconMaterial = new MeshBasicNodeMaterial({
|
|
41
|
+
color: BEACON_COLOR,
|
|
42
|
+
depthTest: false,
|
|
43
|
+
depthWrite: false,
|
|
44
|
+
toneMapped: false,
|
|
45
|
+
transparent: true,
|
|
46
|
+
opacity: 0.9,
|
|
47
|
+
})
|
|
48
|
+
const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8)
|
|
49
|
+
// Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as
|
|
50
|
+
// a flat plate; circles/triangles lie flat via an X rotation at the mesh.
|
|
51
|
+
const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1)
|
|
52
|
+
const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3)
|
|
53
|
+
const CIRCLE_GEOMETRY = new CircleGeometry(1, 28)
|
|
54
|
+
|
|
55
|
+
export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
|
|
56
|
+
const point = useWallSnapIndicator((s) => s.point)
|
|
57
|
+
const levelId = useViewer((s) => s.selection.levelId)
|
|
58
|
+
const groupRef = useRef<Group>(null)
|
|
59
|
+
|
|
60
|
+
// Track the active level's building-local Y each frame so the beacon stands
|
|
61
|
+
// on the floor being edited, not the building base — same source the
|
|
62
|
+
// alignment guide layer and `grid.tsx` read.
|
|
63
|
+
useFrame(() => {
|
|
64
|
+
const group = groupRef.current
|
|
65
|
+
if (!group) return
|
|
66
|
+
const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null
|
|
67
|
+
group.position.y = levelMesh ? levelMesh.position.y : 0
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
if (!point) return null
|
|
71
|
+
return (
|
|
72
|
+
<group ref={groupRef}>
|
|
73
|
+
<mesh
|
|
74
|
+
geometry={PILLAR_GEOMETRY}
|
|
75
|
+
layers={EDITOR_LAYER}
|
|
76
|
+
material={beaconMaterial}
|
|
77
|
+
position={[point.x, BEACON_HEIGHT / 2, point.z]}
|
|
78
|
+
renderOrder={1002}
|
|
79
|
+
/>
|
|
80
|
+
<SnapMarker kind={point.kind} x={point.x} z={point.z} />
|
|
81
|
+
</group>
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
/** Floor glyph whose shape encodes which kind of geometry the point snapped to. */
|
|
86
|
+
function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) {
|
|
87
|
+
const y = FLOOR_LIFT
|
|
88
|
+
if (kind === 'endpoint') {
|
|
89
|
+
return (
|
|
90
|
+
<mesh
|
|
91
|
+
geometry={FLAT_BOX_GEOMETRY}
|
|
92
|
+
layers={EDITOR_LAYER}
|
|
93
|
+
material={beaconMaterial}
|
|
94
|
+
position={[x, y, z]}
|
|
95
|
+
renderOrder={1001}
|
|
96
|
+
scale={[MARKER * 2, 1, MARKER * 2]}
|
|
97
|
+
/>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
if (kind === 'midpoint') {
|
|
101
|
+
return (
|
|
102
|
+
<mesh
|
|
103
|
+
geometry={TRIANGLE_GEOMETRY}
|
|
104
|
+
layers={EDITOR_LAYER}
|
|
105
|
+
material={beaconMaterial}
|
|
106
|
+
position={[x, y, z]}
|
|
107
|
+
renderOrder={1001}
|
|
108
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
109
|
+
scale={[MARKER * 1.4, MARKER * 1.4, MARKER * 1.4]}
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
if (kind === 'intersection') {
|
|
114
|
+
// Two crossed bars → an ✕, the universal "crossing" glyph.
|
|
115
|
+
return (
|
|
116
|
+
<>
|
|
117
|
+
<mesh
|
|
118
|
+
geometry={FLAT_BOX_GEOMETRY}
|
|
119
|
+
layers={EDITOR_LAYER}
|
|
120
|
+
material={beaconMaterial}
|
|
121
|
+
position={[x, y, z]}
|
|
122
|
+
renderOrder={1001}
|
|
123
|
+
rotation={[0, Math.PI / 4, 0]}
|
|
124
|
+
scale={[MARKER * 2.6, 1, MARKER * 0.5]}
|
|
125
|
+
/>
|
|
126
|
+
<mesh
|
|
127
|
+
geometry={FLAT_BOX_GEOMETRY}
|
|
128
|
+
layers={EDITOR_LAYER}
|
|
129
|
+
material={beaconMaterial}
|
|
130
|
+
position={[x, y, z]}
|
|
131
|
+
renderOrder={1001}
|
|
132
|
+
rotation={[0, -Math.PI / 4, 0]}
|
|
133
|
+
scale={[MARKER * 2.6, 1, MARKER * 0.5]}
|
|
134
|
+
/>
|
|
135
|
+
</>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
// 'wall' (edge / along-wall) → circle
|
|
139
|
+
return (
|
|
140
|
+
<mesh
|
|
141
|
+
geometry={CIRCLE_GEOMETRY}
|
|
142
|
+
layers={EDITOR_LAYER}
|
|
143
|
+
material={beaconMaterial}
|
|
144
|
+
position={[x, y, z]}
|
|
145
|
+
renderOrder={1001}
|
|
146
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
147
|
+
scale={[MARKER, MARKER, MARKER]}
|
|
148
|
+
/>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
@@ -14,12 +14,14 @@ export type FloorplanActionMenuHandler = (event: ReactMouseEvent<HTMLButtonEleme
|
|
|
14
14
|
export type FloorplanActionMenuEntry = {
|
|
15
15
|
position: SvgPoint | null
|
|
16
16
|
onDelete: FloorplanActionMenuHandler
|
|
17
|
-
onMove
|
|
17
|
+
onMove?: FloorplanActionMenuHandler
|
|
18
18
|
onAddHole?: FloorplanActionMenuHandler
|
|
19
|
+
onCurve?: FloorplanActionMenuHandler
|
|
19
20
|
onDuplicate?: FloorplanActionMenuHandler
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
type FloorplanActionMenuLayerProps = {
|
|
24
|
+
elevator: FloorplanActionMenuEntry
|
|
23
25
|
item: FloorplanActionMenuEntry
|
|
24
26
|
wall: FloorplanActionMenuEntry
|
|
25
27
|
fence: FloorplanActionMenuEntry
|
|
@@ -33,6 +35,7 @@ type FloorplanActionMenuLayerProps = {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
|
38
|
+
elevator,
|
|
36
39
|
item,
|
|
37
40
|
wall,
|
|
38
41
|
fence,
|
|
@@ -55,6 +58,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
const entries: FloorplanActionMenuEntry[] = [
|
|
61
|
+
elevator,
|
|
58
62
|
item,
|
|
59
63
|
wall,
|
|
60
64
|
fence,
|
|
@@ -81,6 +85,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
|
|
81
85
|
>
|
|
82
86
|
<NodeActionMenu
|
|
83
87
|
onAddHole={entry.onAddHole}
|
|
88
|
+
onCurve={entry.onCurve}
|
|
84
89
|
onDelete={entry.onDelete}
|
|
85
90
|
onDuplicate={entry.onDuplicate}
|
|
86
91
|
onMove={entry.onMove}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
4
|
+
import { memo } from 'react'
|
|
5
|
+
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
6
|
+
import { formatMeasurement } from '../editor/measurement-pill'
|
|
7
|
+
import { useFloorplanRender } from './floorplan-render-context'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Figma-style alignment guides for the 2D floor plan.
|
|
11
|
+
*
|
|
12
|
+
* Subscribes to the editor-local `useAlignmentGuides` store (separate
|
|
13
|
+
* from the core store the 3D layer reads). Guides come in
|
|
14
|
+
* building-local meters, so the layer is mounted INSIDE the rotated
|
|
15
|
+
* `<g data-floorplan-scene>` — the SVG transform that takes the rest
|
|
16
|
+
* of the floor-plan geometry from local → screen carries the guide
|
|
17
|
+
* lines too. Pill labels are counter-rotated by `sceneRotationDeg`
|
|
18
|
+
* (from `FloorplanRenderProvider`) so they stay upright even when the
|
|
19
|
+
* scene `<g>` is rotated by building rotation.
|
|
20
|
+
*
|
|
21
|
+
* Each guide renders as a red line between the moving and matched
|
|
22
|
+
* candidate anchors with small `×` end-caps. A distance pill is drawn
|
|
23
|
+
* at the midpoint when the perpendicular gap is non-zero.
|
|
24
|
+
*
|
|
25
|
+
* Stroke widths and handle radii are scaled by `unitsPerPixel` so they
|
|
26
|
+
* stay a constant size on screen no matter the zoom.
|
|
27
|
+
*/
|
|
28
|
+
export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
|
|
29
|
+
const guides = useAlignmentGuides((s) => s.guides)
|
|
30
|
+
const unit = useViewer((s) => s.unit)
|
|
31
|
+
const ctx = useFloorplanRender()
|
|
32
|
+
|
|
33
|
+
if (guides.length === 0) return null
|
|
34
|
+
|
|
35
|
+
const upp = ctx?.unitsPerPixel ?? 0.01
|
|
36
|
+
const sceneRot = ctx?.sceneRotationDeg ?? 0
|
|
37
|
+
|
|
38
|
+
// Pixel-budgeted sizes converted to world meters so visuals stay
|
|
39
|
+
// constant across zoom. Numbers picked to mirror Figma's snap chrome.
|
|
40
|
+
const stroke = 1 * upp
|
|
41
|
+
const xCapSize = 4 * upp
|
|
42
|
+
const pillFontSize = 11 * upp
|
|
43
|
+
const pillPadX = 5 * upp
|
|
44
|
+
const pillPadY = 3 * upp
|
|
45
|
+
const pillRadius = 3 * upp
|
|
46
|
+
const pillOffset = 8 * upp
|
|
47
|
+
|
|
48
|
+
const color = '#ef4444' // tailwind red-500 — matches Figma's snap red
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<g pointerEvents="none">
|
|
52
|
+
{guides.map((guide, i) => {
|
|
53
|
+
const { from, to, axis } = guide
|
|
54
|
+
const midX = (from.x + to.x) / 2
|
|
55
|
+
const midZ = (from.z + to.z) / 2
|
|
56
|
+
const distMeters = guide.distance
|
|
57
|
+
|
|
58
|
+
// Pill placed offset perpendicular to the guide's axis so it
|
|
59
|
+
// doesn't sit on top of the line itself. For an X-axis guide
|
|
60
|
+
// (vertical line) we offset along Z; for a Z-axis guide we
|
|
61
|
+
// offset along X.
|
|
62
|
+
const pillX = axis === 'x' ? midX + pillOffset : midX
|
|
63
|
+
const pillZ = axis === 'z' ? midZ + pillOffset : midZ
|
|
64
|
+
const distLabel = formatMeasurement(distMeters, unit)
|
|
65
|
+
const charWidth = pillFontSize * 0.55
|
|
66
|
+
const pillWidth = distLabel.length * charWidth + pillPadX * 2
|
|
67
|
+
const pillHeight = pillFontSize + pillPadY * 2
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<g key={i}>
|
|
71
|
+
<line stroke={color} strokeWidth={stroke} x1={from.x} x2={to.x} y1={from.z} y2={to.z} />
|
|
72
|
+
<XCap color={color} size={xCapSize} stroke={stroke} x={from.x} y={from.z} />
|
|
73
|
+
<XCap color={color} size={xCapSize} stroke={stroke} x={to.x} y={to.z} />
|
|
74
|
+
{distMeters > 1e-4 && (
|
|
75
|
+
// Counter-rotate the pill so it stays upright when the
|
|
76
|
+
// scene `<g>` is rotated by building rotation. SVG's
|
|
77
|
+
// `transform` runs in the local coord system, so the
|
|
78
|
+
// rotation pivots around the pill's center.
|
|
79
|
+
<g transform={`rotate(${-sceneRot} ${pillX} ${pillZ})`}>
|
|
80
|
+
<rect
|
|
81
|
+
fill={color}
|
|
82
|
+
height={pillHeight}
|
|
83
|
+
rx={pillRadius}
|
|
84
|
+
ry={pillRadius}
|
|
85
|
+
width={pillWidth}
|
|
86
|
+
x={pillX - pillWidth / 2}
|
|
87
|
+
y={pillZ - pillHeight / 2}
|
|
88
|
+
/>
|
|
89
|
+
<text
|
|
90
|
+
fill="#ffffff"
|
|
91
|
+
fontFamily="-apple-system, system-ui, sans-serif"
|
|
92
|
+
fontSize={pillFontSize}
|
|
93
|
+
fontWeight={500}
|
|
94
|
+
textAnchor="middle"
|
|
95
|
+
x={pillX}
|
|
96
|
+
y={pillZ + pillFontSize * 0.35}
|
|
97
|
+
>
|
|
98
|
+
{distLabel}
|
|
99
|
+
</text>
|
|
100
|
+
</g>
|
|
101
|
+
)}
|
|
102
|
+
</g>
|
|
103
|
+
)
|
|
104
|
+
})}
|
|
105
|
+
</g>
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
function XCap({
|
|
110
|
+
color,
|
|
111
|
+
size,
|
|
112
|
+
stroke,
|
|
113
|
+
x,
|
|
114
|
+
y,
|
|
115
|
+
}: {
|
|
116
|
+
color: string
|
|
117
|
+
size: number
|
|
118
|
+
stroke: number
|
|
119
|
+
x: number
|
|
120
|
+
y: number
|
|
121
|
+
}) {
|
|
122
|
+
return (
|
|
123
|
+
<g>
|
|
124
|
+
<line
|
|
125
|
+
stroke={color}
|
|
126
|
+
strokeWidth={stroke}
|
|
127
|
+
x1={x - size}
|
|
128
|
+
x2={x + size}
|
|
129
|
+
y1={y - size}
|
|
130
|
+
y2={y + size}
|
|
131
|
+
/>
|
|
132
|
+
<line
|
|
133
|
+
stroke={color}
|
|
134
|
+
strokeWidth={stroke}
|
|
135
|
+
x1={x - size}
|
|
136
|
+
x2={x + size}
|
|
137
|
+
y1={y + size}
|
|
138
|
+
y2={y - size}
|
|
139
|
+
/>
|
|
140
|
+
</g>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
@@ -23,7 +23,6 @@ type FloorplanCursorIndicator =
|
|
|
23
23
|
|
|
24
24
|
type FloorplanCursorIndicatorOverlayProps = {
|
|
25
25
|
cursorPosition: SvgPoint | null
|
|
26
|
-
cursorAnchorPosition: SvgPoint | null
|
|
27
26
|
floorplanSelectionTool: FloorplanSelectionTool
|
|
28
27
|
movingOpeningType: 'door' | 'window' | null
|
|
29
28
|
isPanning: boolean
|
|
@@ -35,7 +34,6 @@ type FloorplanCursorIndicatorOverlayProps = {
|
|
|
35
34
|
|
|
36
35
|
export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndicatorOverlay({
|
|
37
36
|
cursorPosition,
|
|
38
|
-
cursorAnchorPosition,
|
|
39
37
|
floorplanSelectionTool,
|
|
40
38
|
movingOpeningType,
|
|
41
39
|
isPanning,
|
|
@@ -78,10 +76,14 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
|
|
|
78
76
|
return { kind: 'icon', icon: 'mdi:trash-can-outline' }
|
|
79
77
|
}
|
|
80
78
|
|
|
79
|
+
if (mode === 'material-paint') {
|
|
80
|
+
return { kind: 'asset', iconSrc: '/icons/paint.png' }
|
|
81
|
+
}
|
|
82
|
+
|
|
81
83
|
return null
|
|
82
84
|
}, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
|
|
83
85
|
|
|
84
|
-
const position =
|
|
86
|
+
const position = cursorPosition
|
|
85
87
|
|
|
86
88
|
if (!(indicator && position) || isPanning) {
|
|
87
89
|
return null
|