@pascal-app/editor 1.0.0-beta.4 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/alignment-3d-guide-layer.tsx +12 -3
- package/src/components/editor/bake-exporter.tsx +1 -0
- 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 +17 -5
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -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 +182 -57
- package/src/components/editor/floating-action-menu.tsx +55 -55
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +81 -57
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- 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/linear-resize-drag.ts +67 -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 +18 -1
- package/src/components/editor/index.tsx +252 -46
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +42 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +132 -47
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
- 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 -232
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- 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-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 +204 -114
- 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/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/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 +494 -0
- package/src/components/tools/item/placement-strategies.ts +192 -24
- 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 +6 -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 +26 -4
- package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
- 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 +63 -8
- 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 +45 -8
- package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
- 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 +527 -4
- package/src/components/tools/wall/wall-drafting.ts +55 -462
- 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 +12 -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/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +19 -3
- 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 +23 -6
- 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 +263 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
- 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/panel-wrapper.tsx +228 -12
- 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 +29 -162
- package/src/components/ui/panels/reference-panel.tsx +31 -1
- 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/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
- 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/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
- package/src/components/ui/sidebar/tab-bar.tsx +9 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +221 -0
- package/src/components/viewer-overlay.tsx +13 -30
- 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 +122 -1
- package/src/hooks/use-auto-save.ts +119 -13
- package/src/hooks/use-ceiling-events.test.ts +122 -0
- 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 +164 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +109 -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/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -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/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 +397 -1
- package/src/lib/floorplan/floorplan-export.tsx +111 -16
- 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.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +85 -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/glb-export.test.ts +1169 -21
- package/src/lib/glb-export.ts +629 -101
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +523 -77
- package/src/lib/history.ts +133 -14
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -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-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- 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/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- 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 +366 -0
- package/src/lib/portable-export.ts +969 -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 +330 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +187 -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 +30 -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.ts +22 -8
- package/src/lib/selection-routing.test.ts +125 -6
- package/src/lib/selection-routing.ts +34 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -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/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- package/src/lib/usdz-export.ts +38 -0
- 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-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-session-groups.ts +103 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { RoofSegmentNode, sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
|
3
|
+
import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer'
|
|
4
|
+
import { _roots, act, createRoot, extend, type Instance, type ThreeEvent } from '@react-three/fiber'
|
|
5
|
+
import { createElement } from 'react'
|
|
6
|
+
import * as THREE from 'three'
|
|
7
|
+
import useInteractionScope from '../../../store/use-interaction-scope'
|
|
8
|
+
import { RoofEditSystem } from './roof-edit-system'
|
|
9
|
+
|
|
10
|
+
extend({ Group: THREE.Group, Mesh: THREE.Mesh, LineSegments: THREE.LineSegments })
|
|
11
|
+
|
|
12
|
+
test('roof trim drag keeps its plane hit over a surface joining and leaving a batch', async () => {
|
|
13
|
+
const previousViewer = useViewer.getState()
|
|
14
|
+
const previousScene = useScene.getState()
|
|
15
|
+
const previousScope = useInteractionScope.getState().scope
|
|
16
|
+
const previousOverrides = useLiveNodeOverrides.getState()
|
|
17
|
+
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
|
18
|
+
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document')
|
|
19
|
+
const events = Object.assign(new EventTarget(), { setTimeout, clearTimeout })
|
|
20
|
+
Object.defineProperty(globalThis, 'window', { configurable: true, value: events })
|
|
21
|
+
Object.defineProperty(globalThis, 'document', {
|
|
22
|
+
configurable: true,
|
|
23
|
+
value: { body: { style: { cursor: '' } } },
|
|
24
|
+
})
|
|
25
|
+
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
26
|
+
const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
|
|
27
|
+
actGlobal.IS_REACT_ACT_ENVIRONMENT = true
|
|
28
|
+
const canvas = Object.assign(new EventTarget(), {
|
|
29
|
+
getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
|
|
30
|
+
}) as unknown as HTMLCanvasElement
|
|
31
|
+
const root = createRoot(canvas)
|
|
32
|
+
const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100)
|
|
33
|
+
camera.position.set(0, 15, 10)
|
|
34
|
+
camera.lookAt(0, 3, 0)
|
|
35
|
+
camera.updateMatrixWorld()
|
|
36
|
+
const scene = new THREE.Scene()
|
|
37
|
+
const segment = RoofSegmentNode.parse({
|
|
38
|
+
id: 'rseg_batch_drag',
|
|
39
|
+
width: 10,
|
|
40
|
+
depth: 10,
|
|
41
|
+
metadata: { showTrimPlanes: true },
|
|
42
|
+
})
|
|
43
|
+
const source = new THREE.Group()
|
|
44
|
+
const surface = new THREE.Mesh(new THREE.BoxGeometry(30, 0.25, 30), new THREE.MeshBasicMaterial())
|
|
45
|
+
surface.position.y = 2
|
|
46
|
+
scene.add(surface)
|
|
47
|
+
scene.updateMatrixWorld(true)
|
|
48
|
+
try {
|
|
49
|
+
sceneRegistry.nodes.set(segment.id, source)
|
|
50
|
+
useScene.setState({ nodes: { [segment.id]: segment }, readOnly: false })
|
|
51
|
+
useViewer.setState({
|
|
52
|
+
hoveredId: null,
|
|
53
|
+
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [segment.id] },
|
|
54
|
+
})
|
|
55
|
+
await root.configure({
|
|
56
|
+
gl: {
|
|
57
|
+
domElement: canvas,
|
|
58
|
+
render() {},
|
|
59
|
+
setSize() {},
|
|
60
|
+
setPixelRatio() {},
|
|
61
|
+
} as unknown as THREE.WebGLRenderer,
|
|
62
|
+
camera,
|
|
63
|
+
scene,
|
|
64
|
+
frameloop: 'never',
|
|
65
|
+
dpr: 1,
|
|
66
|
+
size: { width: 100, height: 100, top: 0, left: 0 },
|
|
67
|
+
})
|
|
68
|
+
await act(async () => {
|
|
69
|
+
root.render(createElement(RoofEditSystem))
|
|
70
|
+
})
|
|
71
|
+
let pointerDown: ((event: ThreeEvent<PointerEvent>) => void) | undefined
|
|
72
|
+
scene.traverse((object) => {
|
|
73
|
+
const instance = (object as THREE.Object3D & { __r3f?: Instance }).__r3f
|
|
74
|
+
pointerDown ??= instance?.handlers.onPointerDown
|
|
75
|
+
})
|
|
76
|
+
expect(pointerDown).toBeDefined()
|
|
77
|
+
await act(async () => {
|
|
78
|
+
pointerDown!({
|
|
79
|
+
button: 0,
|
|
80
|
+
clientX: 50,
|
|
81
|
+
clientY: 50,
|
|
82
|
+
stopPropagation() {},
|
|
83
|
+
} as ThreeEvent<PointerEvent>)
|
|
84
|
+
})
|
|
85
|
+
const move = async () => {
|
|
86
|
+
await act(async () => {
|
|
87
|
+
events.dispatchEvent(Object.assign(new Event('pointermove'), { clientX: 60, clientY: 60 }))
|
|
88
|
+
})
|
|
89
|
+
return useLiveNodeOverrides.getState().overrides.get(segment.id)?.trim
|
|
90
|
+
}
|
|
91
|
+
const before = await move()
|
|
92
|
+
expect(before).toBeDefined()
|
|
93
|
+
expect(Object.values(before!).some((value) => (value as number) > 0)).toBe(true)
|
|
94
|
+
hideFromScene(surface, 'batched')
|
|
95
|
+
expect(await move()).toEqual(before)
|
|
96
|
+
showInScene(surface, 'batched')
|
|
97
|
+
expect(await move()).toEqual(before)
|
|
98
|
+
await act(async () => {
|
|
99
|
+
events.dispatchEvent(new Event('pointercancel'))
|
|
100
|
+
})
|
|
101
|
+
expect(useLiveNodeOverrides.getState().overrides.has(segment.id)).toBe(false)
|
|
102
|
+
} finally {
|
|
103
|
+
await act(async () => {
|
|
104
|
+
root.render(null)
|
|
105
|
+
})
|
|
106
|
+
_roots.delete(canvas)
|
|
107
|
+
sceneRegistry.nodes.delete(segment.id)
|
|
108
|
+
surface.geometry.dispose()
|
|
109
|
+
surface.material.dispose()
|
|
110
|
+
useScene.setState(previousScene)
|
|
111
|
+
useViewer.setState(previousViewer)
|
|
112
|
+
useInteractionScope.setState({ scope: previousScope })
|
|
113
|
+
useLiveNodeOverrides.setState(previousOverrides)
|
|
114
|
+
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
|
|
115
|
+
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
|
|
116
|
+
else Reflect.deleteProperty(globalThis, 'window')
|
|
117
|
+
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument)
|
|
118
|
+
else Reflect.deleteProperty(globalThis, 'document')
|
|
119
|
+
}
|
|
120
|
+
})
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
csgEvaluator,
|
|
23
23
|
generateRoofSegmentGeometry,
|
|
24
24
|
INTERSECTION,
|
|
25
|
+
markPureRaycast,
|
|
25
26
|
prepareBrushForCSG,
|
|
26
27
|
useViewer,
|
|
27
28
|
} from '@pascal-app/viewer'
|
|
@@ -707,7 +708,7 @@ function makeExpandedTrimRaycast(
|
|
|
707
708
|
const halfX = Math.max(0.5, hitScale[0] / Math.max(visualScale[0], 1e-6) / 2)
|
|
708
709
|
const halfY = Math.max(0.5, hitScale[1] / Math.max(visualScale[1], 1e-6) / 2)
|
|
709
710
|
const halfZ = Math.max(0.5, hitScale[2] / Math.max(visualScale[2], 1e-6) / 2)
|
|
710
|
-
return function expandedTrimRaycast(
|
|
711
|
+
return markPureRaycast(function expandedTrimRaycast(
|
|
711
712
|
this: THREE.Mesh,
|
|
712
713
|
raycaster: THREE.Raycaster,
|
|
713
714
|
intersects: THREE.Intersection[],
|
|
@@ -722,7 +723,7 @@ function makeExpandedTrimRaycast(
|
|
|
722
723
|
const distance = raycaster.ray.origin.distanceTo(point)
|
|
723
724
|
if (distance < raycaster.near || distance > raycaster.far) return
|
|
724
725
|
intersects.push({ distance, point, object: this })
|
|
725
|
-
}
|
|
726
|
+
})
|
|
726
727
|
}
|
|
727
728
|
|
|
728
729
|
function trimEquals(a: RoofSegmentTrim, b: RoofSegmentTrim): boolean {
|
|
@@ -1788,16 +1789,14 @@ export const RoofEditSystem = () => {
|
|
|
1788
1789
|
useEffect(() => {
|
|
1789
1790
|
const nodes = useScene.getState().nodes
|
|
1790
1791
|
|
|
1791
|
-
// Roofs where a segment itself is selected
|
|
1792
|
-
// merged, show wrapper).
|
|
1792
|
+
// Roofs where a segment itself is selected enter full edit mode.
|
|
1793
1793
|
const activeRoofIds = new Set<string>()
|
|
1794
1794
|
// Roofs where an accessory (dormer/chimney/etc.) is selected -> only
|
|
1795
1795
|
// reveal the wrapper so handle portals into the segment mesh become
|
|
1796
1796
|
// visible. Merged stays on.
|
|
1797
1797
|
const revealRoofIds = new Set<string>()
|
|
1798
|
-
// Roofs whose selected segment is currently being moved in 3D.
|
|
1799
|
-
//
|
|
1800
|
-
// visible and hide the merged roof to avoid the duplicate shell fighting it.
|
|
1798
|
+
// Roofs whose selected segment is currently being moved in 3D. The merged
|
|
1799
|
+
// roof remains the visual source and rebuilds from the live move override.
|
|
1801
1800
|
const movingRoofIds = new Set<string>()
|
|
1802
1801
|
|
|
1803
1802
|
for (const id of selectedIds) {
|
|
@@ -1842,14 +1841,8 @@ export const RoofEditSystem = () => {
|
|
|
1842
1841
|
const isMoving = movingRoofIds.has(roofId)
|
|
1843
1842
|
const isReveal = revealRoofIds.has(roofId)
|
|
1844
1843
|
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
// trim override (RoofSystem reads getEffectiveNode), so the dragged
|
|
1848
|
-
// cutaway matches the commit. Showing the individual per-segment meshes
|
|
1849
|
-
// instead would expose their abutting end-cap faces (the white planes the
|
|
1850
|
-
// merged union removes) — exactly what the commit doesn't show.
|
|
1851
|
-
if (mergedMesh) mergedMesh.visible = !isMoving
|
|
1852
|
-
if (segmentsWrapper) segmentsWrapper.visible = isReveal || isMoving
|
|
1844
|
+
if (mergedMesh) mergedMesh.visible = true
|
|
1845
|
+
if (segmentsWrapper) segmentsWrapper.visible = isReveal
|
|
1853
1846
|
|
|
1854
1847
|
const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined
|
|
1855
1848
|
if (roofNode?.children?.length) {
|
|
@@ -1857,10 +1850,8 @@ export const RoofEditSystem = () => {
|
|
|
1857
1850
|
const wasMoving = prevMovingRoofIds.current.has(roofId)
|
|
1858
1851
|
const wasReveal = prevRevealRoofIds.current.has(roofId)
|
|
1859
1852
|
if (isActive !== wasActive || isMoving !== wasMoving) {
|
|
1860
|
-
// Entering
|
|
1861
|
-
//
|
|
1862
|
-
// wrapper mesh previously stripped to an empty placeholder is
|
|
1863
|
-
// restored before the drag begins.
|
|
1853
|
+
// Entering or exiting edit and move modes rebuilds the merged shell
|
|
1854
|
+
// from the current segment values.
|
|
1864
1855
|
const { markDirty } = useScene.getState()
|
|
1865
1856
|
for (const childId of roofNode.children) {
|
|
1866
1857
|
markDirty(childId as AnyNodeId)
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
createSceneApi,
|
|
6
|
+
runAsSingleSceneHistoryStep,
|
|
7
|
+
useScene,
|
|
8
|
+
} from '@pascal-app/core'
|
|
4
9
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
10
|
import { type ComponentType, Suspense, useMemo } from 'react'
|
|
6
11
|
import { getRegistryAffordanceTool } from '../tools/shared/affordance-dispatch'
|
|
12
|
+
import type {
|
|
13
|
+
SelectionAffordanceHistoryApi,
|
|
14
|
+
SelectionAffordanceInteractionApi,
|
|
15
|
+
SelectionAffordanceProps,
|
|
16
|
+
} from './selection-affordance-services'
|
|
7
17
|
|
|
8
18
|
/**
|
|
9
19
|
* Editor-mounted dispatcher for a kind's selection-time editing UI.
|
|
@@ -19,20 +29,60 @@ import { getRegistryAffordanceTool } from '../tools/shared/affordance-dispatch'
|
|
|
19
29
|
*/
|
|
20
30
|
export function SelectionAffordanceManager() {
|
|
21
31
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
22
|
-
const
|
|
32
|
+
const selectedNode = useScene((s) => {
|
|
23
33
|
if (selectedIds.length !== 1) return null
|
|
24
|
-
return s.nodes[selectedIds[0] as AnyNodeId]
|
|
34
|
+
return s.nodes[selectedIds[0] as AnyNodeId] ?? null
|
|
25
35
|
})
|
|
36
|
+
const readOnly = useScene((s) => s.readOnly)
|
|
37
|
+
const sceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
38
|
+
const historyApi = useMemo<SelectionAffordanceHistoryApi>(
|
|
39
|
+
() => ({
|
|
40
|
+
depth: () => useScene.temporal.getState().pastStates.length,
|
|
41
|
+
replaceLatest: (expectedDepth, replace) => {
|
|
42
|
+
if (useScene.temporal.getState().pastStates.length !== expectedDepth) return false
|
|
43
|
+
let replaced = false
|
|
44
|
+
runAsSingleSceneHistoryStep(useScene, () => {
|
|
45
|
+
useScene.temporal.getState().undo()
|
|
46
|
+
replaced = replace()
|
|
47
|
+
if (!replaced) useScene.temporal.getState().redo()
|
|
48
|
+
})
|
|
49
|
+
return replaced
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
[],
|
|
53
|
+
)
|
|
54
|
+
const interactionApi = useMemo<SelectionAffordanceInteractionApi>(
|
|
55
|
+
() => ({
|
|
56
|
+
beginInputDrag: () => {
|
|
57
|
+
const previous = useViewer.getState().inputDragging
|
|
58
|
+
let restored = false
|
|
59
|
+
useViewer.getState().setInputDragging(true)
|
|
60
|
+
return () => {
|
|
61
|
+
if (restored) return
|
|
62
|
+
restored = true
|
|
63
|
+
useViewer.getState().setInputDragging(previous)
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
clearSelection: () => useViewer.getState().setSelection({ selectedIds: [] }),
|
|
67
|
+
}),
|
|
68
|
+
[],
|
|
69
|
+
)
|
|
26
70
|
|
|
27
|
-
const Component = useMemo<ComponentType | null>(() => {
|
|
28
|
-
if (!
|
|
29
|
-
return getRegistryAffordanceTool(
|
|
30
|
-
}, [
|
|
71
|
+
const Component = useMemo<ComponentType<SelectionAffordanceProps> | null>(() => {
|
|
72
|
+
if (!selectedNode) return null
|
|
73
|
+
return getRegistryAffordanceTool(selectedNode.type, 'selection')
|
|
74
|
+
}, [selectedNode])
|
|
31
75
|
|
|
32
|
-
if (!Component) return null
|
|
76
|
+
if (!(Component && selectedNode)) return null
|
|
33
77
|
return (
|
|
34
78
|
<Suspense fallback={null}>
|
|
35
|
-
<Component
|
|
79
|
+
<Component
|
|
80
|
+
historyApi={historyApi}
|
|
81
|
+
interactionApi={interactionApi}
|
|
82
|
+
node={selectedNode}
|
|
83
|
+
readOnly={readOnly}
|
|
84
|
+
sceneApi={sceneApi}
|
|
85
|
+
/>
|
|
36
86
|
</Suspense>
|
|
37
87
|
)
|
|
38
88
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AnyNode, SceneApi } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export type SelectionAffordanceHistoryApi = {
|
|
4
|
+
depth: () => number
|
|
5
|
+
replaceLatest: (expectedDepth: number, replace: () => boolean) => boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type SelectionAffordanceInteractionApi = {
|
|
9
|
+
beginInputDrag: () => () => void
|
|
10
|
+
clearSelection: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type SelectionAffordanceProps = {
|
|
14
|
+
historyApi: SelectionAffordanceHistoryApi
|
|
15
|
+
interactionApi: SelectionAffordanceInteractionApi
|
|
16
|
+
node: AnyNode
|
|
17
|
+
readOnly: boolean
|
|
18
|
+
sceneApi: SceneApi
|
|
19
|
+
}
|
|
@@ -67,9 +67,12 @@ export const StairEditSystem = () => {
|
|
|
67
67
|
const mergedMesh = group.getObjectByName('merged-stair')
|
|
68
68
|
const segmentsWrapper = group.getObjectByName('segments-wrapper')
|
|
69
69
|
const isActive = activeStairIds.has(stairId)
|
|
70
|
+
// A straight stair with no segment children has an empty wrapper, so
|
|
71
|
+
// edit mode would hide the merged body and leave nothing on screen.
|
|
72
|
+
const isEditable = !isCurved && (stairNode?.children?.length ?? 0) > 0
|
|
70
73
|
|
|
71
|
-
if (mergedMesh) mergedMesh.visible = !(isActive || isCurved)
|
|
72
|
-
if (segmentsWrapper) segmentsWrapper.visible = isActive &&
|
|
74
|
+
if (mergedMesh) mergedMesh.visible = !((isActive && isEditable) || isCurved)
|
|
75
|
+
if (segmentsWrapper) segmentsWrapper.visible = isActive && isEditable
|
|
73
76
|
|
|
74
77
|
if (stairNode?.children?.length) {
|
|
75
78
|
const wasActive = prevActiveStairIds.current.has(stairId)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
GROUND_SUPPORT_ID,
|
|
6
|
+
type SlabNode,
|
|
7
|
+
spatialGridManager,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
+
import useEditor from '../../../store/use-editor'
|
|
12
|
+
import { createFenceOnCurrentLevel } from './fence-drafting'
|
|
13
|
+
|
|
14
|
+
const LEVEL_ID = 'level_test' as AnyNodeId
|
|
15
|
+
|
|
16
|
+
function seedLevel(extraNodes: AnyNode[] = []) {
|
|
17
|
+
useScene.setState({
|
|
18
|
+
nodes: Object.fromEntries([
|
|
19
|
+
[
|
|
20
|
+
LEVEL_ID,
|
|
21
|
+
{
|
|
22
|
+
id: LEVEL_ID,
|
|
23
|
+
type: 'level',
|
|
24
|
+
object: 'node',
|
|
25
|
+
parentId: null,
|
|
26
|
+
visible: true,
|
|
27
|
+
metadata: {},
|
|
28
|
+
children: extraNodes.map((node) => node.id),
|
|
29
|
+
level: 0,
|
|
30
|
+
} as AnyNode,
|
|
31
|
+
],
|
|
32
|
+
...extraNodes.map((node) => [node.id, node] as const),
|
|
33
|
+
]),
|
|
34
|
+
rootNodeIds: [LEVEL_ID],
|
|
35
|
+
dirtyNodes: new Set(),
|
|
36
|
+
collections: {},
|
|
37
|
+
} as never)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('createFenceOnCurrentLevel', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
spatialGridManager.clear()
|
|
43
|
+
useViewer.setState({
|
|
44
|
+
selection: {
|
|
45
|
+
buildingId: null,
|
|
46
|
+
levelId: LEVEL_ID,
|
|
47
|
+
zoneId: null,
|
|
48
|
+
selectedIds: [],
|
|
49
|
+
},
|
|
50
|
+
} as never)
|
|
51
|
+
useEditor.getState().setToolDefaults('fence', null)
|
|
52
|
+
seedLevel()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('freezes a block top above its underlying slab', () => {
|
|
56
|
+
const slab = {
|
|
57
|
+
id: 'slab_low',
|
|
58
|
+
type: 'slab',
|
|
59
|
+
object: 'node',
|
|
60
|
+
parentId: LEVEL_ID,
|
|
61
|
+
visible: true,
|
|
62
|
+
metadata: {},
|
|
63
|
+
children: [],
|
|
64
|
+
polygon: [
|
|
65
|
+
[-2, -2],
|
|
66
|
+
[2, -2],
|
|
67
|
+
[2, 2],
|
|
68
|
+
[-2, 2],
|
|
69
|
+
],
|
|
70
|
+
holes: [],
|
|
71
|
+
holeMetadata: [],
|
|
72
|
+
elevation: 0.25,
|
|
73
|
+
thickness: 0.25,
|
|
74
|
+
recessed: false,
|
|
75
|
+
autoFromWalls: false,
|
|
76
|
+
} as SlabNode
|
|
77
|
+
seedLevel([slab as AnyNode])
|
|
78
|
+
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
|
|
79
|
+
|
|
80
|
+
const fence = createFenceOnCurrentLevel([-1, 0], [1, 0], {
|
|
81
|
+
supportCap: 2,
|
|
82
|
+
preferredSupportSlabId: slab.id,
|
|
83
|
+
constructionElevation: 2,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
expect(fence?.supportSlabId).toBe(slab.id)
|
|
87
|
+
expect(fence?.supportOffset).toBeCloseTo(1.75)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('pins ground beneath a block top when no slab exists', () => {
|
|
91
|
+
const fence = createFenceOnCurrentLevel([-1, 0], [1, 0], {
|
|
92
|
+
supportCap: 2,
|
|
93
|
+
preferredSupportSlabId: null,
|
|
94
|
+
constructionElevation: 2,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
expect(fence?.supportSlabId).toBe(GROUND_SUPPORT_ID)
|
|
98
|
+
expect(fence?.supportOffset).toBeCloseTo(2)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DEFAULT_ANGLE_STEP,
|
|
3
|
+
type FenceConstructionOptions as FenceCommitOptions,
|
|
3
4
|
FenceNode,
|
|
4
5
|
getTwoPointFenceCurveTangents,
|
|
5
6
|
getWallCurveFrameAt,
|
|
6
7
|
getWallCurveLength,
|
|
7
8
|
isCurvedWall,
|
|
8
|
-
|
|
9
|
+
resolveFenceConstructionSupport,
|
|
9
10
|
snapPointAlongAngleRay,
|
|
10
11
|
useScene,
|
|
11
12
|
type WallNode,
|
|
@@ -188,18 +189,6 @@ export function snapFenceDraftPoint(args: {
|
|
|
188
189
|
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
|
|
189
190
|
}
|
|
190
191
|
|
|
191
|
-
export type FenceCommitOptions = {
|
|
192
|
-
/**
|
|
193
|
-
* Pointer-decided support cap (level-local Y) from
|
|
194
|
-
* `resolvePointerSupportSurface` — the 3D tool passes the elevation of
|
|
195
|
-
* the surface the commit click actually aimed at, so a fence drawn on a
|
|
196
|
-
* deck top persists the deck as its lift host while one drawn at the
|
|
197
|
-
* floor underneath stays grounded. Omitted by 2D floor-plan commits (no
|
|
198
|
-
* camera ray): those keep the uncapped max election.
|
|
199
|
-
*/
|
|
200
|
-
supportCap?: number | null
|
|
201
|
-
}
|
|
202
|
-
|
|
203
192
|
export function createFenceOnCurrentLevel(
|
|
204
193
|
start: FencePlanPoint,
|
|
205
194
|
end: FencePlanPoint,
|
|
@@ -217,7 +206,7 @@ export function createFenceOnCurrentLevel(
|
|
|
217
206
|
// spacing, …) merge in first; `name`/`start`/`end` always win. The
|
|
218
207
|
// schema parse validates and drops anything unexpected.
|
|
219
208
|
const defaults = useEditor.getState().toolDefaults.fence ?? {}
|
|
220
|
-
const
|
|
209
|
+
const authoredFence = FenceNode.parse({
|
|
221
210
|
...defaults,
|
|
222
211
|
name: `Fence ${fenceCount + 1}`,
|
|
223
212
|
start,
|
|
@@ -225,11 +214,7 @@ export function createFenceOnCurrentLevel(
|
|
|
225
214
|
})
|
|
226
215
|
// Fences run no per-frame support election — the persisted host IS the
|
|
227
216
|
// lift (absent = level floor), so elect it at commit, pointer-capped.
|
|
228
|
-
fence
|
|
229
|
-
{ ...fence, parentId: currentLevelId },
|
|
230
|
-
nodes,
|
|
231
|
-
{ maxElevation: options?.supportCap ?? null },
|
|
232
|
-
).supportSlabId
|
|
217
|
+
const fence = resolveFenceConstructionSupport(authoredFence, currentLevelId, nodes, options)
|
|
233
218
|
|
|
234
219
|
createNode(fence, currentLevelId)
|
|
235
220
|
sfxEmitter.emit('sfx:structure-build')
|
|
@@ -264,7 +249,7 @@ export function createSplineFenceOnCurrentLevel(
|
|
|
264
249
|
|
|
265
250
|
const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length
|
|
266
251
|
const defaults = useEditor.getState().toolDefaults.fence ?? {}
|
|
267
|
-
const
|
|
252
|
+
const authoredFence = FenceNode.parse({
|
|
268
253
|
...defaults,
|
|
269
254
|
name: `Fence ${fenceCount + 1}`,
|
|
270
255
|
start,
|
|
@@ -272,11 +257,7 @@ export function createSplineFenceOnCurrentLevel(
|
|
|
272
257
|
path,
|
|
273
258
|
tangents,
|
|
274
259
|
})
|
|
275
|
-
fence
|
|
276
|
-
{ ...fence, parentId: currentLevelId },
|
|
277
|
-
nodes,
|
|
278
|
-
{ maxElevation: options?.supportCap ?? null },
|
|
279
|
-
).supportSlabId
|
|
260
|
+
const fence = resolveFenceConstructionSupport(authoredFence, currentLevelId, nodes, options)
|
|
280
261
|
|
|
281
262
|
createNode(fence, currentLevelId)
|
|
282
263
|
sfxEmitter.emit('sfx:structure-build')
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { shouldCreateFloorDraft } from './draft-creation'
|
|
3
|
+
|
|
4
|
+
describe('shouldCreateFloorDraft', () => {
|
|
5
|
+
test('does not create a level-hosted draft while block-face placement is active', () => {
|
|
6
|
+
expect(shouldCreateFloorDraft(null, undefined, 'block-face')).toBe(false)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
test('creates a draft for an unmounted floor placement', () => {
|
|
10
|
+
expect(shouldCreateFloorDraft(null, undefined, 'floor')).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AssetInput, ItemNode } from '@pascal-app/core'
|
|
2
|
+
import type { PlacementState } from './placement-types'
|
|
3
|
+
|
|
4
|
+
export function shouldCreateFloorDraft(
|
|
5
|
+
draft: ItemNode | null,
|
|
6
|
+
attachTo: AssetInput['attachTo'],
|
|
7
|
+
surface: PlacementState['surface'],
|
|
8
|
+
): boolean {
|
|
9
|
+
return draft === null && attachTo === undefined && surface === 'floor'
|
|
10
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { BlockNode, ItemNode, type LevelNode, useScene } from '@pascal-app/core'
|
|
3
|
+
import { Vector3 } from 'three'
|
|
4
|
+
import { resolveFaceHostPreviewCommit } from './face-host-commit'
|
|
5
|
+
import type { PlacementContext } from './placement-types'
|
|
6
|
+
import { registerTestBlockFaceHost } from './test-face-host'
|
|
7
|
+
|
|
8
|
+
const BLOCK_ID = 'block_self-click'
|
|
9
|
+
const LEVEL_ID = 'level_self-click' as LevelNode['id']
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
registerTestBlockFaceHost()
|
|
13
|
+
useScene.setState((state) => ({
|
|
14
|
+
...state,
|
|
15
|
+
nodes: {
|
|
16
|
+
...state.nodes,
|
|
17
|
+
[BLOCK_ID]: BlockNode.parse({ id: BLOCK_ID, parentId: LEVEL_ID }),
|
|
18
|
+
},
|
|
19
|
+
}))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('resolveFaceHostPreviewCommit', () => {
|
|
23
|
+
test('commits an intercepted draft click to its active block face', () => {
|
|
24
|
+
const asset = {
|
|
25
|
+
id: 'cactus',
|
|
26
|
+
category: 'decor',
|
|
27
|
+
name: 'Cactus',
|
|
28
|
+
thumbnail: '/cactus.png',
|
|
29
|
+
src: '/cactus.glb',
|
|
30
|
+
dimensions: [0.5, 0.39, 0.5] as [number, number, number],
|
|
31
|
+
}
|
|
32
|
+
const context: PlacementContext = {
|
|
33
|
+
asset,
|
|
34
|
+
levelId: LEVEL_ID,
|
|
35
|
+
draftItem: ItemNode.parse({
|
|
36
|
+
id: 'item_self-click',
|
|
37
|
+
asset,
|
|
38
|
+
parentId: BLOCK_ID,
|
|
39
|
+
position: [0.5, -0.5, 0],
|
|
40
|
+
rotation: [Math.PI / 2, 0, 0],
|
|
41
|
+
blockFaceId: 'face-top',
|
|
42
|
+
metadata: { isTransient: true },
|
|
43
|
+
}),
|
|
44
|
+
gridPosition: new Vector3(0.5, -0.5, 0),
|
|
45
|
+
state: {
|
|
46
|
+
surface: 'block-face',
|
|
47
|
+
blockId: BLOCK_ID,
|
|
48
|
+
wallId: null,
|
|
49
|
+
roofSegmentId: null,
|
|
50
|
+
ceilingId: null,
|
|
51
|
+
surfaceItemId: null,
|
|
52
|
+
shelfId: null,
|
|
53
|
+
},
|
|
54
|
+
currentCursorRotationY: 0,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
expect(resolveFaceHostPreviewCommit(context)?.nodeUpdate).toMatchObject({
|
|
58
|
+
parentId: BLOCK_ID,
|
|
59
|
+
position: [0.5, -0.5, 0],
|
|
60
|
+
rotation: [Math.PI / 2, 0, 0],
|
|
61
|
+
blockFaceId: 'face-top',
|
|
62
|
+
metadata: {},
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ItemNode, NodeEvent } from '@pascal-app/core'
|
|
2
|
+
import { nodeRegistry, useScene } from '@pascal-app/core'
|
|
3
|
+
import { stripTransient } from './placement-math'
|
|
4
|
+
import { faceHostStrategy } from './placement-strategies'
|
|
5
|
+
import type { CommitResult, PlacementContext } from './placement-types'
|
|
6
|
+
|
|
7
|
+
export type FaceHostClickCommitOutcome = {
|
|
8
|
+
committedId: string | null
|
|
9
|
+
wasAdopted: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function commitFaceHostClick({
|
|
13
|
+
commitDraft,
|
|
14
|
+
enterFaceHost,
|
|
15
|
+
event,
|
|
16
|
+
getContext,
|
|
17
|
+
}: {
|
|
18
|
+
commitDraft: (nodeUpdate: Partial<ItemNode>) => FaceHostClickCommitOutcome
|
|
19
|
+
enterFaceHost: (event: NodeEvent) => boolean
|
|
20
|
+
event: NodeEvent
|
|
21
|
+
getContext: () => PlacementContext
|
|
22
|
+
}): FaceHostClickCommitOutcome | null {
|
|
23
|
+
let result = faceHostStrategy.click(getContext(), event)
|
|
24
|
+
if (!result && enterFaceHost(event)) {
|
|
25
|
+
result = faceHostStrategy.click(getContext(), event)
|
|
26
|
+
}
|
|
27
|
+
if (!result) return null
|
|
28
|
+
const outcome = commitDraft(result.nodeUpdate)
|
|
29
|
+
event.stopPropagation()
|
|
30
|
+
return outcome
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveFaceHostPreviewCommit(context: PlacementContext): CommitResult | null {
|
|
34
|
+
const { draftItem, gridPosition, state } = context
|
|
35
|
+
if (state.surface !== 'block-face' || !state.blockId || !draftItem) {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
const host = useScene.getState().nodes[state.blockId]
|
|
39
|
+
const faceHost = host ? nodeRegistry.get(host.type)?.capabilities.faceHost : undefined
|
|
40
|
+
if (!(host && faceHost)) return null
|
|
41
|
+
const nodeUpdate = faceHost?.storedPlacementPatch({
|
|
42
|
+
host,
|
|
43
|
+
item: draftItem,
|
|
44
|
+
position: [gridPosition.x, gridPosition.y, gridPosition.z],
|
|
45
|
+
})
|
|
46
|
+
if (!nodeUpdate) return null
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
nodeUpdate: {
|
|
50
|
+
...nodeUpdate,
|
|
51
|
+
metadata: stripTransient(draftItem.metadata),
|
|
52
|
+
},
|
|
53
|
+
stopPropagation: true,
|
|
54
|
+
dirtyNodeId: null,
|
|
55
|
+
}
|
|
56
|
+
}
|