@pascal-app/editor 1.0.0-beta.5 → 1.0.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/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/bake-exporter.tsx +5 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +9 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +180 -12
- package/src/components/editor/floating-action-menu.tsx +55 -51
- package/src/components/editor/floorplan-panel.tsx +99 -15
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +78 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +23 -3
- package/src/components/editor/index.tsx +151 -24
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +67 -119
- package/src/components/editor/selection-manager.tsx +153 -42
- package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +295 -239
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +331 -4
- package/src/components/tools/item/placement-strategies.ts +304 -171
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +10 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +116 -44
- package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +57 -7
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +56 -17
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +320 -2
- package/src/components/tools/wall/wall-drafting.ts +23 -114
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +63 -13
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +44 -21
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +103 -10
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +35 -164
- package/src/components/ui/panels/reference-panel.tsx +34 -4
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +5 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-overlay.tsx +1 -22
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +73 -1
- package/src/hooks/use-auto-save.ts +76 -5
- package/src/hooks/use-ceiling-events.test.ts +144 -0
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +132 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +87 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +536 -2
- package/src/lib/floorplan/floorplan-export.tsx +120 -19
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.ts +5 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +1375 -21
- package/src/lib/glb-export.ts +781 -106
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +528 -147
- package/src/lib/history.ts +81 -7
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +455 -0
- package/src/lib/portable-export.ts +1042 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +38 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -0
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +65 -0
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/store/use-send-to-app.ts +21 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { nodeRegistry, type ToolOption } from '@pascal-app/core'
|
|
4
|
+
import Image from 'next/image'
|
|
5
|
+
import { useSyncExternalStore } from 'react'
|
|
6
|
+
import { cn } from '../../../lib/utils'
|
|
7
|
+
import { triggerSFX } from '../../../lib/sfx-bus'
|
|
8
|
+
|
|
9
|
+
const ALWAYS_VISIBLE = {
|
|
10
|
+
subscribe: () => () => {},
|
|
11
|
+
value: () => true,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ToolOptionRow({
|
|
15
|
+
option,
|
|
16
|
+
onSelect,
|
|
17
|
+
getChoiceThumbnail,
|
|
18
|
+
active = true,
|
|
19
|
+
}: {
|
|
20
|
+
option: ToolOption
|
|
21
|
+
onSelect?: (option: ToolOption, value: string) => void
|
|
22
|
+
getChoiceThumbnail?: (option: ToolOption, value: string) => string | undefined
|
|
23
|
+
active?: boolean
|
|
24
|
+
}) {
|
|
25
|
+
const visibility = option.visible ?? ALWAYS_VISIBLE
|
|
26
|
+
const visible = useSyncExternalStore(visibility.subscribe, visibility.value, visibility.value)
|
|
27
|
+
const value = useSyncExternalStore(option.subscribe, option.value, option.value)
|
|
28
|
+
if (
|
|
29
|
+
!visible ||
|
|
30
|
+
(!active && !option.choices.some((choice) => getChoiceThumbnail?.(option, choice.value)))
|
|
31
|
+
) return null
|
|
32
|
+
|
|
33
|
+
const activeChoice = active && option.choices.find((choice) => choice.value === value)
|
|
34
|
+
return (
|
|
35
|
+
<div className="flex flex-col gap-2">
|
|
36
|
+
<div className="px-0.5 font-medium text-muted-foreground text-xs">{option.label}</div>
|
|
37
|
+
<div
|
|
38
|
+
className="grid gap-1.5"
|
|
39
|
+
style={{
|
|
40
|
+
gridTemplateColumns: `repeat(${Math.min(option.choices.length, 3)}, minmax(0, 1fr))`,
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
{option.choices.map((choice) => {
|
|
44
|
+
const selected = active && choice.value === value
|
|
45
|
+
const thumbnail = getChoiceThumbnail?.(option, choice.value)
|
|
46
|
+
return (
|
|
47
|
+
<button
|
|
48
|
+
aria-pressed={selected}
|
|
49
|
+
className={cn(
|
|
50
|
+
'rounded-lg px-2 py-2 text-center font-medium text-xs transition-colors',
|
|
51
|
+
selected
|
|
52
|
+
? 'bg-primary/10 text-primary ring-1 ring-primary/50'
|
|
53
|
+
: 'bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
54
|
+
)}
|
|
55
|
+
key={choice.value}
|
|
56
|
+
onClick={() => {
|
|
57
|
+
triggerSFX('sfx:menu-click')
|
|
58
|
+
option.set(choice.value)
|
|
59
|
+
onSelect?.(option, choice.value)
|
|
60
|
+
}}
|
|
61
|
+
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
|
62
|
+
type="button"
|
|
63
|
+
>
|
|
64
|
+
{thumbnail && (
|
|
65
|
+
<Image
|
|
66
|
+
alt=""
|
|
67
|
+
className={cn(
|
|
68
|
+
'mx-auto mb-1 size-14 object-contain',
|
|
69
|
+
!selected && 'opacity-70 grayscale',
|
|
70
|
+
)}
|
|
71
|
+
height={56}
|
|
72
|
+
src={thumbnail}
|
|
73
|
+
width={56}
|
|
74
|
+
/>
|
|
75
|
+
)}
|
|
76
|
+
{choice.label}
|
|
77
|
+
</button>
|
|
78
|
+
)
|
|
79
|
+
})}
|
|
80
|
+
</div>
|
|
81
|
+
{activeChoice && activeChoice.description ? (
|
|
82
|
+
<p className="px-0.5 text-[11px] text-muted-foreground leading-relaxed">
|
|
83
|
+
{activeChoice.description}
|
|
84
|
+
</p>
|
|
85
|
+
) : null}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The pick-one option rows a kind declares via `def.toolOptions` (e.g. the
|
|
92
|
+
* roof's 'Create from: Draw / Room'), for whichever sidebar the host mounts
|
|
93
|
+
* it in — the standalone Build tab and the community Build sidebar both get
|
|
94
|
+
* every kind's options with no per-kind wiring. Renders nothing for kinds
|
|
95
|
+
* without options. Selecting a choice only writes the kind's own state;
|
|
96
|
+
* hosts that want selection to also arm the tool pass `onSelect`.
|
|
97
|
+
*/
|
|
98
|
+
export function ToolOptionsPanel({
|
|
99
|
+
kind,
|
|
100
|
+
className,
|
|
101
|
+
onSelect,
|
|
102
|
+
getChoiceThumbnail,
|
|
103
|
+
active = true,
|
|
104
|
+
}: {
|
|
105
|
+
kind: string | null | undefined
|
|
106
|
+
className?: string
|
|
107
|
+
onSelect?: (option: ToolOption, value: string) => void
|
|
108
|
+
getChoiceThumbnail?: (option: ToolOption, value: string) => string | undefined
|
|
109
|
+
active?: boolean
|
|
110
|
+
}) {
|
|
111
|
+
const options = (kind ? nodeRegistry.get(kind)?.toolOptions : undefined) ?? []
|
|
112
|
+
if (options.length === 0) return null
|
|
113
|
+
return (
|
|
114
|
+
<div className={cn('flex flex-col gap-3', className)}>
|
|
115
|
+
{options.map((option) => (
|
|
116
|
+
<ToolOptionRow
|
|
117
|
+
active={active}
|
|
118
|
+
getChoiceThumbnail={getChoiceThumbnail}
|
|
119
|
+
key={option.id}
|
|
120
|
+
onSelect={onSelect}
|
|
121
|
+
option={option}
|
|
122
|
+
/>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
@@ -27,8 +27,16 @@ import {
|
|
|
27
27
|
LevelNode,
|
|
28
28
|
useScene,
|
|
29
29
|
} from '@pascal-app/core'
|
|
30
|
-
import { useViewer } from '@pascal-app/viewer'
|
|
31
|
-
import {
|
|
30
|
+
import { markPerfAction, useViewer } from '@pascal-app/viewer'
|
|
31
|
+
import {
|
|
32
|
+
ClipboardPaste,
|
|
33
|
+
Copy,
|
|
34
|
+
GripVertical,
|
|
35
|
+
MoreVertical,
|
|
36
|
+
Plus,
|
|
37
|
+
Trash2,
|
|
38
|
+
X,
|
|
39
|
+
} from 'lucide-react'
|
|
32
40
|
import {
|
|
33
41
|
type ButtonHTMLAttributes,
|
|
34
42
|
type CSSProperties,
|
|
@@ -45,6 +53,7 @@ import {
|
|
|
45
53
|
} from '../../lib/level-duplication'
|
|
46
54
|
import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core'
|
|
47
55
|
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
|
|
56
|
+
import { unitMemberLevels, leaveUnitFocus } from '../../lib/units'
|
|
48
57
|
import { useLinearDisplay } from '../../lib/use-linear-display'
|
|
49
58
|
import { cn } from '../../lib/utils'
|
|
50
59
|
import { ActionButton } from './controls/action-button'
|
|
@@ -131,12 +140,15 @@ function LevelRow({
|
|
|
131
140
|
onDuplicate,
|
|
132
141
|
onPaste,
|
|
133
142
|
onRequestDelete,
|
|
143
|
+
unitDotColor,
|
|
134
144
|
}: {
|
|
135
145
|
level: LevelNode
|
|
136
146
|
isSelected: boolean
|
|
137
147
|
isDragging?: boolean
|
|
138
148
|
dragHandleProps?: ButtonHTMLAttributes<HTMLButtonElement>
|
|
139
149
|
dragHandleRef?: (element: HTMLButtonElement | null) => void
|
|
150
|
+
/** Focused-unit color when the unit has zones on this level. */
|
|
151
|
+
unitDotColor?: string
|
|
140
152
|
onSelect: () => void
|
|
141
153
|
onDuplicate: (preset?: LevelDuplicatePreset) => void
|
|
142
154
|
onPaste?: () => void
|
|
@@ -145,11 +157,17 @@ function LevelRow({
|
|
|
145
157
|
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
|
|
146
158
|
const [isEditing, setIsEditing] = useState(false)
|
|
147
159
|
const updateNode = useScene((s) => s.updateNode)
|
|
148
|
-
const { isImperial, toDisplay, displayUnit } = useLinearDisplay('m', 2)
|
|
160
|
+
const { isImperial, toDisplay, displayUnit, precision: displayPrecision } = useLinearDisplay('m', 2)
|
|
149
161
|
|
|
150
162
|
const storeyHeight = getStoredLevelHeight(level)
|
|
151
|
-
//
|
|
152
|
-
const
|
|
163
|
+
// Decimal units keep the compact readout; integer millimeters must retain trailing zeroes.
|
|
164
|
+
const formattedStoreyHeight = toDisplay(storeyHeight).toFixed(displayPrecision)
|
|
165
|
+
const storeyHeightLabel = `${
|
|
166
|
+
displayPrecision > 0 ? formattedStoreyHeight.replace(/0$/, '') : formattedStoreyHeight
|
|
167
|
+
} ${displayUnit}`
|
|
168
|
+
// Same rule as the site panel and command palette: the ordinal-0 ground
|
|
169
|
+
// floor is the vertical model's zero anchor and must never be deletable.
|
|
170
|
+
const canDeleteLevel = level.level !== 0
|
|
153
171
|
|
|
154
172
|
// Clean preset values per display system; imperial stores exact meters
|
|
155
173
|
// for whole-foot storey heights.
|
|
@@ -212,6 +230,13 @@ function LevelRow({
|
|
|
212
230
|
type="button"
|
|
213
231
|
>
|
|
214
232
|
<span className="truncate">{getLevelDisplayName(level)}</span>
|
|
233
|
+
{unitDotColor && (
|
|
234
|
+
<span
|
|
235
|
+
className="ml-1.5 h-1.5 w-1.5 shrink-0 rounded-full"
|
|
236
|
+
style={{ backgroundColor: unitDotColor }}
|
|
237
|
+
title="Focused unit has zones here"
|
|
238
|
+
/>
|
|
239
|
+
)}
|
|
215
240
|
</button>
|
|
216
241
|
|
|
217
242
|
{/* Storey height badge — opens the height popover */}
|
|
@@ -241,7 +266,7 @@ function LevelRow({
|
|
|
241
266
|
precision={3}
|
|
242
267
|
step={0.1}
|
|
243
268
|
unit="m"
|
|
244
|
-
value={
|
|
269
|
+
value={storeyHeight}
|
|
245
270
|
/>
|
|
246
271
|
<div className="mt-1.5 grid grid-cols-3 gap-1.5">
|
|
247
272
|
{heightPresets.map((preset) => (
|
|
@@ -304,11 +329,13 @@ function LevelRow({
|
|
|
304
329
|
</button>
|
|
305
330
|
)}
|
|
306
331
|
<button
|
|
307
|
-
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-red-400"
|
|
332
|
+
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors enabled:hover:bg-white/10 enabled:hover:text-red-400 disabled:cursor-not-allowed disabled:opacity-50"
|
|
333
|
+
disabled={!canDeleteLevel}
|
|
308
334
|
onClick={(e) => {
|
|
309
335
|
e.stopPropagation()
|
|
310
336
|
onRequestDelete()
|
|
311
337
|
}}
|
|
338
|
+
title={canDeleteLevel ? 'Delete level' : 'The ground level cannot be deleted'}
|
|
312
339
|
type="button"
|
|
313
340
|
>
|
|
314
341
|
<Trash2 className="h-3 w-3" />
|
|
@@ -338,9 +365,11 @@ function SortableLevelRow({
|
|
|
338
365
|
onDuplicate,
|
|
339
366
|
onPaste,
|
|
340
367
|
onRequestDelete,
|
|
368
|
+
unitDotColor,
|
|
341
369
|
}: {
|
|
342
370
|
level: LevelNode
|
|
343
371
|
isSelected: boolean
|
|
372
|
+
unitDotColor?: string
|
|
344
373
|
onSelect: () => void
|
|
345
374
|
onDuplicate: (preset?: LevelDuplicatePreset) => void
|
|
346
375
|
onPaste?: () => void
|
|
@@ -376,7 +405,42 @@ function SortableLevelRow({
|
|
|
376
405
|
onPaste={onPaste}
|
|
377
406
|
onRequestDelete={onRequestDelete}
|
|
378
407
|
onSelect={onSelect}
|
|
408
|
+
unitDotColor={unitDotColor}
|
|
409
|
+
/>
|
|
410
|
+
</div>
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── Unit focus chip: name + × to end focus, only while a unit is focused ────
|
|
415
|
+
|
|
416
|
+
function UnitFocusChip() {
|
|
417
|
+
const focusedUnitId = useViewer((s) => s.focusedUnitId)
|
|
418
|
+
const focusedUnit = useScene((s) => {
|
|
419
|
+
const unit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
|
|
420
|
+
return unit?.type === 'unit' ? unit : null
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
if (!focusedUnit) return null
|
|
424
|
+
|
|
425
|
+
return (
|
|
426
|
+
<div
|
|
427
|
+
className="mt-4 flex h-7 max-w-48 items-center gap-1.5 rounded-full border border-border bg-background/90 pr-1 pl-2.5 font-medium text-xs shadow-2xl backdrop-blur-md"
|
|
428
|
+
data-testid="unit-focus-chip"
|
|
429
|
+
>
|
|
430
|
+
<span
|
|
431
|
+
className="h-2 w-2 shrink-0 rounded-full"
|
|
432
|
+
style={{ backgroundColor: focusedUnit.color }}
|
|
379
433
|
/>
|
|
434
|
+
<span className="truncate">{focusedUnit.name || 'Unit'}</span>
|
|
435
|
+
<button
|
|
436
|
+
aria-label="Exit unit focus"
|
|
437
|
+
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
|
438
|
+
onClick={() => leaveUnitFocus()}
|
|
439
|
+
title="Exit unit focus"
|
|
440
|
+
type="button"
|
|
441
|
+
>
|
|
442
|
+
<X className="h-3 w-3" />
|
|
443
|
+
</button>
|
|
380
444
|
</div>
|
|
381
445
|
)
|
|
382
446
|
}
|
|
@@ -393,6 +457,17 @@ export function FloatingLevelSelector() {
|
|
|
393
457
|
|
|
394
458
|
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
|
|
395
459
|
const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
|
|
460
|
+
const focusedUnitId = useViewer((s) => s.focusedUnitId)
|
|
461
|
+
const focusedUnitColor = useScene((s) => {
|
|
462
|
+
const unit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
|
|
463
|
+
return unit?.type === 'unit' ? unit.color : null
|
|
464
|
+
})
|
|
465
|
+
const focusedUnitLevelIds = useScene(
|
|
466
|
+
useShallow((s) => {
|
|
467
|
+
const unit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
|
|
468
|
+
return unit?.type === 'unit' ? unitMemberLevels(unit, s.nodes).map((level) => level.id) : []
|
|
469
|
+
}),
|
|
470
|
+
)
|
|
396
471
|
const sensors = useSensors(
|
|
397
472
|
useSensor(PointerSensor, {
|
|
398
473
|
activationConstraint: { distance: 4 },
|
|
@@ -572,6 +647,9 @@ export function FloatingLevelSelector() {
|
|
|
572
647
|
{!draggingLevelId && (
|
|
573
648
|
<button
|
|
574
649
|
className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
|
|
650
|
+
// A stable hook for host-app onboarding to point at. Static, and
|
|
651
|
+
// read only from outside: nothing here depends on it.
|
|
652
|
+
data-guide-target="level-add"
|
|
575
653
|
onClick={handleAddAbove}
|
|
576
654
|
title="Add level above"
|
|
577
655
|
type="button"
|
|
@@ -608,20 +686,34 @@ export function FloatingLevelSelector() {
|
|
|
608
686
|
const showGapBelow = i < reversedLevels.length - 1
|
|
609
687
|
|
|
610
688
|
return (
|
|
611
|
-
<div
|
|
689
|
+
<div
|
|
690
|
+
className="relative"
|
|
691
|
+
// A stable hook for host-app onboarding to point at, on
|
|
692
|
+
// the ground floor only — the one level a guide can name
|
|
693
|
+
// without knowing the building. Static, and read only
|
|
694
|
+
// from outside: nothing here depends on it.
|
|
695
|
+
data-guide-target={level.level === 0 ? 'level-ground' : undefined}
|
|
696
|
+
key={level.id}
|
|
697
|
+
>
|
|
612
698
|
<SortableLevelRow
|
|
613
699
|
isSelected={isSelected}
|
|
614
700
|
level={level}
|
|
701
|
+
unitDotColor={
|
|
702
|
+
focusedUnitColor && focusedUnitLevelIds.includes(level.id)
|
|
703
|
+
? focusedUnitColor
|
|
704
|
+
: undefined
|
|
705
|
+
}
|
|
615
706
|
onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
|
|
616
707
|
onPaste={() => handlePasteToLevel(level)}
|
|
617
708
|
onRequestDelete={() => setDeletingLevel(level)}
|
|
618
|
-
onSelect={() =>
|
|
709
|
+
onSelect={() => {
|
|
710
|
+
if (!isSelected) markPerfAction('level-switch', level.id)
|
|
619
711
|
setSelection(
|
|
620
712
|
resolvedBuildingId
|
|
621
713
|
? { buildingId: resolvedBuildingId, levelId: level.id }
|
|
622
714
|
: { levelId: level.id },
|
|
623
715
|
)
|
|
624
|
-
}
|
|
716
|
+
}}
|
|
625
717
|
/>
|
|
626
718
|
|
|
627
719
|
{showGapBelow && !draggingLevelId && (
|
|
@@ -641,6 +733,7 @@ export function FloatingLevelSelector() {
|
|
|
641
733
|
</SortableContext>
|
|
642
734
|
</DndContext>
|
|
643
735
|
</div>
|
|
736
|
+
<UnitFocusChip />
|
|
644
737
|
</div>
|
|
645
738
|
|
|
646
739
|
{/* Delete confirmation dialog */}
|
|
@@ -90,6 +90,7 @@ function ShortcutSequence({
|
|
|
90
90
|
function ChipRow({
|
|
91
91
|
ariaLabel,
|
|
92
92
|
disabled = false,
|
|
93
|
+
guideTarget,
|
|
93
94
|
icon,
|
|
94
95
|
label,
|
|
95
96
|
onClick,
|
|
@@ -98,6 +99,11 @@ function ChipRow({
|
|
|
98
99
|
}: {
|
|
99
100
|
ariaLabel?: string
|
|
100
101
|
disabled?: boolean
|
|
102
|
+
/**
|
|
103
|
+
* A static hook for a host app's first-run tour to point at, written to
|
|
104
|
+
* `data-guide-target`. Nothing here reads it.
|
|
105
|
+
*/
|
|
106
|
+
guideTarget?: string
|
|
101
107
|
icon?: string
|
|
102
108
|
label: string
|
|
103
109
|
onClick?: () => void
|
|
@@ -130,6 +136,7 @@ function ChipRow({
|
|
|
130
136
|
'pointer-events-auto cursor-pointer items-center rounded-md text-left transition-colors hover:bg-muted/60',
|
|
131
137
|
disabled && 'opacity-45 saturate-0',
|
|
132
138
|
)}
|
|
139
|
+
data-guide-target={guideTarget}
|
|
133
140
|
onClick={onClick}
|
|
134
141
|
type="button"
|
|
135
142
|
>
|
|
@@ -181,6 +188,7 @@ function SnappingChips({ context }: { context: SnapContext }) {
|
|
|
181
188
|
<>
|
|
182
189
|
<ChipRow
|
|
183
190
|
ariaLabel={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
|
|
191
|
+
guideTarget="snap-mode"
|
|
184
192
|
icon={SNAPPING_MODE_ICONS[snappingMode]}
|
|
185
193
|
label={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
|
|
186
194
|
onClick={() => {
|
|
@@ -193,6 +201,7 @@ function SnappingChips({ context }: { context: SnapContext }) {
|
|
|
193
201
|
{gridActive ? (
|
|
194
202
|
<ChipRow
|
|
195
203
|
ariaLabel={`Grid step: ${gridSnapStep.toFixed(2)} m`}
|
|
204
|
+
guideTarget="snap-grid-step"
|
|
196
205
|
label={`Grid: ${gridSnapStep.toFixed(2)} m`}
|
|
197
206
|
onClick={() => {
|
|
198
207
|
setGridSnapStep(nextGridSnapStep(gridSnapStep))
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
useScene,
|
|
9
9
|
} from '@pascal-app/core'
|
|
10
10
|
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
11
|
+
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
|
12
12
|
import { useShallow } from 'zustand/react/shallow'
|
|
13
13
|
import { useIsMobile } from '../../../hooks/use-mobile'
|
|
14
14
|
import {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
resolveRotateHandleHelpHints,
|
|
20
20
|
resolveSelectModeHelpHints,
|
|
21
21
|
} from '../../../lib/contextual-help'
|
|
22
|
+
import { getContextualHelpNodeExtension } from '../../../lib/contextual-help-extension'
|
|
22
23
|
import { continuationContextOf } from '../../../lib/continuation'
|
|
23
24
|
import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation'
|
|
24
25
|
import type { ReshapeKind } from '../../../lib/interaction/scope'
|
|
@@ -33,7 +34,6 @@ import { BuildingHelper } from './building-helper'
|
|
|
33
34
|
import { ContextualHelperPanel } from './contextual-helper-panel'
|
|
34
35
|
import { ItemHelper } from './item-helper'
|
|
35
36
|
import { RegisteredToolHelper } from './registered-tool-helper'
|
|
36
|
-
import { RoofHelper } from './roof-helper'
|
|
37
37
|
|
|
38
38
|
// Reshaping a selected node's geometry (endpoint / curve / polygon corner). The
|
|
39
39
|
// snapping chip is the main control; these just name the gesture + Esc.
|
|
@@ -88,6 +88,9 @@ type ActiveModifierKeys = {
|
|
|
88
88
|
shift: boolean
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
const EMPTY_CONTEXTUAL_HINTS: ContextualShortcutHint[] = []
|
|
92
|
+
const NO_CONTEXTUAL_HELP_SUBSCRIPTION = () => () => {}
|
|
93
|
+
|
|
91
94
|
function useActiveModifierKeys(): ActiveModifierKeys {
|
|
92
95
|
const [modifiers, setModifiers] = useState<ActiveModifierKeys>({
|
|
93
96
|
command: false,
|
|
@@ -143,6 +146,21 @@ export function HelperManager() {
|
|
|
143
146
|
.filter((node): node is AnyNode => node !== undefined),
|
|
144
147
|
),
|
|
145
148
|
)
|
|
149
|
+
const contextualHelpNode =
|
|
150
|
+
scope.kind === 'mesh-editing'
|
|
151
|
+
? selectedNodes.find((node) => node.id === scope.nodeId) ?? null
|
|
152
|
+
: null
|
|
153
|
+
const contextualHelpExtension = contextualHelpNode
|
|
154
|
+
? getContextualHelpNodeExtension(nodeRegistry.get(contextualHelpNode.type))
|
|
155
|
+
: undefined
|
|
156
|
+
const contextualEditHints = useSyncExternalStore(
|
|
157
|
+
contextualHelpExtension?.subscribe ?? NO_CONTEXTUAL_HELP_SUBSCRIPTION,
|
|
158
|
+
() =>
|
|
159
|
+
contextualHelpNode
|
|
160
|
+
? (contextualHelpExtension?.getHints(contextualHelpNode.id) ?? EMPTY_CONTEXTUAL_HINTS)
|
|
161
|
+
: EMPTY_CONTEXTUAL_HINTS,
|
|
162
|
+
() => EMPTY_CONTEXTUAL_HINTS,
|
|
163
|
+
)
|
|
146
164
|
// The snapping context for whatever's active (wall / item / polygon) — drives
|
|
147
165
|
// which snapping chips the HUD shows, derived once and shared by every branch.
|
|
148
166
|
const snapContext = useMemo(
|
|
@@ -152,6 +170,10 @@ export function HelperManager() {
|
|
|
152
170
|
mode,
|
|
153
171
|
tool,
|
|
154
172
|
profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile,
|
|
173
|
+
profileOfNode: (nodeId) => {
|
|
174
|
+
const node = useScene.getState().nodes[nodeId as AnyNodeId]
|
|
175
|
+
return node ? nodeRegistry.get(node.type)?.snapProfile : undefined
|
|
176
|
+
},
|
|
155
177
|
draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
|
|
156
178
|
}),
|
|
157
179
|
[scope, mode, tool],
|
|
@@ -212,6 +234,22 @@ export function HelperManager() {
|
|
|
212
234
|
)
|
|
213
235
|
}
|
|
214
236
|
|
|
237
|
+
// A single-node resize arrow is still an active snapping interaction. Roof
|
|
238
|
+
// width/depth handles opt into grid snapping, so keep the mode and grid-step
|
|
239
|
+
// controls visible for the whole drag instead of falling through to idle
|
|
240
|
+
// selection hints.
|
|
241
|
+
if (activeHandleDrag) {
|
|
242
|
+
return (
|
|
243
|
+
<ContextualHelperPanel
|
|
244
|
+
hints={[
|
|
245
|
+
{ keys: ['Drag'], label: 'Resize' },
|
|
246
|
+
{ keys: ['Esc'], label: 'Cancel' },
|
|
247
|
+
]}
|
|
248
|
+
snapContext={snapContext}
|
|
249
|
+
/>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
215
253
|
// Reshaping a node's geometry (endpoint / curve / polygon corner). Checked
|
|
216
254
|
// before the select branch so the idle "drag selected / add objects" hints
|
|
217
255
|
// never leak over an in-progress reshape — and it gets its own snapping chip.
|
|
@@ -227,13 +265,13 @@ export function HelperManager() {
|
|
|
227
265
|
const movingContinuationContext = isFreshPlacementMetadata(movingNode.metadata)
|
|
228
266
|
? continuationContextOf(movingNode.type)
|
|
229
267
|
: null
|
|
230
|
-
|
|
231
|
-
|
|
268
|
+
const collisionValidatesDrop =
|
|
269
|
+
nodeRegistry.get(movingNode.type)?.capabilities.floorPlaced?.collides === true
|
|
232
270
|
return (
|
|
233
271
|
<ItemHelper
|
|
234
272
|
continuationContext={movingContinuationContext}
|
|
235
273
|
showEsc
|
|
236
|
-
showForce={
|
|
274
|
+
showForce={collisionValidatesDrop}
|
|
237
275
|
snapContext={snapContext}
|
|
238
276
|
/>
|
|
239
277
|
)
|
|
@@ -255,6 +293,10 @@ export function HelperManager() {
|
|
|
255
293
|
return <ContextualHelperPanel hints={terrainSculptHints(terrainVerb, terrainSampling)} />
|
|
256
294
|
}
|
|
257
295
|
|
|
296
|
+
if (scope.kind === 'mesh-editing') {
|
|
297
|
+
return <ContextualHelperPanel hints={contextualEditHints} snapContext={snapContext} />
|
|
298
|
+
}
|
|
299
|
+
|
|
258
300
|
// Idle select only — an active scope (handle-drag, box-select, …) must not show
|
|
259
301
|
// the idle selection hints.
|
|
260
302
|
if (mode === 'select' && scope.kind === 'idle') {
|
|
@@ -273,12 +315,6 @@ export function HelperManager() {
|
|
|
273
315
|
)
|
|
274
316
|
}
|
|
275
317
|
|
|
276
|
-
// Legacy fallback — only `roof` remains because it hasn't migrated to
|
|
277
|
-
// `def.tool` / `def.toolHints` yet (no Stage D port). Checked before the
|
|
278
|
-
// generic tool branch so the snap-context fallback below doesn't capture it
|
|
279
|
-
// and drop its bespoke `RoofHelper` hints. When roof migrates, this deletes.
|
|
280
|
-
if (tool === 'roof') return <RoofHelper snapContext={snapContext} />
|
|
281
|
-
|
|
282
318
|
// Registry-first: a kind renders the generic `RegisteredToolHelper` when it
|
|
283
319
|
// declares `def.toolHints`, OR whenever its draft resolves to a snap /
|
|
284
320
|
// continuation context — so a snappable tool with NO hand-written hints (e.g.
|
|
@@ -6,7 +6,7 @@ interface ItemHelperProps {
|
|
|
6
6
|
showEsc?: boolean
|
|
7
7
|
snapContext?: SnapContext | null
|
|
8
8
|
// Whether to advertise Alt = force-place. Only meaningful for kinds that
|
|
9
|
-
// collision-validate their drop
|
|
9
|
+
// collision-validate their drop.
|
|
10
10
|
showForce?: boolean
|
|
11
11
|
// Set for a fresh point-kind placement (e.g. a positioned preset) so the
|
|
12
12
|
// once/repeat continuation chip shows; null for an existing-node move.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolHint } from '@pascal-app/core'
|
|
2
|
+
import { useMemo, useSyncExternalStore } from 'react'
|
|
2
3
|
import type { ContinuationContext } from '../../../lib/continuation'
|
|
3
4
|
import type { SnapContext } from '../../../lib/snapping-mode'
|
|
4
5
|
import useEditor from '../../../store/use-editor'
|
|
@@ -27,11 +28,31 @@ export function RegisteredToolHelper({
|
|
|
27
28
|
// Live vertex count of an in-progress polygon draft, so hints gated on a
|
|
28
29
|
// minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible.
|
|
29
30
|
const draftVertexCount = useEditor((s) => s.draftVertexCount)
|
|
31
|
+
const visibilityStore = useMemo(
|
|
32
|
+
() => ({
|
|
33
|
+
subscribe: (onChange: () => void) => {
|
|
34
|
+
const unsubscribers = hints.flatMap((hint) =>
|
|
35
|
+
hint.visible ? [hint.visible.subscribe(onChange)] : [],
|
|
36
|
+
)
|
|
37
|
+
return () => {
|
|
38
|
+
for (const unsubscribe of unsubscribers) unsubscribe()
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
getSnapshot: () => hints.map((hint) => (hint.visible?.value() === false ? '0' : '1')).join(''),
|
|
42
|
+
}),
|
|
43
|
+
[hints],
|
|
44
|
+
)
|
|
45
|
+
useSyncExternalStore(
|
|
46
|
+
visibilityStore.subscribe,
|
|
47
|
+
visibilityStore.getSnapshot,
|
|
48
|
+
visibilityStore.getSnapshot,
|
|
49
|
+
)
|
|
30
50
|
// Some hints are replaced by live contextual chips, so keep the generic
|
|
31
51
|
// registry renderer from duplicating stale/static versions.
|
|
32
52
|
const visible = hints.filter(
|
|
33
53
|
(hint) =>
|
|
34
54
|
!(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') &&
|
|
55
|
+
hint.visible?.value() !== false &&
|
|
35
56
|
(hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
|
|
36
57
|
)
|
|
37
58
|
if (visible.length === 0 && !snapContext && !continuationContext) return null
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
|
3
|
+
import {
|
|
4
|
+
resolveHomogeneousSelection,
|
|
5
|
+
resolveUniqueSelectionIds,
|
|
6
|
+
} from './homogeneous-selection'
|
|
7
|
+
|
|
8
|
+
function node(
|
|
9
|
+
id: string,
|
|
10
|
+
type: string,
|
|
11
|
+
metadata?: Record<string, unknown>,
|
|
12
|
+
): AnyNode {
|
|
13
|
+
return {
|
|
14
|
+
object: 'node',
|
|
15
|
+
id: id as AnyNodeId,
|
|
16
|
+
type,
|
|
17
|
+
parentId: null,
|
|
18
|
+
visible: true,
|
|
19
|
+
metadata: metadata ?? {},
|
|
20
|
+
children: [],
|
|
21
|
+
} as unknown as AnyNode
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('resolveHomogeneousSelection', () => {
|
|
25
|
+
test('mixed selection is null', () => {
|
|
26
|
+
const nodes = {
|
|
27
|
+
wall_a: node('wall_a', 'wall'),
|
|
28
|
+
slab_a: node('slab_a', 'slab'),
|
|
29
|
+
}
|
|
30
|
+
expect(resolveHomogeneousSelection(['wall_a', 'slab_a'], nodes)).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('three walls share the wall type', () => {
|
|
34
|
+
const nodes = {
|
|
35
|
+
wall_a: node('wall_a', 'wall'),
|
|
36
|
+
wall_b: node('wall_b', 'wall'),
|
|
37
|
+
wall_c: node('wall_c', 'wall'),
|
|
38
|
+
}
|
|
39
|
+
expect(resolveHomogeneousSelection(['wall_a', 'wall_b', 'wall_c'], nodes)).toBe('wall')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('proxy-promoted children resolve to the parent type', () => {
|
|
43
|
+
const nodes = {
|
|
44
|
+
cabinet_run: node('cabinet_run', 'cabinet'),
|
|
45
|
+
'cabinet-module_a': node('cabinet-module_a', 'cabinet-module', {
|
|
46
|
+
nodeSelectionProxyId: 'cabinet_run',
|
|
47
|
+
}),
|
|
48
|
+
'cabinet-module_b': node('cabinet-module_b', 'cabinet-module', {
|
|
49
|
+
nodeSelectionProxyId: 'cabinet_run',
|
|
50
|
+
}),
|
|
51
|
+
'cabinet-module_c': node('cabinet-module_c', 'cabinet-module', {
|
|
52
|
+
nodeSelectionProxyId: 'cabinet_run',
|
|
53
|
+
}),
|
|
54
|
+
cabinet_run_b: node('cabinet_run_b', 'cabinet'),
|
|
55
|
+
'cabinet-module_d': node('cabinet-module_d', 'cabinet-module', {
|
|
56
|
+
nodeSelectionProxyId: 'cabinet_run_b',
|
|
57
|
+
}),
|
|
58
|
+
}
|
|
59
|
+
expect(resolveHomogeneousSelection(['cabinet-module_a', 'cabinet-module_d'], nodes)).toBe(
|
|
60
|
+
'cabinet',
|
|
61
|
+
)
|
|
62
|
+
expect(resolveUniqueSelectionIds(['cabinet-module_a', 'cabinet-module_b', 'cabinet-module_c'], nodes)).toEqual(
|
|
63
|
+
['cabinet_run'],
|
|
64
|
+
)
|
|
65
|
+
expect(resolveHomogeneousSelection(['cabinet-module_a', 'cabinet-module_b'], nodes)).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('stale ids are skipped without breaking a homogeneous remainder', () => {
|
|
69
|
+
const nodes = {
|
|
70
|
+
wall_a: node('wall_a', 'wall'),
|
|
71
|
+
wall_b: node('wall_b', 'wall'),
|
|
72
|
+
}
|
|
73
|
+
expect(resolveHomogeneousSelection(['wall_a', 'gone', 'wall_b'], nodes)).toBe('wall')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('a single live node after skips is not homogeneous', () => {
|
|
77
|
+
const nodes = { wall_a: node('wall_a', 'wall') }
|
|
78
|
+
expect(resolveHomogeneousSelection(['wall_a', 'gone'], nodes)).toBeNull()
|
|
79
|
+
})
|
|
80
|
+
})
|