@pascal-app/editor 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +63 -25
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
- package/src/components/editor/first-person/build-collider-world.ts +32 -5
- package/src/components/editor/first-person-controls.tsx +118 -7
- package/src/components/editor/floating-action-menu.tsx +410 -33
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +1804 -849
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/use-handle-drag.ts +37 -4
- package/src/components/editor/index.tsx +248 -27
- package/src/components/editor/measurement-pill.tsx +62 -22
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +366 -173
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +812 -466
- package/src/components/editor/site-edge-labels.tsx +4 -4
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +136 -43
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +3 -13
- package/src/components/editor/wall-move-side-handles.tsx +36 -21
- package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +80 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +31 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
- package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
- package/src/components/tools/roof/roof-tool.tsx +320 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +88 -36
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +82 -11
- package/src/components/tools/tool-manager.tsx +86 -25
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +180 -103
- package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +82 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -93
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +2 -2
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +86 -17
- package/src/components/ui/controls/material-picker.tsx +83 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +227 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +22 -13
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +15 -2
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +85 -10
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-keyboard.ts +385 -40
- package/src/index.tsx +219 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +240 -3
- package/src/lib/fresh-planar-placement.ts +68 -1
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +9 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +73 -8
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +50 -13
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +111 -66
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +484 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/level-name.ts +0 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pascal-app/editor",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Pascal building editor component",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -8,17 +8,17 @@
|
|
|
8
8
|
"./catalog": "./src/components/ui/item-catalog/catalog-items.tsx"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"check-types": "
|
|
11
|
+
"check-types": "tsgo --noEmit"
|
|
12
12
|
},
|
|
13
13
|
"peerDependencies": {
|
|
14
|
-
"@pascal-app/core": "^0.9.
|
|
15
|
-
"@pascal-app/viewer": "^0.9.
|
|
14
|
+
"@pascal-app/core": "^0.9.2",
|
|
15
|
+
"@pascal-app/viewer": "^0.9.2",
|
|
16
16
|
"@react-three/drei": "^10",
|
|
17
17
|
"@react-three/fiber": "^9",
|
|
18
18
|
"next": ">=15",
|
|
19
19
|
"react": "^18 || ^19",
|
|
20
20
|
"react-dom": "^18 || ^19",
|
|
21
|
-
"three": "^0.
|
|
21
|
+
"three": "^0.185"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@dnd-kit/core": "^6.3.1",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"@dnd-kit/utilities": "^3.2.2",
|
|
27
27
|
"@iconify/react": "^6.0.2",
|
|
28
28
|
"@number-flow/react": "^0.6.0",
|
|
29
|
+
"@pascal-app/lingo": "^0.2.0",
|
|
29
30
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
|
30
31
|
"@radix-ui/react-context-menu": "^2.2.16",
|
|
31
32
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
@@ -43,18 +44,20 @@
|
|
|
43
44
|
"clsx": "^2.1.1",
|
|
44
45
|
"cmdk": "^1.1.1",
|
|
45
46
|
"howler": "^2.2.4",
|
|
47
|
+
"jspdf": "^4.2.1",
|
|
46
48
|
"lucide-react": "^1.7.0",
|
|
47
49
|
"mitt": "^3.0.1",
|
|
48
50
|
"motion": "^12.34.3",
|
|
49
51
|
"nanoid": "^5.1.6",
|
|
52
|
+
"svg2pdf.js": "^2.7.0",
|
|
50
53
|
"tailwind-merge": "^3.5.0",
|
|
54
|
+
"three-mesh-bvh": "~0.9.8",
|
|
51
55
|
"zod": "^4.3.6",
|
|
52
|
-
"zustand": "^5.0.11"
|
|
53
|
-
"three-mesh-bvh": "~0.9.8"
|
|
56
|
+
"zustand": "^5.0.11"
|
|
54
57
|
},
|
|
55
58
|
"devDependencies": {
|
|
56
|
-
"@pascal-app/core": "^0.9.
|
|
57
|
-
"@pascal-app/viewer": "^0.9.
|
|
59
|
+
"@pascal-app/core": "^0.9.2",
|
|
60
|
+
"@pascal-app/viewer": "^0.9.2",
|
|
58
61
|
"@pascal/typescript-config": "*",
|
|
59
62
|
"@types/bun": "^1.3.0",
|
|
60
63
|
"@types/howler": "^2.2.12",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { type AlignmentGuide, sceneRegistry } from '@pascal-app/core'
|
|
4
|
-
import { useAlignmentGuides } from '@pascal-app/editor'
|
|
5
4
|
import { useViewer } from '@pascal-app/viewer'
|
|
6
5
|
import { Html } from '@react-three/drei'
|
|
7
6
|
import { useFrame } from '@react-three/fiber'
|
|
@@ -9,6 +8,7 @@ import { memo, useMemo, useRef } from 'react'
|
|
|
9
8
|
import { BoxGeometry, CircleGeometry, type Group, Vector3 } from 'three'
|
|
10
9
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
11
10
|
import { EDITOR_LAYER } from '../../lib/constants'
|
|
11
|
+
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
12
12
|
import { formatMeasurement } from './measurement-pill'
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -49,6 +49,14 @@ const guideMaterial = new MeshBasicNodeMaterial({
|
|
|
49
49
|
toneMapped: false,
|
|
50
50
|
transparent: true,
|
|
51
51
|
})
|
|
52
|
+
const DOT_COLOR = 0x22_c5_5e // green-500 — matches the wall-snap marker
|
|
53
|
+
const dotMaterial = new MeshBasicNodeMaterial({
|
|
54
|
+
color: DOT_COLOR,
|
|
55
|
+
depthTest: false,
|
|
56
|
+
depthWrite: false,
|
|
57
|
+
toneMapped: false,
|
|
58
|
+
transparent: true,
|
|
59
|
+
})
|
|
52
60
|
const DASH_GEOMETRY = new BoxGeometry(1, 1, 1)
|
|
53
61
|
const DOT_GEOMETRY = new CircleGeometry(1, 24)
|
|
54
62
|
|
|
@@ -150,7 +158,7 @@ function Dot({ position }: { position: Vec3 }) {
|
|
|
150
158
|
<mesh
|
|
151
159
|
geometry={DOT_GEOMETRY}
|
|
152
160
|
layers={EDITOR_LAYER}
|
|
153
|
-
material={
|
|
161
|
+
material={dotMaterial}
|
|
154
162
|
position={position}
|
|
155
163
|
renderOrder={1001}
|
|
156
164
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { useThree } from '@react-three/fiber'
|
|
6
|
+
import { useEffect, useRef } from 'react'
|
|
7
|
+
import { exportSceneToGlb, nextFrames } from '../../lib/glb-export'
|
|
8
|
+
|
|
9
|
+
export function BakeExporter({
|
|
10
|
+
active,
|
|
11
|
+
onComplete,
|
|
12
|
+
onError,
|
|
13
|
+
}: {
|
|
14
|
+
active: boolean
|
|
15
|
+
onComplete: (buffer: ArrayBuffer) => void
|
|
16
|
+
onError: (message: string) => void
|
|
17
|
+
}) {
|
|
18
|
+
const scene = useThree((s) => s.scene)
|
|
19
|
+
const doneRef = useRef(false)
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!(active && !doneRef.current)) return
|
|
22
|
+
doneRef.current = true
|
|
23
|
+
const run = async () => {
|
|
24
|
+
try {
|
|
25
|
+
// Signal export so instanced kinds (trees/flowers/grass) swap their
|
|
26
|
+
// invisible proxy for real, exportable geometry, then wait for the
|
|
27
|
+
// commit before cloning the scene graph.
|
|
28
|
+
useViewer.getState().setExporting(true)
|
|
29
|
+
await nextFrames()
|
|
30
|
+
const sceneGroup = scene.getObjectByName('scene-renderer')
|
|
31
|
+
if (!sceneGroup) throw new Error('scene-renderer group not found')
|
|
32
|
+
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
|
|
33
|
+
onComplete(buffer)
|
|
34
|
+
} catch (err) {
|
|
35
|
+
// The bake worker relays page console output into the job's error
|
|
36
|
+
// trail; the message alone rarely localises an exporter crash, so
|
|
37
|
+
// surface the full stack here.
|
|
38
|
+
console.error('[bake-exporter]', err instanceof Error ? (err.stack ?? err.message) : err)
|
|
39
|
+
onError(err instanceof Error ? err.message : String(err))
|
|
40
|
+
} finally {
|
|
41
|
+
useViewer.getState().setExporting(false)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
void run()
|
|
45
|
+
}, [active, scene, onComplete, onError])
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
@@ -22,6 +22,11 @@ import {
|
|
|
22
22
|
} from 'three'
|
|
23
23
|
import { EDITOR_LAYER } from '../../lib/constants'
|
|
24
24
|
import useEditor from '../../store/use-editor'
|
|
25
|
+
import {
|
|
26
|
+
useActiveHandleDrag,
|
|
27
|
+
useEndpointReshape,
|
|
28
|
+
useMovingNode,
|
|
29
|
+
} from '../../store/use-interaction-scope'
|
|
25
30
|
|
|
26
31
|
const currentTarget = new Vector3()
|
|
27
32
|
const tempBox = new Box3()
|
|
@@ -364,10 +369,15 @@ export const CustomCameraControls = () => {
|
|
|
364
369
|
const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
|
|
365
370
|
const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
|
|
366
371
|
const lastApplied2dNavigationRevision = useRef(0)
|
|
372
|
+
const savedSmoothTimeRef = useRef<number | null>(null)
|
|
367
373
|
const maxPolarAngle =
|
|
368
374
|
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
|
|
369
375
|
const clearPendingFloorplanNavigationPose = useCallback(() => {
|
|
370
376
|
pendingFloorplanNavigationPose.current = null
|
|
377
|
+
if (savedSmoothTimeRef.current !== null && controls.current) {
|
|
378
|
+
controls.current.smoothTime = savedSmoothTimeRef.current
|
|
379
|
+
savedSmoothTimeRef.current = null
|
|
380
|
+
}
|
|
371
381
|
}, [])
|
|
372
382
|
|
|
373
383
|
const camera = useThree((state) => state.camera)
|
|
@@ -381,6 +391,20 @@ export const CustomCameraControls = () => {
|
|
|
381
391
|
raycaster.layers.enable(ZONE_LAYER)
|
|
382
392
|
}, [camera, raycaster])
|
|
383
393
|
|
|
394
|
+
useEffect(() => {
|
|
395
|
+
// Dev-only: deterministic camera poses for screenshot/automation tooling.
|
|
396
|
+
// A getter, not a snapshot — drei recreates the impl when the default
|
|
397
|
+
// camera changes, so a captured instance goes stale.
|
|
398
|
+
if (process.env.NODE_ENV !== 'development') return
|
|
399
|
+
const w = window as typeof window & {
|
|
400
|
+
__pascalCameraControls?: (() => CameraControlsImpl | null) | null
|
|
401
|
+
}
|
|
402
|
+
w.__pascalCameraControls = () => controls.current
|
|
403
|
+
return () => {
|
|
404
|
+
w.__pascalCameraControls = null
|
|
405
|
+
}
|
|
406
|
+
}, [])
|
|
407
|
+
|
|
384
408
|
useEffect(() => {
|
|
385
409
|
if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return
|
|
386
410
|
let targetY = 0
|
|
@@ -452,12 +476,7 @@ export const CustomCameraControls = () => {
|
|
|
452
476
|
|
|
453
477
|
return useEditor.subscribe((state) => {
|
|
454
478
|
const pose = state.navigationSyncPose
|
|
455
|
-
if (
|
|
456
|
-
!pose ||
|
|
457
|
-
pose.source !== '2d' ||
|
|
458
|
-
pose.revision === lastApplied2dNavigationRevision.current
|
|
459
|
-
)
|
|
460
|
-
return
|
|
479
|
+
if (pose?.source !== '2d' || pose.revision === lastApplied2dNavigationRevision.current) return
|
|
461
480
|
|
|
462
481
|
const control = controls.current
|
|
463
482
|
if (!control) return
|
|
@@ -478,6 +497,13 @@ export const CustomCameraControls = () => {
|
|
|
478
497
|
Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >=
|
|
479
498
|
NAVIGATION_SYNC_VIEW_WIDTH_EPSILON,
|
|
480
499
|
}
|
|
500
|
+
// Match 3D settle time to 2D exponential decay (τ=90ms). SmoothDamp's
|
|
501
|
+
// effective time constant is smoothTime/2, so smoothTime=0.18 gives
|
|
502
|
+
// τ≈90ms and visual convergence in ~350-400ms, matching the 2D panel.
|
|
503
|
+
if (savedSmoothTimeRef.current === null) {
|
|
504
|
+
savedSmoothTimeRef.current = control.smoothTime
|
|
505
|
+
}
|
|
506
|
+
control.smoothTime = 0.18
|
|
481
507
|
control.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
|
|
482
508
|
control.rotateTo(targetAzimuth, control.polarAngle, true)
|
|
483
509
|
applyCameraViewWidth(control, viewWidthUpdate)
|
|
@@ -499,7 +525,7 @@ export const CustomCameraControls = () => {
|
|
|
499
525
|
isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth)
|
|
500
526
|
) {
|
|
501
527
|
lastPublishedNavigationSync.current = pendingFloorplanPose
|
|
502
|
-
|
|
528
|
+
clearPendingFloorplanNavigationPose()
|
|
503
529
|
if (pendingFloorplanPose.publishOnComplete) {
|
|
504
530
|
useEditor.getState().publishNavigationSyncPose({
|
|
505
531
|
source: '3d',
|
|
@@ -540,7 +566,7 @@ export const CustomCameraControls = () => {
|
|
|
540
566
|
azimuth: syncSpherical.theta,
|
|
541
567
|
viewWidth,
|
|
542
568
|
})
|
|
543
|
-
}, [camera, isFirstPersonMode, viewportSize])
|
|
569
|
+
}, [camera, clearPendingFloorplanNavigationPose, isFirstPersonMode, viewportSize])
|
|
544
570
|
|
|
545
571
|
useEffect(() => {
|
|
546
572
|
if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
|
|
@@ -573,7 +599,7 @@ export const CustomCameraControls = () => {
|
|
|
573
599
|
)
|
|
574
600
|
const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
|
|
575
601
|
|
|
576
|
-
|
|
602
|
+
clearPendingFloorplanNavigationPose()
|
|
577
603
|
if (horizontal !== 0) control.truck(horizontal * step, 0, true)
|
|
578
604
|
if (vertical !== 0) control.forward(vertical * step, true)
|
|
579
605
|
})
|
|
@@ -611,18 +637,12 @@ export const CustomCameraControls = () => {
|
|
|
611
637
|
const tool = useEditor((s) => s.tool)
|
|
612
638
|
const mode = useEditor((s) => s.mode)
|
|
613
639
|
const selectionTool = useEditor((s) => s.floorplanSelectionTool)
|
|
614
|
-
const movingNode =
|
|
615
|
-
const
|
|
616
|
-
const
|
|
617
|
-
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
|
|
640
|
+
const movingNode = useMovingNode()
|
|
641
|
+
const endpointReshape = useEndpointReshape()
|
|
642
|
+
const activeHandleDrag = useActiveHandleDrag()
|
|
618
643
|
const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
|
|
619
644
|
const isInteracting = Boolean(
|
|
620
|
-
tool ||
|
|
621
|
-
movingNode ||
|
|
622
|
-
movingWallEndpoint ||
|
|
623
|
-
movingFenceEndpoint ||
|
|
624
|
-
activeHandleDrag ||
|
|
625
|
-
isBoxSelectActive,
|
|
645
|
+
tool || movingNode || endpointReshape || activeHandleDrag || isBoxSelectActive,
|
|
626
646
|
)
|
|
627
647
|
const touches = useMemo(() => {
|
|
628
648
|
const twoFingerAction =
|
|
@@ -731,7 +751,7 @@ export const CustomCameraControls = () => {
|
|
|
731
751
|
!isEditableKeyboardTarget(event.target)
|
|
732
752
|
) {
|
|
733
753
|
setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
|
|
734
|
-
|
|
754
|
+
clearPendingFloorplanNavigationPose()
|
|
735
755
|
event.preventDefault()
|
|
736
756
|
event.stopPropagation()
|
|
737
757
|
}
|
|
@@ -794,7 +814,7 @@ export const CustomCameraControls = () => {
|
|
|
794
814
|
|
|
795
815
|
const onPointerDown = (event: PointerEvent) => {
|
|
796
816
|
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
|
|
797
|
-
|
|
817
|
+
clearPendingFloorplanNavigationPose()
|
|
798
818
|
if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
|
|
799
819
|
|
|
800
820
|
panPointerId = event.pointerId
|
|
@@ -803,7 +823,7 @@ export const CustomCameraControls = () => {
|
|
|
803
823
|
}
|
|
804
824
|
|
|
805
825
|
const onWheel = () => {
|
|
806
|
-
|
|
826
|
+
clearPendingFloorplanNavigationPose()
|
|
807
827
|
}
|
|
808
828
|
|
|
809
829
|
const onPointerUp = (event: PointerEvent) => {
|
|
@@ -845,7 +865,25 @@ export const CustomCameraControls = () => {
|
|
|
845
865
|
clearKeyboardPanKeys()
|
|
846
866
|
clearNavigationCursor()
|
|
847
867
|
}
|
|
848
|
-
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode])
|
|
868
|
+
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode, clearPendingFloorplanNavigationPose])
|
|
869
|
+
|
|
870
|
+
// Cancel any in-progress 2D-origin navigation pose when the user starts
|
|
871
|
+
// dragging (right-click orbit, middle-click pan, touch). `controlstart`
|
|
872
|
+
// fires only for user pointer interactions — not for programmatic
|
|
873
|
+
// moveTo/rotateTo which emit `transitionstart` instead.
|
|
874
|
+
useEffect(() => {
|
|
875
|
+
if (isFirstPersonMode) return
|
|
876
|
+
const control = controls.current
|
|
877
|
+
if (!control) return
|
|
878
|
+
|
|
879
|
+
const onControlStart = () => {
|
|
880
|
+
clearPendingFloorplanNavigationPose()
|
|
881
|
+
}
|
|
882
|
+
control.addEventListener('controlstart', onControlStart)
|
|
883
|
+
return () => {
|
|
884
|
+
control.removeEventListener('controlstart', onControlStart)
|
|
885
|
+
}
|
|
886
|
+
}, [isFirstPersonMode, clearPendingFloorplanNavigationPose])
|
|
849
887
|
|
|
850
888
|
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
|
851
889
|
const previewTargetNodeId = isPreviewMode
|
|
@@ -1154,12 +1192,12 @@ export const CustomCameraControls = () => {
|
|
|
1154
1192
|
}, [])
|
|
1155
1193
|
|
|
1156
1194
|
// Preset capture mode frames a single subtree (often a 0.3–2m preset),
|
|
1157
|
-
// so the default
|
|
1195
|
+
// so the default 2m minDistance prevents the user from getting close
|
|
1158
1196
|
// enough to compose a good thumbnail. Relax the clamp to 0.5m while
|
|
1159
1197
|
// capturing presets; reset on exit so general editing keeps the looser
|
|
1160
1198
|
// navigation guardrails.
|
|
1161
1199
|
const isPresetCapture = captureMode.mode === 'preset'
|
|
1162
|
-
const minDistance = isPresetCapture ? 0.5 :
|
|
1200
|
+
const minDistance = isPresetCapture ? 0.5 : 2
|
|
1163
1201
|
|
|
1164
1202
|
if (isFirstPersonMode) {
|
|
1165
1203
|
return null
|
|
@@ -159,6 +159,11 @@ export function EditorLayoutMobile({
|
|
|
159
159
|
const expandedThreshold = Math.max(SHEET_HANDLE_PX, defaultPx * 0.5)
|
|
160
160
|
if (current > expandedThreshold) {
|
|
161
161
|
sheetRef.current?.snapTo(SHEET_HANDLE_PX)
|
|
162
|
+
// Closing the sheet disarms any build tool back to select
|
|
163
|
+
const { mode, setMode } = useEditor.getState()
|
|
164
|
+
if (mode === 'build') {
|
|
165
|
+
setMode('select')
|
|
166
|
+
}
|
|
162
167
|
} else {
|
|
163
168
|
sheetRef.current?.snapTo(defaultPx)
|
|
164
169
|
}
|
|
@@ -52,6 +52,15 @@ function LeftColumn({
|
|
|
52
52
|
}
|
|
53
53
|
}, [activePanel])
|
|
54
54
|
|
|
55
|
+
// Closing (collapsing) the sidebar disarms any build tool back to select
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!isCollapsed) return
|
|
58
|
+
const { mode, setMode } = useEditor.getState()
|
|
59
|
+
if (mode === 'build') {
|
|
60
|
+
setMode('select')
|
|
61
|
+
}
|
|
62
|
+
}, [isCollapsed])
|
|
63
|
+
|
|
55
64
|
const handleResizerDown = useCallback(
|
|
56
65
|
(e: React.PointerEvent) => {
|
|
57
66
|
e.preventDefault()
|
|
@@ -150,11 +159,13 @@ function RightColumn({
|
|
|
150
159
|
toolbarRight,
|
|
151
160
|
children,
|
|
152
161
|
overlays,
|
|
162
|
+
stageOverlay,
|
|
153
163
|
}: {
|
|
154
164
|
toolbarLeft?: ReactNode
|
|
155
165
|
toolbarRight?: ReactNode
|
|
156
166
|
children: ReactNode
|
|
157
167
|
overlays?: ReactNode
|
|
168
|
+
stageOverlay?: ReactNode
|
|
158
169
|
}) {
|
|
159
170
|
return (
|
|
160
171
|
<div
|
|
@@ -174,6 +185,10 @@ function RightColumn({
|
|
|
174
185
|
)}
|
|
175
186
|
{/* Canvas area */}
|
|
176
187
|
<div className="relative flex-1 overflow-hidden">{children}</div>
|
|
188
|
+
{/* Stage overlay — replaces the canvas visually (e.g. studio gallery)
|
|
189
|
+
while keeping it mounted. Sits below the viewer toolbar (z-20) so
|
|
190
|
+
the stage switch stays reachable. */}
|
|
191
|
+
{stageOverlay && <div className="absolute inset-0 z-10">{stageOverlay}</div>}
|
|
177
192
|
{/* Overlays scoped to the viewer column. `data-viewer-bounds` marks the
|
|
178
193
|
draggable region the floating inspector clamps itself to. */}
|
|
179
194
|
{overlays && (
|
|
@@ -200,6 +215,7 @@ export interface EditorLayoutV2Props {
|
|
|
200
215
|
viewerToolbarRight?: ReactNode
|
|
201
216
|
viewerContent: ReactNode
|
|
202
217
|
overlays?: ReactNode
|
|
218
|
+
stageOverlay?: ReactNode
|
|
203
219
|
}
|
|
204
220
|
|
|
205
221
|
export function EditorLayoutV2({
|
|
@@ -211,6 +227,7 @@ export function EditorLayoutV2({
|
|
|
211
227
|
viewerToolbarRight,
|
|
212
228
|
viewerContent,
|
|
213
229
|
overlays,
|
|
230
|
+
stageOverlay,
|
|
214
231
|
}: EditorLayoutV2Props) {
|
|
215
232
|
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
|
216
233
|
const isMobile = useIsMobile()
|
|
@@ -246,6 +263,7 @@ export function EditorLayoutV2({
|
|
|
246
263
|
)}
|
|
247
264
|
<RightColumn
|
|
248
265
|
overlays={overlays}
|
|
266
|
+
stageOverlay={stageOverlay}
|
|
249
267
|
toolbarLeft={isCaptureMode ? undefined : viewerToolbarLeft}
|
|
250
268
|
toolbarRight={isCaptureMode ? undefined : viewerToolbarRight}
|
|
251
269
|
>
|
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import { emitter, useScene } from '@pascal-app/core'
|
|
3
4
|
import { useViewer } from '@pascal-app/viewer'
|
|
4
5
|
import { useThree } from '@react-three/fiber'
|
|
5
6
|
import { useEffect } from 'react'
|
|
6
|
-
import
|
|
7
|
-
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
|
|
7
|
+
import * as THREE from 'three'
|
|
8
8
|
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
|
|
9
9
|
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
|
10
|
+
import { exportSceneToGlb, nextFrames, prepareSceneForExport } from '../../lib/glb-export'
|
|
11
|
+
|
|
12
|
+
// prepareSceneForExport neutralises container meshes (door/window hitbox roots,
|
|
13
|
+
// material-less renderables) with an attribute-less geometry — GLTFExporter
|
|
14
|
+
// emits those as plain transform nodes, but STL/OBJExporter read
|
|
15
|
+
// `position.count` unconditionally and crash. Swap in a geometry with an empty
|
|
16
|
+
// (count-0) position so they iterate zero vertices instead. Shared: the export
|
|
17
|
+
// scene is a throwaway clone, only its geometry *ref* is swapped.
|
|
18
|
+
const EMPTY_POSITION_GEOMETRY = new THREE.BufferGeometry()
|
|
19
|
+
EMPTY_POSITION_GEOMETRY.setAttribute(
|
|
20
|
+
'position',
|
|
21
|
+
new THREE.Float32BufferAttribute(new Float32Array(0), 3),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
function ensurePositionAttributes(root: THREE.Object3D) {
|
|
25
|
+
root.traverse((object) => {
|
|
26
|
+
const renderable = object as THREE.Mesh & { isLine?: boolean; isPoints?: boolean }
|
|
27
|
+
if (!(renderable.isMesh || renderable.isLine || renderable.isPoints)) return
|
|
28
|
+
if (!renderable.geometry?.getAttribute('position')) {
|
|
29
|
+
renderable.geometry = EMPTY_POSITION_GEOMETRY
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
}
|
|
10
33
|
|
|
11
34
|
export function ExportManager() {
|
|
12
35
|
const scene = useThree((state) => state.scene)
|
|
@@ -22,42 +45,54 @@ export function ExportManager() {
|
|
|
22
45
|
}
|
|
23
46
|
|
|
24
47
|
const date = new Date().toISOString().split('T')[0]
|
|
25
|
-
const exportScene = prepareSceneForExport(sceneGroup)
|
|
26
|
-
|
|
27
|
-
if (format === 'stl') {
|
|
28
|
-
const exporter = new STLExporter()
|
|
29
|
-
const result = exporter.parse(exportScene, { binary: true })
|
|
30
|
-
const blob = new Blob([result], { type: 'model/stl' })
|
|
31
|
-
downloadBlob(blob, `model_${date}.stl`)
|
|
32
|
-
return
|
|
33
|
-
}
|
|
34
48
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
49
|
+
// Signal export so instanced kinds (trees/flowers/grass) swap their
|
|
50
|
+
// invisible proxy for real, exportable geometry, then wait for the
|
|
51
|
+
// commit before cloning the scene graph (same dance as BakeExporter —
|
|
52
|
+
// without it every plant exports as its raycast collider, a white box).
|
|
53
|
+
useViewer.getState().setExporting(true)
|
|
54
|
+
try {
|
|
55
|
+
await nextFrames()
|
|
56
|
+
|
|
57
|
+
if (format === 'glb') {
|
|
58
|
+
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
|
|
59
|
+
const blob = new Blob([buffer], { type: 'model/gltf-binary' })
|
|
60
|
+
downloadBlob(blob, `model_${date}.glb`)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Hide editor affordances that live on the scene layer (selection handles,
|
|
65
|
+
// ceiling/site brackets) and let wall-cutout reveal all walls — the same
|
|
66
|
+
// synchronous capture path thumbnails use. We clone the scene inside the
|
|
67
|
+
// window, so the export snapshots the clean building, then restore.
|
|
68
|
+
emitter.emit('thumbnail:before-capture', undefined)
|
|
69
|
+
let prepared: ReturnType<typeof prepareSceneForExport>
|
|
70
|
+
try {
|
|
71
|
+
prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes)
|
|
72
|
+
} finally {
|
|
73
|
+
emitter.emit('thumbnail:after-capture', undefined)
|
|
74
|
+
}
|
|
75
|
+
const { scene: exportScene } = prepared
|
|
76
|
+
ensurePositionAttributes(exportScene)
|
|
77
|
+
|
|
78
|
+
if (format === 'stl') {
|
|
79
|
+
const exporter = new STLExporter()
|
|
80
|
+
const result = exporter.parse(exportScene, { binary: true })
|
|
81
|
+
const blob = new Blob([result], { type: 'model/stl' })
|
|
82
|
+
downloadBlob(blob, `model_${date}.stl`)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (format === 'obj') {
|
|
87
|
+
const exporter = new OBJExporter()
|
|
88
|
+
const result = exporter.parse(exportScene)
|
|
89
|
+
const blob = new Blob([result], { type: 'model/obj' })
|
|
90
|
+
downloadBlob(blob, `model_${date}.obj`)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
useViewer.getState().setExporting(false)
|
|
41
95
|
}
|
|
42
|
-
|
|
43
|
-
// Default: GLB export (existing behavior)
|
|
44
|
-
const exporter = new GLTFExporter()
|
|
45
|
-
|
|
46
|
-
return new Promise<void>((resolve, reject) => {
|
|
47
|
-
exporter.parse(
|
|
48
|
-
exportScene,
|
|
49
|
-
(gltf) => {
|
|
50
|
-
const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })
|
|
51
|
-
downloadBlob(blob, `model_${date}.glb`)
|
|
52
|
-
resolve()
|
|
53
|
-
},
|
|
54
|
-
(error) => {
|
|
55
|
-
console.error('Export error:', error)
|
|
56
|
-
reject(error)
|
|
57
|
-
},
|
|
58
|
-
{ binary: true },
|
|
59
|
-
)
|
|
60
|
-
})
|
|
61
96
|
}
|
|
62
97
|
|
|
63
98
|
setExportScene(exportFn)
|
|
@@ -70,33 +105,6 @@ export function ExportManager() {
|
|
|
70
105
|
return null
|
|
71
106
|
}
|
|
72
107
|
|
|
73
|
-
function prepareSceneForExport(source: Object3D) {
|
|
74
|
-
const clone = source.clone(true)
|
|
75
|
-
const meshesToRemove: Mesh[] = []
|
|
76
|
-
|
|
77
|
-
clone.traverse((object) => {
|
|
78
|
-
if (isMeshWithInvalidGeometry(object)) meshesToRemove.push(object)
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
for (const mesh of meshesToRemove) {
|
|
82
|
-
mesh.removeFromParent()
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return clone
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function isMeshWithInvalidGeometry(object: Object3D): object is Mesh {
|
|
89
|
-
if (!isMesh(object)) return false
|
|
90
|
-
|
|
91
|
-
// Three exporters can crash when a Mesh has no readable position attribute.
|
|
92
|
-
const position = object.geometry?.getAttribute('position')
|
|
93
|
-
return !position || position.count === 0
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function isMesh(object: Object3D): object is Mesh {
|
|
97
|
-
return (object as Mesh).isMesh === true
|
|
98
|
-
}
|
|
99
|
-
|
|
100
108
|
function downloadBlob(blob: Blob, filename: string) {
|
|
101
109
|
const url = URL.createObjectURL(blob)
|
|
102
110
|
const link = document.createElement('a')
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
type FenceNode,
|
|
6
|
+
getFenceControlHandle,
|
|
7
|
+
isSplineFence,
|
|
8
|
+
sceneRegistry,
|
|
9
|
+
useLiveNodeOverrides,
|
|
10
|
+
useScene,
|
|
11
|
+
} from '@pascal-app/core'
|
|
12
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
13
|
+
import { createPortal, useFrame } from '@react-three/fiber'
|
|
14
|
+
import { useMemo, useState } from 'react'
|
|
15
|
+
import { BufferGeometry, type Object3D, Vector3 } from 'three'
|
|
16
|
+
import { EDITOR_LAYER } from '../../lib/constants'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Straight connecting line through each spline-fence control point joining its
|
|
20
|
+
* two tangent handle ends (the classic pen-tool look). The handle *dots* are
|
|
21
|
+
* registry tap-handles (see `fence/definition.ts`); this overlay only draws
|
|
22
|
+
* the line between them, in both views' 3D scene.
|
|
23
|
+
*
|
|
24
|
+
* Must match the handle placement's arm scale so the line ends land exactly on
|
|
25
|
+
* the dots. Portals into the fence's own Object3D so it inherits the same world
|
|
26
|
+
* transform as the geometry (path coords are node-local plan meters), and reads
|
|
27
|
+
* the live override so the line tracks an in-flight tangent / point drag.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
// Keep in sync with TANGENT_HANDLE_ARM_SCALE in fence/definition.ts.
|
|
31
|
+
const TANGENT_HANDLE_ARM_SCALE = 3
|
|
32
|
+
const LINE_LIFT_Y = 0.02
|
|
33
|
+
|
|
34
|
+
export function FenceTangentLines3D() {
|
|
35
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
36
|
+
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
|
37
|
+
|
|
38
|
+
const rawNode = useScene((s) => (selectedId ? s.nodes[selectedId as AnyNodeId] : null))
|
|
39
|
+
const liveOverride = useLiveNodeOverrides((s) =>
|
|
40
|
+
selectedId ? s.overrides.get(selectedId as AnyNodeId) : undefined,
|
|
41
|
+
)
|
|
42
|
+
const fence = useMemo<FenceNode | null>(() => {
|
|
43
|
+
if (rawNode?.type !== 'fence') return null
|
|
44
|
+
const merged = liveOverride ? ({ ...rawNode, ...liveOverride } as FenceNode) : rawNode
|
|
45
|
+
return isSplineFence(merged) ? merged : null
|
|
46
|
+
}, [rawNode, liveOverride])
|
|
47
|
+
|
|
48
|
+
const [object, setObject] = useState<{ id: AnyNodeId; object: Object3D } | null>(null)
|
|
49
|
+
const selectedObject = selectedId && object?.id === selectedId ? object.object : null
|
|
50
|
+
|
|
51
|
+
useFrame(() => {
|
|
52
|
+
if (!selectedId || selectedObject) return
|
|
53
|
+
const next = sceneRegistry.nodes.get(selectedId)
|
|
54
|
+
if (next) setObject({ id: selectedId as AnyNodeId, object: next })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const geometry = useMemo(() => {
|
|
58
|
+
if (!fence?.path) return null
|
|
59
|
+
const positions: Vector3[] = []
|
|
60
|
+
for (let i = 0; i < fence.path.length; i += 1) {
|
|
61
|
+
const point = fence.path[i]!
|
|
62
|
+
const handle = getFenceControlHandle(fence.path, fence.tangents, i)
|
|
63
|
+
const ax = handle.x * TANGENT_HANDLE_ARM_SCALE
|
|
64
|
+
const az = handle.y * TANGENT_HANDLE_ARM_SCALE
|
|
65
|
+
// One disjoint segment per point — `lineSegments` pairs vertices, so the
|
|
66
|
+
// in/out ends connect through the point without joining across points.
|
|
67
|
+
positions.push(new Vector3(point[0] - ax, LINE_LIFT_Y, point[1] - az))
|
|
68
|
+
positions.push(new Vector3(point[0] + ax, LINE_LIFT_Y, point[1] + az))
|
|
69
|
+
}
|
|
70
|
+
return new BufferGeometry().setFromPoints(positions)
|
|
71
|
+
}, [fence])
|
|
72
|
+
|
|
73
|
+
if (!(fence && selectedObject && geometry)) return null
|
|
74
|
+
|
|
75
|
+
return createPortal(
|
|
76
|
+
<lineSegments frustumCulled={false} geometry={geometry} layers={EDITOR_LAYER} renderOrder={3}>
|
|
77
|
+
<lineBasicNodeMaterial
|
|
78
|
+
color="#8381ed"
|
|
79
|
+
depthTest={false}
|
|
80
|
+
depthWrite={false}
|
|
81
|
+
opacity={0.85}
|
|
82
|
+
transparent
|
|
83
|
+
/>
|
|
84
|
+
</lineSegments>,
|
|
85
|
+
selectedObject,
|
|
86
|
+
)
|
|
87
|
+
}
|