@pascal-app/editor 0.8.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 +8 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +754 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +58 -51
- 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 +201 -83
- package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
- package/src/components/editor/first-person-controls.tsx +825 -20
- 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 +5829 -12005
- 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 +178 -149
- 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 +433 -156
- 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 +136 -62
- package/src/components/editor/thumbnail-generator.tsx +9 -3
- package/src/components/editor/use-floorplan-background-placement.ts +119 -32
- 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-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- 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 +24 -9
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
- 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 +1 -1
- 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 +41 -105
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +31 -5
- package/src/components/tools/item/placement-strategies.ts +190 -15
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
- 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 -538
- 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 +56 -0
- 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 +16 -6
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/control-modes.tsx +5 -82
- package/src/components/ui/action-menu/index.tsx +1 -113
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +243 -6
- package/src/components/ui/command-palette/editor-commands.tsx +18 -13
- package/src/components/ui/command-palette/index.tsx +2 -1
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +11 -32
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +47 -10
- package/src/components/ui/helpers/helper-manager.tsx +16 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/panel-manager.tsx +35 -47
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- 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 +4 -6
- 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 +7 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +61 -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 +10 -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 +115 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +237 -5
- 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.ts +54 -42
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +211 -19
- 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 +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +23 -1
- package/src/lib/sfx-player.ts +70 -22
- package/src/lib/site-boundary.ts +1 -0
- 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 +320 -37
- 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/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- 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 -264
- 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 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- 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 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- 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 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- 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 -280
- 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 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import type { ReactNode } from 'react'
|
|
4
|
+
import { triggerSFX } from './../../../lib/sfx-bus'
|
|
4
5
|
import { cn } from './../../../lib/utils'
|
|
6
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip'
|
|
5
7
|
|
|
6
8
|
export type SidebarTab = {
|
|
7
9
|
id: string
|
|
8
10
|
label: string
|
|
9
11
|
mobileDefaultSnap?: number
|
|
10
12
|
mobileIcon?: ReactNode
|
|
13
|
+
/** Desktop icon shown in the vertical rail (v2 layout). */
|
|
14
|
+
icon?: ReactNode
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
interface TabBarProps {
|
|
@@ -30,7 +34,11 @@ export function TabBar({ tabs, activeTab, onTabChange }: TabBarProps) {
|
|
|
30
34
|
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
|
|
31
35
|
)}
|
|
32
36
|
key={tab.id}
|
|
33
|
-
onClick={() =>
|
|
37
|
+
onClick={() => {
|
|
38
|
+
triggerSFX('sfx:menu-click')
|
|
39
|
+
onTabChange(tab.id)
|
|
40
|
+
}}
|
|
41
|
+
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
|
34
42
|
type="button"
|
|
35
43
|
>
|
|
36
44
|
{tab.label}
|
|
@@ -40,3 +48,55 @@ export function TabBar({ tabs, activeTab, onTabChange }: TabBarProps) {
|
|
|
40
48
|
</div>
|
|
41
49
|
)
|
|
42
50
|
}
|
|
51
|
+
|
|
52
|
+
interface IconRailProps {
|
|
53
|
+
tabs: SidebarTab[]
|
|
54
|
+
/** Highlighted tab. Stays highlighted while the panel is collapsed. */
|
|
55
|
+
activeTab: string
|
|
56
|
+
/** True when the panel beside the rail is collapsed. */
|
|
57
|
+
collapsed: boolean
|
|
58
|
+
/** Clicking a rail icon: switch tab, or toggle the panel (see layout). */
|
|
59
|
+
onIconClick: (id: string) => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Vertical icon rail for the v2 left column. Always visible (even when the
|
|
64
|
+
* panel is collapsed) so the user can reopen the panel by clicking an icon.
|
|
65
|
+
* The label renders as a hover tooltip on the right.
|
|
66
|
+
*/
|
|
67
|
+
export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailProps) {
|
|
68
|
+
return (
|
|
69
|
+
<TooltipProvider delayDuration={0} disableHoverableContent>
|
|
70
|
+
<div className="flex h-full w-14 shrink-0 flex-col items-center gap-1 border-border/50 border-r py-2">
|
|
71
|
+
{tabs.map((tab) => {
|
|
72
|
+
// Only show the active highlight while the panel is open. When
|
|
73
|
+
// collapsed nothing is "open", so every icon reads as unselected.
|
|
74
|
+
const showActive = activeTab === tab.id && !collapsed
|
|
75
|
+
return (
|
|
76
|
+
<Tooltip key={tab.id}>
|
|
77
|
+
<TooltipTrigger asChild>
|
|
78
|
+
<button
|
|
79
|
+
className={cn(
|
|
80
|
+
'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
|
|
81
|
+
showActive
|
|
82
|
+
? 'bg-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0'
|
|
83
|
+
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0',
|
|
84
|
+
)}
|
|
85
|
+
onClick={() => {
|
|
86
|
+
triggerSFX('sfx:menu-click')
|
|
87
|
+
onIconClick(tab.id)
|
|
88
|
+
}}
|
|
89
|
+
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
|
90
|
+
type="button"
|
|
91
|
+
>
|
|
92
|
+
{tab.icon ?? tab.label.charAt(0)}
|
|
93
|
+
</button>
|
|
94
|
+
</TooltipTrigger>
|
|
95
|
+
<TooltipContent side="right">{tab.label}</TooltipContent>
|
|
96
|
+
</Tooltip>
|
|
97
|
+
)
|
|
98
|
+
})}
|
|
99
|
+
</div>
|
|
100
|
+
</TooltipProvider>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -10,13 +10,35 @@ import {
|
|
|
10
10
|
useScene,
|
|
11
11
|
type ZoneNode,
|
|
12
12
|
} from '@pascal-app/core'
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
import {
|
|
14
|
+
CLAY_PALETTE,
|
|
15
|
+
type EdgeMode,
|
|
16
|
+
getSceneTheme,
|
|
17
|
+
SCENE_THEMES,
|
|
18
|
+
useViewer,
|
|
19
|
+
} from '@pascal-app/viewer'
|
|
20
|
+
import {
|
|
21
|
+
ArrowLeft,
|
|
22
|
+
Box,
|
|
23
|
+
Camera,
|
|
24
|
+
Check,
|
|
25
|
+
ChevronRight,
|
|
26
|
+
Diamond,
|
|
27
|
+
Layers,
|
|
28
|
+
PenLine,
|
|
29
|
+
Sparkles,
|
|
30
|
+
} from 'lucide-react'
|
|
16
31
|
import Link from 'next/link'
|
|
17
32
|
import { useShallow } from 'zustand/react/shallow'
|
|
33
|
+
import { getLevelDisplayName } from '../lib/level-name'
|
|
18
34
|
import { cn } from '../lib/utils'
|
|
19
35
|
import { ActionButton } from './ui/action-menu/action-button'
|
|
36
|
+
import {
|
|
37
|
+
DropdownMenu,
|
|
38
|
+
DropdownMenuContent,
|
|
39
|
+
DropdownMenuItem,
|
|
40
|
+
DropdownMenuTrigger,
|
|
41
|
+
} from './ui/primitives/dropdown-menu'
|
|
20
42
|
import { TooltipProvider } from './ui/primitives/tooltip'
|
|
21
43
|
|
|
22
44
|
type ProjectOwner = {
|
|
@@ -60,6 +82,143 @@ const wallModeConfig = {
|
|
|
60
82
|
},
|
|
61
83
|
}
|
|
62
84
|
|
|
85
|
+
const SHADING_OPTIONS = [
|
|
86
|
+
{ id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box },
|
|
87
|
+
{ id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
|
|
88
|
+
] as const
|
|
89
|
+
|
|
90
|
+
function RenderModeMenu() {
|
|
91
|
+
const shading = useViewer((s) => s.shading)
|
|
92
|
+
const active = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0]
|
|
93
|
+
const ActiveIcon = active.icon
|
|
94
|
+
return (
|
|
95
|
+
<DropdownMenu>
|
|
96
|
+
<DropdownMenuTrigger asChild>
|
|
97
|
+
<ActionButton
|
|
98
|
+
className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
|
|
99
|
+
label={`Render: ${active.name}`}
|
|
100
|
+
size="icon"
|
|
101
|
+
tooltipSide="top"
|
|
102
|
+
variant="ghost"
|
|
103
|
+
>
|
|
104
|
+
<ActiveIcon className="h-6 w-6" />
|
|
105
|
+
</ActionButton>
|
|
106
|
+
</DropdownMenuTrigger>
|
|
107
|
+
<DropdownMenuContent align="center" className="min-w-56" side="top">
|
|
108
|
+
{SHADING_OPTIONS.map((option) => {
|
|
109
|
+
const OptionIcon = option.icon
|
|
110
|
+
return (
|
|
111
|
+
<DropdownMenuItem
|
|
112
|
+
key={option.id}
|
|
113
|
+
onSelect={() => useViewer.getState().setShading(option.id)}
|
|
114
|
+
>
|
|
115
|
+
<OptionIcon />
|
|
116
|
+
<div className="flex flex-col">
|
|
117
|
+
<span className="text-foreground">{option.name}</span>
|
|
118
|
+
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
|
119
|
+
</div>
|
|
120
|
+
{shading === option.id ? <Check className="ml-auto text-foreground" /> : null}
|
|
121
|
+
</DropdownMenuItem>
|
|
122
|
+
)
|
|
123
|
+
})}
|
|
124
|
+
</DropdownMenuContent>
|
|
125
|
+
</DropdownMenu>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function SceneThemeMenu() {
|
|
130
|
+
const sceneTheme = useViewer((s) => s.sceneTheme)
|
|
131
|
+
const active = getSceneTheme(sceneTheme)
|
|
132
|
+
return (
|
|
133
|
+
<DropdownMenu>
|
|
134
|
+
<DropdownMenuTrigger asChild>
|
|
135
|
+
<ActionButton
|
|
136
|
+
className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
|
|
137
|
+
label={`Theme: ${active.name}`}
|
|
138
|
+
size="icon"
|
|
139
|
+
tooltipSide="top"
|
|
140
|
+
variant="ghost"
|
|
141
|
+
>
|
|
142
|
+
<Icon color="currentColor" height={24} icon="lucide:swatch-book" width={24} />
|
|
143
|
+
</ActionButton>
|
|
144
|
+
</DropdownMenuTrigger>
|
|
145
|
+
<DropdownMenuContent align="center" className="min-w-48" side="top">
|
|
146
|
+
{SCENE_THEMES.map((sceneThemeOption) => {
|
|
147
|
+
const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
|
|
148
|
+
(role) => sceneThemeOption.clayTints?.[role] ?? CLAY_PALETTE[role],
|
|
149
|
+
)
|
|
150
|
+
return (
|
|
151
|
+
<DropdownMenuItem
|
|
152
|
+
key={sceneThemeOption.id}
|
|
153
|
+
onSelect={() => useViewer.getState().setSceneTheme(sceneThemeOption.id)}
|
|
154
|
+
>
|
|
155
|
+
<span
|
|
156
|
+
className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
|
|
157
|
+
style={{ backgroundColor: sceneThemeOption.background }}
|
|
158
|
+
>
|
|
159
|
+
{swatches.map((color, index) => (
|
|
160
|
+
<span
|
|
161
|
+
key={`${sceneThemeOption.id}-${index}`}
|
|
162
|
+
style={{ backgroundColor: color }}
|
|
163
|
+
/>
|
|
164
|
+
))}
|
|
165
|
+
</span>
|
|
166
|
+
<span className="text-foreground">{sceneThemeOption.name}</span>
|
|
167
|
+
{sceneTheme === sceneThemeOption.id ? (
|
|
168
|
+
<Check className="ml-auto text-foreground" />
|
|
169
|
+
) : null}
|
|
170
|
+
</DropdownMenuItem>
|
|
171
|
+
)
|
|
172
|
+
})}
|
|
173
|
+
</DropdownMenuContent>
|
|
174
|
+
</DropdownMenu>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const EDGE_OPTIONS = [
|
|
179
|
+
{ id: 'off', name: 'Off', detail: 'No edge lines' },
|
|
180
|
+
{ id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' },
|
|
181
|
+
{ id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
|
|
182
|
+
] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
|
|
183
|
+
|
|
184
|
+
function EdgesMenu() {
|
|
185
|
+
const edges = useViewer((s) => s.edges)
|
|
186
|
+
const active = EDGE_OPTIONS.find((o) => o.id === edges) ?? EDGE_OPTIONS[0]
|
|
187
|
+
return (
|
|
188
|
+
<DropdownMenu>
|
|
189
|
+
<DropdownMenuTrigger asChild>
|
|
190
|
+
<ActionButton
|
|
191
|
+
className={
|
|
192
|
+
edges === 'off'
|
|
193
|
+
? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
|
|
194
|
+
: 'bg-white/10 text-foreground'
|
|
195
|
+
}
|
|
196
|
+
label={`Edges: ${active.name}`}
|
|
197
|
+
size="icon"
|
|
198
|
+
tooltipSide="top"
|
|
199
|
+
variant="ghost"
|
|
200
|
+
>
|
|
201
|
+
<PenLine className="h-6 w-6" />
|
|
202
|
+
</ActionButton>
|
|
203
|
+
</DropdownMenuTrigger>
|
|
204
|
+
<DropdownMenuContent align="center" className="min-w-56" side="top">
|
|
205
|
+
{EDGE_OPTIONS.map((option) => (
|
|
206
|
+
<DropdownMenuItem
|
|
207
|
+
key={option.id}
|
|
208
|
+
onSelect={() => useViewer.getState().setEdges(option.id)}
|
|
209
|
+
>
|
|
210
|
+
<div className="flex flex-col">
|
|
211
|
+
<span className="text-foreground">{option.name}</span>
|
|
212
|
+
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
|
213
|
+
</div>
|
|
214
|
+
{edges === option.id ? <Check className="ml-auto text-foreground" /> : null}
|
|
215
|
+
</DropdownMenuItem>
|
|
216
|
+
))}
|
|
217
|
+
</DropdownMenuContent>
|
|
218
|
+
</DropdownMenu>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
63
222
|
const getNodeName = (node: AnyNode): string => {
|
|
64
223
|
if ('name' in node && node.name) return node.name
|
|
65
224
|
if (node.type === 'wall') return 'Wall'
|
|
@@ -93,7 +252,6 @@ export const ViewerOverlay = ({
|
|
|
93
252
|
const cameraMode = useViewer((s) => s.cameraMode)
|
|
94
253
|
const levelMode = useViewer((s) => s.levelMode)
|
|
95
254
|
const wallMode = useViewer((s) => s.wallMode)
|
|
96
|
-
const theme = useViewer((s) => s.theme)
|
|
97
255
|
|
|
98
256
|
// Subscribe only to the specific nodes we read so that creating an unrelated
|
|
99
257
|
// node elsewhere in the scene doesn't re-render this overlay.
|
|
@@ -206,7 +364,7 @@ export const ViewerOverlay = ({
|
|
|
206
364
|
className={`truncate transition-colors ${zone ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
|
|
207
365
|
onClick={() => handleBreadcrumbClick('level')}
|
|
208
366
|
>
|
|
209
|
-
{level
|
|
367
|
+
{getLevelDisplayName(level)}
|
|
210
368
|
</button>
|
|
211
369
|
</>
|
|
212
370
|
)}
|
|
@@ -280,53 +438,6 @@ export const ViewerOverlay = ({
|
|
|
280
438
|
<div className="dark absolute bottom-6 left-1/2 z-20 -translate-x-1/2 text-foreground">
|
|
281
439
|
<TooltipProvider delayDuration={0}>
|
|
282
440
|
<div className="pointer-events-auto flex h-14 flex-row items-center justify-center gap-1.5 rounded-2xl border border-border/40 bg-background/95 p-1.5 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out">
|
|
283
|
-
{/* Theme Toggle */}
|
|
284
|
-
<button
|
|
285
|
-
aria-label="Toggle theme"
|
|
286
|
-
className="flex h-[36px] shrink-0 cursor-pointer items-center rounded-full border border-border/50 bg-accent/50 p-1"
|
|
287
|
-
onClick={() => useViewer.getState().setTheme(theme === 'dark' ? 'light' : 'dark')}
|
|
288
|
-
type="button"
|
|
289
|
-
>
|
|
290
|
-
<div className="relative flex">
|
|
291
|
-
{/* Sliding Background */}
|
|
292
|
-
<motion.div
|
|
293
|
-
animate={{
|
|
294
|
-
x: theme === 'light' ? '100%' : '0%',
|
|
295
|
-
}}
|
|
296
|
-
className="absolute inset-0 rounded-full bg-white shadow-sm dark:bg-white/20"
|
|
297
|
-
initial={false}
|
|
298
|
-
style={{ width: '50%' }}
|
|
299
|
-
transition={{
|
|
300
|
-
type: 'spring',
|
|
301
|
-
stiffness: 500,
|
|
302
|
-
damping: 35,
|
|
303
|
-
}}
|
|
304
|
-
/>
|
|
305
|
-
|
|
306
|
-
{/* Dark Mode Icon */}
|
|
307
|
-
<div
|
|
308
|
-
className={cn(
|
|
309
|
-
'pointer-events-none relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200',
|
|
310
|
-
theme === 'dark' ? 'text-foreground' : 'text-muted-foreground',
|
|
311
|
-
)}
|
|
312
|
-
>
|
|
313
|
-
<Moon className="h-4 w-4" />
|
|
314
|
-
</div>
|
|
315
|
-
|
|
316
|
-
{/* Light Mode Icon */}
|
|
317
|
-
<div
|
|
318
|
-
className={cn(
|
|
319
|
-
'pointer-events-none relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200',
|
|
320
|
-
theme === 'light' ? 'text-foreground' : 'text-muted-foreground',
|
|
321
|
-
)}
|
|
322
|
-
>
|
|
323
|
-
<Sun className="h-4 w-4" />
|
|
324
|
-
</div>
|
|
325
|
-
</div>
|
|
326
|
-
</button>
|
|
327
|
-
|
|
328
|
-
<div className="mx-1 h-5 w-px bg-border/40" />
|
|
329
|
-
|
|
330
441
|
{/* Scans and Guides Visibility */}
|
|
331
442
|
{canShowScans && (
|
|
332
443
|
<ActionButton
|
|
@@ -392,6 +503,12 @@ export const ViewerOverlay = ({
|
|
|
392
503
|
<Camera className="h-6 w-6" />
|
|
393
504
|
</ActionButton>
|
|
394
505
|
|
|
506
|
+
<RenderModeMenu />
|
|
507
|
+
|
|
508
|
+
<SceneThemeMenu />
|
|
509
|
+
|
|
510
|
+
<EdgesMenu />
|
|
511
|
+
|
|
395
512
|
{/* Level Mode */}
|
|
396
513
|
<ActionButton
|
|
397
514
|
className={cn(
|
|
@@ -12,7 +12,7 @@ export const ViewerZoneSystem = () => {
|
|
|
12
12
|
const structureLayer = useEditor.getState().structureLayer
|
|
13
13
|
const nodes = useScene.getState().nodes
|
|
14
14
|
|
|
15
|
-
sceneRegistry.byType.zone
|
|
15
|
+
sceneRegistry.byType.zone!.forEach((id) => {
|
|
16
16
|
const obj = sceneRegistry.nodes.get(id)
|
|
17
17
|
if (!obj) return
|
|
18
18
|
|
|
@@ -9,7 +9,7 @@ const AUTOSAVE_DEBOUNCE_MS = 1000
|
|
|
9
9
|
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
|
|
10
10
|
|
|
11
11
|
interface UseAutoSaveOptions {
|
|
12
|
-
onSave?: (scene: SceneGraph) => Promise<void>
|
|
12
|
+
onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
|
|
13
13
|
onDirty?: () => void
|
|
14
14
|
onSaveStatusChange?: (status: SaveStatus) => void
|
|
15
15
|
isVersionPreviewMode?: boolean
|
|
@@ -148,23 +148,30 @@ export function useAutoSave({
|
|
|
148
148
|
}, AUTOSAVE_DEBOUNCE_MS)
|
|
149
149
|
})
|
|
150
150
|
|
|
151
|
+
// Flush any unsaved change while the page is going away. The network
|
|
152
|
+
// save MUST set `keepalive` — a normal fetch is cancelled by the browser
|
|
153
|
+
// the moment the page unloads, so a quick refresh right after an edit
|
|
154
|
+
// would otherwise drop the change entirely. `pagehide` fires in cases
|
|
155
|
+
// (mobile Safari, bfcache) where `beforeunload` does not.
|
|
151
156
|
function flushOnExit() {
|
|
152
157
|
if (!hasDirtyChangesRef.current) return
|
|
158
|
+
hasDirtyChangesRef.current = false
|
|
153
159
|
const { nodes, rootNodeIds } = useScene.getState()
|
|
154
160
|
const sceneGraph = { nodes, rootNodeIds } as SceneGraph
|
|
155
161
|
if (onSaveRef.current) {
|
|
156
|
-
onSaveRef.current(sceneGraph).catch(() => {})
|
|
162
|
+
onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {})
|
|
157
163
|
} else {
|
|
158
164
|
saveSceneToLocalStorage(sceneGraph)
|
|
159
165
|
}
|
|
160
|
-
hasDirtyChangesRef.current = false
|
|
161
166
|
}
|
|
162
167
|
|
|
163
168
|
window.addEventListener('beforeunload', flushOnExit)
|
|
169
|
+
window.addEventListener('pagehide', flushOnExit)
|
|
164
170
|
|
|
165
171
|
return () => {
|
|
166
172
|
executeSaveRef.current = null
|
|
167
173
|
window.removeEventListener('beforeunload', flushOnExit)
|
|
174
|
+
window.removeEventListener('pagehide', flushOnExit)
|
|
168
175
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
|
169
176
|
flushOnExit()
|
|
170
177
|
unsubscribe()
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
type CeilingEvent,
|
|
4
|
+
type CeilingNode,
|
|
5
|
+
emitter,
|
|
6
|
+
resolveLevelId,
|
|
7
|
+
sceneRegistry,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
+
import { useThree } from '@react-three/fiber'
|
|
12
|
+
import { useEffect, useRef } from 'react'
|
|
13
|
+
import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
|
|
14
|
+
import useEditor from '../store/use-editor'
|
|
15
|
+
|
|
16
|
+
const UP = new Vector3(0, 1, 0)
|
|
17
|
+
|
|
18
|
+
/** Ray-casting point-in-polygon on ceiling-local [x, z] tuples. */
|
|
19
|
+
function pointInPolygon(x: number, z: number, polygon: Array<[number, number]>): boolean {
|
|
20
|
+
let inside = false
|
|
21
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
22
|
+
const a = polygon[i]!
|
|
23
|
+
const b = polygon[j]!
|
|
24
|
+
const intersects =
|
|
25
|
+
a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0]
|
|
26
|
+
if (intersects) inside = !inside
|
|
27
|
+
}
|
|
28
|
+
return inside
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reliable pointer events for placing ceiling-attached items.
|
|
33
|
+
*
|
|
34
|
+
* Mirrors {@link useGridEvents} (the floor path): rather than relying on the
|
|
35
|
+
* thin, single-sided `ceiling-grid` overlay mesh to be hit by R3F's raycaster —
|
|
36
|
+
* which misses from the "wrong" camera side, near polygon edges/holes, or while
|
|
37
|
+
* the overlay is mid-reveal, dropping the commit click even though the green box
|
|
38
|
+
* still shows — it intersects a math plane at each ceiling's height (double-sided,
|
|
39
|
+
* so it hits from above or below) and point-in-polygon tests the hit. Emits
|
|
40
|
+
* `ceiling:enter/move/leave/click` so both the placement coordinator and the 2D
|
|
41
|
+
* floor-plan item preview keep working.
|
|
42
|
+
*
|
|
43
|
+
* Active only while a ceiling-attached item is being placed or moved. The
|
|
44
|
+
* `ceiling-grid` mesh no longer emits these events (see `CeilingRenderer`), so
|
|
45
|
+
* this is the single, reliable source.
|
|
46
|
+
*/
|
|
47
|
+
export function useCeilingEvents() {
|
|
48
|
+
const { camera, gl } = useThree()
|
|
49
|
+
const raycaster = useRef(new Raycaster())
|
|
50
|
+
const pointer = useRef(new Vector2())
|
|
51
|
+
const plane = useRef(new Plane(UP.clone(), 0))
|
|
52
|
+
const worldHit = useRef(new Vector3())
|
|
53
|
+
const meshWorld = useRef(new Vector3())
|
|
54
|
+
const hoveredRef = useRef<string | null>(null)
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const canvas = gl.domElement
|
|
58
|
+
|
|
59
|
+
const isActive = (): boolean => {
|
|
60
|
+
const ed = useEditor.getState()
|
|
61
|
+
if (ed.selectedItem?.attachTo === 'ceiling') return true
|
|
62
|
+
const moving = ed.movingNode
|
|
63
|
+
return moving?.type === 'item' && moving.asset?.attachTo === 'ceiling'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Hit = { node: CeilingNode; mesh: Object3D; world: Vector3; local: Vector3 }
|
|
67
|
+
|
|
68
|
+
// The ceiling on the active level whose plane the cursor ray crosses inside
|
|
69
|
+
// its polygon, nearest the camera.
|
|
70
|
+
const pick = (nativeEvent: PointerEvent | MouseEvent): Hit | null => {
|
|
71
|
+
const activeLevelId = useViewer.getState().selection.levelId
|
|
72
|
+
if (!activeLevelId) return null
|
|
73
|
+
|
|
74
|
+
const rect = canvas.getBoundingClientRect()
|
|
75
|
+
pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1
|
|
76
|
+
pointer.current.y = -((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1
|
|
77
|
+
raycaster.current.setFromCamera(pointer.current, camera)
|
|
78
|
+
|
|
79
|
+
const nodes = useScene.getState().nodes
|
|
80
|
+
const ceilingIds = sceneRegistry.byType.ceiling
|
|
81
|
+
if (!ceilingIds) return null
|
|
82
|
+
|
|
83
|
+
let best: Hit | null = null
|
|
84
|
+
let bestDist = Number.POSITIVE_INFINITY
|
|
85
|
+
|
|
86
|
+
for (const id of ceilingIds) {
|
|
87
|
+
const node = nodes[id as AnyNodeId] as CeilingNode | undefined
|
|
88
|
+
if (!node || node.type !== 'ceiling' || node.polygon.length < 3) continue
|
|
89
|
+
if (resolveLevelId(node, nodes) !== activeLevelId) continue
|
|
90
|
+
const mesh = sceneRegistry.nodes.get(id)
|
|
91
|
+
if (!mesh) continue
|
|
92
|
+
|
|
93
|
+
mesh.getWorldPosition(meshWorld.current)
|
|
94
|
+
plane.current.set(UP, -meshWorld.current.y)
|
|
95
|
+
if (!raycaster.current.ray.intersectPlane(plane.current, worldHit.current)) continue
|
|
96
|
+
|
|
97
|
+
const local = mesh.worldToLocal(worldHit.current.clone())
|
|
98
|
+
if (!pointInPolygon(local.x, local.z, node.polygon)) continue
|
|
99
|
+
|
|
100
|
+
const dist = raycaster.current.ray.origin.distanceToSquared(worldHit.current)
|
|
101
|
+
if (dist < bestDist) {
|
|
102
|
+
bestDist = dist
|
|
103
|
+
best = { node, mesh, world: worldHit.current.clone(), local }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return best
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const buildEvent = (hit: Hit, nativeEvent: PointerEvent | MouseEvent): CeilingEvent => ({
|
|
110
|
+
node: hit.node,
|
|
111
|
+
position: [hit.world.x, hit.world.y, hit.world.z],
|
|
112
|
+
localPosition: [hit.local.x, hit.local.y, hit.local.z],
|
|
113
|
+
normal: [0, 1, 0],
|
|
114
|
+
object: hit.mesh,
|
|
115
|
+
stopPropagation: () => {},
|
|
116
|
+
nativeEvent: nativeEvent as never,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const emitLeave = (nativeEvent: PointerEvent | MouseEvent) => {
|
|
120
|
+
const id = hoveredRef.current
|
|
121
|
+
if (!id) return
|
|
122
|
+
hoveredRef.current = null
|
|
123
|
+
const node = useScene.getState().nodes[id as AnyNodeId] as CeilingNode | undefined
|
|
124
|
+
const mesh = sceneRegistry.nodes.get(id)
|
|
125
|
+
if (!node || !mesh) return
|
|
126
|
+
emitter.emit(
|
|
127
|
+
'ceiling:leave',
|
|
128
|
+
buildEvent(
|
|
129
|
+
{ node, mesh, world: meshWorld.current.clone(), local: new Vector3() },
|
|
130
|
+
nativeEvent,
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const onMove = (e: PointerEvent) => {
|
|
136
|
+
if (!isActive()) {
|
|
137
|
+
emitLeave(e)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
const hit = pick(e)
|
|
141
|
+
if (!hit) {
|
|
142
|
+
emitLeave(e)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
const ev = buildEvent(hit, e)
|
|
146
|
+
if (hoveredRef.current !== hit.node.id) {
|
|
147
|
+
if (hoveredRef.current) emitLeave(e)
|
|
148
|
+
hoveredRef.current = hit.node.id
|
|
149
|
+
emitter.emit('ceiling:enter', ev)
|
|
150
|
+
}
|
|
151
|
+
emitter.emit('ceiling:move', ev)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const onClick = (e: MouseEvent) => {
|
|
155
|
+
if (useViewer.getState().cameraDragging) return
|
|
156
|
+
if (e.button !== 0) return
|
|
157
|
+
if (!isActive()) return
|
|
158
|
+
const hit = pick(e)
|
|
159
|
+
if (!hit) return
|
|
160
|
+
emitter.emit('ceiling:click', buildEvent(hit, e))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const onPointerLeave = (e: PointerEvent) => emitLeave(e)
|
|
164
|
+
|
|
165
|
+
canvas.addEventListener('pointermove', onMove)
|
|
166
|
+
canvas.addEventListener('click', onClick)
|
|
167
|
+
canvas.addEventListener('pointerleave', onPointerLeave)
|
|
168
|
+
|
|
169
|
+
return () => {
|
|
170
|
+
canvas.removeEventListener('pointermove', onMove)
|
|
171
|
+
canvas.removeEventListener('click', onClick)
|
|
172
|
+
canvas.removeEventListener('pointerleave', onPointerLeave)
|
|
173
|
+
}
|
|
174
|
+
}, [camera, gl])
|
|
175
|
+
}
|