@pascal-app/editor 1.0.0 → 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/package.json +7 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +23 -3
- 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 +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +5 -2
- package/src/components/editor/index.tsx +12 -4
- package/src/components/editor/node-arrow-handles.tsx +21 -62
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- 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/placement-strategies.ts +169 -169
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +95 -45
- package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
- 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.tsx +215 -70
- package/src/components/tools/shared/pointer-support-cap.ts +11 -9
- 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/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +25 -18
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +6 -2
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- 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/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/hooks/use-ceiling-events.test.ts +24 -2
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-keyboard.ts +3 -1
- package/src/index.tsx +5 -0
- 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/floorplan/floorplan-export.test.ts +139 -1
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-readonly.ts +4 -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 +206 -0
- package/src/lib/glb-export.ts +171 -24
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- 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/node-action-movement.ts +21 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- 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/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-send-to-app.ts +21 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
createSceneApi,
|
|
4
|
+
getSurfaceProvider,
|
|
5
|
+
sceneRegistry,
|
|
6
|
+
surfaceRegionContainsPoint,
|
|
7
|
+
useScene,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
import { Box3, type Camera, Matrix4, Ray, Vector3 } from 'three'
|
|
10
|
+
|
|
11
|
+
export function createShelfStickiness() {
|
|
12
|
+
const ray = new Ray()
|
|
13
|
+
const box = new Box3()
|
|
14
|
+
const matrix = new Matrix4()
|
|
15
|
+
const cameraPosition = new Vector3()
|
|
16
|
+
|
|
17
|
+
// A ray through a board gap can hit the floor behind the shelf while still targeting its volume.
|
|
18
|
+
return (
|
|
19
|
+
shelfId: string | null | undefined,
|
|
20
|
+
camera: Camera,
|
|
21
|
+
worldPoint: readonly [number, number, number],
|
|
22
|
+
) => {
|
|
23
|
+
if (!shelfId) return false
|
|
24
|
+
const mesh = sceneRegistry.nodes.get(shelfId)
|
|
25
|
+
const shelf = useScene.getState().nodes[shelfId as AnyNodeId]
|
|
26
|
+
if (!mesh || !shelf || (shelf.type !== 'shelf' && shelf.type !== 'cabinet')) return false
|
|
27
|
+
camera.getWorldPosition(cameraPosition)
|
|
28
|
+
ray.origin.copy(cameraPosition)
|
|
29
|
+
ray.direction
|
|
30
|
+
.set(...worldPoint)
|
|
31
|
+
.sub(cameraPosition)
|
|
32
|
+
.normalize()
|
|
33
|
+
ray.applyMatrix4(matrix.copy(mesh.matrixWorld).invert())
|
|
34
|
+
if (shelf.type === 'cabinet') {
|
|
35
|
+
const surfaces =
|
|
36
|
+
getSurfaceProvider(shelf).surfaces?.(shelf, { scene: createSceneApi(useScene) }) ?? []
|
|
37
|
+
return surfaces.some((surface) => {
|
|
38
|
+
if (Math.abs(ray.direction.y) < 1e-8) return false
|
|
39
|
+
const distance = (surface.position[1] - ray.origin.y) / ray.direction.y
|
|
40
|
+
if (distance < 0) return false
|
|
41
|
+
const point = ray.at(distance, new Vector3())
|
|
42
|
+
return surfaceRegionContainsPoint(surface.region, [
|
|
43
|
+
point.x - surface.position[0],
|
|
44
|
+
point.z - surface.position[2],
|
|
45
|
+
])
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
if (!shelf.width || !shelf.depth || !shelf.height) return false
|
|
49
|
+
const margin = 0.08
|
|
50
|
+
box.min.set(-shelf.width / 2 - margin, -margin, -shelf.depth / 2 - margin)
|
|
51
|
+
box.max.set(shelf.width / 2 + margin, shelf.height + margin, shelf.depth / 2 + margin)
|
|
52
|
+
return ray.intersectsBox(box)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { type ItemEvent, ItemNode, sceneRegistry } from '@pascal-app/core'
|
|
3
|
+
import { Group, Matrix3, Vector3 } from 'three'
|
|
4
|
+
import { itemEventToSurfaceHit } from './surface-hit'
|
|
5
|
+
|
|
6
|
+
const host = ItemNode.parse({
|
|
7
|
+
id: 'item_hit-adapter',
|
|
8
|
+
asset: { id: 'host', name: 'Host', category: 'furniture', thumbnail: '', src: '/host.glb' },
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterEach(() => sceneRegistry.nodes.delete(host.id))
|
|
12
|
+
|
|
13
|
+
test('matches the legacy host-local point and hit-mesh normal exactly under rotation and scale', () => {
|
|
14
|
+
const mesh = new Group()
|
|
15
|
+
mesh.position.set(4.3, 2.1, -3.7)
|
|
16
|
+
mesh.rotation.set(0.2, 0.7, -0.1)
|
|
17
|
+
mesh.scale.set(2, 0.7, 1.3)
|
|
18
|
+
const object = new Group()
|
|
19
|
+
object.name = 'surface-mesh'
|
|
20
|
+
object.position.set(0.4, 0.8, -0.2)
|
|
21
|
+
object.rotation.set(-0.1, 0.3, 0.2)
|
|
22
|
+
object.scale.set(0.8, 1.4, 0.6)
|
|
23
|
+
mesh.add(object)
|
|
24
|
+
mesh.updateMatrixWorld(true)
|
|
25
|
+
sceneRegistry.nodes.set(host.id, mesh)
|
|
26
|
+
const event = {
|
|
27
|
+
node: host,
|
|
28
|
+
object,
|
|
29
|
+
position: object.localToWorld(new Vector3(0.37, 0.81, -0.39)).toArray(),
|
|
30
|
+
localPosition: [0.37, 0.81, -0.39],
|
|
31
|
+
normal: [0.2, 1, -0.3],
|
|
32
|
+
nativeEvent: {} as ItemEvent['nativeEvent'],
|
|
33
|
+
stopPropagation() {},
|
|
34
|
+
} as ItemEvent
|
|
35
|
+
const before = structuredClone({ position: event.position, normal: event.normal })
|
|
36
|
+
const expectedPoint = mesh.worldToLocal(new Vector3(...event.position)).toArray()
|
|
37
|
+
const expectedNormal = new Vector3(...event.normal!)
|
|
38
|
+
.applyNormalMatrix(new Matrix3().getNormalMatrix(object.matrixWorld))
|
|
39
|
+
.normalize().y
|
|
40
|
+
const hit = itemEventToSurfaceHit(host, event)!
|
|
41
|
+
expect(hit.point).toEqual(expectedPoint)
|
|
42
|
+
expect(hit.point).not.toEqual(event.localPosition)
|
|
43
|
+
expect(hit.normalWorldY).toBe(expectedNormal)
|
|
44
|
+
expect(hit.meshName).toBe(object.name)
|
|
45
|
+
expect({ position: event.position, normal: event.normal }).toEqual(before)
|
|
46
|
+
|
|
47
|
+
const roundTrip = mesh.localToWorld(new Vector3(...expectedPoint)).toArray()
|
|
48
|
+
expect(itemEventToSurfaceHit(host, { ...event, position: roundTrip })!.point).toEqual(
|
|
49
|
+
mesh.worldToLocal(new Vector3(...roundTrip)).toArray(),
|
|
50
|
+
)
|
|
51
|
+
expect(itemEventToSurfaceHit(host, { ...event, normal: undefined })!.normalWorldY).toBeNaN()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('a missing registered host mesh yields no hit', () => {
|
|
55
|
+
expect(itemEventToSurfaceHit(host, {} as ItemEvent)).toBeNull()
|
|
56
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type AnyNode, type NodeEvent, type SurfaceHit, sceneRegistry } from '@pascal-app/core'
|
|
2
|
+
import { Matrix3, type Matrix4, Vector3 } from 'three'
|
|
3
|
+
|
|
4
|
+
export function surfaceWorldNormalY(
|
|
5
|
+
normal: NodeEvent<AnyNode>['normal'],
|
|
6
|
+
matrixWorld: Matrix4,
|
|
7
|
+
): number {
|
|
8
|
+
return normal
|
|
9
|
+
? new Vector3(...normal)
|
|
10
|
+
.applyNormalMatrix(new Matrix3().getNormalMatrix(matrixWorld))
|
|
11
|
+
.normalize().y
|
|
12
|
+
: Number.NaN
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function itemEventToSurfaceHit(host: AnyNode, event: NodeEvent<AnyNode>): SurfaceHit | null {
|
|
16
|
+
const mesh = sceneRegistry.nodes.get(host.id)
|
|
17
|
+
if (!mesh) return null
|
|
18
|
+
return {
|
|
19
|
+
point: mesh.worldToLocal(new Vector3(...event.position)).toArray(),
|
|
20
|
+
normalWorldY: surfaceWorldNormalY(event.normal, event.object.matrixWorld),
|
|
21
|
+
meshName: event.object.name,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
ItemNode,
|
|
5
|
+
nodeRegistry,
|
|
6
|
+
registerNode,
|
|
7
|
+
resolveSurfacePlacement,
|
|
8
|
+
type SceneApi,
|
|
9
|
+
type SurfaceProvider,
|
|
10
|
+
type SurfaceRejectReason,
|
|
11
|
+
} from '@pascal-app/core'
|
|
12
|
+
import { createSurfaceRejectionFeedback } from './surface-rejection'
|
|
13
|
+
|
|
14
|
+
let restore: () => void
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
restore = nodeRegistry._snapshot()
|
|
17
|
+
nodeRegistry._reset()
|
|
18
|
+
})
|
|
19
|
+
afterEach(() => restore())
|
|
20
|
+
|
|
21
|
+
for (const reason of [
|
|
22
|
+
'footprint-outside-surface',
|
|
23
|
+
'footprint-exceeds-host',
|
|
24
|
+
'surface-cutout',
|
|
25
|
+
'child-not-accepted',
|
|
26
|
+
'host-not-eligible',
|
|
27
|
+
'no-surface',
|
|
28
|
+
'invalid-hit',
|
|
29
|
+
] as const) {
|
|
30
|
+
test(`${reason} remains available from onReject without user-facing text`, () => {
|
|
31
|
+
const provider: SurfaceProvider = {
|
|
32
|
+
childFrame: 'host-local',
|
|
33
|
+
resolveHit: () =>
|
|
34
|
+
reason === 'no-surface'
|
|
35
|
+
? null
|
|
36
|
+
: {
|
|
37
|
+
id: 'top',
|
|
38
|
+
position: [0, 1, 0],
|
|
39
|
+
normal: [0, 1, 0],
|
|
40
|
+
region: {
|
|
41
|
+
kind: 'rect',
|
|
42
|
+
size: [1, 1],
|
|
43
|
+
...(reason === 'surface-cutout'
|
|
44
|
+
? {
|
|
45
|
+
holes: [
|
|
46
|
+
[
|
|
47
|
+
[-0.2, -0.2],
|
|
48
|
+
[0.2, -0.2],
|
|
49
|
+
[0.2, 0.2],
|
|
50
|
+
[-0.2, 0.2],
|
|
51
|
+
],
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
: {}),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
accepts: () => reason !== 'child-not-accepted',
|
|
58
|
+
}
|
|
59
|
+
registerNode({
|
|
60
|
+
kind: 'test-host',
|
|
61
|
+
schemaVersion: 1,
|
|
62
|
+
schema: ItemNode,
|
|
63
|
+
category: 'furnish',
|
|
64
|
+
defaults: () => ({}),
|
|
65
|
+
capabilities:
|
|
66
|
+
reason === 'footprint-exceeds-host'
|
|
67
|
+
? { dragBounds: () => ({ size: [0.1, 1, 0.1] }) }
|
|
68
|
+
: { surfaces: { hosting: provider } },
|
|
69
|
+
})
|
|
70
|
+
const feedback = createSurfaceRejectionFeedback()
|
|
71
|
+
expect(
|
|
72
|
+
resolveSurfacePlacement({
|
|
73
|
+
host: {
|
|
74
|
+
id: 'test_host',
|
|
75
|
+
type: reason === 'host-not-eligible' ? 'guide' : 'test-host',
|
|
76
|
+
} as unknown as AnyNode,
|
|
77
|
+
childKind: 'item',
|
|
78
|
+
childFootprint: {
|
|
79
|
+
size: reason === 'footprint-outside-surface' ? [3, 1, 3] : [0.3, 0.3, 0.3],
|
|
80
|
+
rotationY: 0,
|
|
81
|
+
},
|
|
82
|
+
hit: {
|
|
83
|
+
point: [reason.startsWith('footprint-') ? 2 : 0, reason === 'invalid-hit' ? NaN : 1, 0],
|
|
84
|
+
normalWorldY: 1,
|
|
85
|
+
},
|
|
86
|
+
scene: { get: () => undefined, nodes: () => ({}) } as unknown as SceneApi,
|
|
87
|
+
onReject: (r) => feedback.reject(r),
|
|
88
|
+
}),
|
|
89
|
+
).toBeNull()
|
|
90
|
+
expect(feedback.reason).toBe(reason)
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test('the paired grid cannot erase a refusal; a new floor move and cleanup clear it', () => {
|
|
95
|
+
const changes: (SurfaceRejectReason | null)[] = []
|
|
96
|
+
const feedback = createSurfaceRejectionFeedback((r) => changes.push(r))
|
|
97
|
+
const event = {}
|
|
98
|
+
feedback.reject('footprint-exceeds-host', event)
|
|
99
|
+
feedback.grid(event)
|
|
100
|
+
expect(feedback.reason).toBe('footprint-exceeds-host')
|
|
101
|
+
feedback.grid({})
|
|
102
|
+
expect(feedback.reason).toBeNull()
|
|
103
|
+
feedback.reject('surface-cutout', event)
|
|
104
|
+
feedback.clear()
|
|
105
|
+
expect(changes).toEqual(['footprint-exceeds-host', null, 'surface-cutout', null])
|
|
106
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SurfaceRejectReason } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export function createSurfaceRejectionFeedback(
|
|
4
|
+
onChange?: (reason: SurfaceRejectReason | null) => void,
|
|
5
|
+
) {
|
|
6
|
+
let reason: SurfaceRejectReason | null = null
|
|
7
|
+
let rejectedEvent: object | undefined
|
|
8
|
+
const set = (next: SurfaceRejectReason | null) => {
|
|
9
|
+
reason = next
|
|
10
|
+
onChange?.(next)
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
get reason() {
|
|
14
|
+
return reason
|
|
15
|
+
},
|
|
16
|
+
reject(next: SurfaceRejectReason, event?: object) {
|
|
17
|
+
if (event) rejectedEvent = event
|
|
18
|
+
set(next)
|
|
19
|
+
},
|
|
20
|
+
clear() {
|
|
21
|
+
rejectedEvent = undefined
|
|
22
|
+
set(null)
|
|
23
|
+
},
|
|
24
|
+
grid(event: object) {
|
|
25
|
+
// A host refusal and the floor ray beneath it belong to the same pointer move.
|
|
26
|
+
if (event !== rejectedEvent) this.clear()
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
emitter,
|
|
4
4
|
type GridEvent,
|
|
5
5
|
type LevelNode,
|
|
6
|
+
runAsSingleSceneHistoryStep,
|
|
6
7
|
snapPointAlongAngleRay,
|
|
7
8
|
useScene,
|
|
8
9
|
ZoneNode,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
clearSurfacePlanSnapFeedback,
|
|
17
18
|
resolveSurfacePlanPointSnap,
|
|
18
19
|
} from './../../../lib/surface-plan-snap'
|
|
20
|
+
import { focusedUnitNode } from './../../../lib/units'
|
|
19
21
|
import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
|
|
20
22
|
import useEditor, { isAngleSnapActive, isGridSnapActive } from './../../../store/use-editor'
|
|
21
23
|
import { useFloorplanDraftPreview } from './../../../store/use-floorplan-draft-preview'
|
|
@@ -27,7 +29,7 @@ const Y_OFFSET = 0.02
|
|
|
27
29
|
* Creates a zone with the given polygon points
|
|
28
30
|
*/
|
|
29
31
|
const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>) => {
|
|
30
|
-
const { createNode, nodes } = useScene.getState()
|
|
32
|
+
const { createNode, updateNode, nodes } = useScene.getState()
|
|
31
33
|
|
|
32
34
|
// Count existing zones for naming and color cycling
|
|
33
35
|
const zoneCount = Object.values(nodes).filter((n) => n.type === 'zone').length
|
|
@@ -42,10 +44,17 @@ const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, numb
|
|
|
42
44
|
color,
|
|
43
45
|
})
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
const focusedUnit = focusedUnitNode()
|
|
46
48
|
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
+
// Joining the focused unit rides in the zone's own undo step.
|
|
50
|
+
runAsSingleSceneHistoryStep(useScene, () => {
|
|
51
|
+
createNode(zone, levelId)
|
|
52
|
+
if (focusedUnit) updateNode(focusedUnit.id, { members: [...focusedUnit.members, zone.id] })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Selecting the zone would end unit focus; while painting a unit the new
|
|
56
|
+
// zone just joins it and drawing continues.
|
|
57
|
+
if (!focusedUnit) useViewer.getState().setSelection({ zoneId: zone.id })
|
|
49
58
|
|
|
50
59
|
// Play structure build sound
|
|
51
60
|
sfxEmitter.emit('sfx:structure-build')
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import type { AnyNodeId } from '@pascal-app/core'
|
|
4
|
-
import { DEFAULT_LEVEL_HEIGHT, LevelNode, useScene } from '@pascal-app/core'
|
|
4
|
+
import { DEFAULT_LEVEL_HEIGHT, LevelNode, type UnitNode, useScene } from '@pascal-app/core'
|
|
5
5
|
import { useViewer } from '@pascal-app/viewer'
|
|
6
6
|
import {
|
|
7
7
|
AppWindow,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
EyeOff,
|
|
16
16
|
FileJson,
|
|
17
17
|
Grid3X3,
|
|
18
|
+
Group,
|
|
18
19
|
Hexagon,
|
|
19
20
|
Layers,
|
|
20
21
|
Map,
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
import { useEffect } from 'react'
|
|
40
41
|
import { getHistoryCommandState, runRedo, runUndo } from '../../../lib/history'
|
|
41
42
|
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
|
|
43
|
+
import { createUnitInBuilding, enterUnitFocus, leaveUnitFocus } from '../../../lib/units'
|
|
42
44
|
import { useCommandRegistry } from '../../../store/use-command-registry'
|
|
43
45
|
import type { StructureTool } from '../../../store/use-editor'
|
|
44
46
|
import useEditor from '../../../store/use-editor'
|
|
@@ -56,13 +58,25 @@ export function EditorCommands() {
|
|
|
56
58
|
const setPreviewMode = useEditor((s) => s.setPreviewMode)
|
|
57
59
|
|
|
58
60
|
const exportScene = useViewer((s) => s.exportScene)
|
|
61
|
+
// Focusable units are listed one command each; the key changes only when a
|
|
62
|
+
// unit is added, removed or renamed.
|
|
63
|
+
const unitListKey = useScene((s) =>
|
|
64
|
+
Object.values(s.nodes)
|
|
65
|
+
.filter((n): n is UnitNode => n.type === 'unit')
|
|
66
|
+
.map((n) => `${n.id}:${n.name ?? ''}`)
|
|
67
|
+
.join('|'),
|
|
68
|
+
)
|
|
59
69
|
|
|
60
70
|
// Re-register when exportScene availability changes (it's a conditional action)
|
|
61
71
|
useEffect(() => {
|
|
72
|
+
void unitListKey
|
|
62
73
|
const run = (fn: () => void) => {
|
|
63
74
|
fn()
|
|
64
75
|
setOpen(false)
|
|
65
76
|
}
|
|
77
|
+
const focusableUnits = Object.values(useScene.getState().nodes).filter(
|
|
78
|
+
(n): n is UnitNode => n.type === 'unit',
|
|
79
|
+
)
|
|
66
80
|
|
|
67
81
|
const activateTool = (tool: StructureTool) => {
|
|
68
82
|
run(() => {
|
|
@@ -249,6 +263,44 @@ export function EditorCommands() {
|
|
|
249
263
|
}),
|
|
250
264
|
},
|
|
251
265
|
|
|
266
|
+
// ── Units ────────────────────────────────────────────────────────────
|
|
267
|
+
{
|
|
268
|
+
id: 'editor.unit.new',
|
|
269
|
+
label: 'New unit',
|
|
270
|
+
group: 'Units',
|
|
271
|
+
icon: <Group className="h-4 w-4" />,
|
|
272
|
+
keywords: ['unit', 'apartment', 'hotel', 'room', 'add', 'create', 'new'],
|
|
273
|
+
when: () => Object.values(useScene.getState().nodes).some((n) => n.type === 'building'),
|
|
274
|
+
execute: () =>
|
|
275
|
+
run(() => {
|
|
276
|
+
const { nodes } = useScene.getState()
|
|
277
|
+
const selectedBuildingId = useViewer.getState().selection.buildingId
|
|
278
|
+
const building =
|
|
279
|
+
(selectedBuildingId ? nodes[selectedBuildingId] : undefined) ??
|
|
280
|
+
Object.values(nodes).find((n) => n.type === 'building')
|
|
281
|
+
if (building?.type !== 'building') return
|
|
282
|
+
createUnitInBuilding(building.id)
|
|
283
|
+
}),
|
|
284
|
+
},
|
|
285
|
+
...focusableUnits.map((unit) => ({
|
|
286
|
+
id: `editor.unit.focus.${unit.id}`,
|
|
287
|
+
label: `Focus unit: ${unit.name || 'Unit'}`,
|
|
288
|
+
group: 'Units',
|
|
289
|
+
icon: <Group className="h-4 w-4" />,
|
|
290
|
+
keywords: ['unit', 'focus', 'apartment', unit.name || 'Unit'],
|
|
291
|
+
when: () => useViewer.getState().focusedUnitId !== unit.id,
|
|
292
|
+
execute: () => run(() => enterUnitFocus(unit.id)),
|
|
293
|
+
})),
|
|
294
|
+
{
|
|
295
|
+
id: 'editor.unit.exit-focus',
|
|
296
|
+
label: 'Exit unit focus',
|
|
297
|
+
group: 'Units',
|
|
298
|
+
icon: <Group className="h-4 w-4" />,
|
|
299
|
+
keywords: ['unit', 'focus', 'exit', 'leave', 'apartment'],
|
|
300
|
+
when: () => !!useViewer.getState().focusedUnitId,
|
|
301
|
+
execute: () => run(() => leaveUnitFocus()),
|
|
302
|
+
},
|
|
303
|
+
|
|
252
304
|
// ── Viewer Controls ──────────────────────────────────────────────────
|
|
253
305
|
{
|
|
254
306
|
id: 'editor.viewer.wall-mode',
|
|
@@ -431,6 +483,7 @@ export function EditorCommands() {
|
|
|
431
483
|
isPreviewMode,
|
|
432
484
|
setPreviewMode,
|
|
433
485
|
exportScene,
|
|
486
|
+
unitListKey,
|
|
434
487
|
])
|
|
435
488
|
|
|
436
489
|
return null
|
|
@@ -2,11 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useScene } from '@pascal-app/core'
|
|
4
4
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
5
|
-
import {
|
|
6
|
-
lingoUnitSpec,
|
|
7
|
-
measurementHint,
|
|
8
|
-
parseMeasurement,
|
|
9
|
-
} from '../../../lib/measurement-parser'
|
|
5
|
+
import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser'
|
|
10
6
|
import { useLinearDisplay } from '../../../lib/use-linear-display'
|
|
11
7
|
import { cn } from '../../../lib/utils'
|
|
12
8
|
|
|
@@ -31,8 +27,8 @@ export function MetricControl({
|
|
|
31
27
|
onCommit,
|
|
32
28
|
min = Number.NEGATIVE_INFINITY,
|
|
33
29
|
max = Number.POSITIVE_INFINITY,
|
|
34
|
-
precision = 2,
|
|
35
|
-
step = 1,
|
|
30
|
+
precision: storedPrecision = 2,
|
|
31
|
+
step: storedStep = 1,
|
|
36
32
|
className,
|
|
37
33
|
unit = '',
|
|
38
34
|
restoreOnCommit = true,
|
|
@@ -40,9 +36,12 @@ export function MetricControl({
|
|
|
40
36
|
const {
|
|
41
37
|
isImperial,
|
|
42
38
|
displayUnit,
|
|
39
|
+
parseUnit,
|
|
40
|
+
precision,
|
|
41
|
+
step,
|
|
43
42
|
toDisplay: toDisplayValue,
|
|
44
43
|
toStored: toStoredValue,
|
|
45
|
-
} = useLinearDisplay(unit,
|
|
44
|
+
} = useLinearDisplay(unit, storedPrecision, storedStep)
|
|
46
45
|
|
|
47
46
|
const clamp = useCallback(
|
|
48
47
|
(val: number) => {
|
|
@@ -110,7 +109,14 @@ export function MetricControl({
|
|
|
110
109
|
|
|
111
110
|
container.addEventListener('wheel', handleWheel, { passive: false })
|
|
112
111
|
return () => container.removeEventListener('wheel', handleWheel)
|
|
113
|
-
}, [
|
|
112
|
+
}, [
|
|
113
|
+
isEditing,
|
|
114
|
+
step,
|
|
115
|
+
clamp,
|
|
116
|
+
applyCommittedValue,
|
|
117
|
+
toStoredValue,
|
|
118
|
+
roundStoredValueForDisplayPrecision,
|
|
119
|
+
])
|
|
114
120
|
|
|
115
121
|
useEffect(() => {
|
|
116
122
|
if (!isHovered || isEditing) return
|
|
@@ -226,7 +232,7 @@ export function MetricControl({
|
|
|
226
232
|
const spec = lingoUnitSpec(unit)
|
|
227
233
|
let stored = spec
|
|
228
234
|
? parseMeasurement(inputValue, spec, {
|
|
229
|
-
bareUnit:
|
|
235
|
+
bareUnit: parseUnit ?? spec.unitId,
|
|
230
236
|
system: isImperial ? 'us' : 'metric',
|
|
231
237
|
})
|
|
232
238
|
: null
|
|
@@ -244,6 +250,7 @@ export function MetricControl({
|
|
|
244
250
|
inputValue,
|
|
245
251
|
unit,
|
|
246
252
|
isImperial,
|
|
253
|
+
parseUnit,
|
|
247
254
|
applyCommittedValue,
|
|
248
255
|
clamp,
|
|
249
256
|
toStoredValue,
|
|
@@ -256,9 +263,9 @@ export function MetricControl({
|
|
|
256
263
|
const hint =
|
|
257
264
|
isEditing && spec
|
|
258
265
|
? measurementHint(inputValue, spec, {
|
|
259
|
-
bareUnit:
|
|
266
|
+
bareUnit: parseUnit ?? spec.unitId,
|
|
260
267
|
system: isImperial ? 'us' : 'metric',
|
|
261
|
-
displayUnit:
|
|
268
|
+
displayUnit: parseUnit ?? spec.unitId,
|
|
262
269
|
precision,
|
|
263
270
|
clamp,
|
|
264
271
|
})
|
|
@@ -287,7 +294,16 @@ export function MetricControl({
|
|
|
287
294
|
setInputValue(toDisplayValue(newV).toFixed(precision))
|
|
288
295
|
}
|
|
289
296
|
},
|
|
290
|
-
[
|
|
297
|
+
[
|
|
298
|
+
submitValue,
|
|
299
|
+
value,
|
|
300
|
+
toDisplayValue,
|
|
301
|
+
precision,
|
|
302
|
+
step,
|
|
303
|
+
clamp,
|
|
304
|
+
applyCommittedValue,
|
|
305
|
+
toStoredValue,
|
|
306
|
+
],
|
|
291
307
|
)
|
|
292
308
|
|
|
293
309
|
return (
|
|
@@ -2,11 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useScene } from '@pascal-app/core'
|
|
4
4
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
5
|
-
import {
|
|
6
|
-
lingoUnitSpec,
|
|
7
|
-
measurementHint,
|
|
8
|
-
parseMeasurement,
|
|
9
|
-
} from '../../../lib/measurement-parser'
|
|
5
|
+
import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser'
|
|
10
6
|
import { useLinearDisplay } from '../../../lib/use-linear-display'
|
|
11
7
|
import { cn } from '../../../lib/utils'
|
|
12
8
|
|
|
@@ -60,19 +56,16 @@ export function SliderControl({
|
|
|
60
56
|
onCommit,
|
|
61
57
|
min = Number.NEGATIVE_INFINITY,
|
|
62
58
|
max = Number.POSITIVE_INFINITY,
|
|
63
|
-
precision = 0,
|
|
64
|
-
step = 1,
|
|
59
|
+
precision: storedPrecision = 0,
|
|
60
|
+
step: storedStep = 1,
|
|
65
61
|
className,
|
|
66
62
|
unit = '',
|
|
67
63
|
restoreOnCommit = true,
|
|
68
64
|
mixed = false,
|
|
69
65
|
}: SliderControlProps) {
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// and rendered number are in the DISPLAY unit (feet when imperial). For
|
|
74
|
-
// metric and non-length units these conversions are the identity.
|
|
75
|
-
const { isImperial, displayUnit, toDisplay, toStored } = useLinearDisplay(unit, precision)
|
|
66
|
+
// Values and bounds stay in meters; gestures and input use the displayed unit.
|
|
67
|
+
const { isImperial, displayUnit, parseUnit, precision, step, toDisplay, toStored } =
|
|
68
|
+
useLinearDisplay(unit, storedPrecision, storedStep)
|
|
76
69
|
|
|
77
70
|
const [isEditing, setIsEditing] = useState(false)
|
|
78
71
|
const [isDragging, setIsDragging] = useState(false)
|
|
@@ -106,7 +99,9 @@ export function SliderControl({
|
|
|
106
99
|
(storedValue: number, displayDelta: number, displayStep: number) =>
|
|
107
100
|
clamp(
|
|
108
101
|
toStored(
|
|
109
|
-
Number.parseFloat(
|
|
102
|
+
Number.parseFloat(
|
|
103
|
+
(toDisplay(storedValue) + displayDelta).toFixed(stepPrecision(displayStep)),
|
|
104
|
+
),
|
|
110
105
|
),
|
|
111
106
|
),
|
|
112
107
|
[clamp, toDisplay, toStored],
|
|
@@ -231,7 +226,7 @@ export function SliderControl({
|
|
|
231
226
|
const spec = lingoUnitSpec(unit)
|
|
232
227
|
let stored = spec
|
|
233
228
|
? parseMeasurement(inputValue, spec, {
|
|
234
|
-
bareUnit:
|
|
229
|
+
bareUnit: parseUnit ?? spec.unitId,
|
|
235
230
|
system: isImperial ? 'us' : 'metric',
|
|
236
231
|
})
|
|
237
232
|
: null
|
|
@@ -248,15 +243,27 @@ export function SliderControl({
|
|
|
248
243
|
onCommit?.(nextValue)
|
|
249
244
|
}
|
|
250
245
|
setIsEditing(false)
|
|
251
|
-
}, [
|
|
246
|
+
}, [
|
|
247
|
+
inputValue,
|
|
248
|
+
unit,
|
|
249
|
+
isImperial,
|
|
250
|
+
parseUnit,
|
|
251
|
+
onChange,
|
|
252
|
+
onCommit,
|
|
253
|
+
clamp,
|
|
254
|
+
precision,
|
|
255
|
+
value,
|
|
256
|
+
toDisplay,
|
|
257
|
+
toStored,
|
|
258
|
+
])
|
|
252
259
|
|
|
253
260
|
const spec = lingoUnitSpec(unit)
|
|
254
261
|
const hint =
|
|
255
262
|
isEditing && spec
|
|
256
263
|
? measurementHint(inputValue, spec, {
|
|
257
|
-
bareUnit:
|
|
264
|
+
bareUnit: parseUnit ?? spec.unitId,
|
|
258
265
|
system: isImperial ? 'us' : 'metric',
|
|
259
|
-
displayUnit:
|
|
266
|
+
displayUnit: parseUnit ?? spec.unitId,
|
|
260
267
|
precision,
|
|
261
268
|
clamp,
|
|
262
269
|
})
|