@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.
Files changed (97) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/floating-action-menu.tsx +14 -1
  5. package/src/components/editor/floorplan-panel.tsx +23 -3
  6. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  7. package/src/components/editor/handles/handle-placement.ts +69 -0
  8. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  9. package/src/components/editor/handles/use-handle-drag.ts +5 -2
  10. package/src/components/editor/index.tsx +12 -4
  11. package/src/components/editor/node-arrow-handles.tsx +21 -62
  12. package/src/components/editor/selection-manager.tsx +49 -0
  13. package/src/components/editor/thumbnail-generator.tsx +11 -2
  14. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  15. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
  16. package/src/components/procedural-items/inspector.tsx +223 -0
  17. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  18. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  19. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  20. package/src/components/tools/item/placement-strategies.ts +169 -169
  21. package/src/components/tools/item/placement-types.ts +4 -0
  22. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  23. package/src/components/tools/item/use-draft-node.ts +95 -45
  24. package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
  25. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  26. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  27. package/src/components/tools/registry/item-surface-move.ts +441 -0
  28. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  29. package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
  30. package/src/components/tools/shared/pointer-support-cap.ts +11 -9
  31. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  32. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  33. package/src/components/tools/shared/surface-hit.ts +23 -0
  34. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  35. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  36. package/src/components/tools/zone/zone-tool.tsx +13 -4
  37. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  38. package/src/components/ui/controls/metric-control.tsx +29 -13
  39. package/src/components/ui/controls/slider-control.tsx +25 -18
  40. package/src/components/ui/floating-level-selector.tsx +81 -5
  41. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  42. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  43. package/src/components/ui/panels/parametric-inspector.tsx +6 -2
  44. package/src/components/ui/panels/reference-panel.tsx +3 -3
  45. package/src/components/ui/primitives/checkbox.tsx +34 -0
  46. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
  47. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  48. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
  49. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  50. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  51. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  52. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  53. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  54. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  55. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  56. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  57. package/src/components/ui/snap-target-badge.tsx +2 -0
  58. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  59. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  60. package/src/hooks/use-ceiling-events.test.ts +24 -2
  61. package/src/hooks/use-ceiling-events.ts +5 -1
  62. package/src/hooks/use-keyboard.ts +3 -1
  63. package/src/index.tsx +5 -0
  64. package/src/lib/export-texture-utils.test.ts +167 -0
  65. package/src/lib/export-texture-utils.ts +121 -0
  66. package/src/lib/export-user-data.test.ts +48 -0
  67. package/src/lib/export-user-data.ts +69 -0
  68. package/src/lib/floorplan/floorplan-export.test.ts +139 -1
  69. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  70. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  71. package/src/lib/fresh-planar-placement.ts +13 -5
  72. package/src/lib/glb-export-serialize.test.ts +280 -0
  73. package/src/lib/glb-export.test.ts +206 -0
  74. package/src/lib/glb-export.ts +171 -24
  75. package/src/lib/level-duplication.test.ts +73 -0
  76. package/src/lib/level-duplication.ts +32 -2
  77. package/src/lib/linear-display.test.ts +59 -0
  78. package/src/lib/linear-display.ts +39 -0
  79. package/src/lib/local-guide-image.test.ts +24 -0
  80. package/src/lib/local-guide-image.ts +34 -1
  81. package/src/lib/node-action-movement.ts +21 -0
  82. package/src/lib/portable-export.test.ts +89 -0
  83. package/src/lib/portable-export.ts +84 -11
  84. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  85. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  86. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  87. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  88. package/src/lib/scene-clipboard.ts +1 -1
  89. package/src/lib/send-to-app.test.ts +108 -0
  90. package/src/lib/send-to-app.ts +121 -0
  91. package/src/lib/sfx/movement-tick.ts +37 -0
  92. package/src/lib/surface-attachment.ts +77 -0
  93. package/src/lib/units.ts +254 -0
  94. package/src/lib/usdz-export.ts +43 -16
  95. package/src/lib/use-linear-display.ts +8 -32
  96. package/src/store/use-handle-group.ts +4 -0
  97. 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
- createNode(zone, levelId)
47
+ const focusedUnit = focusedUnitNode()
46
48
 
47
- // Select the newly created zone
48
- useViewer.getState().setSelection({ zoneId: zone.id })
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, precision)
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
- }, [isEditing, step, clamp, applyCommittedValue, toStoredValue, roundStoredValueForDisplayPrecision])
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: isImperial ? 'ft' : spec.unitId,
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: isImperial ? 'ft' : spec.unitId,
266
+ bareUnit: parseUnit ?? spec.unitId,
260
267
  system: isImperial ? 'us' : 'metric',
261
- displayUnit: isImperial ? 'ft' : spec.unitId,
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
- [submitValue, value, toDisplayValue, precision, step, clamp, applyCommittedValue, toStoredValue],
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
- // Display/storage conversion so the value honors the metric/imperial toggle.
71
- // `value`, `onChange`, `onCommit`, `min`/`max`/`clamp` are always in the
72
- // stored unit (meters for `unit === 'm'`); the step, drag deltas, text field
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((toDisplay(storedValue) + displayDelta).toFixed(stepPrecision(displayStep))),
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: isImperial ? 'ft' : spec.unitId,
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
- }, [inputValue, unit, isImperial, onChange, onCommit, clamp, precision, value, toDisplay, toStored])
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: isImperial ? 'ft' : spec.unitId,
264
+ bareUnit: parseUnit ?? spec.unitId,
258
265
  system: isImperial ? 'us' : 'metric',
259
- displayUnit: isImperial ? 'ft' : spec.unitId,
266
+ displayUnit: parseUnit ?? spec.unitId,
260
267
  precision,
261
268
  clamp,
262
269
  })