From 2480be91b129059733ab98b8bdf12c530a299274 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sat, 9 May 2026 19:09:48 +0530 Subject: [PATCH 1/9] Optimize elevator editing performance with live previews --- apps/editor/public/icons/elevator.svg | 10 + bun.lock | 7 - packages/core/src/events/bus.ts | 3 + .../hooks/scene-registry/scene-registry.ts | 1 + packages/core/src/index.ts | 13 + packages/core/src/schema/index.ts | 1 + packages/core/src/schema/nodes/building.ts | 5 +- packages/core/src/schema/nodes/ceiling.ts | 2 +- packages/core/src/schema/nodes/elevator.ts | 47 + packages/core/src/schema/nodes/slab.ts | 2 +- .../src/schema/nodes/surface-hole-metadata.ts | 3 +- packages/core/src/schema/types.ts | 2 + packages/core/src/store/use-interactive.ts | 94 ++ .../core/src/store/use-live-node-overrides.ts | 31 + .../elevator/elevator-opening-sync.test.ts | 164 ++++ .../systems/elevator/elevator-opening-sync.ts | 266 ++++++ .../src/systems/elevator/elevator-service.ts | 67 ++ .../systems/stair/stair-opening-sync.test.ts | 195 ++++- .../src/systems/stair/stair-opening-sync.ts | 56 +- .../editor/first-person-controls.tsx | 776 ++++++++++++++++- .../editor/floating-action-menu.tsx | 12 +- .../src/components/editor/floorplan-panel.tsx | 10 +- .../components/editor/selection-manager.tsx | 22 +- .../tools/elevator/elevator-defaults.ts | 8 + .../tools/elevator/elevator-tool.tsx | 201 +++++ .../tools/elevator/move-elevator-tool.tsx | 209 +++++ .../src/components/tools/item/move-tool.tsx | 3 + .../src/components/tools/tool-manager.tsx | 2 + .../ui/action-menu/structure-tools.tsx | 1 + .../ui/command-palette/editor-commands.tsx | 5 +- .../components/ui/controls/metric-control.tsx | 44 +- .../components/ui/panels/ceiling-panel.tsx | 7 +- .../components/ui/panels/elevator-panel.tsx | 471 ++++++++++ .../src/components/ui/panels/node-display.ts | 1 + .../components/ui/panels/panel-manager.tsx | 8 + .../src/components/ui/panels/slab-panel.tsx | 7 +- .../panels/site-panel/building-tree-node.tsx | 4 +- .../panels/site-panel/elevator-tree-node.tsx | 75 ++ .../sidebar/panels/site-panel/tree-node.tsx | 3 + .../editor/src/hooks/use-contextual-tools.ts | 1 + packages/editor/src/hooks/use-keyboard.ts | 32 +- packages/editor/src/lib/history.ts | 3 +- packages/editor/src/store/use-editor.tsx | 14 +- packages/viewer/package.json | 1 - .../renderers/elevator/elevator-renderer.tsx | 811 ++++++++++++++++++ .../components/renderers/node-renderer.tsx | 2 + .../renderers/site/site-renderer.tsx | 24 +- .../src/components/viewer/ground-occluder.tsx | 32 +- .../viewer/src/components/viewer/index.tsx | 27 +- .../src/components/viewer/post-processing.tsx | 20 - packages/viewer/src/hooks/use-node-events.ts | 3 + packages/viewer/src/lib/polygon-union.test.ts | 77 ++ packages/viewer/src/lib/polygon-union.ts | 294 +++++++ .../src/systems/ceiling/ceiling-system.tsx | 5 +- .../elevator/elevator-animation-system.tsx | 155 ++++ .../elevator/elevator-opening-system.tsx | 54 ++ .../src/systems/elevator/elevator-utils.ts | 68 ++ .../viewer/src/systems/slab/slab-system.tsx | 11 +- .../src/systems/surface-hole-geometry.ts | 5 + 59 files changed, 4300 insertions(+), 177 deletions(-) create mode 100644 apps/editor/public/icons/elevator.svg create mode 100644 packages/core/src/schema/nodes/elevator.ts create mode 100644 packages/core/src/store/use-live-node-overrides.ts create mode 100644 packages/core/src/systems/elevator/elevator-opening-sync.test.ts create mode 100644 packages/core/src/systems/elevator/elevator-opening-sync.ts create mode 100644 packages/core/src/systems/elevator/elevator-service.ts create mode 100644 packages/editor/src/components/tools/elevator/elevator-defaults.ts create mode 100644 packages/editor/src/components/tools/elevator/elevator-tool.tsx create mode 100644 packages/editor/src/components/tools/elevator/move-elevator-tool.tsx create mode 100644 packages/editor/src/components/ui/panels/elevator-panel.tsx create mode 100644 packages/editor/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx create mode 100644 packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx create mode 100644 packages/viewer/src/lib/polygon-union.test.ts create mode 100644 packages/viewer/src/lib/polygon-union.ts create mode 100644 packages/viewer/src/systems/elevator/elevator-animation-system.tsx create mode 100644 packages/viewer/src/systems/elevator/elevator-opening-system.tsx create mode 100644 packages/viewer/src/systems/elevator/elevator-utils.ts create mode 100644 packages/viewer/src/systems/surface-hole-geometry.ts diff --git a/apps/editor/public/icons/elevator.svg b/apps/editor/public/icons/elevator.svg new file mode 100644 index 000000000..ddc9563e1 --- /dev/null +++ b/apps/editor/public/icons/elevator.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/bun.lock b/bun.lock index c0e5fbbea..6bbf8bb85 100644 --- a/bun.lock +++ b/bun.lock @@ -193,7 +193,6 @@ "name": "@pascal-app/viewer", "version": "0.6.0", "dependencies": { - "polygon-clipping": "^0.15.7", "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", "zustand": "^5", @@ -1289,8 +1288,6 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "polygon-clipping": ["polygon-clipping@0.15.7", "", { "dependencies": { "robust-predicates": "^3.0.2", "splaytree": "^3.1.0" } }, "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA=="], - "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], @@ -1351,8 +1348,6 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -1409,8 +1404,6 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "splaytree": ["splaytree@3.2.3", "", {}, "sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w=="], - "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 0364a8804..d539ada6b 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -6,6 +6,7 @@ import type { CeilingNode, ColumnNode, DoorNode, + ElevatorNode, FenceNode, GuideNode, ItemNode, @@ -65,6 +66,7 @@ export type StairEvent = NodeEvent export type StairSegmentEvent = NodeEvent export type WindowEvent = NodeEvent export type DoorEvent = NodeEvent +export type ElevatorEvent = NodeEvent // Event suffixes, exported for use in hooks export const eventSuffixes = [ @@ -180,6 +182,7 @@ type EditorEvents = GridEvents & NodeEvents<'item', ItemEvent> & NodeEvents<'site', SiteEvent> & NodeEvents<'building', BuildingEvent> & + NodeEvents<'elevator', ElevatorEvent> & NodeEvents<'level', LevelEvent> & NodeEvents<'zone', ZoneEvent> & NodeEvents<'slab', SlabEvent> & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index 62efe95d5..ab1a1a89b 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -14,6 +14,7 @@ export const sceneRegistry = { building: new Set(), ceiling: new Set(), column: new Set(), + elevator: new Set(), level: new Set(), wall: new Set(), fence: new Set(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d3875421f..49f5063e1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,7 @@ export type { CeilingEvent, ColumnEvent, DoorEvent, + ElevatorEvent, EventSuffix, FenceEvent, GridEvent, @@ -71,13 +72,25 @@ export { type ControlValue, type DoorAnimationState, type DoorInteractiveState, + type ElevatorInteractiveState, + type ElevatorPhase, type ItemInteractiveState, useInteractive, type WindowAnimationState, type WindowInteractiveState, } from './store/use-interactive' +export { + default as useLiveNodeOverrides, + type LiveNodeOverrides, +} from './store/use-live-node-overrides' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { clearSceneHistory, default as useScene } from './store/use-scene' +export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync' +export { + resolveElevatorBuildingLevels, + resolveElevatorServiceLevelIds, + resolveElevatorServiceLevels, +} from './systems/elevator/elevator-service' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' export { getClampedWallCurveOffset, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index b088079a5..14c8db61c 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -42,6 +42,7 @@ export { ColumnSupportStyle, } from './nodes/column' export { DoorNode, DoorSegment } from './nodes/door' +export { ElevatorDoorStyle, ElevatorNode } from './nodes/elevator' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode, GuideScaleReference } from './nodes/guide' export type { diff --git a/packages/core/src/schema/nodes/building.ts b/packages/core/src/schema/nodes/building.ts index c4a86f5d0..e4a4e470f 100644 --- a/packages/core/src/schema/nodes/building.ts +++ b/packages/core/src/schema/nodes/building.ts @@ -1,12 +1,13 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { ElevatorNode } from './elevator' import { LevelNode } from './level' export const BuildingNode = BaseNode.extend({ id: objectId('building'), type: nodeType('building'), - children: z.array(LevelNode.shape.id).default([]), + children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), }).describe( @@ -14,7 +15,7 @@ export const BuildingNode = BaseNode.extend({ Building node - used to represent a building - position: position in site coordinate system - rotation: rotation in site coordinate system - - children: array of level nodes (each level is a tree of floor and wall nodes) + - children: array of level nodes and building-level systems such as elevators `, ) diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts index 599422a68..724bb0fde 100644 --- a/packages/core/src/schema/nodes/ceiling.ts +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -21,7 +21,7 @@ export const CeilingNode = BaseNode.extend({ Ceiling node - used to represent a ceiling in the building - polygon: array of [x, z] points defining the ceiling boundary - holes: array of polygons representing holes in the ceiling - - holeMetadata: metadata parallel to holes, used to preserve manual and stair-managed cutouts + - holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts - autoFromWalls: whether the ceiling is automatically generated from a closed wall loop `, ) diff --git a/packages/core/src/schema/nodes/elevator.ts b/packages/core/src/schema/nodes/elevator.ts new file mode 100644 index 000000000..01a142b4e --- /dev/null +++ b/packages/core/src/schema/nodes/elevator.ts @@ -0,0 +1,47 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const ElevatorDoorStyle = z.enum(['center-opening', 'single-left', 'single-right']) + +export type ElevatorDoorStyle = z.infer + +export const ElevatorNode = BaseNode.extend({ + id: objectId('elevator'), + type: nodeType('elevator'), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Rotation around the Y axis in radians. + rotation: z.number().default(0), + width: z.number().default(1.6), + depth: z.number().default(1.6), + cabHeight: z.number().default(2.35), + doorWidth: z.number().default(0.95), + doorHeight: z.number().default(2.1), + doorStyle: ElevatorDoorStyle.default('center-opening'), + fromLevelId: z.string().nullable().default(null), + toLevelId: z.string().nullable().default(null), + servedLevelIds: z.array(z.string()).optional(), + defaultLevelId: z.string().nullable().default(null), + speed: z.number().default(2.2), + doorDurationMs: z.number().default(900), + dwellMs: z.number().default(1400), +}).describe( + dedent` + Elevator node - a vertical transport core attached to a building. + - parentId: building that owns this elevator + - position: building-local shaft center on the X/Z plane + - rotation: rotation around the Y axis + - width/depth: shaft and cab footprint + - cabHeight: visible elevator cab height + - doorWidth/doorHeight/doorStyle: landing and cab door presentation + - fromLevelId / toLevelId: source and destination levels used for service range and auto cutouts + - servedLevelIds: legacy optional explicit level list; used only when from/to are missing + - defaultLevelId: starting/resting level, falling back to the lowest served level + - speed/doorDurationMs/dwellMs: runtime animation defaults + `, +) + +export type ElevatorNode = z.infer diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index a13b303f7..5232eaaf8 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -19,7 +19,7 @@ export const SlabNode = BaseNode.extend({ Slab node - used to represent a slab/floor in the building - polygon: array of [x, z] points defining the slab boundary - holes: array of [x, z] polygons representing cutouts in the slab - - holeMetadata: metadata parallel to holes, used to preserve manual and stair-managed cutouts + - holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts - elevation: elevation in meters - autoFromWalls: whether the slab is automatically generated from a closed wall loop `, diff --git a/packages/core/src/schema/nodes/surface-hole-metadata.ts b/packages/core/src/schema/nodes/surface-hole-metadata.ts index 6e5358cb5..bb223876b 100644 --- a/packages/core/src/schema/nodes/surface-hole-metadata.ts +++ b/packages/core/src/schema/nodes/surface-hole-metadata.ts @@ -1,8 +1,9 @@ import { z } from 'zod' export const SurfaceHoleMetadata = z.object({ - source: z.enum(['manual', 'stair']).default('manual'), + source: z.enum(['manual', 'stair', 'elevator']).default('manual'), stairId: z.string().optional(), + elevatorId: z.string().optional(), }) export type SurfaceHoleMetadata = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index a4977b5d4..d04c4be3a 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -3,6 +3,7 @@ import { BuildingNode } from './nodes/building' import { CeilingNode } from './nodes/ceiling' import { ColumnNode } from './nodes/column' import { DoorNode } from './nodes/door' +import { ElevatorNode } from './nodes/elevator' import { FenceNode } from './nodes/fence' import { GuideNode } from './nodes/guide' import { ItemNode } from './nodes/item' @@ -22,6 +23,7 @@ import { ZoneNode } from './nodes/zone' export const AnyNode = z.discriminatedUnion('type', [ SiteNode, BuildingNode, + ElevatorNode, LevelNode, ColumnNode, WallNode, diff --git a/packages/core/src/store/use-interactive.ts b/packages/core/src/store/use-interactive.ts index fa72904e8..76455a49a 100644 --- a/packages/core/src/store/use-interactive.ts +++ b/packages/core/src/store/use-interactive.ts @@ -39,12 +39,25 @@ export type WindowAnimationState = { persist: boolean } +export type ElevatorPhase = 'idle' | 'closing' | 'moving' | 'opening' | 'open' + +export type ElevatorInteractiveState = { + currentLevelId: AnyNodeId | null + targetLevelId: AnyNodeId | null + carY: number + doorOpen: number + phase: ElevatorPhase + phaseStartedAt: number | null + queue: AnyNodeId[] +} + type InteractiveStore = { items: Record doors: Record doorAnimations: Record windows: Record windowAnimations: Record + elevators: Record /** Initialize a node's interactive state from its asset definition (idempotent) */ initItem: (itemId: AnyNodeId, interactive: Interactive) => void @@ -78,6 +91,18 @@ type InteractiveStore = { /** Cancel a queued window animation */ cancelWindowAnimation: (windowId: AnyNodeId) => void + + /** Initialize an elevator's runtime state from its default served level. */ + initElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId, carY: number) => void + + /** Queue a request for an elevator to travel to a level. */ + requestElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId) => void + + /** Merge runtime elevator state. */ + setElevatorState: (elevatorId: AnyNodeId, value: Partial) => void + + /** Remove elevator runtime state when its renderer unmounts. */ + removeElevator: (elevatorId: AnyNodeId) => void } const defaultControlValue = (interactive: Interactive, index: number): ControlValue => { @@ -99,6 +124,7 @@ export const useInteractive = create((set, get) => ({ doorAnimations: {}, windows: {}, windowAnimations: {}, + elevators: {}, initItem: (itemId, interactive) => { const { controls } = interactive @@ -203,4 +229,72 @@ export const useInteractive = create((set, get) => ({ return { windowAnimations: rest } }) }, + + initElevator: (elevatorId, levelId, carY) => { + if (get().elevators[elevatorId]) return + + set((state) => ({ + elevators: { + ...state.elevators, + [elevatorId]: { + currentLevelId: levelId, + targetLevelId: null, + carY, + doorOpen: 0, + phase: 'idle', + phaseStartedAt: null, + queue: [], + }, + }, + })) + }, + + requestElevator: (elevatorId, levelId) => { + set((state) => { + const elevator = state.elevators[elevatorId] ?? { + currentLevelId: null, + targetLevelId: null, + carY: 0, + doorOpen: 0, + phase: 'idle' as const, + phaseStartedAt: null, + queue: [], + } + const isAlreadyQueued = elevator.queue.includes(levelId) || elevator.targetLevelId === levelId + + return { + elevators: { + ...state.elevators, + [elevatorId]: { + ...elevator, + queue: isAlreadyQueued ? elevator.queue : [...elevator.queue, levelId], + }, + }, + } + }) + }, + + setElevatorState: (elevatorId, value) => { + set((state) => { + const current = state.elevators[elevatorId] + if (!current) return state + + return { + elevators: { + ...state.elevators, + [elevatorId]: { + ...current, + ...value, + }, + }, + } + }) + }, + + removeElevator: (elevatorId) => { + set((state) => { + const { [elevatorId]: _, ...rest } = state.elevators + return { elevators: rest } + }) + }, })) diff --git a/packages/core/src/store/use-live-node-overrides.ts b/packages/core/src/store/use-live-node-overrides.ts new file mode 100644 index 000000000..04f952727 --- /dev/null +++ b/packages/core/src/store/use-live-node-overrides.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand' + +export type LiveNodeOverrides = Record + +type LiveNodeOverrideState = { + overrides: Map + set(nodeId: string, values: LiveNodeOverrides): void + get(nodeId: string): LiveNodeOverrides | undefined + clear(nodeId: string): void + clearAll(): void +} + +const useLiveNodeOverrides = create((set, get) => ({ + overrides: new Map(), + set: (nodeId, values) => + set((state) => { + const next = new Map(state.overrides) + next.set(nodeId, { ...(next.get(nodeId) ?? {}), ...values }) + return { overrides: next } + }), + get: (nodeId) => get().overrides.get(nodeId), + clear: (nodeId) => + set((state) => { + const next = new Map(state.overrides) + next.delete(nodeId) + return { overrides: next } + }), + clearAll: () => set({ overrides: new Map() }), +})) + +export default useLiveNodeOverrides diff --git a/packages/core/src/systems/elevator/elevator-opening-sync.test.ts b/packages/core/src/systems/elevator/elevator-opening-sync.test.ts new file mode 100644 index 000000000..bfaf09f46 --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-opening-sync.test.ts @@ -0,0 +1,164 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; core does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '../../schema' +import { BuildingNode, CeilingNode, ElevatorNode, LevelNode, SlabNode } from '../../schema' +import { syncAutoElevatorOpenings } from './elevator-opening-sync' + +describe('syncAutoElevatorOpenings', () => { + test('does not add elevator holes when a manual surface hole already covers them', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const elevator = ElevatorNode.parse({ + name: 'Elevator', + parentId: building.id, + position: [2, 0, 1.5], + width: 1.6, + depth: 1.6, + }) + const buildingWithChildren = { + ...building, + children: [ground.id, upper.id, elevator.id], + } + const manualOpening: Array<[number, number]> = [ + [1, 0.5], + [3, 0.5], + [3, 2.5], + [1, 2.5], + ] + const sourceCeiling = CeilingNode.parse({ + name: 'Source Ceiling', + parentId: ground.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const upperSlab = SlabNode.parse({ + name: 'Upper Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const nodes = Object.fromEntries( + [buildingWithChildren, ground, upper, elevator, sourceCeiling, upperSlab].map((node) => [ + node.id, + node, + ]), + ) as Record + + const updates = syncAutoElevatorOpenings(nodes) + + expect(updates.find((update) => update.id === upperSlab.id)).toBeUndefined() + expect(updates.find((update) => update.id === sourceCeiling.id)).toBeUndefined() + }) + + test('adds elevator holes when an existing manual hole is too small', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const elevator = ElevatorNode.parse({ + name: 'Elevator', + parentId: building.id, + position: [2, 0, 1.5], + width: 1.6, + depth: 1.6, + }) + const buildingWithChildren = { + ...building, + children: [ground.id, upper.id, elevator.id], + } + const smallManualOpening: Array<[number, number]> = [ + [1.7, 1.2], + [2.3, 1.2], + [2.3, 1.8], + [1.7, 1.8], + ] + const upperSlab = SlabNode.parse({ + name: 'Upper Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [smallManualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const nodes = Object.fromEntries( + [buildingWithChildren, ground, upper, elevator, upperSlab].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoElevatorOpenings(nodes) + const slabUpdate = updates.find((update) => update.id === upperSlab.id) + + expect(slabUpdate?.data.holes).toHaveLength(2) + expect(slabUpdate?.data.holes?.[0]).toEqual(smallManualOpening) + expect(slabUpdate?.data.holeMetadata).toEqual([ + { source: 'manual' }, + { source: 'elevator', elevatorId: elevator.id }, + ]) + }) + + test('removes stale auto elevator holes when a manual hole overlaps the elevator opening', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const elevator = ElevatorNode.parse({ + name: 'Elevator', + parentId: building.id, + position: [2, 0, 1.5], + width: 1.6, + depth: 1.6, + }) + const buildingWithChildren = { + ...building, + children: [ground.id, upper.id, elevator.id], + } + const manualOpening: Array<[number, number]> = [ + [1, 0.5], + [3, 0.5], + [3, 2.5], + [1, 2.5], + ] + const staleAutoOpening: Array<[number, number]> = [ + [1.12, 0.62], + [2.88, 0.62], + [2.88, 2.38], + [1.12, 2.38], + ] + const upperSlab = SlabNode.parse({ + name: 'Upper Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening, staleAutoOpening], + holeMetadata: [{ source: 'manual' }, { source: 'elevator', elevatorId: elevator.id }], + }) + const nodes = Object.fromEntries( + [buildingWithChildren, ground, upper, elevator, upperSlab].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoElevatorOpenings(nodes) + const slabUpdate = updates.find((update) => update.id === upperSlab.id) + + expect(slabUpdate?.data.holes).toEqual([manualOpening]) + expect(slabUpdate?.data.holeMetadata).toEqual([{ source: 'manual' }]) + }) +}) diff --git a/packages/core/src/systems/elevator/elevator-opening-sync.ts b/packages/core/src/systems/elevator/elevator-opening-sync.ts new file mode 100644 index 000000000..b1ef1bd09 --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-opening-sync.ts @@ -0,0 +1,266 @@ +import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' +import type { AnyNode, AnyNodeId, CeilingNode, ElevatorNode, SlabNode } from '../../schema' +import { resolveElevatorServiceLevels } from './elevator-service' + +type Point2D = [number, number] + +type SurfaceHoleMetadata = { + source: 'manual' | 'stair' | 'elevator' + elevatorId?: string + stairId?: string +} + +const ELEVATOR_OPENING_PADDING = 0.08 + +function pointsEqual(a: Point2D, b: Point2D, tolerance = 1e-5) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + return dx * dx + dz * dz <= tolerance * tolerance +} + +function polygonsEqual(left: Point2D[][], right: Point2D[][]) { + if (left.length !== right.length) return false + return left.every((polygon, polygonIndex) => { + const other = right[polygonIndex] + if (!(other && polygon.length === other.length)) return false + return polygon.every((point, pointIndex) => { + const otherPoint = other[pointIndex] + if (!otherPoint) return false + return pointsEqual(point, otherPoint) + }) + }) +} + +function metadataEqual(left: SurfaceHoleMetadata[], right: SurfaceHoleMetadata[]) { + if (left.length !== right.length) return false + return left.every( + (entry, index) => + entry.source === right[index]?.source && + (entry.elevatorId ?? null) === (right[index]?.elevatorId ?? null) && + (entry.stairId ?? null) === (right[index]?.stairId ?? null), + ) +} + +function normalizeExistingMetadata( + holes: Point2D[][], + metadata: SurfaceHoleMetadata[] | undefined, +): SurfaceHoleMetadata[] { + return holes.map((_, index) => metadata?.[index] ?? { source: 'manual' }) +} + +function rotateXZ(x: number, z: number, angle: number): [number, number] { + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return [x * cos + z * sin, -x * sin + z * cos] +} + +function pointOnSegment(point: Point2D, a: Point2D, b: Point2D, tolerance = 1e-6) { + const cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1]) + if (Math.abs(cross) > tolerance) return false + const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1]) + if (dot < -tolerance) return false + const lenSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2 + return dot <= lenSq + tolerance +} + +function pointInPolygon(point: Point2D, polygon: Point2D[]) { + if (polygon.length < 3) return false + let inside = false + const [x, z] = point + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const a = polygon[i]! + const b = polygon[j]! + if (pointOnSegment(point, a, b)) return true + const intersects = + a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0] + if (intersects) inside = !inside + } + + return inside +} + +function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) { + return inner.every((point) => pointInPolygon(point, outer)) +} + +function isCoveredByExistingHole(existingHoles: Point2D[][], autoHole: Point2D[]) { + return existingHoles.some((existingHole) => polygonContainsPolygon(existingHole, autoHole)) +} + +function getServedLevelRange(elevator: ElevatorNode, nodes: Record) { + const servedLevels = resolveElevatorServiceLevels(elevator, nodes) + if (servedLevels.length < 2) return null + + const levelNumbers = servedLevels.map((level) => level.level) + return { + max: Math.max(...levelNumbers), + min: Math.min(...levelNumbers), + sortedServedLevels: servedLevels, + } +} + +function getLevelNumber(levelId: string | null, nodes: Record) { + if (!levelId) return undefined + const node = nodes[levelId as AnyNodeId] + return node?.type === 'level' ? node.level : undefined +} + +function shouldApplyElevatorToSlab( + elevator: ElevatorNode, + slabLevelId: string, + nodes: Record, +) { + const range = getServedLevelRange(elevator, nodes) + if (!range) return false + + const slabLevel = getLevelNumber(slabLevelId, nodes) + if (slabLevel !== undefined) { + return slabLevel > range.min && slabLevel <= range.max + } + + const servedIndex = range.sortedServedLevels.findIndex((level) => level.id === slabLevelId) + return servedIndex > 0 +} + +function shouldApplyElevatorToCeiling( + elevator: ElevatorNode, + ceilingLevelId: string, + nodes: Record, +) { + const range = getServedLevelRange(elevator, nodes) + if (!range) return false + + const ceilingLevel = getLevelNumber(ceilingLevelId, nodes) + if (ceilingLevel !== undefined) { + return ceilingLevel >= range.min && ceilingLevel < range.max + } + + const servedIndex = range.sortedServedLevels.findIndex((level) => level.id === ceilingLevelId) + return servedIndex >= 0 && servedIndex < range.sortedServedLevels.length - 1 +} + +function getElevatorOpeningPolygon(elevator: ElevatorNode): Point2D[] { + const halfWidth = Math.max(elevator.width, 0.8) / 2 + ELEVATOR_OPENING_PADDING + const halfDepth = Math.max(elevator.depth, 0.8) / 2 + ELEVATOR_OPENING_PADDING + const corners: Point2D[] = [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] + + return corners.map(([x, z]) => { + const [rotatedX, rotatedZ] = rotateXZ(x, z, elevator.rotation ?? 0) + return [elevator.position[0] + rotatedX, elevator.position[2] + rotatedZ] + }) +} + +export function syncAutoElevatorOpenings(nodes: Record) { + const elevators = Object.values(nodes).filter( + (node): node is ElevatorNode => node.type === 'elevator' && node.visible !== false, + ) + const slabs = Object.values(nodes).filter((node): node is SlabNode => node.type === 'slab') + const ceilings = Object.values(nodes).filter( + (node): node is CeilingNode => node.type === 'ceiling', + ) + const updates: Array<{ id: AnyNodeId; data: Partial }> = [] + + for (const slab of slabs) { + const slabLevelId = resolveLevelId(slab, nodes) + const existingHoles = slab.holes ?? [] + const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata) + const preservedHoles = existingHoles + .map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon })) + .filter((entry) => entry.metadata.source !== 'elevator') + const manualHoles = preservedHoles.filter((entry) => entry.metadata.source !== 'stair') + const stairHoles = preservedHoles.filter((entry) => entry.metadata.source === 'stair') + const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon) + + const elevatorHoles = elevators + .filter((elevator) => shouldApplyElevatorToSlab(elevator, slabLevelId, nodes)) + .map((elevator) => ({ + polygon: getElevatorOpeningPolygon(elevator), + metadata: { + elevatorId: elevator.id, + source: 'elevator' as const, + }, + })) + .filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon)) + .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) + + const nextHoles = [ + ...manualHoles.map((hole) => hole.polygon), + ...elevatorHoles.map((hole) => hole.polygon), + ...stairHoles.map((hole) => hole.polygon), + ] + const nextMetadata = [ + ...manualHoles.map((hole) => ({ ...hole.metadata })), + ...elevatorHoles.map((hole) => hole.metadata), + ...stairHoles.map((hole) => ({ ...hole.metadata })), + ] + + if ( + !polygonsEqual(existingHoles, nextHoles) || + !metadataEqual(existingMetadata, nextMetadata) + ) { + updates.push({ + id: slab.id, + data: { + holes: nextHoles, + holeMetadata: nextMetadata, + }, + }) + } + } + + for (const ceiling of ceilings) { + const ceilingLevelId = resolveLevelId(ceiling, nodes) + const existingHoles = ceiling.holes ?? [] + const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata) + const preservedHoles = existingHoles + .map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon })) + .filter((entry) => entry.metadata.source !== 'elevator') + const manualHoles = preservedHoles.filter((entry) => entry.metadata.source !== 'stair') + const stairHoles = preservedHoles.filter((entry) => entry.metadata.source === 'stair') + const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon) + + const elevatorHoles = elevators + .filter((elevator) => shouldApplyElevatorToCeiling(elevator, ceilingLevelId, nodes)) + .map((elevator) => ({ + polygon: getElevatorOpeningPolygon(elevator), + metadata: { + elevatorId: elevator.id, + source: 'elevator' as const, + }, + })) + .filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon)) + .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) + + const nextHoles = [ + ...manualHoles.map((hole) => hole.polygon), + ...elevatorHoles.map((hole) => hole.polygon), + ...stairHoles.map((hole) => hole.polygon), + ] + const nextMetadata = [ + ...manualHoles.map((hole) => ({ ...hole.metadata })), + ...elevatorHoles.map((hole) => hole.metadata), + ...stairHoles.map((hole) => ({ ...hole.metadata })), + ] + + if ( + !polygonsEqual(existingHoles, nextHoles) || + !metadataEqual(existingMetadata, nextMetadata) + ) { + updates.push({ + id: ceiling.id, + data: { + holes: nextHoles, + holeMetadata: nextMetadata, + }, + }) + } + } + + return updates +} diff --git a/packages/core/src/systems/elevator/elevator-service.ts b/packages/core/src/systems/elevator/elevator-service.ts new file mode 100644 index 000000000..c775d7089 --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-service.ts @@ -0,0 +1,67 @@ +import type { AnyNode, AnyNodeId, ElevatorNode, LevelNode } from '../../schema' + +function getBuildingLevels(elevator: ElevatorNode, nodes: Record): LevelNode[] { + const building = + elevator.parentId && nodes[elevator.parentId as AnyNodeId]?.type === 'building' + ? nodes[elevator.parentId as AnyNodeId] + : null + + if (building?.type !== 'building') return [] + + return building.children + .map((childId) => nodes[childId as AnyNodeId]) + .filter((node): node is LevelNode => node?.type === 'level') + .sort((left, right) => left.level - right.level) +} + +function findLevelIndex(levels: LevelNode[], levelId: string | null | undefined) { + if (!levelId) return -1 + return levels.findIndex((level) => level.id === levelId) +} + +function getDefaultToIndex(levels: LevelNode[], fromIndex: number) { + if (levels.length === 0) return -1 + if (fromIndex < 0) return Math.min(1, levels.length - 1) + return Math.min(fromIndex + 1, levels.length - 1) +} + +export function resolveElevatorBuildingLevels( + elevator: ElevatorNode, + nodes: Record, +): LevelNode[] { + return getBuildingLevels(elevator, nodes) +} + +export function resolveElevatorServiceLevelIds( + elevator: ElevatorNode, + nodes: Record, +): string[] { + return resolveElevatorServiceLevels(elevator, nodes).map((level) => level.id) +} + +export function resolveElevatorServiceLevels( + elevator: ElevatorNode, + nodes: Record, +): LevelNode[] { + const levels = getBuildingLevels(elevator, nodes) + if (levels.length === 0) return [] + + const hasServiceBounds = Boolean(elevator.fromLevelId || elevator.toLevelId) + let legacyServedLevels: LevelNode[] = [] + if (!hasServiceBounds && elevator.servedLevelIds && elevator.servedLevelIds.length > 0) { + const servedIds = new Set(elevator.servedLevelIds) + legacyServedLevels = levels.filter((level) => servedIds.has(level.id)) + } + + const legacyFromLevelId = legacyServedLevels[0]?.id ?? null + const legacyToLevelId = legacyServedLevels[legacyServedLevels.length - 1]?.id ?? null + const explicitFromIndex = findLevelIndex(levels, elevator.fromLevelId ?? legacyFromLevelId) + const defaultFromIndex = findLevelIndex(levels, elevator.defaultLevelId) + const fromIndex = explicitFromIndex >= 0 ? explicitFromIndex : Math.max(defaultFromIndex, 0) + const toIndex = findLevelIndex(levels, elevator.toLevelId ?? legacyToLevelId) + const resolvedToIndex = toIndex >= 0 ? toIndex : getDefaultToIndex(levels, fromIndex) + const minIndex = Math.min(fromIndex, resolvedToIndex) + const maxIndex = Math.max(fromIndex, resolvedToIndex) + + return levels.slice(minIndex, maxIndex + 1) +} diff --git a/packages/core/src/systems/stair/stair-opening-sync.test.ts b/packages/core/src/systems/stair/stair-opening-sync.test.ts index 80221b12c..4ab030f28 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.test.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.test.ts @@ -2,7 +2,14 @@ // depend on @types/bun so the import type is unresolved at compile time. import { describe, expect, test } from 'bun:test' import type { AnyNode } from '../../schema' -import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' +import { + BuildingNode, + CeilingNode, + LevelNode, + SlabNode, + StairNode, + StairSegmentNode, +} from '../../schema' import { syncAutoStairOpenings } from './stair-opening-sync' describe('syncAutoStairOpenings', () => { @@ -68,4 +75,190 @@ describe('syncAutoStairOpenings', () => { expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) expect(bedroomUpdate).toBeUndefined() }) + + test('does not add stair holes when a manual surface hole already covers them', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const manualOpening: Array<[number, number]> = [ + [1.2, 0.8], + [2.8, 0.8], + [2.8, 2.9], + [1.2, 2.9], + ] + const sourceCeiling = CeilingNode.parse({ + name: 'Source Ceiling', + parentId: ground.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_main', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_main', + name: 'Main Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [ + building, + ground, + upper, + sourceCeiling, + landingSlab, + stair, + { ...segment, parentId: stair.id }, + ].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + + expect(updates.find((update) => update.id === landingSlab.id)).toBeUndefined() + expect(updates.find((update) => update.id === sourceCeiling.id)).toBeUndefined() + }) + + test('adds stair holes when an existing manual hole is too small', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const smallManualOpening: Array<[number, number]> = [ + [1.8, 1.6], + [2.2, 1.6], + [2.2, 2.1], + [1.8, 2.1], + ] + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [smallManualOpening], + holeMetadata: [{ source: 'manual' }], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_main', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_main', + name: 'Main Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + + expect(landingUpdate?.data.holes).toHaveLength(2) + expect(landingUpdate?.data.holes?.[0]).toEqual(smallManualOpening) + expect(landingUpdate?.data.holeMetadata).toEqual([ + { source: 'manual' }, + { source: 'stair', stairId: stair.id }, + ]) + }) + + test('removes stale auto stair holes when a manual hole overlaps the stair opening', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const manualOpening: Array<[number, number]> = [ + [1.2, 0.8], + [2.8, 0.8], + [2.8, 2.9], + [1.2, 2.9], + ] + const staleAutoOpening: Array<[number, number]> = [ + [1.5, 1], + [2.5, 1], + [2.5, 2.8], + [1.5, 2.8], + ] + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [manualOpening, staleAutoOpening], + holeMetadata: [{ source: 'manual' }, { source: 'stair', stairId: 'stair_main' }], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_main', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_main', + name: 'Main Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + + expect(landingUpdate?.data.holes).toEqual([manualOpening]) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'manual' }]) + }) }) diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index 2e478cd26..ca9b1904b 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -1,20 +1,19 @@ +import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' import type { AnyNode, AnyNodeId, CeilingNode, - LevelNode, SlabNode, StairNode, StairSegmentNode, } from '../../schema' - -import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' type Point2D = [number, number] type SurfaceHoleMetadata = { - source: 'manual' | 'stair' + source: 'manual' | 'stair' | 'elevator' + elevatorId?: string stairId?: string } @@ -69,6 +68,7 @@ function metadataEqual(left: SurfaceHoleMetadata[], right: SurfaceHoleMetadata[] return left.every( (entry, index) => entry.source === right[index]?.source && + (entry.elevatorId ?? null) === (right[index]?.elevatorId ?? null) && (entry.stairId ?? null) === (right[index]?.stairId ?? null), ) } @@ -316,6 +316,10 @@ function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) { return inner.every((point) => pointInPolygon(point, outer)) } +function isCoveredByExistingHole(existingHoles: Point2D[][], autoHole: Point2D[]) { + return existingHoles.some((existingHole) => polygonContainsPolygon(existingHole, autoHole)) +} + function getAxisAlignedRectFromPolygon(polygon: Point2D[]): AxisAlignedRect | null { if (polygon.length < 4) return null const xs = polygon.map(([x]) => x) @@ -741,12 +745,10 @@ export function syncAutoStairOpenings(nodes: Record) { const slabLevelId = resolveLevelId(slab, nodes) const existingHoles = slab.holes ?? [] const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata) - const manualHoles = existingHoles.filter( - (_hole, index) => existingMetadata[index]?.source !== 'stair', - ) - const manualMetadata = existingMetadata - .filter((entry) => entry.source !== 'stair') - .map((entry) => ({ ...entry })) + const preservedHoles = existingHoles + .map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon })) + .filter((entry) => entry.metadata.source !== 'stair') + const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon) const stairHoles = stairs .filter((stair) => shouldApplyStairToSlab(stair, slabLevelId, nodes)) @@ -770,9 +772,16 @@ export function syncAutoStairOpenings(nodes: Record) { })), ) .filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon)) + .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) - const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)] - const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)] + const nextHoles = [ + ...preservedHoles.map((hole) => hole.polygon), + ...stairHoles.map((hole) => hole.polygon), + ] + const nextMetadata = [ + ...preservedHoles.map((hole) => ({ ...hole.metadata })), + ...stairHoles.map((hole) => hole.metadata), + ] if ( !polygonsEqual(existingHoles, nextHoles) || @@ -792,12 +801,10 @@ export function syncAutoStairOpenings(nodes: Record) { const ceilingLevelId = resolveLevelId(ceiling, nodes) const existingHoles = ceiling.holes ?? [] const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata) - const manualHoles = existingHoles.filter( - (_hole, index) => existingMetadata[index]?.source !== 'stair', - ) - const manualMetadata = existingMetadata - .filter((entry) => entry.source !== 'stair') - .map((entry) => ({ ...entry })) + const preservedHoles = existingHoles + .map((polygon, index) => ({ metadata: existingMetadata[index]!, polygon })) + .filter((entry) => entry.metadata.source !== 'stair') + const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon) const stairHoles = stairs .filter((stair) => shouldApplyStairToCeiling(stair, ceilingLevelId, nodes)) @@ -821,9 +828,16 @@ export function syncAutoStairOpenings(nodes: Record) { })), ) .filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon)) - - const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)] - const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)] + .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) + + const nextHoles = [ + ...preservedHoles.map((hole) => hole.polygon), + ...stairHoles.map((hole) => hole.polygon), + ] + const nextMetadata = [ + ...preservedHoles.map((hole) => ({ ...hole.metadata })), + ...stairHoles.map((hole) => hole.metadata), + ] if ( !polygonsEqual(existingHoles, nextHoles) || diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index cbc7ad140..ae4a0a1e5 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1,12 +1,36 @@ 'use client' import '../../three-types' -import { type AnyNodeId, emitter, sceneRegistry, useInteractive, useScene } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + type ElevatorNode, + emitter, + resolveElevatorBuildingLevels, + resolveElevatorServiceLevels, + sceneRegistry, + useInteractive, + useScene, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { KeyboardControls } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three' +import { + BoxGeometry, + Box3, + Euler, + type Group, + Matrix4, + Mesh, + MeshBasicMaterial, + type Object3D, + Ray, + Raycaster, + Vector2, + Vector3, +} from 'three' +import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import { closeDoorOpenState, DOOR_SWING_OPEN_ANGLE, @@ -34,6 +58,13 @@ const LOOK_SENSITIVITY = 0.002 const CONTROLLER_CENTER_FROM_EYE = 0.85 const DOOR_INTERACTION_DISTANCE = 2.5 const DOOR_LEAF_INTERACTION_DEPTH = 0.08 +const ELEVATOR_RIDE_HORIZONTAL_PADDING = 0.18 +const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14 +const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08 +const ELEVATOR_COLLIDER_WALL_THICKNESS = 0.16 +const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 +const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 +const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 const keyboardMap = [ { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, { name: 'backward', keys: ['ArrowDown', 'KeyS'] }, @@ -59,13 +90,410 @@ const doorOpeningLocalHit = new Vector3() const doorOpeningLocalRay = new Ray() const doorOpeningMatrix = new Matrix4() const doorOpeningWorldHit = new Vector3() +const elevatorLocalControllerPosition = new Vector3() +const elevatorInteractionRaycaster = new Raycaster() +const elevatorColliderMatrix = new Matrix4() +const elevatorColliderLocalMatrix = new Matrix4() +const elevatorLocalEyePosition = new Vector3() +const elevatorWorldControllerPosition = new Vector3() +const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false }) const spawnWorldPosition = new Vector3() const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') const windowInteractionRaycaster = new Raycaster() -type FirstPersonInteractableTarget = { - id: AnyNodeId - type: 'door' | 'window' +type ElevatorColliderKind = + | 'cab-back' + | 'cab-ceiling' + | 'cab-door-left' + | 'cab-door-right' + | 'cab-door-gate' + | 'cab-floor' + | 'cab-left' + | 'cab-right' + | 'landing-door-gate' + | 'landing-door-left' + | 'landing-door-right' + | 'shaft-back' + | 'shaft-front-header' + | 'shaft-front-left' + | 'shaft-front-right' + | 'shaft-left' + | 'shaft-right' + | 'shaft-top' + +type ElevatorColliderUserData = { + doorWidth?: number + dynamic?: boolean + elevatorId: AnyNodeId + kind: ElevatorColliderKind + levelId?: AnyNodeId + localPosition: [number, number, number] + matrixInitialized?: boolean + side?: 'left' | 'right' +} + +type ElevatorColliderMesh = Mesh & { + userData: Mesh['userData'] & ElevatorColliderUserData +} + +type FirstPersonInteractableTarget = + | { + id: AnyNodeId + type: 'door' | 'window' + } + | { + buttonKind: 'cab' | 'landing' + id: AnyNodeId + levelId: AnyNodeId + type: 'elevator' + } + +type ElevatorButtonTarget = { + buttonKind: 'cab' | 'landing' + elevatorId: AnyNodeId + levelId: AnyNodeId +} + +function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null { + let current: Object3D | null = object + + while (current) { + const candidate = ( + current.userData as { + elevatorButton?: { + elevatorId?: unknown + kind?: unknown + levelId?: unknown + } + } + ).elevatorButton + + if ( + typeof candidate?.elevatorId === 'string' && + typeof candidate.levelId === 'string' && + (candidate.kind === 'cab' || candidate.kind === 'landing') + ) { + return { + buttonKind: candidate.kind, + elevatorId: candidate.elevatorId as AnyNodeId, + levelId: candidate.levelId as AnyNodeId, + } + } + + current = current.parent + } + + return null +} + +function getInteractableTargetKey(target: FirstPersonInteractableTarget | null) { + if (!target) return null + return target.type === 'elevator' + ? `${target.type}:${target.id}:${target.levelId}` + : `${target.type}:${target.id}` +} + +function getElevatorDoorLeafX(side: 'left' | 'right', width: number, doorOpen: number) { + const direction = side === 'left' ? -1 : 1 + return direction * (width / 4 + doorOpen * width * 0.34) +} + +function isDynamicElevatorCollider(kind: ElevatorColliderKind) { + return kind.startsWith('cab-') || kind.startsWith('landing-door') +} + +function isInsideElevatorCab( + elevator: ElevatorNode, + runtime: NonNullable['elevators'][AnyNodeId]>, + localEyePosition: Vector3, +) { + const halfWidth = Math.max(elevator.width, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const halfDepth = Math.max(elevator.depth, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const cabHeight = Math.max(elevator.cabHeight, 1.4) + + return ( + Math.abs(localEyePosition.x) <= Math.max(halfWidth, 0.24) && + Math.abs(localEyePosition.z) <= Math.max(halfDepth, 0.24) && + localEyePosition.y >= runtime.carY + 0.35 && + localEyePosition.y <= runtime.carY + cabHeight + 0.7 + ) +} + +function getFirstPersonLevelHeight(levelId: string, nodes: Record) { + const level = nodes[levelId as AnyNodeId] + if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT + + let maxTop = 0 + for (const childId of level.children) { + const child = nodes[childId as AnyNodeId] + if (!child) continue + + if (child.type === 'ceiling') { + maxTop = Math.max(maxTop, child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT) + continue + } + + if (child.type === 'wall') { + const meshY = Math.max(sceneRegistry.nodes.get(childId as AnyNodeId)?.position.y ?? 0, 0) + maxTop = Math.max(maxTop, meshY + (child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT)) + } + } + + return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT +} + +function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record) { + const allLevels = resolveElevatorBuildingLevels(elevator, nodes) + + const baseYByLevelId = new Map() + let cumulativeY = 0 + for (const level of allLevels) { + baseYByLevelId.set(level.id, cumulativeY) + cumulativeY += getFirstPersonLevelHeight(level.id, nodes) + } + + const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) + const entries = serviceLevels.map((level) => ({ + baseY: baseYByLevelId.get(level.id) ?? 0, + id: level.id as AnyNodeId, + })) + const firstServedLevel = serviceLevels[0] ?? null + const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null + const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0 + const lastServedIndex = lastServedLevel + ? allLevels.findIndex((level) => level.id === lastServedLevel.id) + : -1 + const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null + const shaftTopY = nextLevel + ? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY) + : lastServedLevel + ? cumulativeY + : elevator.cabHeight + 0.3 + + return { + entries, + shaftBaseY, + shaftTopY, + totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3), + } +} + +function createElevatorColliderMesh( + elevatorId: AnyNodeId, + kind: ElevatorColliderKind, + size: [number, number, number], + localPosition: [number, number, number], + userData: Partial = {}, +) { + const geometry = new BoxGeometry(size[0], size[1], size[2]) + + const bvhGeometry = geometry as typeof geometry & { + computeBoundsTree?: typeof computeBoundsTree + disposeBoundsTree?: typeof disposeBoundsTree + } + ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree + ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree + bvhGeometry.computeBoundsTree?.({ + maxLeafSize: 12, + strategy: 0, + } as never) + bvhGeometry.computeBoundingBox() + + const mesh = new Mesh(bvhGeometry, elevatorColliderMaterial) as unknown as ElevatorColliderMesh + mesh.raycast = acceleratedRaycast + mesh.matrixAutoUpdate = false + mesh.visible = true + mesh.userData = { + ...userData, + dynamic: isDynamicElevatorCollider(kind), + elevatorId, + excludeCollisionCheck: false, + excludeFloatHit: false, + friction: 0.8, + kind, + localPosition, + matrixInitialized: false, + restitution: 0.03, + type: 'ELEVATOR_COLLIDER', + } + return mesh +} + +function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { + const nodes = useScene.getState().nodes + const meshes: ElevatorColliderMesh[] = [] + + for (const elevatorId of sceneRegistry.byType.elevator) { + const typedElevatorId = elevatorId as AnyNodeId + const node = nodes[typedElevatorId] + if (node?.type !== 'elevator' || node.visible === false) continue + + const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorColliderLevels( + node, + nodes, + ) + const shaftWidth = Math.max(node.width, 0.8) + const shaftDepth = Math.max(node.depth, 0.8) + const cabHeight = Math.max(node.cabHeight, 1.4) + const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), shaftWidth - 0.18) + const doorHeight = Math.min(Math.max(node.doorHeight, 1.2), cabHeight - 0.1) + const shaftHeight = Math.max(totalHeight, cabHeight + 0.3) + const wallThickness = ELEVATOR_COLLIDER_WALL_THICKNESS + const cabFloorWidth = Math.max(shaftWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) + const cabFloorDepth = Math.max(shaftDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) + const frontWallZ = -shaftDepth / 2 - wallThickness / 2 + const frontZ = frontWallZ - wallThickness / 2 - 0.018 + const leafWidth = Math.max(doorWidth / 2 - 0.018, 0.12) + const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight) + + meshes.push( + createElevatorColliderMesh( + typedElevatorId, + 'shaft-back', + [shaftWidth + wallThickness * 2, shaftHeight, wallThickness], + [0, shaftBaseY + shaftHeight / 2, shaftDepth / 2 + wallThickness / 2], + ), + createElevatorColliderMesh( + typedElevatorId, + 'shaft-left', + [wallThickness, shaftHeight, shaftDepth + wallThickness * 2], + [-shaftWidth / 2 - wallThickness / 2, shaftBaseY + shaftHeight / 2, 0], + ), + createElevatorColliderMesh( + typedElevatorId, + 'shaft-right', + [wallThickness, shaftHeight, shaftDepth + wallThickness * 2], + [shaftWidth / 2 + wallThickness / 2, shaftBaseY + shaftHeight / 2, 0], + ), + createElevatorColliderMesh( + typedElevatorId, + 'shaft-top', + [shaftWidth + wallThickness * 2, wallThickness, shaftDepth + wallThickness * 2], + [0, shaftBaseY + shaftHeight - wallThickness / 2, 0], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-floor', + [cabFloorWidth, ELEVATOR_COLLIDER_FLOOR_THICKNESS, cabFloorDepth], + [0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, 0], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-ceiling', + [shaftWidth, wallThickness, shaftDepth], + [0, cabHeight - wallThickness / 2, 0], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-back', + [shaftWidth, cabHeight, wallThickness], + [0, cabHeight / 2, shaftDepth / 2 - wallThickness / 2], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-left', + [wallThickness, cabHeight, shaftDepth], + [-shaftWidth / 2 + wallThickness / 2, cabHeight / 2, 0], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-right', + [wallThickness, cabHeight, shaftDepth], + [shaftWidth / 2 - wallThickness / 2, cabHeight / 2, 0], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-door-left', + [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, doorHeight / 2, frontZ], + { doorWidth, side: 'left' }, + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-door-gate', + [doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, doorHeight / 2, frontZ], + { doorWidth }, + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-door-right', + [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, doorHeight / 2, frontZ], + { doorWidth, side: 'right' }, + ), + ) + + const entrySpans = entries.map((entry, index) => { + const nextEntry = entries[index + 1] + return { + entry, + levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24), + } + }) + + for (const { entry, levelTopY } of entrySpans) { + const wallDepth = wallThickness + const levelHeight = Math.max(levelTopY - entry.baseY, doorHeight + 0.24) + const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08) + const jambCenterOffset = doorWidth / 2 + jambWidth / 2 + const headerHeight = Math.max(levelHeight - doorHeight, 0.14) + + meshes.push( + createElevatorColliderMesh( + typedElevatorId, + 'shaft-front-left', + [jambWidth, levelHeight, wallDepth], + [-jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ], + ), + createElevatorColliderMesh( + typedElevatorId, + 'shaft-front-right', + [jambWidth, levelHeight, wallDepth], + [jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ], + ), + createElevatorColliderMesh( + typedElevatorId, + 'shaft-front-header', + [shaftWidth, headerHeight, wallDepth], + [0, entry.baseY + doorHeight + headerHeight / 2, frontWallZ], + ), + createElevatorColliderMesh( + typedElevatorId, + 'landing-door-left', + [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, entry.baseY + doorHeight / 2, frontZ - 0.02], + { doorWidth, levelId: entry.id, side: 'left' }, + ), + createElevatorColliderMesh( + typedElevatorId, + 'landing-door-gate', + [doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, entry.baseY + doorHeight / 2, frontZ - 0.02], + { doorWidth, levelId: entry.id }, + ), + createElevatorColliderMesh( + typedElevatorId, + 'landing-door-right', + [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, entry.baseY + doorHeight / 2, frontZ - 0.02], + { doorWidth, levelId: entry.id, side: 'right' }, + ), + ) + } + } + + return meshes +} + +function disposeElevatorColliderMeshes(meshes: ElevatorColliderMesh[]) { + for (const mesh of meshes) { + const geometry = mesh.geometry as typeof mesh.geometry & { + disposeBoundsTree?: typeof disposeBoundsTree + } + geometry.disposeBoundsTree?.() + geometry.dispose() + } } const resolvePlacedSpawnNode = ( @@ -86,8 +514,17 @@ export const FirstPersonControls = () => { const yawRef = useRef(0) const pitchRef = useRef(0) const interactableTargetRef = useRef(null) + const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false) + const ridingElevatorRef = useRef<{ + elevatorId: AnyNodeId + localControllerY: number | null + previousCarY: number + } | null>(null) + const rideLockedRef = useRef(false) const worldRef = useRef(null) + const elevatorColliderMeshesRef = useRef([]) const [world, setWorld] = useState(null) + const [elevatorColliderMeshes, setElevatorColliderMeshes] = useState([]) const [controllerStart, setControllerStart] = useState<{ position: [number, number, number] yaw: number @@ -99,9 +536,22 @@ export const FirstPersonControls = () => { setWorld(nextWorld) }, []) + const replaceElevatorColliderMeshes = useCallback((nextMeshes: ElevatorColliderMesh[]) => { + disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current) + elevatorColliderMeshesRef.current = nextMeshes + setElevatorColliderMeshes(nextMeshes) + }, []) + const rebuildColliderWorld = useCallback(() => { replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry()) - }, [replaceColliderWorld]) + replaceElevatorColliderMeshes(buildElevatorColliderMeshes()) + }, [replaceColliderWorld, replaceElevatorColliderMeshes]) + + const setElevatorRideLocked = useCallback((locked: boolean) => { + if (rideLockedRef.current === locked) return + rideLockedRef.current = locked + setIsElevatorRideLocked(locked) + }, []) const resolveInteractableDoorId = useCallback((): AnyNodeId | null => { const nodes = useScene.getState().nodes @@ -230,7 +680,58 @@ export const FirstPersonControls = () => { return closestWindowId }, [camera]) + const resolveInteractableElevatorTarget = + useCallback((): FirstPersonInteractableTarget | null => { + const nodes = useScene.getState().nodes + camera.updateMatrixWorld(true) + elevatorInteractionRaycaster.setFromCamera(centerScreenPoint, camera) + + let closestTarget: FirstPersonInteractableTarget | null = null + let closestDistance = DOOR_INTERACTION_DISTANCE + + for (const elevatorId of sceneRegistry.byType.elevator) { + const typedElevatorId = elevatorId as AnyNodeId + const node = nodes[typedElevatorId] + if (node?.type !== 'elevator') continue + + const object = sceneRegistry.nodes.get(typedElevatorId) + if (!object) continue + + const runtime = useInteractive.getState().elevators[typedElevatorId] + object.updateWorldMatrix(true, true) + if (runtime) { + elevatorLocalEyePosition.copy(camera.position) + object.worldToLocal(elevatorLocalEyePosition) + } + const canUseCabButtons = + runtime && isInsideElevatorCab(node, runtime, elevatorLocalEyePosition) + + const intersections = elevatorInteractionRaycaster.intersectObject(object, true) + for (const intersection of intersections) { + if (intersection.distance > closestDistance) continue + + const target = resolveElevatorButtonTarget(intersection.object) + if (!target || target.elevatorId !== elevatorId) continue + if (nodes[target.levelId]?.type !== 'level') continue + if (target.buttonKind === 'cab' && !canUseCabButtons) continue + + closestTarget = { + buttonKind: target.buttonKind, + id: target.elevatorId, + levelId: target.levelId, + type: 'elevator', + } + closestDistance = intersection.distance + } + } + + return closestTarget + }, [camera]) + const resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => { + const elevatorTarget = resolveInteractableElevatorTarget() + if (elevatorTarget) return elevatorTarget + const doorId = resolveInteractableDoorId() if (doorId) return { id: doorId, type: 'door' } @@ -238,12 +739,27 @@ export const FirstPersonControls = () => { if (windowId) return { id: windowId, type: 'window' } return null - }, [resolveInteractableDoorId, resolveInteractableWindowId]) + }, [resolveInteractableDoorId, resolveInteractableElevatorTarget, resolveInteractableWindowId]) const toggleInteractableTarget = useCallback(() => { const target = interactableTargetRef.current ?? resolveInteractableTarget() if (!target) return + if (target.type === 'elevator') { + if (target.buttonKind === 'cab') { + const state = useInteractive.getState().elevators[target.id] + if (state) { + ridingElevatorRef.current = { + elevatorId: target.id, + localControllerY: null, + previousCarY: state.carY, + } + } + } + useInteractive.getState().requestElevator(target.id, target.levelId) + return + } + if (target.type === 'window') { const node = useScene.getState().nodes[target.id] if ( @@ -270,6 +786,8 @@ export const FirstPersonControls = () => { const target = interactableTargetRef.current ?? resolveInteractableTarget() if (!target) return + if (target.type === 'elevator') return + if (target.type === 'window') { const node = useScene.getState().nodes[target.id] if ( @@ -325,6 +843,9 @@ export const FirstPersonControls = () => { return () => { worldRef.current?.dispose() worldRef.current = null + disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current) + elevatorColliderMeshesRef.current = [] + setElevatorColliderMeshes([]) setWorld(null) } }, [rebuildColliderWorld]) @@ -373,17 +894,28 @@ export const FirstPersonControls = () => { } } + const handleMouseDown = (event: MouseEvent) => { + if (document.pointerLockElement !== canvas) return + if (event.button !== 0) return + + event.preventDefault() + event.stopPropagation() + toggleInteractableTarget() + } + document.addEventListener('mousemove', handleMouseMove) document.addEventListener('click', handleClick) + document.addEventListener('mousedown', handleMouseDown, true) return () => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('click', handleClick) + document.removeEventListener('mousedown', handleMouseDown, true) if (document.pointerLockElement === canvas) { document.exitPointerLock() } } - }, [gl]) + }, [gl, toggleInteractableTarget]) useEffect(() => { const canvas = gl.domElement @@ -417,7 +949,216 @@ export const FirstPersonControls = () => { } }, [closeInteractableTarget, gl, toggleInteractableTarget]) - useFrame((_, delta) => { + const syncElevatorColliderMeshes = useCallback(() => { + const nodes = useScene.getState().nodes + const interactive = useInteractive.getState() + + for (const mesh of elevatorColliderMeshesRef.current) { + const { doorWidth, dynamic, elevatorId, kind, levelId, localPosition, side } = mesh.userData + const node = nodes[elevatorId] + const runtime = interactive.elevators[elevatorId] + const object = sceneRegistry.nodes.get(elevatorId) + if (!(node?.type === 'elevator' && object && node.visible !== false)) { + mesh.visible = false + continue + } + + if (!dynamic && mesh.userData.matrixInitialized && mesh.visible) { + continue + } + + let [localX, localY, localZ] = localPosition + const isCabCollider = kind.startsWith('cab-') + const isDoorCollider = kind === 'landing-door-left' || kind === 'landing-door-right' + const isCabDoorGate = kind === 'cab-door-gate' + const isLandingDoorGate = kind === 'landing-door-gate' + + if (isCabCollider) { + localY += runtime?.carY ?? 0 + } + + if (kind === 'cab-door-left' || kind === 'cab-door-right') { + if (!runtime) { + mesh.visible = false + continue + } + localX = getElevatorDoorLeafX(side ?? 'left', doorWidth ?? node.doorWidth, runtime.doorOpen) + mesh.visible = true + } else if (isCabDoorGate) { + if (!runtime) { + mesh.visible = false + continue + } + mesh.visible = runtime.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD + } else if (isDoorCollider) { + const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0 + localX = getElevatorDoorLeafX(side ?? 'left', doorWidth ?? node.doorWidth, doorOpen) + mesh.visible = true + } else if (isLandingDoorGate) { + const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0 + mesh.visible = doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD + } else { + mesh.visible = true + } + + object.updateWorldMatrix(true, false) + elevatorColliderMatrix.copy(object.matrixWorld) + elevatorColliderMatrix.multiply( + elevatorColliderLocalMatrix.makeTranslation(localX, localY, localZ), + ) + mesh.matrix.copy(elevatorColliderMatrix) + mesh.matrixWorld.copy(elevatorColliderMatrix) + mesh.userData.matrixInitialized = true + } + }, []) + + useFrame(() => { + syncElevatorColliderMeshes() + }, -1) + + const syncElevatorRide = useCallback( + (group: Group) => { + const nodes = useScene.getState().nodes + const interactive = useInteractive.getState() + const activeRide = ridingElevatorRef.current + let nextRide: { + cabHeight: number + carY: number + doorOpen: number + elevatorId: AnyNodeId + halfDepth: number + halfWidth: number + object: Object3D + phase: NonNullable<(typeof interactive.elevators)[AnyNodeId]>['phase'] + } | null = null + + const elevatorIds = activeRide + ? [ + activeRide.elevatorId, + ...Array.from(sceneRegistry.byType.elevator).filter( + (elevatorId) => elevatorId !== activeRide.elevatorId, + ), + ] + : Array.from(sceneRegistry.byType.elevator) + + for (const elevatorId of elevatorIds) { + const typedElevatorId = elevatorId as AnyNodeId + const node = nodes[typedElevatorId] + if (node?.type !== 'elevator') continue + + const runtime = interactive.elevators[typedElevatorId] + const object = sceneRegistry.nodes.get(typedElevatorId) + if (!(runtime && object)) continue + + object.updateWorldMatrix(true, true) + elevatorLocalEyePosition.copy(camera.position) + object.worldToLocal(elevatorLocalEyePosition) + + const halfWidth = Math.max(node.width, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const halfDepth = Math.max(node.depth, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const cabHeight = Math.max(node.cabHeight, 1.4) + const insideFootprint = + Math.abs(elevatorLocalEyePosition.x) <= Math.max(halfWidth, 0.24) && + Math.abs(elevatorLocalEyePosition.z) <= Math.max(halfDepth, 0.24) + const insideCabHeight = + elevatorLocalEyePosition.y >= runtime.carY + 0.35 && + elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7 + const continuingRide = + activeRide?.elevatorId === typedElevatorId && + insideFootprint && + elevatorLocalEyePosition.y >= runtime.carY - 0.2 && + elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 1.25 + + if ((insideFootprint && insideCabHeight) || continuingRide) { + nextRide = { + cabHeight, + carY: runtime.carY, + doorOpen: runtime.doorOpen, + elevatorId: typedElevatorId, + halfDepth: Math.max(halfDepth, 0.24), + halfWidth: Math.max(halfWidth, 0.24), + object, + phase: runtime.phase, + } + break + } + } + + if (!nextRide) { + ridingElevatorRef.current = null + setElevatorRideLocked(false) + return + } + + const previousCarY = + activeRide?.elevatorId === nextRide.elevatorId ? activeRide.previousCarY : nextRide.carY + const deltaY = nextRide.carY - previousCarY + nextRide.object.updateWorldMatrix(true, true) + elevatorLocalControllerPosition.copy(group.position) + nextRide.object.worldToLocal(elevatorLocalControllerPosition) + const localControllerY = + activeRide?.elevatorId === nextRide.elevatorId && activeRide.localControllerY !== null + ? activeRide.localControllerY + : elevatorLocalControllerPosition.y - nextRide.carY + + if (Math.abs(deltaY) > 0.0001) { + group.position.y += deltaY + controllerRef.current?.resetLinVel() + } + + const shouldLockToCab = + nextRide.phase === 'closing' || + nextRide.phase === 'moving' || + (nextRide.phase === 'opening' && nextRide.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD) + if (shouldLockToCab) { + elevatorLocalControllerPosition.copy(group.position) + nextRide.object.worldToLocal(elevatorLocalControllerPosition) + const desiredLocalY = nextRide.carY + localControllerY + if (Math.abs(elevatorLocalControllerPosition.y - desiredLocalY) > 0.002) { + elevatorLocalControllerPosition.y = desiredLocalY + elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition) + nextRide.object.localToWorld(elevatorWorldControllerPosition) + group.position.y = elevatorWorldControllerPosition.y + controllerRef.current?.resetLinVel() + elevatorLocalControllerPosition.copy(group.position) + nextRide.object.worldToLocal(elevatorLocalControllerPosition) + } + + const clampedX = Math.max( + -nextRide.halfWidth, + Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x), + ) + const clampedZ = Math.max( + -nextRide.halfDepth, + Math.min(nextRide.halfDepth, elevatorLocalControllerPosition.z), + ) + + if ( + Math.abs(clampedX - elevatorLocalControllerPosition.x) > 0.0001 || + Math.abs(clampedZ - elevatorLocalControllerPosition.z) > 0.0001 + ) { + elevatorLocalControllerPosition.x = clampedX + elevatorLocalControllerPosition.z = clampedZ + elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition) + nextRide.object.localToWorld(elevatorWorldControllerPosition) + group.position.x = elevatorWorldControllerPosition.x + group.position.z = elevatorWorldControllerPosition.z + controllerRef.current?.resetLinVel() + } + } + + setElevatorRideLocked(shouldLockToCab) + + ridingElevatorRef.current = { + elevatorId: nextRide.elevatorId, + localControllerY, + previousCarY: nextRide.carY, + } + }, + [camera, setElevatorRideLocked], + ) + + useFrame(() => { if (!controllerRef.current?.group) return const group = controllerRef.current.group @@ -426,17 +1167,20 @@ export const FirstPersonControls = () => { cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ') camera.quaternion.setFromEuler(cameraEuler) camera.updateMatrixWorld(true) + syncElevatorRide(group) + camera.position.copy(group.position).add(cameraOffset) + camera.updateMatrixWorld(true) const nextInteractableTarget = resolveInteractableTarget() const previousInteractableTarget = interactableTargetRef.current if ( - previousInteractableTarget?.id !== nextInteractableTarget?.id || - previousInteractableTarget?.type !== nextInteractableTarget?.type + getInteractableTargetKey(previousInteractableTarget) !== + getInteractableTargetKey(nextInteractableTarget) ) { interactableTargetRef.current = nextInteractableTarget useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null) } - }) + }, 2.5) useEffect(() => { return () => { @@ -446,6 +1190,11 @@ export const FirstPersonControls = () => { } }, []) + const firstPersonColliderMeshes = useMemo( + () => (world ? [world.mesh, ...elevatorColliderMeshes] : elevatorColliderMeshes), + [world, elevatorColliderMeshes], + ) + if (!world) { return null } @@ -458,7 +1207,7 @@ export const FirstPersonControls = () => { ref={controllerRef} key="first-person-controller" colliderCapsuleArgs={[0.25, 0.8, 4, 8]} - colliderMeshes={[world.mesh]} + colliderMeshes={firstPersonColliderMeshes} collisionCheckIteration={3} collisionPushBackDamping={0.1} collisionPushBackThreshold={0.001} @@ -476,6 +1225,7 @@ export const FirstPersonControls = () => { maxRunSpeed={5.5} maxSlope={1.2} maxWalkSpeed={4} + paused={isElevatorRideLocked} position={controllerStart.position} acceleration={26} airDragFactor={0.3} diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 2f696d275..b58acf7a5 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -6,6 +6,7 @@ import { type CeilingNode, ColumnNode, DoorNode, + ElevatorNode, FenceNode, generateId, ItemNode, @@ -35,6 +36,7 @@ const ALLOWED_TYPES = [ 'item', 'door', 'window', + 'elevator', 'roof', 'roof-segment', 'stair', @@ -183,6 +185,7 @@ export function FloatingActionMenu() { node.type === 'item' || node.type === 'window' || node.type === 'door' || + node.type === 'elevator' || node.type === 'wall' || node.type === 'fence' || node.type === 'column' || @@ -263,6 +266,8 @@ export function FloatingActionMenu() { duplicate = WindowNode.parse(duplicateInfo) } else if (node.type === 'item') { duplicate = ItemNode.parse(duplicateInfo) + } else if (node.type === 'elevator') { + duplicate = ElevatorNode.parse(duplicateInfo) } else if (node.type === 'column') { duplicate = ColumnNode.parse(duplicateInfo) } else if (node.type === 'wall') { @@ -296,7 +301,11 @@ export function FloatingActionMenu() { } if (duplicate) { - if (duplicate.type === 'door' || duplicate.type === 'window') { + if ( + duplicate.type === 'door' || + duplicate.type === 'window' || + duplicate.type === 'elevator' + ) { useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) } else if (duplicate.type === 'wall') { useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) @@ -325,6 +334,7 @@ export function FloatingActionMenu() { } if ( duplicate.type === 'item' || + duplicate.type === 'elevator' || duplicate.type === 'column' || duplicate.type === 'wall' || duplicate.type === 'fence' || diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index aa44ad6a1..25e9cc2ee 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -14175,7 +14175,10 @@ export function FloorplanPanel() { } const currentHoles = slab.holes ?? [] - if (!currentHoles[holeIndex] || slab.holeMetadata?.[holeIndex]?.source === 'stair') { + if ( + !currentHoles[holeIndex] || + (slab.holeMetadata?.[holeIndex]?.source ?? 'manual') !== 'manual' + ) { return } @@ -14310,7 +14313,10 @@ export function FloorplanPanel() { } const currentHoles = ceiling.holes ?? [] - if (!currentHoles[holeIndex] || ceiling.holeMetadata?.[holeIndex]?.source === 'stair') { + if ( + !currentHoles[holeIndex] || + (ceiling.holeMetadata?.[holeIndex]?.source ?? 'manual') !== 'manual' + ) { return } diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 71f278cf1..ab5d0d2fc 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -56,6 +56,7 @@ import useEditor, { import { boxSelectHandled } from '../tools/select/box-select-tool' const isNodeInCurrentLevel = (node: AnyNode): boolean => { + if (node.type === 'elevator') return true const currentLevelId = useViewer.getState().selection.levelId if (!currentLevelId) return true // No level selected, allow all const nodeLevelId = resolveLevelId(node, useScene.getState().nodes) @@ -68,6 +69,7 @@ type SelectableNodeType = | 'item' | 'column' | 'building' + | 'elevator' | 'zone' | 'slab' | 'ceiling' @@ -565,6 +567,7 @@ const SELECTION_STRATEGIES: Record = { 'fence', 'item', 'column', + 'elevator', 'zone', 'slab', 'ceiling', @@ -579,11 +582,18 @@ const SELECTION_STRATEGIES: Record = { handleSelect: (node, nativeEvent, modifierKeys) => { const { selection, setSelection } = useViewer.getState() const nodes = useScene.getState().nodes - const nodeLevelId = resolveLevelId(node, nodes) - const buildingId = resolveBuildingId(nodeLevelId, nodes) + const nodeLevelId = node.type === 'elevator' ? null : resolveLevelId(node, nodes) + const buildingId = + node.type === 'elevator' && + node.parentId && + nodes[node.parentId as AnyNodeId]?.type === 'building' + ? node.parentId + : nodeLevelId + ? resolveBuildingId(nodeLevelId, nodes) + : null const updates: any = {} - if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) { + if (nodeLevelId && nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) { updates.levelId = nodeLevelId } if (buildingId && buildingId !== selection.buildingId) { @@ -619,6 +629,7 @@ const SELECTION_STRATEGIES: Record = { node.type === 'wall' || node.type === 'fence' || node.type === 'column' || + node.type === 'elevator' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -683,6 +694,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => { node.type === 'wall' || node.type === 'fence' || node.type === 'column' || + node.type === 'elevator' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -1163,6 +1175,7 @@ export const SelectionManager = () => { 'item', 'column', 'building', + 'elevator', 'zone', 'slab', 'ceiling', @@ -1259,6 +1272,7 @@ export const SelectionManager = () => { node.type === 'wall' || node.type === 'fence' || node.type === 'column' || + node.type === 'elevator' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -1313,6 +1327,7 @@ export const SelectionManager = () => { 'item', 'column', 'building', + 'elevator', 'slab', 'ceiling', 'roof', @@ -1386,6 +1401,7 @@ export const SelectionManager = () => { 'fence', 'item', 'column', + 'elevator', 'slab', 'ceiling', 'roof', diff --git a/packages/editor/src/components/tools/elevator/elevator-defaults.ts b/packages/editor/src/components/tools/elevator/elevator-defaults.ts new file mode 100644 index 000000000..d7cfa0e0b --- /dev/null +++ b/packages/editor/src/components/tools/elevator/elevator-defaults.ts @@ -0,0 +1,8 @@ +export const DEFAULT_ELEVATOR_WIDTH = 1.6 +export const DEFAULT_ELEVATOR_DEPTH = 1.6 +export const DEFAULT_ELEVATOR_CAB_HEIGHT = 2.35 +export const DEFAULT_ELEVATOR_DOOR_WIDTH = 0.95 +export const DEFAULT_ELEVATOR_DOOR_HEIGHT = 2.1 +export const DEFAULT_ELEVATOR_SPEED = 2.2 +export const DEFAULT_ELEVATOR_DOOR_DURATION_MS = 900 +export const DEFAULT_ELEVATOR_DWELL_MS = 1400 diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx new file mode 100644 index 000000000..9d10a82be --- /dev/null +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -0,0 +1,201 @@ +import { + type AnyNodeId, + type BuildingNode, + ElevatorNode, + emitter, + type GridEvent, + type LevelNode, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' +import { sfxEmitter } from '../../../lib/sfx-bus' +import { CursorSphere } from '../shared/cursor-sphere' +import { + DEFAULT_ELEVATOR_CAB_HEIGHT, + DEFAULT_ELEVATOR_DEPTH, + DEFAULT_ELEVATOR_DOOR_DURATION_MS, + DEFAULT_ELEVATOR_DOOR_HEIGHT, + DEFAULT_ELEVATOR_DOOR_WIDTH, + DEFAULT_ELEVATOR_DWELL_MS, + DEFAULT_ELEVATOR_SPEED, + DEFAULT_ELEVATOR_WIDTH, +} from './elevator-defaults' + +const GRID_OFFSET = 0.02 + +function resolveCurrentBuildingId(): BuildingNode['id'] | null { + const { buildingId, levelId } = useViewer.getState().selection + if (buildingId) return buildingId as BuildingNode['id'] + if (!levelId) return null + + const level = useScene.getState().nodes[levelId as AnyNodeId] + if (level?.type === 'level' && level.parentId) { + return level.parentId as BuildingNode['id'] + } + + return null +} + +function resolveDefaultServiceRange(buildingId: BuildingNode['id']): { + defaultLevelId: LevelNode['id'] | null + fromLevelId: LevelNode['id'] | null + toLevelId: LevelNode['id'] | null +} { + const { levelId } = useViewer.getState().selection + const nodes = useScene.getState().nodes + const building = nodes[buildingId as AnyNodeId] + if (building?.type !== 'building') { + return { defaultLevelId: null, fromLevelId: null, toLevelId: null } + } + + const levels = building.children + .map((childId) => nodes[childId as AnyNodeId]) + .filter((node): node is LevelNode => node?.type === 'level') + .sort((left, right) => left.level - right.level) + const selectedLevelIndex = levels.findIndex((level) => level.id === levelId) + const fromIndex = selectedLevelIndex >= 0 ? selectedLevelIndex : 0 + const fromLevel = levels[fromIndex] + const toLevel = levels[Math.min(fromIndex + 1, levels.length - 1)] ?? fromLevel + + return { + defaultLevelId: fromLevel?.id ?? null, + fromLevelId: fromLevel?.id ?? null, + toLevelId: toLevel?.id ?? fromLevel?.id ?? null, + } +} + +function createElevatorPreviewGeometry(): THREE.BufferGeometry { + return new THREE.BoxGeometry( + DEFAULT_ELEVATOR_WIDTH, + DEFAULT_ELEVATOR_CAB_HEIGHT, + DEFAULT_ELEVATOR_DEPTH, + ) +} + +function commitElevatorPlacement( + buildingId: BuildingNode['id'], + position: [number, number, number], + rotation: number, +): void { + const { createNode, nodes } = useScene.getState() + const elevatorCount = Object.values(nodes).filter((node) => node.type === 'elevator').length + const serviceRange = resolveDefaultServiceRange(buildingId) + const elevator = ElevatorNode.parse({ + name: `Elevator ${elevatorCount + 1}`, + parentId: buildingId, + position, + rotation, + width: DEFAULT_ELEVATOR_WIDTH, + depth: DEFAULT_ELEVATOR_DEPTH, + cabHeight: DEFAULT_ELEVATOR_CAB_HEIGHT, + doorWidth: DEFAULT_ELEVATOR_DOOR_WIDTH, + doorHeight: DEFAULT_ELEVATOR_DOOR_HEIGHT, + ...serviceRange, + speed: DEFAULT_ELEVATOR_SPEED, + doorDurationMs: DEFAULT_ELEVATOR_DOOR_DURATION_MS, + dwellMs: DEFAULT_ELEVATOR_DWELL_MS, + }) + + createNode(elevator, buildingId) + useViewer.getState().setSelection({ buildingId, selectedIds: [elevator.id] }) + sfxEmitter.emit('sfx:structure-build') +} + +export const ElevatorTool: React.FC = () => { + const cursorRef = useRef(null) + const previewRef = useRef(null) + const rotationRef = useRef(0) + const previousGridPosRef = useRef<[number, number] | null>(null) + const buildingId = useViewer((state) => state.selection.buildingId) + const levelId = useViewer((state) => state.selection.levelId) + const previewGeometry = useMemo(() => createElevatorPreviewGeometry(), []) + + useEffect(() => { + const currentBuildingId = + (buildingId as BuildingNode['id'] | null) ?? + (levelId + ? (() => { + const level = useScene.getState().nodes[levelId as AnyNodeId] + return level?.type === 'level' && level.parentId + ? (level.parentId as BuildingNode['id']) + : null + })() + : null) + if (!currentBuildingId) return + + rotationRef.current = 0 + if (previewRef.current) previewRef.current.rotation.y = 0 + + const onGridMove = (event: GridEvent) => { + const gridX = Math.round(event.localPosition[0] * 2) / 2 + const gridZ = Math.round(event.localPosition[2] * 2) / 2 + const y = event.localPosition[1] + + cursorRef.current?.position.set(gridX, y + GRID_OFFSET, gridZ) + previewRef.current?.position.set(gridX, y + DEFAULT_ELEVATOR_CAB_HEIGHT / 2, gridZ) + + if ( + previousGridPosRef.current && + (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) + ) { + sfxEmitter.emit('sfx:grid-snap') + } + + previousGridPosRef.current = [gridX, gridZ] + } + + const onGridClick = (event: GridEvent) => { + const latestBuildingId = resolveCurrentBuildingId() + if (!latestBuildingId) return + + const gridX = Math.round(event.localPosition[0] * 2) / 2 + const gridZ = Math.round(event.localPosition[2] * 2) / 2 + commitElevatorPlacement(latestBuildingId, [gridX, 0, gridZ], rotationRef.current) + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return + } + + const ROTATION_STEP = Math.PI / 4 + let rotationDelta = 0 + if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP + else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP + + if (rotationDelta !== 0) { + event.preventDefault() + sfxEmitter.emit('sfx:item-rotate') + rotationRef.current += rotationDelta + if (previewRef.current) previewRef.current.rotation.y = rotationRef.current + } + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + window.addEventListener('keydown', onKeyDown) + + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + window.removeEventListener('keydown', onKeyDown) + } + }, [buildingId, levelId]) + + return ( + + + + + + + + + + + + + ) +} diff --git a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx new file mode 100644 index 000000000..43f8a31e1 --- /dev/null +++ b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx @@ -0,0 +1,209 @@ +import { + type AnyNodeId, + type ElevatorNode, + ElevatorNode as ElevatorNodeSchema, + emitter, + type GridEvent, + sceneRegistry, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useCallback, useEffect, useRef, useState } from 'react' +import { markToolCancelConsumed } from '../../../hooks/use-keyboard' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { CursorSphere } from '../shared/cursor-sphere' + +function stripMoveMetadata(metadata: ElevatorNode['metadata']) { + if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) { + return metadata + } + + const nextMeta = { ...(metadata as Record) } + delete nextMeta.isNew + delete nextMeta.isTransient + return nextMeta as ElevatorNode['metadata'] +} + +export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) { + const previousGridPosRef = useRef<[number, number] | null>(null) + const previewPositionRef = useRef([ + movingNode.position[0], + movingNode.position[1], + movingNode.position[2], + ]) + const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(() => [ + movingNode.position[0], + movingNode.position[1], + movingNode.position[2], + ]) + + const exitMoveMode = useCallback(() => { + useEditor.getState().setMovingNode(null) + }, []) + + useEffect(() => { + useScene.temporal.getState().pause() + const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id + + const meta = + typeof movingNode.metadata === 'object' && movingNode.metadata !== null + ? (movingNode.metadata as Record) + : {} + const isNew = !!meta.isNew + const committedMeta = stripMoveMetadata(movingNode.metadata) + const original = { + position: [...movingNode.position] as ElevatorNode['position'], + rotation: movingNode.rotation, + metadata: movingNode.metadata, + } + + let wasCommitted = false + let wasCancelled = false + let pendingRotation = movingNode.rotation + + const applyPreview = ( + position: ElevatorNode['position'], + rotation: ElevatorNode['rotation'], + ) => { + if (movingNodeId) { + useLiveTransforms.getState().set(movingNodeId, { position, rotation }) + } + + const object = movingNodeId ? sceneRegistry.nodes.get(movingNodeId) : null + if (object) { + object.position.set(position[0], position[1], position[2]) + object.rotation.y = rotation + } + } + + const resetObject = ( + position: ElevatorNode['position'], + rotation: ElevatorNode['rotation'], + ) => { + const object = movingNodeId ? sceneRegistry.nodes.get(movingNodeId) : null + if (object) { + object.position.set(position[0], position[1], position[2]) + object.rotation.y = rotation + } + } + + const clearPreview = () => { + if (movingNodeId) { + useLiveTransforms.getState().clear(movingNodeId) + } + } + + const onGridMove = (event: GridEvent) => { + const gridX = Math.round(event.localPosition[0] * 2) / 2 + const gridZ = Math.round(event.localPosition[2] * 2) / 2 + const y = event.localPosition[1] + + if ( + previousGridPosRef.current && + (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) + ) { + sfxEmitter.emit('sfx:grid-snap') + } + + previousGridPosRef.current = [gridX, gridZ] + setCursorPosition([gridX, y, gridZ]) + previewPositionRef.current = [gridX, movingNode.position[1], gridZ] + applyPreview(previewPositionRef.current, pendingRotation) + } + + const onGridClick = (event: GridEvent) => { + const gridX = Math.round(event.localPosition[0] * 2) / 2 + const gridZ = Math.round(event.localPosition[2] * 2) / 2 + + wasCommitted = true + clearPreview() + useScene.temporal.getState().resume() + if (movingNodeId && useScene.getState().nodes[movingNodeId as AnyNodeId]) { + useScene.getState().updateNode(movingNodeId as AnyNodeId, { + position: [gridX, movingNode.position[1], gridZ], + rotation: pendingRotation, + metadata: committedMeta, + }) + useViewer.getState().setSelection({ selectedIds: [movingNodeId] }) + } else if (movingNode.parentId) { + const elevator = ElevatorNodeSchema.parse({ + ...movingNode, + id: undefined, + position: [gridX, movingNode.position[1], gridZ], + rotation: pendingRotation, + metadata: committedMeta, + }) + useScene.getState().createNode(elevator, movingNode.parentId as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [elevator.id] }) + } + + sfxEmitter.emit('sfx:item-place') + exitMoveMode() + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + wasCancelled = true + clearPreview() + if (isNew && movingNodeId) { + useScene.getState().deleteNode(movingNodeId as AnyNodeId) + } else { + if (movingNodeId) { + useScene.getState().updateNode(movingNodeId as AnyNodeId, { + position: original.position, + rotation: original.rotation, + metadata: original.metadata, + }) + } + } + resetObject(original.position, original.rotation) + useScene.temporal.getState().resume() + markToolCancelConsumed() + exitMoveMode() + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return + } + + const ROTATION_STEP = Math.PI / 4 + let rotationDelta = 0 + if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP + else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP + + if (rotationDelta !== 0) { + event.preventDefault() + sfxEmitter.emit('sfx:item-rotate') + pendingRotation += rotationDelta + applyPreview(previewPositionRef.current, pendingRotation) + } + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + + return () => { + clearPreview() + if (!(wasCommitted || wasCancelled || isNew) && movingNodeId) { + useScene.getState().updateNode(movingNodeId as AnyNodeId, { + position: original.position, + rotation: original.rotation, + metadata: original.metadata, + }) + resetObject(original.position, original.rotation) + } + useScene.temporal.getState().resume() + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + } + }, [movingNode, exitMoveMode]) + + return +} diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 8556d4e75..1db6a11f9 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -3,6 +3,7 @@ import type { CeilingNode, ColumnNode, DoorNode, + ElevatorNode, FenceNode, ItemNode, RoofNode, @@ -21,6 +22,7 @@ import { MoveBuildingContent } from '../building/move-building-tool' import { MoveCeilingTool } from '../ceiling/move-ceiling-tool' import { MoveColumnTool } from '../column/move-column-tool' import { MoveDoorTool } from '../door/move-door-tool' +import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveFenceTool } from '../fence/move-fence-tool' import { MoveRoofTool } from '../roof/move-roof-tool' import { MoveSlabTool } from '../slab/move-slab-tool' @@ -99,6 +101,7 @@ export const MoveTool: React.FC<{ if (movingNode.type === 'building') return if (movingNode.type === 'door') return + if (movingNode.type === 'elevator') return if (movingNode.type === 'window') return if (movingNode.type === 'fence') return if (movingNode.type === 'ceiling') return diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 777c8d724..600e22428 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -12,6 +12,7 @@ import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor' import { CeilingTool } from './ceiling/ceiling-tool' import { ColumnTool } from './column/column-tool' import { DoorTool } from './door/door-tool' +import { ElevatorTool } from './elevator/elevator-tool' import { CurveFenceTool } from './fence/curve-fence-tool' import { FenceTool } from './fence/fence-tool' import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool' @@ -41,6 +42,7 @@ const tools: Record>> = { slab: SlabTool, ceiling: CeilingTool, roof: RoofTool, + elevator: ElevatorTool, stair: StairTool, door: DoorTool, item: ItemTool, diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index cc060af61..352820c72 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -25,6 +25,7 @@ export const tools: ToolConfig[] = [ { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' }, { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' }, { id: 'column', iconSrc: '/icons/column.png', label: 'Column' }, + { id: 'elevator', iconSrc: '/icons/elevator.svg', label: 'Elevator' }, { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' }, { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' }, { id: 'door', iconSrc: '/icons/door.png', label: 'Door' }, diff --git a/packages/editor/src/components/ui/command-palette/editor-commands.tsx b/packages/editor/src/components/ui/command-palette/editor-commands.tsx index 3b0fe2746..b3c6cd5cf 100644 --- a/packages/editor/src/components/ui/command-palette/editor-commands.tsx +++ b/packages/editor/src/components/ui/command-palette/editor-commands.tsx @@ -190,8 +190,11 @@ export function EditorCommands() { const { nodes } = useScene.getState() const building = Object.values(nodes).find((n) => n.type === 'building') if (!building) return + const levelCount = building.children.filter( + (childId) => nodes[childId as keyof typeof nodes]?.type === 'level', + ).length const newLevel = LevelNode.parse({ - level: building.children.length, + level: levelCount, children: [], parentId: building.id, }) diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx index e12ee12a3..14e152fa9 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -9,24 +9,28 @@ interface MetricControlProps { label: React.ReactNode value: number onChange: (value: number) => void + onCommit?: (value: number) => void min?: number max?: number precision?: number step?: number className?: string unit?: string + restoreOnCommit?: boolean } export function MetricControl({ label, value, onChange, + onCommit, min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY, precision = 2, step = 1, className, unit = '', + restoreOnCommit = true, }: MetricControlProps) { const viewerUnit = useViewer((state) => state.unit) const isImperial = viewerUnit === 'imperial' && unit === 'm' @@ -53,6 +57,17 @@ export function MetricControl({ [min, max], ) + const applyCommittedValue = useCallback( + (nextValue: number) => { + if (onCommit) { + onCommit(nextValue) + } else { + onChange(nextValue) + } + }, + [onChange, onCommit], + ) + useEffect(() => { if (!isEditing) { setInputValue(displayValue.toFixed(precision)) @@ -77,13 +92,13 @@ export function MetricControl({ const finalValue = Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier if (Math.abs(finalValue - valueRef.current) > 1e-6) { - onChange(finalValue) + applyCommittedValue(finalValue) } } container.addEventListener('wheel', handleWheel, { passive: false }) return () => container.removeEventListener('wheel', handleWheel) - }, [isEditing, step, clamp, onChange, precision, multiplier]) + }, [isEditing, step, clamp, applyCommittedValue, precision, multiplier]) useEffect(() => { if (!isHovered || isEditing) return @@ -104,14 +119,14 @@ export function MetricControl({ Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier if (Math.abs(finalValue - valueRef.current) > 1e-6) { - onChange(finalValue) + applyCommittedValue(finalValue) } } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [isHovered, isEditing, step, clamp, onChange, precision, multiplier]) + }, [isHovered, isEditing, step, clamp, applyCommittedValue, precision, multiplier]) const handlePointerDown = useCallback( (e: React.PointerEvent) => { @@ -148,7 +163,14 @@ export function MetricControl({ document.removeEventListener('pointermove', handlePointerMove) document.removeEventListener('pointerup', handlePointerUp) - if (Math.abs(finalValue - startValueRef.current) > 1e-6) { + const changed = Math.abs(finalValue - startValueRef.current) > 1e-6 + if (onCommit) { + if (changed && restoreOnCommit) { + onChange(startValueRef.current) + } + useScene.temporal.getState().resume() + onCommit(finalValue) + } else if (changed) { onChange(startValueRef.current) useScene.temporal.getState().resume() onChange(finalValue) @@ -160,7 +182,7 @@ export function MetricControl({ document.addEventListener('pointermove', handlePointerMove) document.addEventListener('pointerup', handlePointerUp) }, - [isEditing, value, onChange, clamp, precision, step, multiplier], + [isEditing, value, onChange, onCommit, restoreOnCommit, clamp, precision, step, multiplier], ) const handleValueClick = useCallback(() => { @@ -177,10 +199,10 @@ export function MetricControl({ if (Number.isNaN(numValue)) { setInputValue((value * multiplier).toFixed(precision)) } else { - onChange(clamp(numValue / multiplier)) + applyCommittedValue(clamp(numValue / multiplier)) } setIsEditing(false) - }, [inputValue, onChange, clamp, multiplier, value, precision]) + }, [inputValue, applyCommittedValue, clamp, multiplier, value, precision]) const handleInputBlur = useCallback(() => { submitValue() @@ -196,16 +218,16 @@ export function MetricControl({ } else if (e.key === 'ArrowUp') { e.preventDefault() const newV = clamp(value + step / multiplier) - onChange(newV) + applyCommittedValue(newV) setInputValue((newV * multiplier).toFixed(precision)) } else if (e.key === 'ArrowDown') { e.preventDefault() const newV = clamp(value - step / multiplier) - onChange(newV) + applyCommittedValue(newV) setInputValue((newV * multiplier).toFixed(precision)) } }, - [submitValue, value, multiplier, precision, step, clamp, onChange], + [submitValue, value, multiplier, precision, step, clamp, applyCommittedValue], ) return ( diff --git a/packages/editor/src/components/ui/panels/ceiling-panel.tsx b/packages/editor/src/components/ui/panels/ceiling-panel.tsx index ee9991ebf..e12ab956d 100755 --- a/packages/editor/src/components/ui/panels/ceiling-panel.tsx +++ b/packages/editor/src/components/ui/panels/ceiling-panel.tsx @@ -91,7 +91,7 @@ export function CeilingPanel() { (index: number) => { if (!selectedId) return const currentHoles = node?.holes || [] - if (node?.holeMetadata?.[index]?.source === 'stair') return + if ((node?.holeMetadata?.[index]?.source ?? 'manual') !== 'manual') return const newHoles = currentHoles.filter((_, i) => i !== index) const currentMetadata = currentHoles.map( (_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const }, @@ -172,7 +172,8 @@ export function CeilingPanel() { const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index const source = node.holeMetadata?.[index]?.source ?? 'manual' - const isAutoHole = source === 'stair' + const isAutoHole = source !== 'manual' + const autoLabel = source === 'elevator' ? 'Auto elevator cutout' : 'Auto stair cutout' return (

{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '} - {isAutoHole ? 'Auto stair cutout' : 'Manual'} + {isAutoHole ? autoLabel : 'Manual'}

diff --git a/packages/editor/src/components/ui/panels/elevator-panel.tsx b/packages/editor/src/components/ui/panels/elevator-panel.tsx new file mode 100644 index 000000000..f5e4fa9d1 --- /dev/null +++ b/packages/editor/src/components/ui/panels/elevator-panel.tsx @@ -0,0 +1,471 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + type ElevatorNode, + ElevatorNode as ElevatorNodeSchema, + type LevelNode, + useInteractive, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Copy, Move, Send, Trash2 } from 'lucide-react' +import { useCallback, useEffect } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { ActionButton, ActionGroup } from '../controls/action-button' +import { MetricControl } from '../controls/metric-control' +import { PanelSection } from '../controls/panel-section' +import { SliderControl } from '../controls/slider-control' +import { PanelWrapper } from './panel-wrapper' + +function findLevelId(levels: LevelNode[], levelId: string | null | undefined) { + if (!levelId) return null + return levels.some((level) => level.id === levelId) ? levelId : null +} + +function getLegacyServedLevels(node: ElevatorNode | undefined, levels: LevelNode[]) { + if (!node || node.fromLevelId || node.toLevelId || !node.servedLevelIds?.length) return [] + const servedIds = new Set(node.servedLevelIds) + return levels.filter((level) => servedIds.has(level.id)) +} + +function getResolvedFromLevelId(node: ElevatorNode | undefined, levels: LevelNode[]) { + if (!node) return levels[0]?.id ?? '' + const legacyServedLevels = getLegacyServedLevels(node, levels) + return ( + findLevelId(levels, node.fromLevelId) ?? + legacyServedLevels[0]?.id ?? + findLevelId(levels, node.defaultLevelId) ?? + levels[0]?.id ?? + '' + ) +} + +function getResolvedToLevelId( + node: ElevatorNode | undefined, + levels: LevelNode[], + fromLevelId: string, +) { + if (!node) return levels[0]?.id ?? '' + + const explicitTo = findLevelId(levels, node.toLevelId) + if (explicitTo) return explicitTo + const legacyServedLevels = getLegacyServedLevels(node, levels) + const legacyTo = legacyServedLevels[legacyServedLevels.length - 1]?.id + if (legacyTo) return legacyTo + + const fromIndex = levels.findIndex((level) => level.id === fromLevelId) + const fallbackIndex = fromIndex >= 0 ? Math.min(fromIndex + 1, levels.length - 1) : 0 + return levels[fallbackIndex]?.id ?? fromLevelId +} + +function getServiceLevels(levels: LevelNode[], fromLevelId: string, toLevelId: string) { + const fromIndex = levels.findIndex((level) => level.id === fromLevelId) + const toIndex = levels.findIndex((level) => level.id === toLevelId) + if (fromIndex < 0 && toIndex < 0) return [] + + const resolvedFromIndex = fromIndex >= 0 ? fromIndex : toIndex + const resolvedToIndex = + toIndex >= 0 ? toIndex : Math.min(Math.max(resolvedFromIndex, 0) + 1, levels.length - 1) + const minIndex = Math.min(resolvedFromIndex, resolvedToIndex) + const maxIndex = Math.max(resolvedFromIndex, resolvedToIndex) + + return levels.slice(minIndex, maxIndex + 1) +} + +function stripDuplicateFlags(metadata: ElevatorNode['metadata']) { + if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) { + return metadata + } + + const nextMeta = { ...(metadata as Record) } + delete nextMeta.isNew + delete nextMeta.isTransient + return nextMeta as ElevatorNode['metadata'] +} + +type ElevatorMetricKey = 'width' | 'depth' | 'cabHeight' | 'doorWidth' | 'doorHeight' + +export function ElevatorPanel() { + const selectedId = useViewer((s) => s.selection.selectedIds[0]) + const selectedCount = useViewer((s) => s.selection.selectedIds.length) + const setSelection = useViewer((s) => s.setSelection) + const updateNode = useScene((s) => s.updateNode) + const createNode = useScene((s) => s.createNode) + const setMovingNode = useEditor((s) => s.setMovingNode) + const runtime = useInteractive( + useShallow((s) => { + const state = selectedId ? s.elevators[selectedId as AnyNodeId] : null + if (!state) return null + return { + currentLevelId: state.currentLevelId, + queue: state.queue, + targetLevelId: state.targetLevelId, + } + }), + ) + + const node = useScene((s) => + selectedId ? (s.nodes[selectedId as AnyNode['id']] as ElevatorNode | undefined) : undefined, + ) + const liveOverrides = useLiveNodeOverrides((s) => + selectedId ? s.get(selectedId as AnyNodeId) : undefined, + ) + + useEffect(() => { + return () => { + if (selectedId) useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) + } + }, [selectedId]) + + const levels = useScene( + useShallow((s) => { + if (!(node?.parentId && s.nodes[node.parentId as AnyNodeId]?.type === 'building')) return [] + const building = s.nodes[node.parentId as AnyNodeId] + if (building?.type !== 'building') return [] + return building.children + .map((childId) => s.nodes[childId as AnyNodeId]) + .filter((entry): entry is LevelNode => entry?.type === 'level') + .sort((left, right) => left.level - right.level) + }), + ) + + const handleUpdate = useCallback( + (updates: Partial) => { + if (!selectedId) return + updateNode(selectedId as AnyNode['id'], updates) + }, + [selectedId, updateNode], + ) + + const clearLivePreview = useCallback(() => { + if (selectedId) useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) + }, [selectedId]) + + const previewMetric = useCallback( + (key: K, value: ElevatorNode[K]) => { + if (!selectedId) return + useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, { [key]: value }) + }, + [selectedId], + ) + + const commitMetric = useCallback( + (key: K, value: ElevatorNode[K]) => { + if (!selectedId) return + + const hasChange = !(node && Math.abs(Number(node[key]) - Number(value)) <= 1e-6) + if (hasChange) { + updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial) + } + useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) + }, + [node, selectedId, updateNode], + ) + + const handleClose = useCallback(() => { + clearLivePreview() + setSelection({ selectedIds: [] }) + }, [clearLivePreview, setSelection]) + + const handleMove = useCallback(() => { + if (!node) return + sfxEmitter.emit('sfx:item-pick') + clearLivePreview() + setMovingNode(node) + setSelection({ selectedIds: [] }) + }, [clearLivePreview, node, setMovingNode, setSelection]) + + const handleDuplicate = useCallback(() => { + if (!(node && node.parentId)) return + sfxEmitter.emit('sfx:item-pick') + + const duplicate = ElevatorNodeSchema.parse({ + ...structuredClone(node), + id: undefined, + name: node.name ? `${node.name} Copy` : 'Elevator Copy', + position: [node.position[0] + 1, node.position[1], node.position[2] + 1], + metadata: { ...(stripDuplicateFlags(node.metadata) as Record), isNew: true }, + }) + + createNode(duplicate, node.parentId as AnyNodeId) + clearLivePreview() + setMovingNode(duplicate) + setSelection({ selectedIds: [] }) + }, [clearLivePreview, node, createNode, setMovingNode, setSelection]) + + const handleDelete = useCallback(() => { + if (!(selectedId && node)) return + sfxEmitter.emit('sfx:structure-delete') + clearLivePreview() + useScene.getState().deleteNode(selectedId as AnyNodeId) + setSelection({ selectedIds: [] }) + }, [clearLivePreview, selectedId, node, setSelection]) + + const requestLevel = useCallback( + (levelId: LevelNode['id']) => { + if (!node) return + useInteractive.getState().requestElevator(node.id as AnyNodeId, levelId as AnyNodeId) + }, + [node], + ) + + const handleServiceBoundaryChange = useCallback( + (field: 'fromLevelId' | 'toLevelId', levelId: string) => { + if (!node) return + const nextFromLevelId = + field === 'fromLevelId' ? levelId : getResolvedFromLevelId(node, levels) + const nextToLevelId = + field === 'toLevelId' ? levelId : getResolvedToLevelId(node, levels, nextFromLevelId) + const nextServedLevels = getServiceLevels(levels, nextFromLevelId, nextToLevelId) + const currentDefaultIsServed = nextServedLevels.some( + (level) => level.id === node.defaultLevelId, + ) + + handleUpdate({ + [field]: levelId || null, + defaultLevelId: currentDefaultIsServed + ? node.defaultLevelId + : nextFromLevelId || nextServedLevels[0]?.id || null, + servedLevelIds: undefined, + } as Partial) + }, + [node, levels, handleUpdate], + ) + + if (!(node && node.type === 'elevator' && selectedId && selectedCount === 1)) return null + + const displayNode = liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node + const fromLevelId = getResolvedFromLevelId(node, levels) + const toLevelId = getResolvedToLevelId(node, levels, fromLevelId) + const servedLevels = getServiceLevels(levels, fromLevelId, toLevelId) + const defaultLevelOptions = servedLevels.length > 0 ? servedLevels : levels + const selectedDefaultLevelId = defaultLevelOptions.some( + (level) => level.id === node.defaultLevelId, + ) + ? (node.defaultLevelId ?? '') + : fromLevelId + const activeLevelId = + runtime?.currentLevelId ?? + (servedLevels.some((level) => level.id === node.defaultLevelId) + ? node.defaultLevelId + : fromLevelId || levels[0]?.id) ?? + null + const queuedLevelIds = new Set() + for (const levelId of runtime?.queue ?? []) queuedLevelIds.add(levelId) + if (runtime?.targetLevelId) queuedLevelIds.add(runtime.targetLevelId) + + return ( + + + + } label="Move" onClick={handleMove} /> + } + label="Duplicate" + onClick={handleDuplicate} + /> + } + label="Delete" + onClick={handleDelete} + /> + + + + + previewMetric('width', value)} + onCommit={(value) => commitMetric('width', value)} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={displayNode.width} + /> + previewMetric('depth', value)} + onCommit={(value) => commitMetric('depth', value)} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={displayNode.depth} + /> + previewMetric('cabHeight', value)} + onCommit={(value) => commitMetric('cabHeight', value)} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={displayNode.cabHeight} + /> + + + + previewMetric('doorWidth', value)} + onCommit={(value) => commitMetric('doorWidth', value)} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={displayNode.doorWidth} + /> + previewMetric('doorHeight', value)} + onCommit={(value) => commitMetric('doorHeight', value)} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={displayNode.doorHeight} + /> + + + +
+
+
+ From +
+ +
+ +
+
+ To +
+ +
+
+ +
+
+ Default Floor +
+ +
+
+ + +
+ {servedLevels.map((level) => { + const isActive = activeLevelId === level.id + const isQueued = queuedLevelIds.has(level.id) + return ( + + ) + })} +
+
+ + + handleUpdate({ speed: value })} + precision={1} + step={0.1} + unit="m/s" + value={node.speed} + /> + handleUpdate({ doorDurationMs: value })} + step={50} + unit="ms" + value={node.doorDurationMs} + /> + handleUpdate({ dwellMs: value })} + step={100} + unit="ms" + value={node.dwellMs} + /> + +
+ ) +} diff --git a/packages/editor/src/components/ui/panels/node-display.ts b/packages/editor/src/components/ui/panels/node-display.ts index 3b5456801..16ee20d9b 100644 --- a/packages/editor/src/components/ui/panels/node-display.ts +++ b/packages/editor/src/components/ui/panels/node-display.ts @@ -13,6 +13,7 @@ const TYPE_DEFAULTS: Record = { slab: { icon: '/icons/floor.png', label: 'Slab' }, ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' }, column: { icon: '/icons/column.png', label: 'Column' }, + elevator: { icon: '/icons/elevator.svg', label: 'Elevator' }, fence: { icon: '/icons/fence.png', label: 'Fence' }, roof: { icon: '/icons/roof.png', label: 'Roof' }, 'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' }, diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index e948ab808..1052238f0 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -7,6 +7,7 @@ import { type CeilingNode, type ColumnNode, type DoorNode, + type ElevatorNode, type FenceNode, type ItemNode, type RoofNode, @@ -26,6 +27,7 @@ import useEditor from '../../../store/use-editor' import { CeilingPanel } from './ceiling-panel' import { ColumnPanel } from './column-panel' import { DoorPanel } from './door-panel' +import { ElevatorPanel } from './elevator-panel' import { FencePanel } from './fence-panel' import { ItemPanel } from './item-panel' import { MobilePanelSheet } from './mobile-panel-sheet' @@ -46,6 +48,7 @@ type MovableNode = | ItemNode | WindowNode | DoorNode + | ElevatorNode | CeilingNode | ColumnNode | SlabNode @@ -61,6 +64,7 @@ const MOVABLE_TYPES = new Set([ 'item', 'window', 'door', + 'elevator', 'ceiling', 'column', 'slab', @@ -102,6 +106,8 @@ function panelForType(type: string | null) { return case 'door': return + case 'elevator': + return case 'window': return default: @@ -264,6 +270,8 @@ export function PanelManager() { return case 'door': return + case 'elevator': + return case 'window': return } diff --git a/packages/editor/src/components/ui/panels/slab-panel.tsx b/packages/editor/src/components/ui/panels/slab-panel.tsx index b44b1a64a..0034c7ca8 100755 --- a/packages/editor/src/components/ui/panels/slab-panel.tsx +++ b/packages/editor/src/components/ui/panels/slab-panel.tsx @@ -91,7 +91,7 @@ export function SlabPanel() { (index: number) => { if (!selectedId) return const currentHoles = node?.holes || [] - if (node?.holeMetadata?.[index]?.source === 'stair') return + if ((node?.holeMetadata?.[index]?.source ?? 'manual') !== 'manual') return const newHoles = currentHoles.filter((_, i) => i !== index) const currentMetadata = currentHoles.map( (_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const }, @@ -173,7 +173,8 @@ export function SlabPanel() { const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index const source = node.holeMetadata?.[index]?.source ?? 'manual' - const isAutoHole = source === 'stair' + const isAutoHole = source !== 'manual' + const autoLabel = source === 'elevator' ? 'Auto elevator cutout' : 'Auto stair cutout' return (

{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '} - {isAutoHole ? 'Auto stair cutout' : 'Manual'} + {isAutoHole ? autoLabel : 'Manual'}

diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx index fdbae4e90..5407b9299 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx @@ -39,8 +39,10 @@ export const BuildingTreeNode = memo(function BuildingTreeNode({ const handleAddLevel = (e: React.MouseEvent) => { e.stopPropagation() + const nodes = useScene.getState().nodes + const levelCount = children.filter((childId) => nodes[childId]?.type === 'level').length const newLevel = LevelNode.parse({ - level: children.length, + level: levelCount, children: [], parentId: nodeId, }) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx new file mode 100644 index 000000000..0f3451717 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx @@ -0,0 +1,75 @@ +import { type AnyNodeId, type ElevatorNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import Image from 'next/image' +import { memo, useCallback, useState } from 'react' +import useEditor from './../../../../../store/use-editor' +import { InlineRenameInput } from './inline-rename-input' +import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' +import { TreeNodeActions } from './tree-node-actions' + +interface ElevatorTreeNodeProps { + nodeId: AnyNodeId + depth: number + isLast?: boolean +} + +export const ElevatorTreeNode = memo(function ElevatorTreeNode({ + nodeId, + depth, + isLast, +}: ElevatorTreeNodeProps) { + const [isEditing, setIsEditing] = useState(false) + const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) + const node = useScene((s) => s.nodes[nodeId] as ElevatorNode | undefined) + const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) + const isHovered = useViewer((state) => state.hoveredId === nodeId) + const setSelection = useViewer((state) => state.setSelection) + const setHoveredId = useViewer((state) => state.setHoveredId) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation() + const handled = handleTreeSelection( + event, + nodeId, + useViewer.getState().selection.selectedIds, + setSelection, + ) + if (!handled && useEditor.getState().phase === 'furnish') { + useEditor.getState().setPhase('structure') + } + }, + [nodeId, setSelection], + ) + + return ( + } + depth={depth} + expanded={false} + hasChildren={false} + icon={ + + } + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={isVisible} + label={ + setIsEditing(true)} + onStopEditing={() => setIsEditing(false)} + /> + } + nodeId={nodeId} + onClick={handleClick} + onDoubleClick={() => focusTreeNode(nodeId)} + onMouseEnter={() => setHoveredId(nodeId)} + onMouseLeave={() => setHoveredId(null)} + onToggle={() => {}} + /> + ) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 2e7ba5df9..3b0717d03 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -58,6 +58,7 @@ import { BuildingTreeNode } from './building-tree-node' import { CeilingTreeNode } from './ceiling-tree-node' import { ColumnTreeNode } from './column-tree-node' import { DoorTreeNode } from './door-tree-node' +import { ElevatorTreeNode } from './elevator-tree-node' import { FenceTreeNode } from './fence-tree-node' import { ItemTreeNode } from './item-tree-node' import { LevelTreeNode } from './level-tree-node' @@ -87,6 +88,8 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr return case 'column': return + case 'elevator': + return case 'level': return case 'slab': diff --git a/packages/editor/src/hooks/use-contextual-tools.ts b/packages/editor/src/hooks/use-contextual-tools.ts index ebb288a61..e51121b19 100644 --- a/packages/editor/src/hooks/use-contextual-tools.ts +++ b/packages/editor/src/hooks/use-contextual-tools.ts @@ -28,6 +28,7 @@ export function useContextualTools() { 'slab', 'ceiling', 'roof', + 'elevator', 'door', 'window', ] diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 3183fd56d..cc05f3ac9 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -133,13 +133,19 @@ export const useKeyboard = ({ const { buildingId, levelId } = useViewer.getState().selection if (buildingId) { const building = useScene.getState().nodes[buildingId] - if (building && building.type === 'building' && building.children.length > 0) { - const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1 - const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx + const levels = + building?.type === 'building' + ? building.children.filter( + (childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level', + ) + : [] + if (levels.length > 0) { + const currentIdx = levelId ? levels.indexOf(levelId as any) : -1 + const nextIdx = currentIdx < levels.length - 1 ? currentIdx + 1 : currentIdx if (nextIdx !== -1 && nextIdx !== currentIdx) { - useViewer.getState().setSelection({ levelId: building.children[nextIdx] as any }) + useViewer.getState().setSelection({ levelId: levels[nextIdx] as any }) } else if (currentIdx === -1) { - useViewer.getState().setSelection({ levelId: building.children[0] as any }) + useViewer.getState().setSelection({ levelId: levels[0] as any }) } } } @@ -148,15 +154,19 @@ export const useKeyboard = ({ const { buildingId, levelId } = useViewer.getState().selection if (buildingId) { const building = useScene.getState().nodes[buildingId] - if (building && building.type === 'building' && building.children.length > 0) { - const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1 + const levels = + building?.type === 'building' + ? building.children.filter( + (childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level', + ) + : [] + if (levels.length > 0) { + const currentIdx = levelId ? levels.indexOf(levelId as any) : -1 const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx if (prevIdx !== -1 && prevIdx !== currentIdx) { - useViewer.getState().setSelection({ levelId: building.children[prevIdx] as any }) + useViewer.getState().setSelection({ levelId: levels[prevIdx] as any }) } else if (currentIdx === -1) { - useViewer - .getState() - .setSelection({ levelId: building.children[building.children.length - 1] as any }) + useViewer.getState().setSelection({ levelId: levels[levels.length - 1] as any }) } } } diff --git a/packages/editor/src/lib/history.ts b/packages/editor/src/lib/history.ts index bdd37fbbc..281a476e3 100644 --- a/packages/editor/src/lib/history.ts +++ b/packages/editor/src/lib/history.ts @@ -1,6 +1,7 @@ -import { useLiveTransforms, useScene } from '@pascal-app/core' +import { useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core' function refreshSceneAfterHistoryJump() { + useLiveNodeOverrides.getState().clearAll() useLiveTransforms.getState().clearAll() const state = useScene.getState() diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index b3aa2616c..5536c13dc 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -7,6 +7,7 @@ import { type CeilingNode, type ColumnNode, type DoorNode, + type ElevatorNode, type FenceNode, type ItemNode, type LevelNode, @@ -58,6 +59,7 @@ export type StructureTool = | 'ceiling' | 'roof' | 'column' + | 'elevator' | 'stair' | 'item' | 'zone' @@ -138,6 +140,7 @@ type EditorState = { | ItemNode | WindowNode | DoorNode + | ElevatorNode | FenceNode | CeilingNode | ColumnNode @@ -155,6 +158,7 @@ type EditorState = { | ItemNode | WindowNode | DoorNode + | ElevatorNode | FenceNode | CeilingNode | ColumnNode @@ -449,9 +453,14 @@ export function selectDefaultBuildingAndLevel() { }) if (level0Id) { viewer.setSelection({ levelId: level0Id as LevelNode['id'] }) - } else if (buildingNode.children[0]) { + } else { // Fallback to first level if level 0 doesn't exist - viewer.setSelection({ levelId: buildingNode.children[0] as LevelNode['id'] }) + const firstLevelId = buildingNode.children.find( + (childId) => scene.nodes[childId]?.type === 'level', + ) + if (firstLevelId) { + viewer.setSelection({ levelId: firstLevelId as LevelNode['id'] }) + } } } } @@ -580,6 +589,7 @@ const useEditor = create()( | ItemNode | WindowNode | DoorNode + | ElevatorNode | FenceNode | CeilingNode | SlabNode diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 77c4ed753..6915df023 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -29,7 +29,6 @@ "three": "^0.184" }, "dependencies": { - "polygon-clipping": "^0.15.7", "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", "zustand": "^5" diff --git a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx new file mode 100644 index 000000000..0b5dc3556 --- /dev/null +++ b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx @@ -0,0 +1,811 @@ +import { + type AnyNodeId, + type ElevatorNode, + useInteractive, + useLiveNodeOverrides, + useRegistry, + useScene, +} from '@pascal-app/core' +import { useFrame, type ThreeEvent } from '@react-three/fiber' +import { useEffect, useMemo, useRef } from 'react' +import type { Group } from 'three' +import { useShallow } from 'zustand/react/shallow' +import { useNodeEvents } from '../../../hooks/use-node-events' +import { resolveElevatorLevels } from '../../../systems/elevator/elevator-utils' + +const SHAFT_WALL_COLOR = '#d7dce4' +const SHAFT_SIDE_COLOR = '#4b5563' +const SHAFT_TRIM_COLOR = '#eef2f7' +const CAB_COLOR = '#d7dde5' +const GLASS_COLOR = '#f8fafc' +const DOOR_COLOR = '#8e98a6' +const PANEL_COLOR = '#1f2937' + +type SegmentName = + | 'bottom' + | 'lowerLeft' + | 'lowerRight' + | 'middle' + | 'top' + | 'upperLeft' + | 'upperRight' + +const DIGIT_SEGMENTS: Record = { + '0': ['top', 'upperLeft', 'upperRight', 'lowerLeft', 'lowerRight', 'bottom'], + '1': ['upperRight', 'lowerRight'], + '2': ['top', 'upperRight', 'middle', 'lowerLeft', 'bottom'], + '3': ['top', 'upperRight', 'middle', 'lowerRight', 'bottom'], + '4': ['upperLeft', 'upperRight', 'middle', 'lowerRight'], + '5': ['top', 'upperLeft', 'middle', 'lowerRight', 'bottom'], + '6': ['top', 'upperLeft', 'middle', 'lowerLeft', 'lowerRight', 'bottom'], + '7': ['top', 'upperRight', 'lowerRight'], + '8': ['top', 'upperLeft', 'upperRight', 'middle', 'lowerLeft', 'lowerRight', 'bottom'], + '9': ['top', 'upperLeft', 'upperRight', 'middle', 'lowerRight', 'bottom'], + '-': ['middle'], +} + +const SEGMENT_PROPS: Record< + SegmentName, + { position: [number, number, number]; size: [number, number, number] } +> = { + bottom: { position: [0, -0.44, 0], size: [0.56, 0.11, 0.018] }, + lowerLeft: { position: [-0.32, -0.22, 0], size: [0.11, 0.42, 0.018] }, + lowerRight: { position: [0.32, -0.22, 0], size: [0.11, 0.42, 0.018] }, + middle: { position: [0, 0, 0], size: [0.52, 0.1, 0.018] }, + top: { position: [0, 0.44, 0], size: [0.56, 0.11, 0.018] }, + upperLeft: { position: [-0.32, 0.22, 0], size: [0.11, 0.42, 0.018] }, + upperRight: { position: [0.32, 0.22, 0], size: [0.11, 0.42, 0.018] }, +} + +function MeshButtonLabel({ + color, + label, + position, + scale, +}: { + color: string + label: string + position: [number, number, number] + scale: number +}) { + const characters = label.split('').filter((character) => DIGIT_SEGMENTS[character]) + const spacing = 0.72 * scale + const startX = -((characters.length - 1) * spacing) / 2 + + if (characters.length === 0) return null + + return ( + + {characters.map((character, charIndex) => ( + + {(DIGIT_SEGMENTS[character] ?? []).map((segment) => { + const props = SEGMENT_PROPS[segment] + return ( + + + + + ) + })} + + ))} + + ) +} + +function ElevatorDirectionGlyph({ + color, + direction, + position, + scale, +}: { + color: string + direction: 'down' | 'up' | null + position: [number, number, number] + scale: number +}) { + if (!direction) { + return ( + + + + + ) + } + + const ySign = direction === 'up' ? 1 : -1 + return ( + + + + + + + + + + + ) +} + +function ElevatorFloorIndicator({ + active, + direction, + faceSign = -1, + label, + position, + scale = 1, +}: { + active: boolean + direction: 'down' | 'up' | null + faceSign?: -1 | 1 + label: string + position: [number, number, number] + scale?: number +}) { + const glowColor = active ? '#38bdf8' : '#94a3b8' + const screenColor = active ? '#041f2f' : '#111827' + const displayLabel = label || '-' + const screenZ = faceSign * 0.026 * scale + const glyphZ = faceSign * 0.041 * scale + + return ( + + + + + + + + + + + + + ) +} + +function ElevatorMeshButton({ + active, + buttonKind, + elevatorId, + faceSign = -1, + label, + levelId, + onRequest, + position, + queued, + radius = 0.055, +}: { + active: boolean + buttonKind: 'cab' | 'landing' + elevatorId: AnyNodeId + faceSign?: -1 | 1 + label?: string + levelId: AnyNodeId + onRequest: () => void + position: [number, number, number] + queued: boolean + radius?: number +}) { + const buttonColor = active ? '#38bdf8' : queued ? '#fbbf24' : '#d6dde7' + const labelColor = active || queued ? '#111827' : '#334155' + const ringColor = active ? '#0ea5e9' : queued ? '#f59e0b' : '#64748b' + const depth = active ? 0.028 : 0.04 + const faceZ = faceSign * (depth / 2 + 0.004) + const userData = useMemo( + () => ({ + elevatorButton: { + elevatorId, + kind: buttonKind, + levelId, + }, + }), + [buttonKind, elevatorId, levelId], + ) + + const press = (event: ThreeEvent) => { + if (event.button !== 0) return + onRequest() + } + + return ( + + {(active || queued) && ( + + + + + )} + + + + + + + + + {label && ( + + )} + + ) +} + +function DoorLeaf({ + animated, + doorOpen, + height, + side, + width, + y, + z, +}: { + animated?: + | { + elevatorId: AnyNodeId + kind: 'cab' + } + | { + elevatorId: AnyNodeId + kind: 'landing' + levelId: AnyNodeId + } + doorOpen: number + height: number + side: 'left' | 'right' + width: number + y: number + z: number +}) { + const ref = useRef(null) + const direction = side === 'left' ? -1 : 1 + const getLeafX = (openAmount: number) => direction * (width / 4 + openAmount * width * 0.34) + const leafWidth = Math.max(width / 2 - 0.018, 0.12) + const railHeight = Math.min(0.09, Math.max(0.055, height * 0.04)) + const stileWidth = Math.min(0.07, Math.max(0.04, leafWidth * 0.18)) + const glassWidth = Math.max(leafWidth - stileWidth * 2.2, 0.03) + const glassHeight = Math.max(height - railHeight * 3, 0.2) + + useFrame(() => { + if (!(animated && ref.current)) return + const runtime = useInteractive.getState().elevators[animated.elevatorId] + const nextDoorOpen = + animated.kind === 'cab' + ? (runtime?.doorOpen ?? 0) + : runtime?.currentLevelId === animated.levelId + ? (runtime?.doorOpen ?? 0) + : 0 + ref.current.position.x = getLeafX(nextDoorOpen) + }, 2.6) + + return ( + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function LandingDoorFrame({ + doorHeight, + doorWidth, + levelTopY, + levelY, + shaftWidth, + z, +}: { + doorHeight: number + doorWidth: number + levelTopY: number + levelY: number + shaftWidth: number + z: number +}) { + const wallDepth = 0.09 + const levelHeight = Math.max(levelTopY - levelY, doorHeight + 0.24) + const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08) + const jambCenterOffset = doorWidth / 2 + jambWidth / 2 + const headerHeight = Math.max(levelHeight - doorHeight, 0.14) + const trim = 0.055 + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function LandingDoor({ + animated, + elevatorId, + doorOpen, + doorHeight, + doorWidth, + levelId, + levelY, + z, +}: { + animated: boolean + elevatorId: AnyNodeId + doorOpen: number + doorHeight: number + doorWidth: number + levelId: AnyNodeId + levelY: number + z: number +}) { + return ( + <> + + + + ) +} + +export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { + const ref = useRef(null!) + const cabRef = useRef(null) + const nodes = useScene((state) => state.nodes) + const handlers = useNodeEvents(node, 'elevator') + const liveOverrides = useLiveNodeOverrides((state) => state.get(node.id)) + const renderNode = useMemo( + () => (liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node), + [liveOverrides, node], + ) + + useRegistry(node.id, 'elevator', ref) + + const { entries, defaultEntry, shaftBaseY, shaftTopY, totalHeight } = useMemo( + () => resolveElevatorLevels(renderNode, nodes), + [renderNode, nodes], + ) + const elevatorId = node.id as AnyNodeId + const runtimeStatus = useInteractive( + useShallow((state) => { + const runtime = state.elevators[elevatorId] + if (!runtime) return null + return { + currentLevelId: runtime.currentLevelId, + phase: runtime.phase, + queue: runtime.queue, + targetLevelId: runtime.targetLevelId, + } + }), + ) + + useEffect(() => { + if (!defaultEntry) return + + const elevatorId = node.id as AnyNodeId + const interactive = useInteractive.getState() + const current = interactive.elevators[elevatorId] + if (!current) { + interactive.initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY) + } else if (!entries.some((entry) => entry.id === current.currentLevelId)) { + interactive.setElevatorState(elevatorId, { + carY: defaultEntry.baseY, + currentLevelId: defaultEntry.id as AnyNodeId, + doorOpen: 0, + phase: 'idle', + phaseStartedAt: null, + queue: [], + targetLevelId: null, + }) + } + }, [defaultEntry, entries, node.id]) + + useEffect(() => { + return () => { + useInteractive.getState().removeElevator(elevatorId) + } + }, [elevatorId]) + + useFrame(() => { + if (!cabRef.current) return + const runtime = useInteractive.getState().elevators[elevatorId] + if (!runtime) return + cabRef.current.position.y = runtime.carY + }, 2.6) + + const shaftWidth = Math.max(renderNode.width, 0.8) + const shaftDepth = Math.max(renderNode.depth, 0.8) + const cabHeight = Math.max(renderNode.cabHeight, 1.4) + const doorWidth = Math.min(Math.max(renderNode.doorWidth, 0.45), shaftWidth - 0.18) + const doorHeight = Math.min(Math.max(renderNode.doorHeight, 1.2), cabHeight - 0.1) + const shaftHeight = Math.max(totalHeight, cabHeight + 0.3) + const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight) + const shaftWallThickness = 0.09 + const runtimeSnapshot = useInteractive.getState().elevators[elevatorId] + const cabBaseY = runtimeSnapshot?.carY ?? defaultEntry?.baseY ?? 0 + const activeLevelId = + runtimeStatus?.currentLevelId ?? runtimeSnapshot?.currentLevelId ?? defaultEntry?.id ?? null + const pendingLevelId = + runtimeStatus?.targetLevelId ?? + runtimeSnapshot?.targetLevelId ?? + runtimeStatus?.queue[0] ?? + runtimeSnapshot?.queue[0] ?? + null + const currentEntry = + entries.find((entry) => entry.id === activeLevelId) ?? defaultEntry ?? entries[0] ?? null + const pendingEntry = pendingLevelId ? entries.find((entry) => entry.id === pendingLevelId) : null + const indicatorEntry = pendingEntry ?? currentEntry + const indicatorDirection = + currentEntry && pendingEntry && Math.abs(pendingEntry.baseY - currentEntry.baseY) > 0.001 + ? pendingEntry.baseY > currentEntry.baseY + ? 'up' + : 'down' + : null + const indicatorActive = Boolean( + pendingEntry || + runtimeStatus?.phase === 'moving' || + runtimeSnapshot?.phase === 'moving' || + runtimeStatus?.phase === 'opening' || + runtimeSnapshot?.phase === 'opening', + ) + const queuedLevelIds = new Set() + for (const levelId of runtimeStatus?.queue ?? runtimeSnapshot?.queue ?? []) + queuedLevelIds.add(levelId) + if (runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId) { + queuedLevelIds.add((runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId)!) + } + const doorOpen = runtimeSnapshot?.doorOpen ?? 0 + const frontWallZ = -shaftDepth / 2 - shaftWallThickness / 2 + const frontZ = frontWallZ - shaftWallThickness / 2 - 0.018 + const landingPanelX = Math.min(shaftWidth / 2 - 0.16, doorWidth / 2 + 0.18) + const cabPanelX = shaftWidth / 2 - 0.075 + const cabPanelZ = -shaftDepth / 2 + 0.36 + const cabButtonColumns = entries.length > 1 ? 2 : 1 + const cabButtonRows = Math.max(1, Math.ceil(entries.length / cabButtonColumns)) + const cabButtonSpacingX = 0.14 + const cabButtonSpacingY = 0.15 + const cabPanelWidth = cabButtonColumns * cabButtonSpacingX + 0.13 + const cabPanelHeight = cabButtonRows * cabButtonSpacingY + 0.12 + const panelRelativeY = Math.min(Math.max(doorHeight * 0.6, 0.95), cabHeight - 0.35) + const cabPanelY = panelRelativeY + const entrySpans = entries.map((entry, index) => { + const nextEntry = entries[index + 1] + return { + entry, + levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24), + } + }) + const requestLevel = (levelId: AnyNodeId) => { + useInteractive.getState().requestElevator(elevatorId, levelId) + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {entries.map((entry, index) => { + const column = index % cabButtonColumns + const row = Math.floor(index / cabButtonColumns) + const x = (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX + const y = ((cabButtonRows - 1) / 2 - row) * cabButtonSpacingY + + return ( + requestLevel(entry.id as AnyNodeId)} + position={[x, y, 0.045]} + queued={queuedLevelIds.has(entry.id)} + /> + ) + })} + + + + {entrySpans.map(({ entry, levelTopY }) => ( + + + + + + + + + + 0.5} + buttonKind="landing" + elevatorId={elevatorId} + levelId={entry.id as AnyNodeId} + onRequest={() => requestLevel(entry.id as AnyNodeId)} + position={[0, 0.06, -0.045]} + queued={queuedLevelIds.has(entry.id)} + radius={0.045} + /> + + + + + + + ))} + + ) +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index c4b6db979..48a8bc183 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -5,6 +5,7 @@ import { BuildingRenderer } from './building/building-renderer' import { CeilingRenderer } from './ceiling/ceiling-renderer' import { ColumnRenderer } from './column/column-renderer' import { DoorRenderer } from './door/door-renderer' +import { ElevatorRenderer } from './elevator/elevator-renderer' import { FenceRenderer } from './fence/fence-renderer' import { GuideRenderer } from './guide/guide-renderer' import { ItemRenderer } from './item/item-renderer' @@ -32,6 +33,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'building' && } {node.type === 'ceiling' && } {node.type === 'column' && } + {node.type === 'elevator' && } {node.type === 'level' && } {node.type === 'item' && } {node.type === 'slab' && } diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx index c16e056a5..301e87c8b 100644 --- a/packages/viewer/src/components/renderers/site/site-renderer.tsx +++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx @@ -1,8 +1,8 @@ import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core' -import polygonClipping from 'polygon-clipping' import { useMemo, useRef } from 'react' import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' +import { unionPolygons } from '../../../lib/polygon-union' import useViewer from '../../../store/use-viewer' import { NodeRenderer } from '../node-renderer' @@ -89,22 +89,12 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { shape.closePath() if (slabPolygons.length > 0) { - const multiPolygons = slabPolygons.map((p) => [ - p.map((pt) => [pt[0], -pt[1]] as [number, number]), - ]) - const unioned = polygonClipping.union( - multiPolygons[0] as polygonClipping.Polygon, - ...(multiPolygons.slice(1) as polygonClipping.Polygon[]), - ) - for (const geom of unioned) { - const ring = geom[0] - if (ring && ring.length > 0) { - const hole = new Path() - hole.moveTo(ring[0]![0], ring[0]![1]) - for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1]) - hole.closePath() - shape.holes.push(hole) - } + for (const ring of unionPolygons(slabPolygons.map((p) => p.map((pt) => [pt[0], -pt[1]])))) { + const hole = new Path() + hole.moveTo(ring[0]![0], ring[0]![1]) + for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1]) + hole.closePath() + shape.holes.push(hole) } } diff --git a/packages/viewer/src/components/viewer/ground-occluder.tsx b/packages/viewer/src/components/viewer/ground-occluder.tsx index 54be8b51a..717f7bc56 100644 --- a/packages/viewer/src/components/viewer/ground-occluder.tsx +++ b/packages/viewer/src/components/viewer/ground-occluder.tsx @@ -1,7 +1,7 @@ import { type LevelNode, useScene } from '@pascal-app/core' -import polygonClipping from 'polygon-clipping' import { useMemo } from 'react' import * as THREE from 'three' +import { unionPolygons } from '../../lib/polygon-union' import useViewer from '../../store/use-viewer' export const GroundOccluder = () => { @@ -64,31 +64,15 @@ export const GroundOccluder = () => { }) if (polygons.length > 0) { - // Format for polygon-clipping: [[[x, y], [x, y], ...]] - const multiPolygons = polygons.map((pts) => { - const ring = pts.map((p) => [p[0], -p[1]] as [number, number]) // Negate Y (which was Z) - return [ring] - }) + for (const ring of unionPolygons(polygons.map((pts) => pts.map((p) => [p[0], -p[1]])))) { + const hole = new THREE.Path() - // Union all polygons together to prevent artifacts from overlapping - const unionedPolygons = polygonClipping.union(multiPolygons[0]!, ...multiPolygons.slice(1)) - - // Add each resulting unioned polygon as a hole - for (const geom of unionedPolygons) { - // First ring in each geometry is the exterior ring - if (geom.length > 0) { - const ring = geom[0]! - const hole = new THREE.Path() - - if (ring.length > 0) { - hole.moveTo(ring[0]![0], ring[0]![1]) - for (let i = 1; i < ring.length; i++) { - hole.lineTo(ring[i]![0], ring[i]![1]) - } - hole.closePath() - s.holes.push(hole) - } + hole.moveTo(ring[0]![0], ring[0]![1]) + for (let i = 1; i < ring.length; i++) { + hole.lineTo(ring[i]![0], ring[i]![1]) } + hole.closePath() + s.holes.push(hole) } } diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index bc2b5b85b..0ae36bf1b 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -8,6 +8,8 @@ import useViewer from '../../store/use-viewer' import { CeilingSystem } from '../../systems/ceiling/ceiling-system' import { DoorAnimationSystem } from '../../systems/door/door-animation-system' import { DoorSystem } from '../../systems/door/door-system' +import { ElevatorAnimationSystem } from '../../systems/elevator/elevator-animation-system' +import { ElevatorOpeningSystem } from '../../systems/elevator/elevator-opening-system' import { FenceSystem } from '../../systems/fence/fence-system' import { GuideSystem } from '../../systems/guide/guide-system' import { ItemSystem } from '../../systems/item/item-system' @@ -94,8 +96,6 @@ type WebGPUDeviceLossInfo = { type WebGPUDeviceLike = { lost: Promise - label?: string - features?: Set addEventListener?: (type: string, listener: EventListener) => void removeEventListener?: (type: string, listener: EventListener) => void } @@ -108,18 +108,9 @@ function GPUDeviceWatcher() { const device = backend?.device as WebGPUDeviceLike | undefined if (!device) { - console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', { - backend: backend?.constructor?.name ?? 'unknown', - rendererType: (gl as any).constructor?.name ?? 'unknown', - }) return } - console.log('[viewer] WebGPU device ready', { - label: device.label, - features: device.features ? Array.from(device.features) : [], - }) - device.lost.then((info: WebGPUDeviceLossInfo) => { console.error( `[viewer] WebGPU device lost: reason="${info.reason ?? 'unknown'}", message="${info.message ?? ''}". ` + @@ -167,24 +158,12 @@ const Viewer: React.FC = ({ const canvas = props.canvas const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined if (cached) return cached - // Surface the env we're about to ask WebGPU for — catches "no - // navigator.gpu" / "adapter request failed" silently failing in - // mobile WebViews where WebGPU is gated behind flags. - const hasGpu = typeof navigator !== 'undefined' && 'gpu' in navigator - console.log('[viewer] Creating WebGPURenderer', { - hasNavigatorGPU: hasGpu, - ua: typeof navigator !== 'undefined' ? navigator.userAgent : 'n/a', - }) const promise = (async () => { try { const renderer = new THREE.WebGPURenderer(props as any) renderer.toneMapping = THREE.ACESFilmicToneMapping renderer.toneMappingExposure = 0.9 await renderer.init() - console.log('[viewer] WebGPURenderer ready', { - backend: (renderer as any).backend?.constructor?.name, - isWebGPU: (renderer as any).isWebGPURenderer === true, - }) return renderer } catch (err) { // Drop the failed promise from the cache so a future Canvas @@ -228,6 +207,8 @@ const Viewer: React.FC = ({ {/* Core systems */} + + diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 8dd494b11..5babfa055 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -174,22 +174,9 @@ const PostProcessingPasses = ({ void pipelineVersion if (!(renderer && scene && camera)) { - console.warn('[viewer/post-processing] Skipping pipeline build — missing dependency.', { - hasRenderer: !!renderer, - hasScene: !!scene, - hasCamera: !!camera, - }) return } - console.log('[viewer/post-processing] Building pipeline', { - version: pipelineVersion, - ssgi: SSGI_PARAMS.enabled, - hoverHighlightMode, - projectId, - rendererCtor: (renderer as any).constructor?.name, - }) - hasPipelineErrorRef.current = false // WebGPU availability check: SSGI, denoise, and RenderPipeline are all @@ -202,9 +189,6 @@ const PostProcessingPasses = ({ // exclusively and never attempts the TSL pipeline. const hasWebGPU = typeof navigator !== 'undefined' && 'gpu' in navigator if (!hasWebGPU) { - console.warn( - '[viewer] WebGPU unavailable — rendering without post-processing (SSGI, outlines, denoise).', - ) hasPipelineErrorRef.current = true renderPipelineRef.current = null return @@ -331,7 +315,6 @@ const PostProcessingPasses = ({ renderPipeline.outputNode = finalOutput renderPipelineRef.current = renderPipeline retryCountRef.current = 0 - console.log('[viewer/post-processing] Pipeline built OK', { version: pipelineVersion }) } catch (error) { hasPipelineErrorRef.current = true console.error( @@ -410,9 +393,6 @@ const PostProcessingPasses = ({ if (retryCountRef.current < MAX_PIPELINE_RETRIES) { // Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit retryCountRef.current++ - console.warn( - `[viewer/post-processing] Scheduling pipeline rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`, - ) if (rebuildTimeoutRef.current !== null) { clearTimeout(rebuildTimeoutRef.current) } diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 18487347e..e1e105039 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -7,6 +7,8 @@ import { type ColumnNode, type DoorEvent, type DoorNode, + type ElevatorEvent, + type ElevatorNode, type EventSuffix, emitter, type FenceEvent, @@ -57,6 +59,7 @@ type NodeConfig = { 'stair-segment': { node: StairSegmentNode; event: StairSegmentEvent } window: { node: WindowNode; event: WindowEvent } door: { node: DoorNode; event: DoorEvent } + elevator: { node: ElevatorNode; event: ElevatorEvent } } type NodeType = keyof NodeConfig diff --git a/packages/viewer/src/lib/polygon-union.test.ts b/packages/viewer/src/lib/polygon-union.test.ts new file mode 100644 index 000000000..6bbaaa0e5 --- /dev/null +++ b/packages/viewer/src/lib/polygon-union.test.ts @@ -0,0 +1,77 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import { type Point2D, unionPolygons } from './polygon-union' + +function polygonArea(points: Point2D[]) { + let area = 0 + for (let i = 0; i < points.length; i++) { + const current = points[i]! + const next = points[(i + 1) % points.length]! + area += current[0] * next[1] - next[0] * current[1] + } + return Math.abs(area / 2) +} + +describe('unionPolygons', () => { + test('collapses a contained polygon into the containing polygon', () => { + const small: Point2D[] = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ] + const large: Point2D[] = [ + [-1, -1], + [2, -1], + [2, 2], + [-1, 2], + ] + + const result = unionPolygons([small, large]) + + expect(result).toHaveLength(1) + expect(polygonArea(result[0]!)).toBeCloseTo(9) + }) + + test('combines overlapping rectangles into one boundary', () => { + const left: Point2D[] = [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ] + const right: Point2D[] = [ + [1, 1], + [3, 1], + [3, 3], + [1, 3], + ] + + const result = unionPolygons([left, right]) + + expect(result).toHaveLength(1) + expect(result[0]).toHaveLength(8) + expect(polygonArea(result[0]!)).toBeCloseTo(7) + }) + + test('keeps disjoint polygons as separate boundaries', () => { + const left: Point2D[] = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ] + const right: Point2D[] = [ + [2, 0], + [3, 0], + [3, 1], + [2, 1], + ] + + const result = unionPolygons([left, right]) + + expect(result).toHaveLength(2) + expect(result.map(polygonArea)).toEqual([1, 1]) + }) +}) diff --git a/packages/viewer/src/lib/polygon-union.ts b/packages/viewer/src/lib/polygon-union.ts new file mode 100644 index 000000000..f078ccdd2 --- /dev/null +++ b/packages/viewer/src/lib/polygon-union.ts @@ -0,0 +1,294 @@ +export type Point2D = [number, number] + +const EPSILON = 1e-7 +const KEY_SCALE = 1e6 + +type Edge = { + start: Point2D + end: Point2D + polygonIndex: number + splits: number[] +} + +type Segment = { + start: Point2D + end: Point2D + used: boolean +} + +function pointsEqual(a: Point2D, b: Point2D, tolerance = EPSILON) { + return Math.hypot(a[0] - b[0], a[1] - b[1]) <= tolerance +} + +function pointKey(point: Point2D) { + return `${Math.round(point[0] * KEY_SCALE)}:${Math.round(point[1] * KEY_SCALE)}` +} + +function interpolate(a: Point2D, b: Point2D, t: number): Point2D { + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] +} + +function cross(ax: number, ay: number, bx: number, by: number) { + return ax * by - ay * bx +} + +function polygonArea(points: Point2D[]) { + let area = 0 + for (let i = 0; i < points.length; i++) { + const current = points[i]! + const next = points[(i + 1) % points.length]! + area += current[0] * next[1] - next[0] * current[1] + } + return area / 2 +} + +function pointOnSegment(point: Point2D, start: Point2D, end: Point2D) { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const crossValue = cross(point[0] - start[0], point[1] - start[1], dx, dz) + if (Math.abs(crossValue) > EPSILON) return false + + const dot = + (point[0] - start[0]) * (point[0] - end[0]) + (point[1] - start[1]) * (point[1] - end[1]) + return dot <= EPSILON +} + +function pointInPolygon(point: Point2D, polygon: Point2D[]) { + let inside = false + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const pi = polygon[i]! + const pj = polygon[j]! + + if (pointOnSegment(point, pj, pi)) return false + + const intersects = + pi[1] > point[1] !== pj[1] > point[1] && + point[0] < ((pj[0] - pi[0]) * (point[1] - pi[1])) / (pj[1] - pi[1]) + pi[0] + + if (intersects) inside = !inside + } + + return inside +} + +function normalizeRing(ring: Point2D[]) { + const normalized: Point2D[] = [] + + for (const [x, z] of ring) { + if (!Number.isFinite(x) || !Number.isFinite(z)) continue + + const point: Point2D = [x, z] + const previous = normalized[normalized.length - 1] + if (!previous || !pointsEqual(previous, point)) { + normalized.push(point) + } + } + + const first = normalized[0] + const last = normalized[normalized.length - 1] + if (first && last && pointsEqual(first, last)) { + normalized.pop() + } + + if (normalized.length < 3 || Math.abs(polygonArea(normalized)) <= EPSILON) return [] + return polygonArea(normalized) < 0 ? [...normalized].reverse() : normalized +} + +function addSplit(edge: Edge, t: number) { + if (t < -EPSILON || t > 1 + EPSILON) return + const clamped = Math.max(0, Math.min(1, t)) + if (edge.splits.some((split) => Math.abs(split - clamped) <= EPSILON)) return + edge.splits.push(clamped) +} + +function parameterOnEdge(point: Point2D, edge: Edge) { + const dx = edge.end[0] - edge.start[0] + const dz = edge.end[1] - edge.start[1] + const lengthSquared = dx * dx + dz * dz + if (lengthSquared <= EPSILON) return 0 + return ((point[0] - edge.start[0]) * dx + (point[1] - edge.start[1]) * dz) / lengthSquared +} + +function addIntersectionSplits(left: Edge, right: Edge) { + const rx = left.end[0] - left.start[0] + const rz = left.end[1] - left.start[1] + const sx = right.end[0] - right.start[0] + const sz = right.end[1] - right.start[1] + const qpx = right.start[0] - left.start[0] + const qpz = right.start[1] - left.start[1] + const denominator = cross(rx, rz, sx, sz) + const numerator = cross(qpx, qpz, rx, rz) + + if (Math.abs(denominator) <= EPSILON) { + if (Math.abs(numerator) > EPSILON) return + + for (const point of [left.start, left.end, right.start, right.end]) { + if ( + pointOnSegment(point, left.start, left.end) && + pointOnSegment(point, right.start, right.end) + ) { + addSplit(left, parameterOnEdge(point, left)) + addSplit(right, parameterOnEdge(point, right)) + } + } + return + } + + const t = cross(qpx, qpz, sx, sz) / denominator + const u = cross(qpx, qpz, rx, rz) / denominator + if (t < -EPSILON || t > 1 + EPSILON || u < -EPSILON || u > 1 + EPSILON) return + + addSplit(left, t) + addSplit(right, u) +} + +function buildEdges(polygons: Point2D[][]) { + const edges: Edge[] = [] + + polygons.forEach((polygon, polygonIndex) => { + for (let i = 0; i < polygon.length; i++) { + edges.push({ + start: polygon[i]!, + end: polygon[(i + 1) % polygon.length]!, + polygonIndex, + splits: [0, 1], + }) + } + }) + + for (let i = 0; i < edges.length; i++) { + for (let j = i + 1; j < edges.length; j++) { + const left = edges[i]! + const right = edges[j]! + if (left.polygonIndex === right.polygonIndex) continue + addIntersectionSplits(left, right) + } + } + + return edges +} + +function buildBoundarySegments(edges: Edge[], polygons: Point2D[][]) { + const segments: Segment[] = [] + + for (const edge of edges) { + const splits = [...edge.splits].sort((a, b) => a - b) + + for (let i = 0; i < splits.length - 1; i++) { + const startT = splits[i]! + const endT = splits[i + 1]! + if (endT - startT <= EPSILON) continue + + const start = interpolate(edge.start, edge.end, startT) + const end = interpolate(edge.start, edge.end, endT) + const mid = interpolate(edge.start, edge.end, (startT + endT) / 2) + const insideAnother = polygons.some( + (polygon, index) => index !== edge.polygonIndex && pointInPolygon(mid, polygon), + ) + + if (!insideAnother) { + segments.push({ start, end, used: false }) + } + } + } + + return removeDuplicateInteriorSegments(segments) +} + +function segmentKey(segment: Segment) { + const start = pointKey(segment.start) + const end = pointKey(segment.end) + return start < end ? `${start}|${end}` : `${end}|${start}` +} + +function removeDuplicateInteriorSegments(segments: Segment[]) { + const groups = new Map() + for (const segment of segments) { + const key = segmentKey(segment) + const group = groups.get(key) + if (group) { + group.push(segment) + } else { + groups.set(key, [segment]) + } + } + + const result: Segment[] = [] + for (const group of groups.values()) { + if (group.length === 1) { + result.push(group[0]!) + continue + } + + const firstStart = pointKey(group[0]!.start) + const firstEnd = pointKey(group[0]!.end) + const hasOppositeDirection = group.some( + (segment) => pointKey(segment.start) === firstEnd && pointKey(segment.end) === firstStart, + ) + + if (!hasOppositeDirection) { + result.push(group[0]!) + } + } + + return result +} + +function assembleRings(segments: Segment[]) { + const byStart = new Map() + for (const segment of segments) { + const key = pointKey(segment.start) + const group = byStart.get(key) + if (group) { + group.push(segment) + } else { + byStart.set(key, [segment]) + } + } + + const rings: Point2D[][] = [] + + for (const firstSegment of segments) { + if (firstSegment.used) continue + + firstSegment.used = true + const ring: Point2D[] = [firstSegment.start, firstSegment.end] + const startKey = pointKey(firstSegment.start) + let currentKey = pointKey(firstSegment.end) + + while (currentKey !== startKey) { + const next = byStart.get(currentKey)?.find((segment) => !segment.used) + if (!next) break + + next.used = true + ring.push(next.end) + currentKey = pointKey(next.end) + } + + if (currentKey !== startKey) continue + + const last = ring[ring.length - 1] + if (last && pointsEqual(ring[0]!, last)) { + ring.pop() + } + + const normalized = normalizeRing(ring) + if (normalized.length >= 3) { + rings.push(normalized) + } + } + + return rings +} + +export function unionPolygons(polygons: Point2D[][]): Point2D[][] { + const validPolygons = polygons.map(normalizeRing).filter((polygon) => polygon.length >= 3) + if (validPolygons.length <= 1) return validPolygons + + const edges = buildEdges(validPolygons) + const segments = buildBoundarySegments(edges, validPolygons) + const rings = assembleRings(segments) + + return rings.length > 0 ? rings : validPolygons +} diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index 5e383d2b2..e37f14c3e 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -1,6 +1,7 @@ -import { useFrame } from '@react-three/fiber' import { type AnyNodeId, type CeilingNode, sceneRegistry, useScene } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' import * as THREE from 'three' +import { mergeSurfaceHolePolygons } from '../surface-hole-geometry' function ensureUv2Attribute(geometry: THREE.BufferGeometry) { const uv = geometry.getAttribute('uv') @@ -82,7 +83,7 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG shape.closePath() // Add holes to the shape - const holes = ceilingNode.holes || [] + const holes = mergeSurfaceHolePolygons(ceilingNode.holes || []) for (const holePolygon of holes) { if (holePolygon.length < 3) continue diff --git a/packages/viewer/src/systems/elevator/elevator-animation-system.tsx b/packages/viewer/src/systems/elevator/elevator-animation-system.tsx new file mode 100644 index 000000000..49be4c84f --- /dev/null +++ b/packages/viewer/src/systems/elevator/elevator-animation-system.tsx @@ -0,0 +1,155 @@ +import { + type AnyNodeId, + type ElevatorNode, + sceneRegistry, + useInteractive, + useScene, +} from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { resolveElevatorLevels } from './elevator-utils' + +const EPSILON = 0.001 + +function moveToward(current: number, target: number, maxDelta: number) { + const delta = target - current + if (Math.abs(delta) <= maxDelta) return target + return current + Math.sign(delta) * maxDelta +} + +export function ElevatorAnimationSystem() { + useFrame(({ clock }, delta) => { + const interactive = useInteractive.getState() + const nodes = useScene.getState().nodes + const now = clock.getElapsedTime() * 1000 + + for (const elevatorId of sceneRegistry.byType.elevator) { + const typedElevatorId = elevatorId as AnyNodeId + const node = nodes[typedElevatorId] + if (node?.type !== 'elevator') { + interactive.removeElevator(typedElevatorId) + continue + } + + const elevator = node as ElevatorNode + const { entries, defaultEntry } = resolveElevatorLevels(elevator, nodes) + if (!defaultEntry) continue + + const state = interactive.elevators[typedElevatorId] + if (!state) { + interactive.initElevator(typedElevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY) + continue + } + + const currentEntry = + entries.find((entry) => entry.id === state.currentLevelId) ?? defaultEntry + if (currentEntry.id !== state.currentLevelId) { + interactive.setElevatorState(typedElevatorId, { + currentLevelId: currentEntry.id as AnyNodeId, + carY: currentEntry.baseY, + targetLevelId: null, + phase: 'idle', + phaseStartedAt: null, + queue: [], + doorOpen: 0, + }) + continue + } + + const targetEntry = state.targetLevelId + ? entries.find((entry) => entry.id === state.targetLevelId) + : state.queue[0] + ? entries.find((entry) => entry.id === state.queue[0]) + : null + + const doorDurationMs = Math.max(elevator.doorDurationMs ?? 900, 1) + const doorStep = (delta * 1000) / doorDurationMs + + switch (state.phase) { + case 'idle': { + const nextLevelId = state.queue[0] ?? null + if (!nextLevelId) { + if (state.doorOpen > EPSILON) { + interactive.setElevatorState(typedElevatorId, { + doorOpen: Math.max(0, state.doorOpen - doorStep), + }) + } + break + } + + interactive.setElevatorState(typedElevatorId, { + targetLevelId: nextLevelId, + phase: + state.doorOpen > EPSILON + ? 'closing' + : nextLevelId === state.currentLevelId + ? 'opening' + : 'moving', + phaseStartedAt: now, + }) + break + } + + case 'closing': { + const doorOpen = Math.max(0, state.doorOpen - doorStep) + interactive.setElevatorState(typedElevatorId, { + doorOpen, + phase: doorOpen <= EPSILON ? (state.targetLevelId ? 'moving' : 'idle') : 'closing', + phaseStartedAt: doorOpen <= EPSILON ? now : state.phaseStartedAt, + }) + break + } + + case 'moving': { + if (!targetEntry) { + interactive.setElevatorState(typedElevatorId, { + targetLevelId: null, + phase: 'idle', + queue: [], + }) + break + } + + const speed = Math.max(elevator.speed ?? 2.2, 0.1) + const nextY = moveToward(state.carY, targetEntry.baseY, speed * delta) + const arrived = Math.abs(nextY - targetEntry.baseY) <= EPSILON + interactive.setElevatorState(typedElevatorId, { + carY: nextY, + currentLevelId: arrived ? (targetEntry.id as AnyNodeId) : state.currentLevelId, + phase: arrived ? 'opening' : 'moving', + phaseStartedAt: arrived ? now : state.phaseStartedAt, + }) + break + } + + case 'opening': { + const doorOpen = Math.min(1, state.doorOpen + doorStep) + interactive.setElevatorState(typedElevatorId, { + doorOpen, + phase: doorOpen >= 1 - EPSILON ? 'open' : 'opening', + phaseStartedAt: doorOpen >= 1 - EPSILON ? now : state.phaseStartedAt, + targetLevelId: doorOpen >= 1 - EPSILON ? null : state.targetLevelId, + queue: + doorOpen >= 1 - EPSILON && state.queue[0] === state.currentLevelId + ? state.queue.slice(1) + : state.queue, + }) + break + } + + case 'open': { + const elapsed = now - (state.phaseStartedAt ?? now) + if (elapsed < Math.max(elevator.dwellMs ?? 1400, 0)) break + + interactive.setElevatorState(typedElevatorId, { + phase: 'closing', + phaseStartedAt: now, + targetLevelId: state.queue[0] ?? null, + }) + break + } + } + } + }, 2) + + return null +} diff --git a/packages/viewer/src/systems/elevator/elevator-opening-system.tsx b/packages/viewer/src/systems/elevator/elevator-opening-system.tsx new file mode 100644 index 000000000..559f9a3b9 --- /dev/null +++ b/packages/viewer/src/systems/elevator/elevator-opening-system.tsx @@ -0,0 +1,54 @@ +import { type AnyNode, syncAutoElevatorOpenings, useScene } from '@pascal-app/core' +import { useEffect, useRef } from 'react' + +function isOpeningRelevantNode(node: AnyNode | undefined) { + return ( + node?.type === 'building' || + node?.type === 'ceiling' || + node?.type === 'elevator' || + node?.type === 'level' || + node?.type === 'slab' + ) +} + +function hasOpeningRelevantNodeChange( + nextNodes: Record, + prevNodes: Record, +) { + if (nextNodes === prevNodes) return false + + const ids = new Set([...Object.keys(nextNodes), ...Object.keys(prevNodes)]) + for (const id of ids) { + const nextNode = nextNodes[id] + const prevNode = prevNodes[id] + if (nextNode === prevNode) continue + if (isOpeningRelevantNode(nextNode) || isOpeningRelevantNode(prevNode)) return true + } + + return false +} + +export const ElevatorOpeningSystem = () => { + const syncingAutoOpeningsRef = useRef(false) + + useEffect(() => { + const applyUpdates = (updates: ReturnType) => { + if (updates.length === 0) return + syncingAutoOpeningsRef.current = true + useScene.getState().updateNodes(updates) + queueMicrotask(() => { + syncingAutoOpeningsRef.current = false + }) + } + + applyUpdates(syncAutoElevatorOpenings(useScene.getState().nodes)) + + return useScene.subscribe((state, prevState) => { + if (syncingAutoOpeningsRef.current) return + if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return + applyUpdates(syncAutoElevatorOpenings(state.nodes)) + }) + }, []) + + return null +} diff --git a/packages/viewer/src/systems/elevator/elevator-utils.ts b/packages/viewer/src/systems/elevator/elevator-utils.ts new file mode 100644 index 000000000..4c0c03fa8 --- /dev/null +++ b/packages/viewer/src/systems/elevator/elevator-utils.ts @@ -0,0 +1,68 @@ +import { + resolveElevatorBuildingLevels, + resolveElevatorServiceLevels, + type AnyNode, + type AnyNodeId, + type ElevatorNode, + type LevelNode, +} from '@pascal-app/core' +import { getLevelHeight } from '../level/level-utils' + +export type ElevatorLevelEntry = { + id: LevelNode['id'] + label: string + baseY: number +} + +export function resolveElevatorLevels( + elevator: ElevatorNode, + nodes: Record, +): { + entries: ElevatorLevelEntry[] + defaultEntry: ElevatorLevelEntry | null + shaftBaseY: number + shaftTopY: number + totalHeight: number +} { + const allLevels = resolveElevatorBuildingLevels(elevator, nodes) + + const baseYByLevelId = new Map() + let cumulativeY = 0 + for (const level of allLevels) { + baseYByLevelId.set(level.id, cumulativeY) + cumulativeY += getLevelHeight(level.id, nodes) + } + + const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) + const entries = serviceLevels.map((level) => ({ + id: level.id, + label: String(level.level), + baseY: baseYByLevelId.get(level.id) ?? 0, + })) + + const defaultEntry = + entries.find((entry) => entry.id === elevator.defaultLevelId) ?? + entries.find((entry) => entry.id === elevator.fromLevelId) ?? + entries[0] ?? + null + const firstServedLevel = serviceLevels[0] ?? null + const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null + const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0 + const lastServedIndex = lastServedLevel + ? allLevels.findIndex((level) => level.id === lastServedLevel.id) + : -1 + const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null + const shaftTopY = nextLevel + ? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY) + : lastServedLevel + ? cumulativeY + : elevator.cabHeight + 0.3 + + return { + entries, + defaultEntry, + shaftBaseY, + shaftTopY, + totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3), + } +} diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index 3d866445e..403f0438e 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -8,6 +8,7 @@ import { import { useFrame } from '@react-three/fiber' import { useEffect } from 'react' import * as THREE from 'three' +import { mergeSurfaceHolePolygons } from '../surface-hole-geometry' function ensureUv2Attribute(geometry: THREE.BufferGeometry) { const uv = geometry.getAttribute('uv') @@ -86,6 +87,7 @@ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { const polygon = getRenderableSlabPolygon(slabNode) const elevation = slabNode.elevation ?? 0.05 + const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []) if (polygon.length < 3) return new THREE.BufferGeometry() @@ -94,7 +96,7 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i]![0], -polygon[i]![1]) shape.closePath() - for (const holePolygon of slabNode.holes ?? []) { + for (const holePolygon of holePolygons) { if (holePolygon.length < 3) continue const holePath = new THREE.Path() holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1]) @@ -122,6 +124,7 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { const polygon = getRenderableSlabPolygon(slabNode) const depth = Math.abs(slabNode.elevation ?? 0.05) + const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []) if (polygon.length < 3) return new THREE.BufferGeometry() @@ -134,7 +137,7 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { for (const [x, z] of polygon) { bounds.expandByPoint(new THREE.Vector2(x, z)) } - for (const hole of slabNode.holes ?? []) { + for (const hole of holePolygons) { for (const [x, z] of hole) { bounds.expandByPoint(new THREE.Vector2(x, z)) } @@ -157,8 +160,8 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { for (const [x, z] of polygon) pushFloorVertex(x!, 0, z!) const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!)) - const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!))) - for (const hole of slabNode.holes ?? []) { + const holesPts2d = holePolygons.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!))) + for (const hole of holePolygons) { for (const [x, z] of hole) pushFloorVertex(x!, 0, z!) } diff --git a/packages/viewer/src/systems/surface-hole-geometry.ts b/packages/viewer/src/systems/surface-hole-geometry.ts new file mode 100644 index 000000000..bd514ea00 --- /dev/null +++ b/packages/viewer/src/systems/surface-hole-geometry.ts @@ -0,0 +1,5 @@ +import { type Point2D, unionPolygons } from '../lib/polygon-union' + +export function mergeSurfaceHolePolygons(holes: Point2D[][]): Point2D[][] { + return unionPolygons(holes) +} From 263228cbf0e52e0f9e8784f023dfff9d3620fd94 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sun, 10 May 2026 02:28:00 +0530 Subject: [PATCH 2/9] Add cab door-open control and align elevator buttons --- packages/core/src/store/use-interactive.ts | 21 + .../editor/first-person-controls.tsx | 32 +- .../tools/elevator/elevator-tool.tsx | 25 +- .../tools/elevator/move-elevator-tool.tsx | 29 +- .../components/ui/panels/elevator-panel.tsx | 233 +++- packages/editor/src/lib/elevator-support.ts | 68 ++ .../renderers/elevator/elevator-renderer.tsx | 996 ++++++++++++------ 7 files changed, 1052 insertions(+), 352 deletions(-) create mode 100644 packages/editor/src/lib/elevator-support.ts diff --git a/packages/core/src/store/use-interactive.ts b/packages/core/src/store/use-interactive.ts index 76455a49a..843545577 100644 --- a/packages/core/src/store/use-interactive.ts +++ b/packages/core/src/store/use-interactive.ts @@ -98,6 +98,9 @@ type InteractiveStore = { /** Queue a request for an elevator to travel to a level. */ requestElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId) => void + /** Open the elevator doors at the current level when the car is not moving. */ + openElevatorDoor: (elevatorId: AnyNodeId) => void + /** Merge runtime elevator state. */ setElevatorState: (elevatorId: AnyNodeId, value: Partial) => void @@ -274,6 +277,24 @@ export const useInteractive = create((set, get) => ({ }) }, + openElevatorDoor: (elevatorId) => { + set((state) => { + const elevator = state.elevators[elevatorId] + if (!elevator?.currentLevelId || elevator.phase === 'moving') return state + + return { + elevators: { + ...state.elevators, + [elevatorId]: { + ...elevator, + phase: 'opening', + phaseStartedAt: null, + }, + }, + } + }) + }, + setElevatorState: (elevatorId, value) => { set((state) => { const current = state.elevators[elevatorId] diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index ae4a0a1e5..3b0bb1e59 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -17,8 +17,8 @@ import { KeyboardControls } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { - BoxGeometry, Box3, + BoxGeometry, Euler, type Group, Matrix4, @@ -142,16 +142,18 @@ type FirstPersonInteractableTarget = type: 'door' | 'window' } | { + action: 'open-door' | 'request-level' buttonKind: 'cab' | 'landing' id: AnyNodeId - levelId: AnyNodeId + levelId?: AnyNodeId type: 'elevator' } type ElevatorButtonTarget = { + action: 'open-door' | 'request-level' buttonKind: 'cab' | 'landing' elevatorId: AnyNodeId - levelId: AnyNodeId + levelId?: AnyNodeId } function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null { @@ -161,6 +163,7 @@ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | n const candidate = ( current.userData as { elevatorButton?: { + action?: unknown elevatorId?: unknown kind?: unknown levelId?: unknown @@ -168,12 +171,24 @@ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | n } ).elevatorButton + if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') { + const action = candidate.action === 'open-door' ? 'open-door' : 'request-level' + if (action === 'open-door') { + return { + action, + buttonKind: candidate.kind, + elevatorId: candidate.elevatorId as AnyNodeId, + } + } + } + if ( typeof candidate?.elevatorId === 'string' && typeof candidate.levelId === 'string' && (candidate.kind === 'cab' || candidate.kind === 'landing') ) { return { + action: 'request-level', buttonKind: candidate.kind, elevatorId: candidate.elevatorId as AnyNodeId, levelId: candidate.levelId as AnyNodeId, @@ -712,10 +727,13 @@ export const FirstPersonControls = () => { const target = resolveElevatorButtonTarget(intersection.object) if (!target || target.elevatorId !== elevatorId) continue - if (nodes[target.levelId]?.type !== 'level') continue + if (target.action === 'request-level') { + if (!target.levelId || nodes[target.levelId]?.type !== 'level') continue + } if (target.buttonKind === 'cab' && !canUseCabButtons) continue closestTarget = { + action: target.action, buttonKind: target.buttonKind, id: target.elevatorId, levelId: target.levelId, @@ -756,7 +774,11 @@ export const FirstPersonControls = () => { } } } - useInteractive.getState().requestElevator(target.id, target.levelId) + if (target.action === 'open-door') { + useInteractive.getState().openElevatorDoor(target.id) + return + } + if (target.levelId) useInteractive.getState().requestElevator(target.id, target.levelId) return } diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx index 9d10a82be..d8e876363 100644 --- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -10,6 +10,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' +import { resolveElevatorSupportY } from '../../../lib/elevator-support' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' import { @@ -76,16 +77,23 @@ function createElevatorPreviewGeometry(): THREE.BufferGeometry { function commitElevatorPlacement( buildingId: BuildingNode['id'], - position: [number, number, number], + x: number, + z: number, rotation: number, ): void { const { createNode, nodes } = useScene.getState() const elevatorCount = Object.values(nodes).filter((node) => node.type === 'elevator').length const serviceRange = resolveDefaultServiceRange(buildingId) + const supportY = resolveElevatorSupportY({ + buildingId, + preferredLevelId: serviceRange.fromLevelId ?? serviceRange.defaultLevelId, + x, + z, + }) const elevator = ElevatorNode.parse({ name: `Elevator ${elevatorCount + 1}`, parentId: buildingId, - position, + position: [x, supportY, z], rotation, width: DEFAULT_ELEVATOR_WIDTH, depth: DEFAULT_ELEVATOR_DEPTH, @@ -131,10 +139,15 @@ export const ElevatorTool: React.FC = () => { const onGridMove = (event: GridEvent) => { const gridX = Math.round(event.localPosition[0] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2 - const y = event.localPosition[1] + const supportY = resolveElevatorSupportY({ + buildingId: currentBuildingId, + preferredLevelId: levelId as LevelNode['id'] | null, + x: gridX, + z: gridZ, + }) - cursorRef.current?.position.set(gridX, y + GRID_OFFSET, gridZ) - previewRef.current?.position.set(gridX, y + DEFAULT_ELEVATOR_CAB_HEIGHT / 2, gridZ) + cursorRef.current?.position.set(gridX, supportY + GRID_OFFSET, gridZ) + previewRef.current?.position.set(gridX, supportY + DEFAULT_ELEVATOR_CAB_HEIGHT / 2, gridZ) if ( previousGridPosRef.current && @@ -152,7 +165,7 @@ export const ElevatorTool: React.FC = () => { const gridX = Math.round(event.localPosition[0] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2 - commitElevatorPlacement(latestBuildingId, [gridX, 0, gridZ], rotationRef.current) + commitElevatorPlacement(latestBuildingId, gridX, gridZ, rotationRef.current) } const onKeyDown = (event: KeyboardEvent) => { diff --git a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx index 43f8a31e1..046bf43a7 100644 --- a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx @@ -1,9 +1,11 @@ import { type AnyNodeId, + type BuildingNode, type ElevatorNode, ElevatorNode as ElevatorNodeSchema, emitter, type GridEvent, + type LevelNode, sceneRegistry, useLiveTransforms, useScene, @@ -11,6 +13,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' +import { resolveElevatorSupportY } from '../../../lib/elevator-support' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' @@ -62,6 +65,10 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) { let wasCommitted = false let wasCancelled = false let pendingRotation = movingNode.rotation + const supportBuildingId = movingNode.parentId as BuildingNode['id'] | null | undefined + const supportLevelId = (movingNode.fromLevelId ?? movingNode.defaultLevelId) as + | LevelNode['id'] + | null const applyPreview = ( position: ElevatorNode['position'], @@ -98,7 +105,12 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) { const onGridMove = (event: GridEvent) => { const gridX = Math.round(event.localPosition[0] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2 - const y = event.localPosition[1] + const supportY = resolveElevatorSupportY({ + buildingId: supportBuildingId, + preferredLevelId: supportLevelId, + x: gridX, + z: gridZ, + }) if ( previousGridPosRef.current && @@ -108,21 +120,28 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) { } previousGridPosRef.current = [gridX, gridZ] - setCursorPosition([gridX, y, gridZ]) - previewPositionRef.current = [gridX, movingNode.position[1], gridZ] + setCursorPosition([gridX, supportY, gridZ]) + previewPositionRef.current = [gridX, supportY, gridZ] applyPreview(previewPositionRef.current, pendingRotation) } const onGridClick = (event: GridEvent) => { const gridX = Math.round(event.localPosition[0] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2 + const supportY = resolveElevatorSupportY({ + buildingId: supportBuildingId, + preferredLevelId: supportLevelId, + x: gridX, + z: gridZ, + }) + const nextPosition: ElevatorNode['position'] = [gridX, supportY, gridZ] wasCommitted = true clearPreview() useScene.temporal.getState().resume() if (movingNodeId && useScene.getState().nodes[movingNodeId as AnyNodeId]) { useScene.getState().updateNode(movingNodeId as AnyNodeId, { - position: [gridX, movingNode.position[1], gridZ], + position: nextPosition, rotation: pendingRotation, metadata: committedMeta, }) @@ -131,7 +150,7 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) { const elevator = ElevatorNodeSchema.parse({ ...movingNode, id: undefined, - position: [gridX, movingNode.position[1], gridZ], + position: nextPosition, rotation: pendingRotation, metadata: committedMeta, }) diff --git a/packages/editor/src/components/ui/panels/elevator-panel.tsx b/packages/editor/src/components/ui/panels/elevator-panel.tsx index f5e4fa9d1..da7f5dd46 100644 --- a/packages/editor/src/components/ui/panels/elevator-panel.tsx +++ b/packages/editor/src/components/ui/panels/elevator-panel.tsx @@ -8,12 +8,14 @@ import { type LevelNode, useInteractive, useLiveNodeOverrides, + useLiveTransforms, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Send, Trash2 } from 'lucide-react' import { useCallback, useEffect } from 'react' import { useShallow } from 'zustand/react/shallow' +import { resolveElevatorNodeSupportY, resolveElevatorSupportY } from '../../../lib/elevator-support' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' @@ -90,6 +92,18 @@ function stripDuplicateFlags(metadata: ElevatorNode['metadata']) { type ElevatorMetricKey = 'width' | 'depth' | 'cabHeight' | 'doorWidth' | 'doorHeight' +function roundMeters(value: number) { + return Math.round(value * 100) / 100 +} + +function radiansToDegrees(radians: number) { + return Math.round((radians * 180) / Math.PI) +} + +function degreesToRadians(degrees: number) { + return (degrees * Math.PI) / 180 +} + export function ElevatorPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedCount = useViewer((s) => s.selection.selectedIds.length) @@ -115,10 +129,15 @@ export function ElevatorPanel() { const liveOverrides = useLiveNodeOverrides((s) => selectedId ? s.get(selectedId as AnyNodeId) : undefined, ) + const liveTransform = useLiveTransforms((s) => + selectedId ? s.get(selectedId as AnyNodeId) : undefined, + ) useEffect(() => { return () => { - if (selectedId) useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) + if (!selectedId) return + useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) + useLiveTransforms.getState().clear(selectedId as AnyNodeId) } }, [selectedId]) @@ -143,9 +162,32 @@ export function ElevatorPanel() { ) const clearLivePreview = useCallback(() => { - if (selectedId) useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) + if (!selectedId) return + useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) + useLiveTransforms.getState().clear(selectedId as AnyNodeId) }, [selectedId]) + useEffect(() => { + if (!(selectedId && node?.type === 'elevator')) return + const supportY = resolveElevatorNodeSupportY(node) + if (node.position[1] >= supportY - 1e-4) return + + updateNode(selectedId as AnyNode['id'], { + position: [node.position[0], supportY, node.position[2]], + }) + }, [ + node?.defaultLevelId, + node?.fromLevelId, + node?.id, + node?.parentId, + node?.position[0], + node?.position[1], + node?.position[2], + node?.type, + selectedId, + updateNode, + ]) + const previewMetric = useCallback( (key: K, value: ElevatorNode[K]) => { if (!selectedId) return @@ -167,6 +209,43 @@ export function ElevatorPanel() { [node, selectedId, updateNode], ) + const previewTransform = useCallback( + (position: ElevatorNode['position'], rotation: ElevatorNode['rotation']) => { + if (!selectedId) return + useLiveTransforms.getState().set(selectedId as AnyNodeId, { position, rotation }) + }, + [selectedId], + ) + + const commitTransform = useCallback( + (position: ElevatorNode['position'], rotation: ElevatorNode['rotation']) => { + if (!(selectedId && node)) return + useLiveTransforms.getState().clear(selectedId as AnyNodeId) + const positionChanged = node.position.some( + (value, index) => Math.abs(value - position[index]!) > 1e-6, + ) + const rotationChanged = Math.abs(node.rotation - rotation) > 1e-6 + if (positionChanged || rotationChanged) { + updateNode(selectedId as AnyNode['id'], { position, rotation }) + } + }, + [node, selectedId, updateNode], + ) + + const getSupportedPosition = useCallback( + (x: number, z: number): ElevatorNode['position'] => { + if (!node) return [x, 0, z] + const supportY = resolveElevatorSupportY({ + buildingId: node.parentId, + preferredLevelId: node.fromLevelId ?? node.defaultLevelId, + x, + z, + }) + return [x, supportY, z] + }, + [node], + ) + const handleClose = useCallback(() => { clearLivePreview() setSelection({ selectedIds: [] }) @@ -231,6 +310,20 @@ export function ElevatorPanel() { defaultLevelId: currentDefaultIsServed ? node.defaultLevelId : nextFromLevelId || nextServedLevels[0]?.id || null, + ...(field === 'fromLevelId' + ? { + position: [ + node.position[0], + resolveElevatorSupportY({ + buildingId: node.parentId, + preferredLevelId: nextFromLevelId, + x: node.position[0], + z: node.position[2], + }), + node.position[2], + ] as ElevatorNode['position'], + } + : {}), servedLevelIds: undefined, } as Partial) }, @@ -240,6 +333,9 @@ export function ElevatorPanel() { if (!(node && node.type === 'elevator' && selectedId && selectedCount === 1)) return null const displayNode = liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node + const displayPosition = liveTransform?.position ?? displayNode.position + const displayRotation = liveTransform?.rotation ?? displayNode.rotation + const displayRotationDegrees = radiansToDegrees(displayRotation) const fromLevelId = getResolvedFromLevelId(node, levels) const toLevelId = getResolvedToLevelId(node, levels, fromLevelId) const servedLevels = getServiceLevels(levels, fromLevelId, toLevelId) @@ -255,9 +351,15 @@ export function ElevatorPanel() { ? node.defaultLevelId : fromLevelId || levels[0]?.id) ?? null - const queuedLevelIds = new Set() - for (const levelId of runtime?.queue ?? []) queuedLevelIds.add(levelId) - if (runtime?.targetLevelId) queuedLevelIds.add(runtime.targetLevelId) + const destinationOrderByLevelId = new Map() + const orderedDestinationIds: string[] = [] + if (runtime?.targetLevelId) orderedDestinationIds.push(runtime.targetLevelId) + for (const levelId of runtime?.queue ?? []) { + if (!orderedDestinationIds.includes(levelId)) orderedDestinationIds.push(levelId) + } + orderedDestinationIds.forEach((levelId, index) => { + destinationOrderByLevelId.set(levelId, index + 1) + }) return ( + + { + const position = getSupportedPosition(value, displayPosition[2]) + previewTransform(position, displayRotation) + }} + onCommit={(value) => { + const position = getSupportedPosition(value, displayPosition[2]) + commitTransform(position, displayRotation) + }} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={roundMeters(displayPosition[0])} + /> + { + const position: ElevatorNode['position'] = [ + displayPosition[0], + value, + displayPosition[2], + ] + previewTransform(position, displayRotation) + }} + onCommit={(value) => { + const position: ElevatorNode['position'] = [ + displayPosition[0], + value, + displayPosition[2], + ] + commitTransform(position, displayRotation) + }} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={roundMeters(displayPosition[1])} + /> + { + const position = getSupportedPosition(displayPosition[0], value) + previewTransform(position, displayRotation) + }} + onCommit={(value) => { + const position = getSupportedPosition(displayPosition[0], value) + commitTransform(position, displayRotation) + }} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={roundMeters(displayPosition[2])} + /> + + + + previewTransform(displayPosition, degreesToRadians(degrees))} + onCommit={(degrees) => commitTransform(displayPosition, degreesToRadians(degrees))} + precision={0} + restoreOnCommit={false} + step={1} + unit="°" + value={displayRotationDegrees} + /> +
+ { + sfxEmitter.emit('sfx:item-rotate') + commitTransform(displayPosition, displayRotation - Math.PI / 4) + }} + /> + { + sfxEmitter.emit('sfx:item-rotate') + commitTransform(displayPosition, displayRotation + Math.PI / 4) + }} + /> +
+
+ {servedLevels.map((level) => { const isActive = activeLevelId === level.id - const isQueued = queuedLevelIds.has(level.id) + const stopOrder = destinationOrderByLevelId.get(level.id) return ( ) diff --git a/packages/editor/src/lib/elevator-support.ts b/packages/editor/src/lib/elevator-support.ts new file mode 100644 index 000000000..fc8745627 --- /dev/null +++ b/packages/editor/src/lib/elevator-support.ts @@ -0,0 +1,68 @@ +import { + type AnyNode, + type AnyNodeId, + type ElevatorNode, + type LevelNode, + spatialGridManager, + useScene, +} from '@pascal-app/core' + +function getBuildingLevels( + buildingId: string | null | undefined, + nodes: Record, +): LevelNode[] { + if (!buildingId) return [] + const building = nodes[buildingId as AnyNodeId] + if (building?.type !== 'building') return [] + + return building.children + .map((childId) => nodes[childId as AnyNodeId]) + .filter((entry): entry is LevelNode => entry?.type === 'level') + .sort((left, right) => left.level - right.level) +} + +export function resolveElevatorSupportLevelId({ + buildingId, + preferredLevelId, +}: { + buildingId: string | null | undefined + preferredLevelId?: string | null +}): LevelNode['id'] | null { + const nodes = useScene.getState().nodes + const levels = getBuildingLevels(buildingId, nodes) + if (levels.length === 0) return null + + const preferred = preferredLevelId + ? levels.find((level) => level.id === preferredLevelId) + : undefined + return preferred?.id ?? levels[0]?.id ?? null +} + +export function resolveElevatorSupportY({ + buildingId, + preferredLevelId, + x, + z, +}: { + buildingId: string | null | undefined + preferredLevelId?: string | null + x: number + z: number +}): number { + const levelId = resolveElevatorSupportLevelId({ buildingId, preferredLevelId }) + if (!levelId) return 0 + + return Math.max(0, spatialGridManager.getSlabElevationAt(levelId, x, z)) +} + +export function resolveElevatorNodeSupportY( + node: ElevatorNode, + position: [number, number, number] = node.position, +): number { + return resolveElevatorSupportY({ + buildingId: node.parentId, + preferredLevelId: node.fromLevelId ?? node.defaultLevelId, + x: position[0], + z: position[2], + }) +} diff --git a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx index 0b5dc3556..136159f90 100644 --- a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx +++ b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx @@ -3,12 +3,21 @@ import { type ElevatorNode, useInteractive, useLiveNodeOverrides, + useLiveTransforms, useRegistry, useScene, } from '@pascal-app/core' -import { useFrame, type ThreeEvent } from '@react-three/fiber' -import { useEffect, useMemo, useRef } from 'react' -import type { Group } from 'three' +import { type ThreeEvent, useFrame } from '@react-three/fiber' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { + BoxGeometry, + CylinderGeometry, + type Group, + type InstancedMesh, + MeshStandardMaterial, + Object3D, + TorusGeometry, +} from 'three' import { useShallow } from 'zustand/react/shallow' import { useNodeEvents } from '../../../hooks/use-node-events' import { resolveElevatorLevels } from '../../../systems/elevator/elevator-utils' @@ -21,6 +30,177 @@ const GLASS_COLOR = '#f8fafc' const DOOR_COLOR = '#8e98a6' const PANEL_COLOR = '#1f2937' +type Vector3Tuple = [number, number, number] + +const UNIT_BOX_GEOMETRY = new BoxGeometry(1, 1, 1) +const BUTTON_FACE_GEOMETRY = new CylinderGeometry(1, 0.92, 1, 24) +const BUTTON_GLOW_GEOMETRY = new CylinderGeometry(1.42, 1.42, 1, 24) +const BUTTON_RING_GEOMETRY = new TorusGeometry(1.12, 0.12, 8, 24) +const LABEL_MATRIX_DUMMY = new Object3D() +const SHAFT_TOP_FRAME_CLEARANCE = 0.006 + +const SHAFT_WALL_MATERIAL = new MeshStandardMaterial({ + color: SHAFT_WALL_COLOR, + metalness: 0.08, + roughness: 0.56, +}) +const SHAFT_SIDE_MATERIAL = new MeshStandardMaterial({ + color: SHAFT_SIDE_COLOR, + metalness: 0.12, + roughness: 0.58, +}) +const SHAFT_TRIM_MATERIAL = new MeshStandardMaterial({ + color: SHAFT_TRIM_COLOR, + metalness: 0.2, + roughness: 0.38, +}) +const CAB_MATERIAL = new MeshStandardMaterial({ + color: CAB_COLOR, + metalness: 0.2, + roughness: 0.48, +}) +const DOOR_MATERIAL = new MeshStandardMaterial({ + color: DOOR_COLOR, + metalness: 0.34, + roughness: 0.34, +}) +const GLASS_MATERIAL = new MeshStandardMaterial({ + color: GLASS_COLOR, + depthWrite: false, + metalness: 0, + opacity: 0.2, + roughness: 0.08, + transparent: true, +}) +const PANEL_MATERIAL = new MeshStandardMaterial({ + color: PANEL_COLOR, + metalness: 0.32, + roughness: 0.36, +}) +const LANDING_PANEL_MATERIAL = new MeshStandardMaterial({ + color: PANEL_COLOR, + metalness: 0.25, + roughness: 0.4, +}) +const INDICATOR_SCREEN_MATERIALS = { + active: new MeshStandardMaterial({ + color: '#041f2f', + emissive: '#0ea5e9', + emissiveIntensity: 0.16, + metalness: 0.12, + roughness: 0.38, + }), + idle: new MeshStandardMaterial({ + color: '#111827', + metalness: 0.12, + roughness: 0.38, + }), +} +const INDICATOR_GLYPH_MATERIALS = { + active: new MeshStandardMaterial({ + color: '#38bdf8', + emissive: '#38bdf8', + emissiveIntensity: 0.36, + metalness: 0.08, + roughness: 0.32, + }), + idle: new MeshStandardMaterial({ + color: '#94a3b8', + emissive: '#94a3b8', + emissiveIntensity: 0.18, + metalness: 0.08, + roughness: 0.32, + }), +} +const BUTTON_FACE_MATERIALS = { + active: new MeshStandardMaterial({ + color: '#38bdf8', + emissive: '#38bdf8', + emissiveIntensity: 0.28, + metalness: 0.22, + roughness: 0.3, + }), + queued: new MeshStandardMaterial({ + color: '#fbbf24', + emissive: '#fbbf24', + emissiveIntensity: 0.18, + metalness: 0.22, + roughness: 0.3, + }), + idle: new MeshStandardMaterial({ + color: '#d6dde7', + metalness: 0.22, + roughness: 0.3, + }), +} +const BUTTON_RING_MATERIALS = { + active: new MeshStandardMaterial({ + color: '#0ea5e9', + emissive: '#0ea5e9', + emissiveIntensity: 0.16, + metalness: 0.48, + roughness: 0.28, + }), + queued: new MeshStandardMaterial({ + color: '#f59e0b', + emissive: '#f59e0b', + emissiveIntensity: 0.1, + metalness: 0.48, + roughness: 0.28, + }), + idle: new MeshStandardMaterial({ + color: '#64748b', + metalness: 0.48, + roughness: 0.28, + }), +} +const BUTTON_GLOW_MATERIALS = { + active: new MeshStandardMaterial({ + color: '#38bdf8', + depthWrite: false, + emissive: '#38bdf8', + emissiveIntensity: 0.28, + opacity: 0.58, + transparent: true, + }), + queued: new MeshStandardMaterial({ + color: '#fbbf24', + depthWrite: false, + emissive: '#fbbf24', + emissiveIntensity: 0.18, + opacity: 0.58, + transparent: true, + }), +} +const BUTTON_LABEL_MATERIALS = { + lit: new MeshStandardMaterial({ + color: '#111827', + metalness: 0.12, + roughness: 0.34, + }), + idle: new MeshStandardMaterial({ + color: '#334155', + metalness: 0.12, + roughness: 0.34, + }), +} +const QUEUE_STRIP_MATERIALS = { + queued: new MeshStandardMaterial({ + color: '#fbbf24', + emissive: '#fbbf24', + emissiveIntensity: 0.16, + metalness: 0.18, + roughness: 0.42, + }), + idle: new MeshStandardMaterial({ + color: '#64748b', + metalness: 0.18, + roughness: 0.42, + }), +} + +type ElevatorButtonAction = 'open-door' | 'request-level' + type SegmentName = | 'bottom' | 'lowerLeft' @@ -57,97 +237,138 @@ const SEGMENT_PROPS: Record< upperRight: { position: [0.32, 0.22, 0], size: [0.11, 0.42, 0.018] }, } +function BoxPrimitive({ + castShadow = false, + material, + position, + receiveShadow = false, + rotation, + scale, +}: { + castShadow?: boolean + material: MeshStandardMaterial + position?: Vector3Tuple + receiveShadow?: boolean + rotation?: Vector3Tuple + scale: Vector3Tuple +}) { + return ( + + ) +} + function MeshButtonLabel({ - color, label, + material, position, scale, }: { - color: string label: string + material: MeshStandardMaterial position: [number, number, number] scale: number }) { - const characters = label.split('').filter((character) => DIGIT_SEGMENTS[character]) - const spacing = 0.72 * scale - const startX = -((characters.length - 1) * spacing) / 2 + const ref = useRef(null) + const instances = useMemo(() => { + const characters = label.split('').filter((character) => DIGIT_SEGMENTS[character]) + const spacing = 0.72 * scale + const startX = -((characters.length - 1) * spacing) / 2 + + return characters.flatMap((character, charIndex) => + (DIGIT_SEGMENTS[character] ?? []).map((segment) => { + const props = SEGMENT_PROPS[segment] + return { + position: [ + startX + charIndex * spacing + props.position[0] * scale, + props.position[1] * scale, + props.position[2], + ] as Vector3Tuple, + scale: [props.size[0] * scale, props.size[1] * scale, props.size[2]] as Vector3Tuple, + } + }), + ) + }, [label, scale]) + + const applyInstanceMatrices = useCallback( + (mesh: InstancedMesh) => { + for (let index = 0; index < instances.length; index += 1) { + const instance = instances[index] + if (!instance) continue + LABEL_MATRIX_DUMMY.position.set(...instance.position) + LABEL_MATRIX_DUMMY.rotation.set(0, 0, 0) + LABEL_MATRIX_DUMMY.scale.set(...instance.scale) + LABEL_MATRIX_DUMMY.updateMatrix() + mesh.setMatrixAt(index, LABEL_MATRIX_DUMMY.matrix) + } + mesh.instanceMatrix.needsUpdate = true + }, + [instances], + ) + + useLayoutEffect(() => { + const mesh = ref.current + if (!mesh) return + applyInstanceMatrices(mesh) + }, [applyInstanceMatrices]) - if (characters.length === 0) return null + if (instances.length === 0) return null return ( - - {characters.map((character, charIndex) => ( - - {(DIGIT_SEGMENTS[character] ?? []).map((segment) => { - const props = SEGMENT_PROPS[segment] - return ( - - - - - ) - })} - - ))} - + ) } function ElevatorDirectionGlyph({ - color, direction, + material, position, scale, }: { - color: string direction: 'down' | 'up' | null + material: MeshStandardMaterial position: [number, number, number] scale: number }) { if (!direction) { return ( - - - - + ) } const ySign = direction === 'up' ? 1 : -1 return ( - - - - - - - - + rotation={[0, 0, (-ySign * Math.PI) / 4]} + scale={[0.16 * scale, 0.035 * scale, 0.018]} + /> + ) } @@ -159,6 +380,7 @@ function ElevatorFloorIndicator({ label, position, scale = 1, + showReadout = true, }: { active: boolean direction: 'down' | 'up' | null @@ -166,132 +388,196 @@ function ElevatorFloorIndicator({ label: string position: [number, number, number] scale?: number + showReadout?: boolean }) { - const glowColor = active ? '#38bdf8' : '#94a3b8' - const screenColor = active ? '#041f2f' : '#111827' + const glyphMaterial = active ? INDICATOR_GLYPH_MATERIALS.active : INDICATOR_GLYPH_MATERIALS.idle + const screenMaterial = active + ? INDICATOR_SCREEN_MATERIALS.active + : INDICATOR_SCREEN_MATERIALS.idle const displayLabel = label || '-' const screenZ = faceSign * 0.026 * scale const glyphZ = faceSign * 0.041 * scale return ( - - - - - - - + + {showReadout ? ( + <> + + + + ) : ( + - - + ) +} + +function DoorOpenGlyph({ + material, + positionZ, + scale, +}: { + material: MeshStandardMaterial + positionZ: number + scale: number +}) { + return ( + + + - + + + ) } function ElevatorMeshButton({ + action = 'request-level', active, buttonKind, elevatorId, faceSign = -1, + glyph, label, levelId, - onRequest, position, queued, radius = 0.055, }: { + action?: ElevatorButtonAction active: boolean buttonKind: 'cab' | 'landing' elevatorId: AnyNodeId faceSign?: -1 | 1 + glyph?: 'door-open' label?: string - levelId: AnyNodeId - onRequest: () => void + levelId?: AnyNodeId position: [number, number, number] queued: boolean radius?: number }) { - const buttonColor = active ? '#38bdf8' : queued ? '#fbbf24' : '#d6dde7' - const labelColor = active || queued ? '#111827' : '#334155' - const ringColor = active ? '#0ea5e9' : queued ? '#f59e0b' : '#64748b' + const state = active ? 'active' : queued ? 'queued' : 'idle' const depth = active ? 0.028 : 0.04 const faceZ = faceSign * (depth / 2 + 0.004) + const labelMaterial = active || queued ? BUTTON_LABEL_MATERIALS.lit : BUTTON_LABEL_MATERIALS.idle const userData = useMemo( () => ({ elevatorButton: { + action, elevatorId, kind: buttonKind, levelId, }, }), - [buttonKind, elevatorId, levelId], + [action, buttonKind, elevatorId, levelId], ) const press = (event: ThreeEvent) => { if (event.button !== 0) return - onRequest() + if (action === 'open-door') { + useInteractive.getState().openElevatorDoor(elevatorId) + return + } + if (levelId) useInteractive.getState().requestElevator(elevatorId, levelId) } return ( {(active || queued) && ( - - - - - )} - - - - - - - - + )} + + {label && ( )} + {glyph === 'door-open' && ( + + )} ) } @@ -345,33 +631,39 @@ function DoorLeaf({ return ( - - - - - - - - - - - - - - - - - - - - + + + + + ) } @@ -392,50 +684,65 @@ function LandingDoorFrame({ z: number }) { const wallDepth = 0.09 - const levelHeight = Math.max(levelTopY - levelY, doorHeight + 0.24) + const levelHeight = Math.max(levelTopY - levelY, 0.01) const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08) const jambCenterOffset = doorWidth / 2 + jambWidth / 2 - const headerHeight = Math.max(levelHeight - doorHeight, 0.14) + const headerHeight = Math.max(levelTopY - (levelY + doorHeight), 0) const trim = 0.055 return ( <> - - - - - - - - - - - - - - - - - + + {headerHeight > 0.01 && ( + + )} + + - - - - + - - - - - - - + scale={[trim, doorHeight, wallDepth * 1.12]} + /> + ) } @@ -489,6 +796,7 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { const nodes = useScene((state) => state.nodes) const handlers = useNodeEvents(node, 'elevator') const liveOverrides = useLiveNodeOverrides((state) => state.get(node.id)) + const liveTransform = useLiveTransforms((state) => state.get(node.id)) const renderNode = useMemo( () => (liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node), [liveOverrides, node], @@ -496,7 +804,7 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { useRegistry(node.id, 'elevator', ref) - const { entries, defaultEntry, shaftBaseY, shaftTopY, totalHeight } = useMemo( + const { entries, defaultEntry, shaftBaseY, totalHeight } = useMemo( () => resolveElevatorLevels(renderNode, nodes), [renderNode, nodes], ) @@ -554,8 +862,11 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { const doorWidth = Math.min(Math.max(renderNode.doorWidth, 0.45), shaftWidth - 0.18) const doorHeight = Math.min(Math.max(renderNode.doorHeight, 1.2), cabHeight - 0.1) const shaftHeight = Math.max(totalHeight, cabHeight + 0.3) - const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight) const shaftWallThickness = 0.09 + const shaftBodyHeight = Math.max(shaftHeight - shaftWallThickness, 0.01) + const shaftBodyCenterY = shaftBaseY + shaftBodyHeight / 2 + const shaftTopCapBottomY = shaftBaseY + shaftHeight - shaftWallThickness + const shaftFrameTopY = Math.max(shaftBaseY, shaftTopCapBottomY - SHAFT_TOP_FRAME_CLEARANCE) const runtimeSnapshot = useInteractive.getState().elevators[elevatorId] const cabBaseY = runtimeSnapshot?.carY ?? defaultEntry?.baseY ?? 0 const activeLevelId = @@ -583,13 +894,25 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { runtimeStatus?.phase === 'opening' || runtimeSnapshot?.phase === 'opening', ) - const queuedLevelIds = new Set() - for (const levelId of runtimeStatus?.queue ?? runtimeSnapshot?.queue ?? []) - queuedLevelIds.add(levelId) - if (runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId) { - queuedLevelIds.add((runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId)!) - } + const queuedLevelIds = useMemo(() => { + const next = new Set() + for (const levelId of runtimeStatus?.queue ?? runtimeSnapshot?.queue ?? []) next.add(levelId) + const targetLevelId = runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId + if (targetLevelId) next.add(targetLevelId) + return next + }, [ + runtimeSnapshot?.queue, + runtimeSnapshot?.targetLevelId, + runtimeStatus?.queue, + runtimeStatus?.targetLevelId, + ]) const doorOpen = runtimeSnapshot?.doorOpen ?? 0 + const doorOpenButtonActive = + doorOpen > 0.12 || + runtimeStatus?.phase === 'opening' || + runtimeSnapshot?.phase === 'opening' || + runtimeStatus?.phase === 'open' || + runtimeSnapshot?.phase === 'open' const frontWallZ = -shaftDepth / 2 - shaftWallThickness / 2 const frontZ = frontWallZ - shaftWallThickness / 2 - 0.018 const landingPanelX = Math.min(shaftWidth / 2 - 0.16, doorWidth / 2 + 0.18) @@ -599,99 +922,111 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { const cabButtonRows = Math.max(1, Math.ceil(entries.length / cabButtonColumns)) const cabButtonSpacingX = 0.14 const cabButtonSpacingY = 0.15 - const cabPanelWidth = cabButtonColumns * cabButtonSpacingX + 0.13 + const cabDoorButtonOffsetX = 0.17 + const cabFloorButtonOffsetX = entries.length > 0 ? -cabDoorButtonOffsetX / 2 : 0 + const cabDoorButtonX = + cabFloorButtonOffsetX + ((cabButtonColumns - 1) / 2) * cabButtonSpacingX + cabDoorButtonOffsetX + const cabDoorButtonY = -((cabButtonRows - 1) / 2) * cabButtonSpacingY + const cabPanelWidth = cabButtonColumns * cabButtonSpacingX + 0.13 + cabDoorButtonOffsetX const cabPanelHeight = cabButtonRows * cabButtonSpacingY + 0.12 const panelRelativeY = Math.min(Math.max(doorHeight * 0.6, 0.95), cabHeight - 0.35) const cabPanelY = panelRelativeY - const entrySpans = entries.map((entry, index) => { - const nextEntry = entries[index + 1] - return { - entry, - levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24), - } - }) - const requestLevel = (levelId: AnyNodeId) => { - useInteractive.getState().requestElevator(elevatorId, levelId) - } + const entrySpans = useMemo( + () => + entries.map((entry, index) => { + const nextEntry = entries[index + 1] + const minDoorFrameTopY = entry.baseY + doorHeight + 0.12 + const targetTopY = Math.max(nextEntry?.baseY ?? shaftFrameTopY, minDoorFrameTopY) + + return { + entry, + levelTopY: nextEntry ? targetTopY : Math.min(targetTopY, shaftFrameTopY), + } + }), + [doorHeight, entries, shaftFrameTopY], + ) return ( - - - - - + - - - - + - - - - + - - - + scale={[ + shaftWidth + shaftWallThickness * 2, + shaftWallThickness, + shaftDepth + shaftWallThickness * 2, + ]} + /> - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + { /> - - - - + {entries.map((entry, index) => { const column = index % cabButtonColumns const row = Math.floor(index / cabButtonColumns) - const x = (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX - const y = ((cabButtonRows - 1) / 2 - row) * cabButtonSpacingY + const x = + cabFloorButtonOffsetX + (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX + const y = (row - (cabButtonRows - 1) / 2) * cabButtonSpacingY return ( { key={entry.id} label={entry.label} levelId={entry.id as AnyNodeId} - onRequest={() => requestLevel(entry.id as AnyNodeId)} position={[x, y, 0.045]} queued={queuedLevelIds.has(entry.id)} /> ) })} + - {entrySpans.map(({ entry, levelTopY }) => ( - - - - - - - - - - 0.5} - buttonKind="landing" + {entrySpans.map(({ entry, levelTopY }) => { + const isCurrentLevel = activeLevelId === entry.id + const isQueuedLevel = queuedLevelIds.has(entry.id) + const isPendingLevel = pendingLevelId === entry.id + const showLandingReadout = isCurrentLevel || isPendingLevel || isQueuedLevel + + return ( + + + requestLevel(entry.id as AnyNodeId)} - position={[0, 0.06, -0.045]} - queued={queuedLevelIds.has(entry.id)} - radius={0.045} + levelY={entry.baseY} + z={frontZ - 0.02} + /> + - - - + - + 0.5} + buttonKind="landing" + elevatorId={elevatorId} + levelId={entry.id as AnyNodeId} + position={[0, 0.06, -0.045]} + queued={isQueuedLevel} + radius={0.045} + /> + + - - ))} + ) + })} ) } From fdf08c091f0b56986212b71294218172139adacd Mon Sep 17 00:00:00 2001 From: sudhir Date: Sun, 10 May 2026 10:50:22 +0530 Subject: [PATCH 3/9] Add elevator floorplan overlay --- .../src/components/editor/floorplan-panel.tsx | 347 ++++++++++++++++++ .../editor/use-floorplan-hit-testing.ts | 12 + .../src/lib/floorplan/selection-tool.ts | 20 + 3 files changed, 379 insertions(+) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 25e9cc2ee..3ba8d9e0e 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -9,6 +9,7 @@ import { type ColumnNode, calculateLevelMiters, DoorNode, + type ElevatorNode, emitter, type FenceNode, type GridEvent, @@ -28,6 +29,7 @@ import { type Point2D, type RoofNode, type RoofSegmentNode, + resolveElevatorServiceLevelIds, type SiteNode, SlabNode, type SpawnNode, @@ -37,6 +39,7 @@ import { StairSegmentNode as StairSegmentNodeSchema, sampleWallCenterline, sceneRegistry, + useInteractive, useLiveTransforms, useScene, type WallNode, @@ -631,6 +634,18 @@ type FloorplanColumnEntry = { polygon: Point2D[] } +type FloorplanElevatorEntry = { + center: Point2D + elevator: ElevatorNode + frontEdge: FloorplanLineSegment + frontNormal: Point2D + isCarOnLevel: boolean + isQueuedLevel: boolean + isTargetLevel: boolean + points: string + polygon: Point2D[] +} + type ReferenceFloorData = { ceilingPolygons: CeilingPolygonEntry[] columnEntries: ReferenceFloorColumnEntry[] @@ -6006,6 +6021,167 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({ ) }) +const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({ + canSelectElevators, + elevatorEntries, + highlightedIdSet, + hoveredElevatorId, + isDeleteMode, + onElevatorHoverChange, + onElevatorHoverEnter, + onElevatorSelect, + palette, + selectedIdSet, + wallSelectionHatchId, +}: { + canSelectElevators: boolean + elevatorEntries: FloorplanElevatorEntry[] + highlightedIdSet: ReadonlySet + hoveredElevatorId: ElevatorNode['id'] | null + isDeleteMode: boolean + onElevatorHoverChange: (elevatorId: ElevatorNode['id'] | null) => void + onElevatorHoverEnter: (elevatorId: ElevatorNode['id']) => void + onElevatorSelect: (elevator: ElevatorNode, event: ReactMouseEvent) => void + palette: FloorplanPalette + selectedIdSet: ReadonlySet + wallSelectionHatchId: string +}) { + if (elevatorEntries.length === 0) { + return null + } + + return ( + + {elevatorEntries.map((entry) => { + const { elevator } = entry + const isSelected = selectedIdSet.has(elevator.id) + const isHighlighted = highlightedIdSet.has(elevator.id) + const isHovered = hoveredElevatorId === elevator.id + const isDeleteHovered = isDeleteMode && isHovered + const isActive = isSelected || isHighlighted + const showChrome = isActive || isHovered + const fill = isDeleteHovered + ? palette.deleteFill + : isActive + ? `url(#${wallSelectionHatchId})` + : '#dbeafe' + const fillOpacity = isDeleteHovered ? 0.38 : isActive ? 0.84 : isHovered ? 0.78 : 0.64 + const stroke = isDeleteHovered + ? palette.deleteStroke + : isActive + ? palette.selectedStroke + : isHovered + ? palette.wallHoverStroke + : '#2563eb' + const doorStroke = isDeleteHovered + ? palette.deleteStroke + : isActive + ? palette.selectedStroke + : '#0284c7' + const centerX = toSvgX(entry.center.x) + const centerY = toSvgY(entry.center.y) + const frontCenter = { + x: (entry.frontEdge.start.x + entry.frontEdge.end.x) / 2, + y: (entry.frontEdge.start.y + entry.frontEdge.end.y) / 2, + } + const doorIndicatorEnd = { + x: frontCenter.x + entry.frontNormal.x * 0.34, + y: frontCenter.y + entry.frontNormal.y * 0.34, + } + const showCarMarker = entry.isCarOnLevel || entry.isTargetLevel || entry.isQueuedLevel + const carFill = entry.isCarOnLevel ? '#22c55e' : '#ffffff' + const carStroke = entry.isCarOnLevel ? '#15803d' : '#0ea5e9' + + return ( + onElevatorHoverEnter(elevator.id) : undefined + } + onPointerLeave={canSelectElevators ? () => onElevatorHoverChange(null) : undefined} + > + {showChrome ? ( + + ) : null} + + + + {showCarMarker ? ( + + ) : null} + { + event.stopPropagation() + onElevatorSelect(elevator, event) + } + : undefined + } + points={entry.points} + pointerEvents={canSelectElevators ? 'all' : 'none'} + style={canSelectElevators ? { cursor: EDITOR_CURSOR } : undefined} + > + {elevator.name || 'Elevator'} + + + ) + })} + + ) +}) + // Renders an item's 2D floor-plan image (top-down view, object-fit:contain) // inside its footprint rectangle. Placed at the same scene position/rotation // as the polygon so it lines up exactly. @@ -7574,6 +7750,19 @@ export function FloorplanPanel() { walls, zones, } = useFloorplanSceneData({ buildingId, levelId }) + const elevators = useScene( + useShallow((state) => { + const building = currentBuildingId ? state.nodes[currentBuildingId] : null + if (!building || building.type !== 'building') { + return [] as ElevatorNode[] + } + + return building.children.flatMap((childId) => { + const node = state.nodes[childId] + return node?.type === 'elevator' && node.visible !== false ? [node] : [] + }) + }), + ) const buildingRotationDeg = (buildingRotationY * 180) / Math.PI const floorplanSceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG - buildingRotationDeg @@ -7631,6 +7820,7 @@ export function FloorplanPanel() { const [hoveredItemId, setHoveredItemId] = useState(null) const [hoveredSpawnId, setHoveredSpawnId] = useState(null) const [hoveredStairId, setHoveredStairId] = useState(null) + const [hoveredElevatorId, setHoveredElevatorId] = useState(null) const [hoveredZoneId, setHoveredZoneId] = useState(null) const [hoveredEndpointId, setHoveredEndpointId] = useState(null) const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState(null) @@ -7655,6 +7845,29 @@ export function FloorplanPanel() { const [rotationModifierPressed, setRotationModifierPressed] = useState(false) const [movingFloorplanNodeRevision, setMovingFloorplanNodeRevision] = useState(0) const movingFloorplanNodeRefreshFrameRef = useRef(null) + const elevatorIds = useMemo(() => elevators.map((elevator) => elevator.id), [elevators]) + const elevatorRuntimeKey = useInteractive( + useCallback( + (state) => + elevatorIds + .map((elevatorId) => { + const runtime = state.elevators[elevatorId] + if (!runtime) { + return `${elevatorId}:` + } + + return [ + elevatorId, + runtime.currentLevelId ?? '', + runtime.targetLevelId ?? '', + runtime.phase, + runtime.queue.join(','), + ].join(':') + }) + .join('|'), + [elevatorIds], + ), + ) const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState(null) const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0) const [isPanning, setIsPanning] = useState(false) @@ -8281,6 +8494,68 @@ export function FloorplanPanel() { ] }) }, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision]) + const floorplanElevatorEntries = useMemo(() => { + if (!levelNode) { + return [] + } + + const nodes = useScene.getState().nodes + const interactiveElevators = useInteractive.getState().elevators + + return elevators.flatMap((elevator) => { + const serviceLevelIds = resolveElevatorServiceLevelIds(elevator, nodes) + if (!serviceLevelIds.includes(levelNode.id)) { + return [] + } + + const live = useLiveTransforms.getState().get(elevator.id) + const position = live?.position ?? elevator.position + const rotation = live?.rotation ?? elevator.rotation + const center = { x: position[0], y: position[2] } + const halfWidth = Math.max(0.1, elevator.width / 2) + const halfDepth = Math.max(0.1, elevator.depth / 2) + const footprintCorners: Array = [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] + const polygon = footprintCorners.map(([localX, localY]) => { + const [offsetX, offsetY] = rotatePlanVector(localX, localY, rotation) + return { + x: center.x + offsetX, + y: center.y + offsetY, + } + }) + const frontStart = polygon[0] + const frontEnd = polygon[1] + if (!(frontStart && frontEnd)) { + return [] + } + const [frontNormalX, frontNormalY] = rotatePlanVector(0, -1, rotation) + const runtime = interactiveElevators[elevator.id] + + return [ + { + center, + elevator, + frontEdge: { + start: frontStart, + end: frontEnd, + }, + frontNormal: { + x: frontNormalX, + y: frontNormalY, + }, + isCarOnLevel: runtime?.currentLevelId === levelNode.id, + isQueuedLevel: runtime?.queue.includes(levelNode.id) ?? false, + isTargetLevel: runtime?.targetLevelId === levelNode.id, + points: formatPolygonPoints(polygon), + polygon, + }, + ] + }) + }, [elevatorRuntimeKey, elevators, levelNode, movingFloorplanNodeRevision]) const referenceFloorLevel = useMemo(() => { if (!(showReferenceFloor && levelNode)) { return null @@ -9114,6 +9389,7 @@ export function FloorplanPanel() { !movingFenceEndpoint && isFloorplanStructureContextActive) || isDeleteMode + const canSelectFloorplanElevators = canSelectFloorplanStairs const canSelectFloorplanSpawns = canSelectFloorplanStairs const canSelectFloorplanItems = (mode === 'select' && @@ -9788,6 +10064,7 @@ export function FloorplanPanel() { ...(visibleSitePolygon ? visibleSitePolygon.polygon : []), ...displayCeilingPolygons.flatMap((entry) => entry.polygon), ...displaySlabPolygons.flatMap((entry) => entry.polygon), + ...floorplanElevatorEntries.flatMap((entry) => entry.polygon), ...floorplanFenceEntries.flatMap((entry) => entry.centerline), ...floorplanItemEntries.flatMap((entry) => entry.polygon), ...floorplanRoofEntries.flatMap((entry) => @@ -9835,6 +10112,7 @@ export function FloorplanPanel() { }, [ displayCeilingPolygons, displaySlabPolygons, + floorplanElevatorEntries, floorplanFenceEntries, floorplanItemEntries, floorplanRoofEntries, @@ -13047,6 +13325,7 @@ export function FloorplanPanel() { columnPolygons: floorplanColumnEntries, displaySlabPolygons, displayWallPolygons, + floorplanElevatorEntries, floorplanItemEntries, floorplanOpeningHitTolerance, floorplanRoofEntries, @@ -13425,6 +13704,14 @@ export function FloorplanPanel() { [syncDeleteHoveredId], ) + const handleElevatorHoverChange = useCallback( + (elevatorId: ElevatorNode['id'] | null) => { + setHoveredElevatorId(elevatorId) + syncDeleteHoveredId(elevatorId) + }, + [syncDeleteHoveredId], + ) + const handleZoneHoverChange = useCallback( (zoneId: ZoneNodeType['id'] | null) => { setHoveredZoneId(zoneId) @@ -13440,11 +13727,13 @@ export function FloorplanPanel() { handleSlabHoverChange(null) handleCeilingHoverChange(null) handleStairHoverChange(null) + handleElevatorHoverChange(null) handleSpawnHoverChange(null) handleZoneHoverChange(null) handleItemHoverChange(itemId) }, [ + handleElevatorHoverChange, handleFenceHoverChange, handleItemHoverChange, handleOpeningHoverChange, @@ -13464,11 +13753,13 @@ export function FloorplanPanel() { handleSlabHoverChange(null) handleCeilingHoverChange(null) handleStairHoverChange(null) + handleElevatorHoverChange(null) handleSpawnHoverChange(null) handleZoneHoverChange(null) handleFenceHoverChange(fenceId) }, [ + handleElevatorHoverChange, handleFenceHoverChange, handleItemHoverChange, handleOpeningHoverChange, @@ -13489,10 +13780,12 @@ export function FloorplanPanel() { handleCeilingHoverChange(null) handleWallHoverChange(null) handleSpawnHoverChange(null) + handleElevatorHoverChange(null) handleZoneHoverChange(null) handleStairHoverChange(stairId) }, [ + handleElevatorHoverChange, handleFenceHoverChange, handleItemHoverChange, handleOpeningHoverChange, @@ -13513,11 +13806,39 @@ export function FloorplanPanel() { handleCeilingHoverChange(null) handleWallHoverChange(null) handleStairHoverChange(null) + handleElevatorHoverChange(null) handleZoneHoverChange(null) handleSpawnHoverChange(spawnId) }, [ handleCeilingHoverChange, + handleElevatorHoverChange, + handleFenceHoverChange, + handleItemHoverChange, + handleOpeningHoverChange, + handleSlabHoverChange, + handleSpawnHoverChange, + handleStairHoverChange, + handleWallHoverChange, + handleZoneHoverChange, + ], + ) + const handleFloorplanElevatorHoverEnter = useCallback( + (elevatorId: ElevatorNode['id']) => { + handleItemHoverChange(null) + handleFenceHoverChange(null) + handleOpeningHoverChange(null) + handleSlabHoverChange(null) + handleCeilingHoverChange(null) + handleWallHoverChange(null) + handleStairHoverChange(null) + handleSpawnHoverChange(null) + handleZoneHoverChange(null) + handleElevatorHoverChange(elevatorId) + }, + [ + handleCeilingHoverChange, + handleElevatorHoverChange, handleFenceHoverChange, handleItemHoverChange, handleOpeningHoverChange, @@ -13615,6 +13936,7 @@ export function FloorplanPanel() { | OpeningNode['id'] | SlabNode['id'] | CeilingNode['id'] + | ElevatorNode['id'] | SpawnNode['id'] | StairNode['id'] | ZoneNodeType['id'], @@ -13629,6 +13951,7 @@ export function FloorplanPanel() { node.type === 'ceiling' || node.type === 'door' || node.type === 'window' || + node.type === 'elevator' || node.type === 'item' || node.type === 'spawn' || node.type === 'stair' || @@ -13852,6 +14175,12 @@ export function FloorplanPanel() { }, [emitFloorplanNodeClick], ) + const handleElevatorSelect = useCallback( + (elevator: ElevatorNode, event: ReactMouseEvent) => { + emitFloorplanNodeClick(elevator.id, 'click', event) + }, + [emitFloorplanNodeClick], + ) const handleZoneLabelClick = useCallback( (zoneId: ZoneNodeType['id'], _event: ReactMouseEvent) => { const currentZoneId = useViewer.getState().selection.zoneId @@ -15648,6 +15977,7 @@ export function FloorplanPanel() { handleCeilingHoverChange(null) handleSpawnHoverChange(null) handleStairHoverChange(null) + handleElevatorHoverChange(null) handleZoneHoverChange(null) setHoveredEndpointId(null) setHoveredSiteHandleId(null) @@ -15661,6 +15991,7 @@ export function FloorplanPanel() { }, [ emitFloorplanWallLeave, handleCeilingHoverChange, + handleElevatorHoverChange, handleItemHoverChange, handleOpeningHoverChange, handleSlabHoverChange, @@ -15772,6 +16103,7 @@ export function FloorplanPanel() { handleSlabHoverChange(null) handleSpawnHoverChange(null) handleStairHoverChange(null) + handleElevatorHoverChange(null) handleZoneHoverChange(null) setHoveredEndpointId(null) floorplanMarqueeSnapPointRef.current = snappedPoint @@ -15788,6 +16120,7 @@ export function FloorplanPanel() { }, [ getPlanPointFromClientPoint, + handleElevatorHoverChange, handleItemHoverChange, handleOpeningHoverChange, handleSlabHoverChange, @@ -16528,6 +16861,20 @@ export function FloorplanPanel() { zonePolygons={visibleZonePolygons} /> + + + isPointInsidePolygon(context.point, polygon), + ) + if (elevatorHit) { + return elevatorHit.elevator.id + } + const columnHit = context.columns.find(({ polygon }) => isPointInsidePolygon(context.point, polygon), ) @@ -181,6 +195,7 @@ type FloorplanSelectionBoundsContext = { slabs: SlabEntry[] ceilings: CeilingEntry[] columns: ColumnEntry[] + elevators: ElevatorEntry[] stairs: StairEntry[] roofs: RoofEntry[] } @@ -195,6 +210,7 @@ export function getFloorplanSelectionIdsInBounds({ slabs, ceilings, columns, + elevators, stairs, roofs, }: FloorplanSelectionBoundsContext) { @@ -223,6 +239,9 @@ export function getFloorplanSelectionIdsInBounds({ const columnIds = columns .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)) .map(({ column }) => column.id) + const elevatorIds = elevators + .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)) + .map(({ elevator }) => elevator.id) const stairIds = stairs .filter((stair) => getStairHitPolygons(stair).some((polygon) => @@ -244,6 +263,7 @@ export function getFloorplanSelectionIdsInBounds({ ...slabIds, ...ceilingIds, ...columnIds, + ...elevatorIds, ...stairIds, ...roofIds, ]), From 909f96ef0954fd9604bdf6572b10232a8f8d5109 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sun, 10 May 2026 12:20:53 +0530 Subject: [PATCH 4/9] Improve elevator floorplan resize and drag controls --- packages/core/src/index.ts | 1 + packages/core/src/schema/index.ts | 7 +- packages/core/src/schema/nodes/elevator.ts | 21 +- .../src/systems/elevator/elevator-dispatch.ts | 90 +++ .../systems/elevator/elevator-opening-sync.ts | 11 +- .../editor-2d/floorplan-action-menu-layer.tsx | 3 + .../editor/first-person-controls.tsx | 199 +++-- .../src/components/editor/floorplan-panel.tsx | 738 ++++++++++++++++-- .../components/ui/panels/elevator-panel.tsx | 273 ++++++- .../renderers/elevator/elevator-renderer.tsx | 420 +++++++--- 10 files changed, 1531 insertions(+), 232 deletions(-) create mode 100644 packages/core/src/systems/elevator/elevator-dispatch.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 49f5063e1..b5996efd0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -85,6 +85,7 @@ export { } from './store/use-live-node-overrides' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { clearSceneHistory, default as useScene } from './store/use-scene' +export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync' export { resolveElevatorBuildingLevels, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 14c8db61c..aede4b6aa 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -42,7 +42,12 @@ export { ColumnSupportStyle, } from './nodes/column' export { DoorNode, DoorSegment } from './nodes/door' -export { ElevatorDoorStyle, ElevatorNode } from './nodes/elevator' +export { + ElevatorDoorPanelStyle, + ElevatorDoorStyle, + ElevatorNode, + ElevatorShaftStyle, +} from './nodes/elevator' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode, GuideScaleReference } from './nodes/guide' export type { diff --git a/packages/core/src/schema/nodes/elevator.ts b/packages/core/src/schema/nodes/elevator.ts index 01a142b4e..37d275729 100644 --- a/packages/core/src/schema/nodes/elevator.ts +++ b/packages/core/src/schema/nodes/elevator.ts @@ -4,8 +4,12 @@ import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' export const ElevatorDoorStyle = z.enum(['center-opening', 'single-left', 'single-right']) +export const ElevatorDoorPanelStyle = z.enum(['glass-frame', 'solid-panel', 'segmented-panel']) +export const ElevatorShaftStyle = z.enum(['solid', 'glass']) +export type ElevatorDoorPanelStyle = z.infer export type ElevatorDoorStyle = z.infer +export type ElevatorShaftStyle = z.infer export const ElevatorNode = BaseNode.extend({ id: objectId('elevator'), @@ -17,13 +21,20 @@ export const ElevatorNode = BaseNode.extend({ rotation: z.number().default(0), width: z.number().default(1.6), depth: z.number().default(1.6), + shaftWidth: z.number().optional(), + shaftDepth: z.number().optional(), + shaftWallThickness: z.number().default(0.09), + shaftStyle: ElevatorShaftStyle.default('solid'), cabHeight: z.number().default(2.35), doorWidth: z.number().default(0.95), doorHeight: z.number().default(2.1), doorStyle: ElevatorDoorStyle.default('center-opening'), + doorPanelStyle: ElevatorDoorPanelStyle.default('glass-frame'), fromLevelId: z.string().nullable().default(null), toLevelId: z.string().nullable().default(null), servedLevelIds: z.array(z.string()).optional(), + disabledLevelIds: z.array(z.string()).default([]), + serviceOnlyLevelIds: z.array(z.string()).default([]), defaultLevelId: z.string().nullable().default(null), speed: z.number().default(2.2), doorDurationMs: z.number().default(900), @@ -34,11 +45,17 @@ export const ElevatorNode = BaseNode.extend({ - parentId: building that owns this elevator - position: building-local shaft center on the X/Z plane - rotation: rotation around the Y axis - - width/depth: shaft and cab footprint + - width/depth: cab footprint + - shaftWidth/shaftDepth: optional clear shaft footprint; falls back to cab footprint + - shaftWallThickness: visible shaft shell thickness + - shaftStyle: solid or glass shaft shell presentation - cabHeight: visible elevator cab height - - doorWidth/doorHeight/doorStyle: landing and cab door presentation + - doorWidth/doorHeight/doorStyle: landing and cab door movement/opening presentation + - doorPanelStyle: visual leaf style for glass-frame, solid-panel, or segmented-panel doors - fromLevelId / toLevelId: source and destination levels used for service range and auto cutouts - servedLevelIds: legacy optional explicit level list; used only when from/to are missing + - disabledLevelIds: stops visible in the service range but unavailable for public/cab requests + - serviceOnlyLevelIds: stops unavailable from landing calls but available from cab/admin controls - defaultLevelId: starting/resting level, falling back to the lowest served level - speed/doorDurationMs/dwellMs: runtime animation defaults `, diff --git a/packages/core/src/systems/elevator/elevator-dispatch.ts b/packages/core/src/systems/elevator/elevator-dispatch.ts new file mode 100644 index 000000000..e3f0f46d4 --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-dispatch.ts @@ -0,0 +1,90 @@ +import type { AnyNode, AnyNodeId, ElevatorNode } from '../../schema' +import type { ElevatorInteractiveState } from '../../store/use-interactive' +import { resolveElevatorServiceLevels } from './elevator-service' + +type ElevatorRuntimeMap = Record + +type ResolveElevatorDispatchTargetArgs = { + elevators: ElevatorRuntimeMap + levelId: AnyNodeId + nodes: Record + requestedElevatorId: AnyNodeId +} + +function getRuntimeLevelId(runtime: ElevatorInteractiveState | undefined, elevator: ElevatorNode) { + return runtime?.currentLevelId ?? elevator.defaultLevelId ?? elevator.fromLevelId ?? null +} + +function scoreDispatchCandidate({ + elevator, + elevators, + levelId, + nodes, +}: { + elevator: ElevatorNode + elevators: ElevatorRuntimeMap + levelId: AnyNodeId + nodes: Record +}) { + if ((elevator.disabledLevelIds ?? []).includes(levelId)) return null + if ((elevator.serviceOnlyLevelIds ?? []).includes(levelId)) return null + + const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) + const targetIndex = serviceLevels.findIndex((level) => level.id === levelId) + if (targetIndex < 0) return null + + const runtime = elevators[elevator.id as AnyNodeId] + const runtimeLevelId = getRuntimeLevelId(runtime, elevator) + const currentIndex = runtimeLevelId + ? serviceLevels.findIndex((level) => level.id === runtimeLevelId) + : -1 + const resolvedCurrentIndex = currentIndex >= 0 ? currentIndex : 0 + const distance = Math.abs(targetIndex - resolvedCurrentIndex) + const queuePenalty = (runtime?.queue.length ?? 0) * 2 + (runtime?.targetLevelId ? 1 : 0) + const motionPenalty = runtime?.phase === 'moving' || runtime?.phase === 'closing' ? 0.5 : 0 + const openPenalty = runtime?.phase === 'open' || runtime?.phase === 'opening' ? 0.2 : 0 + + return distance + queuePenalty + motionPenalty + openPenalty +} + +export function resolveElevatorDispatchTarget({ + elevators, + levelId, + nodes, + requestedElevatorId, +}: ResolveElevatorDispatchTargetArgs): AnyNodeId { + const requestedElevator = nodes[requestedElevatorId] + if (!(requestedElevator?.type === 'elevator' && requestedElevator.parentId)) { + return requestedElevatorId + } + + const building = nodes[requestedElevator.parentId as AnyNodeId] + if (building?.type !== 'building') { + return requestedElevatorId + } + + let bestElevatorId: AnyNodeId | null = null + let bestScore = Number.POSITIVE_INFINITY + + for (const childId of building.children) { + const candidate = nodes[childId as AnyNodeId] + if (!(candidate?.type === 'elevator' && candidate.visible !== false)) continue + + const score = scoreDispatchCandidate({ + elevator: candidate, + elevators, + levelId, + nodes, + }) + if (score === null) continue + + const tieBreaker = candidate.id === requestedElevatorId ? -0.01 : 0 + const finalScore = score + tieBreaker + if (finalScore < bestScore) { + bestScore = finalScore + bestElevatorId = candidate.id as AnyNodeId + } + } + + return bestElevatorId ?? requestedElevatorId +} diff --git a/packages/core/src/systems/elevator/elevator-opening-sync.ts b/packages/core/src/systems/elevator/elevator-opening-sync.ts index b1ef1bd09..c02d1ee8e 100644 --- a/packages/core/src/systems/elevator/elevator-opening-sync.ts +++ b/packages/core/src/systems/elevator/elevator-opening-sync.ts @@ -11,6 +11,7 @@ type SurfaceHoleMetadata = { } const ELEVATOR_OPENING_PADDING = 0.08 +const DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS = 0.09 function pointsEqual(a: Point2D, b: Point2D, tolerance = 1e-5) { const dx = a[0] - b[0] @@ -141,8 +142,14 @@ function shouldApplyElevatorToCeiling( } function getElevatorOpeningPolygon(elevator: ElevatorNode): Point2D[] { - const halfWidth = Math.max(elevator.width, 0.8) / 2 + ELEVATOR_OPENING_PADDING - const halfDepth = Math.max(elevator.depth, 0.8) / 2 + ELEVATOR_OPENING_PADDING + const wallThickness = Math.max( + elevator.shaftWallThickness ?? DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS, + 0.04, + ) + const shaftWidth = Math.max(elevator.shaftWidth ?? elevator.width, elevator.width, 0.8) + const shaftDepth = Math.max(elevator.shaftDepth ?? elevator.depth, elevator.depth, 0.8) + const halfWidth = shaftWidth / 2 + wallThickness + ELEVATOR_OPENING_PADDING + const halfDepth = shaftDepth / 2 + wallThickness + ELEVATOR_OPENING_PADDING const corners: Point2D[] = [ [-halfWidth, -halfDepth], [halfWidth, -halfDepth], diff --git a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx index fefaf949a..5d2e64a5b 100644 --- a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx @@ -20,6 +20,7 @@ export type FloorplanActionMenuEntry = { } type FloorplanActionMenuLayerProps = { + elevator: FloorplanActionMenuEntry item: FloorplanActionMenuEntry wall: FloorplanActionMenuEntry fence: FloorplanActionMenuEntry @@ -33,6 +34,7 @@ type FloorplanActionMenuLayerProps = { } export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ + elevator, item, wall, fence, @@ -55,6 +57,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ } const entries: FloorplanActionMenuEntry[] = [ + elevator, item, wall, fence, diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 3b0bb1e59..a356699f4 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -7,6 +7,7 @@ import { type ElevatorNode, emitter, resolveElevatorBuildingLevels, + resolveElevatorDispatchTarget, resolveElevatorServiceLevels, sceneRegistry, useInteractive, @@ -129,9 +130,12 @@ type ElevatorColliderUserData = { levelId?: AnyNodeId localPosition: [number, number, number] matrixInitialized?: boolean - side?: 'left' | 'right' + side?: ElevatorDoorSide } +type ElevatorDoorSide = 'left' | 'right' +type ElevatorDoorStyleValue = ElevatorNode['doorStyle'] + type ElevatorColliderMesh = Mesh & { userData: Mesh['userData'] & ElevatorColliderUserData } @@ -164,6 +168,7 @@ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | n current.userData as { elevatorButton?: { action?: unknown + disabled?: unknown elevatorId?: unknown kind?: unknown levelId?: unknown @@ -171,6 +176,10 @@ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | n } ).elevatorButton + if (candidate?.disabled === true) { + return null + } + if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') { const action = candidate.action === 'open-door' ? 'open-door' : 'request-level' if (action === 'open-door') { @@ -208,9 +217,65 @@ function getInteractableTargetKey(target: FirstPersonInteractableTarget | null) : `${target.type}:${target.id}` } -function getElevatorDoorLeafX(side: 'left' | 'right', width: number, doorOpen: number) { - const direction = side === 'left' ? -1 : 1 - return direction * (width / 4 + doorOpen * width * 0.34) +function getResolvedElevatorDoorStyle( + doorStyle: ElevatorDoorStyleValue | undefined, +): ElevatorDoorStyleValue { + return doorStyle ?? 'center-opening' +} + +function getElevatorDoorLeafSides( + doorStyle: ElevatorDoorStyleValue | undefined, +): ElevatorDoorSide[] { + const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle) + if (resolvedDoorStyle === 'single-left') return ['left'] + if (resolvedDoorStyle === 'single-right') return ['right'] + return ['left', 'right'] +} + +function getElevatorDoorLeafWidth(width: number, doorStyle: ElevatorDoorStyleValue | undefined) { + return getResolvedElevatorDoorStyle(doorStyle) === 'center-opening' + ? Math.max(width / 2 - 0.018, 0.12) + : Math.max(width - 0.018, 0.18) +} + +function getElevatorDoorLeafX( + side: ElevatorDoorSide, + width: number, + doorOpen: number, + doorStyle: ElevatorDoorStyleValue | undefined, +) { + const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle) + if (resolvedDoorStyle === 'center-opening') { + const direction = side === 'left' ? -1 : 1 + return direction * (width / 4 + doorOpen * width * 0.34) + } + + const direction = resolvedDoorStyle === 'single-left' ? -1 : 1 + return direction * doorOpen * width * 0.68 +} + +function getElevatorCabWidth(elevator: ElevatorNode) { + return Math.max(elevator.width, 0.8) +} + +function getElevatorCabDepth(elevator: ElevatorNode) { + return Math.max(elevator.depth, 0.8) +} + +function getElevatorShaftWallThickness(elevator: ElevatorNode) { + return Math.max(elevator.shaftWallThickness ?? ELEVATOR_COLLIDER_WALL_THICKNESS, 0.04) +} + +function getElevatorShaftWidth(elevator: ElevatorNode, cabWidth = getElevatorCabWidth(elevator)) { + return Math.max(elevator.shaftWidth ?? cabWidth, cabWidth, 0.8) +} + +function getElevatorShaftDepth(elevator: ElevatorNode, cabDepth = getElevatorCabDepth(elevator)) { + return Math.max(elevator.shaftDepth ?? cabDepth, cabDepth, 0.8) +} + +function getElevatorCabCenterZ(elevator: ElevatorNode) { + return -getElevatorShaftDepth(elevator) / 2 + getElevatorCabDepth(elevator) / 2 } function isDynamicElevatorCollider(kind: ElevatorColliderKind) { @@ -222,13 +287,14 @@ function isInsideElevatorCab( runtime: NonNullable['elevators'][AnyNodeId]>, localEyePosition: Vector3, ) { - const halfWidth = Math.max(elevator.width, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING - const halfDepth = Math.max(elevator.depth, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const halfWidth = getElevatorCabWidth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const halfDepth = getElevatorCabDepth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const cabCenterZ = getElevatorCabCenterZ(elevator) const cabHeight = Math.max(elevator.cabHeight, 1.4) return ( Math.abs(localEyePosition.x) <= Math.max(halfWidth, 0.24) && - Math.abs(localEyePosition.z) <= Math.max(halfDepth, 0.24) && + Math.abs(localEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) && localEyePosition.y >= runtime.carY + 0.35 && localEyePosition.y <= runtime.carY + cabHeight + 0.7 ) @@ -347,18 +413,23 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { node, nodes, ) - const shaftWidth = Math.max(node.width, 0.8) - const shaftDepth = Math.max(node.depth, 0.8) + const cabWidth = getElevatorCabWidth(node) + const cabDepth = getElevatorCabDepth(node) + const shaftWidth = getElevatorShaftWidth(node, cabWidth) + const shaftDepth = getElevatorShaftDepth(node, cabDepth) const cabHeight = Math.max(node.cabHeight, 1.4) - const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), shaftWidth - 0.18) + const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18) const doorHeight = Math.min(Math.max(node.doorHeight, 1.2), cabHeight - 0.1) + const doorStyle = getResolvedElevatorDoorStyle(node.doorStyle) const shaftHeight = Math.max(totalHeight, cabHeight + 0.3) - const wallThickness = ELEVATOR_COLLIDER_WALL_THICKNESS - const cabFloorWidth = Math.max(shaftWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) - const cabFloorDepth = Math.max(shaftDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) + const wallThickness = getElevatorShaftWallThickness(node) + const cabFloorWidth = Math.max(cabWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) + const cabFloorDepth = Math.max(cabDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) const frontWallZ = -shaftDepth / 2 - wallThickness / 2 const frontZ = frontWallZ - wallThickness / 2 - 0.018 - const leafWidth = Math.max(doorWidth / 2 - 0.018, 0.12) + const cabCenterZ = -shaftDepth / 2 + cabDepth / 2 + const leafWidth = getElevatorDoorLeafWidth(doorWidth, doorStyle) + const doorLeafSides = getElevatorDoorLeafSides(doorStyle) const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight) meshes.push( @@ -390,38 +461,31 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { typedElevatorId, 'cab-floor', [cabFloorWidth, ELEVATOR_COLLIDER_FLOOR_THICKNESS, cabFloorDepth], - [0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, 0], + [0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, cabCenterZ], ), createElevatorColliderMesh( typedElevatorId, 'cab-ceiling', - [shaftWidth, wallThickness, shaftDepth], - [0, cabHeight - wallThickness / 2, 0], + [cabWidth, wallThickness, cabDepth], + [0, cabHeight - wallThickness / 2, cabCenterZ], ), createElevatorColliderMesh( typedElevatorId, 'cab-back', - [shaftWidth, cabHeight, wallThickness], - [0, cabHeight / 2, shaftDepth / 2 - wallThickness / 2], + [cabWidth, cabHeight, wallThickness], + [0, cabHeight / 2, cabCenterZ + cabDepth / 2 - wallThickness / 2], ), createElevatorColliderMesh( typedElevatorId, 'cab-left', - [wallThickness, cabHeight, shaftDepth], - [-shaftWidth / 2 + wallThickness / 2, cabHeight / 2, 0], + [wallThickness, cabHeight, cabDepth], + [-cabWidth / 2 + wallThickness / 2, cabHeight / 2, cabCenterZ], ), createElevatorColliderMesh( typedElevatorId, 'cab-right', - [wallThickness, cabHeight, shaftDepth], - [shaftWidth / 2 - wallThickness / 2, cabHeight / 2, 0], - ), - createElevatorColliderMesh( - typedElevatorId, - 'cab-door-left', - [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], - [0, doorHeight / 2, frontZ], - { doorWidth, side: 'left' }, + [wallThickness, cabHeight, cabDepth], + [cabWidth / 2 - wallThickness / 2, cabHeight / 2, cabCenterZ], ), createElevatorColliderMesh( typedElevatorId, @@ -430,12 +494,14 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { [0, doorHeight / 2, frontZ], { doorWidth }, ), - createElevatorColliderMesh( - typedElevatorId, - 'cab-door-right', - [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], - [0, doorHeight / 2, frontZ], - { doorWidth, side: 'right' }, + ...doorLeafSides.map((side) => + createElevatorColliderMesh( + typedElevatorId, + side === 'left' ? 'cab-door-left' : 'cab-door-right', + [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, doorHeight / 2, frontZ], + { doorWidth, side }, + ), ), ) @@ -473,13 +539,6 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { [shaftWidth, headerHeight, wallDepth], [0, entry.baseY + doorHeight + headerHeight / 2, frontWallZ], ), - createElevatorColliderMesh( - typedElevatorId, - 'landing-door-left', - [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], - [0, entry.baseY + doorHeight / 2, frontZ - 0.02], - { doorWidth, levelId: entry.id, side: 'left' }, - ), createElevatorColliderMesh( typedElevatorId, 'landing-door-gate', @@ -487,12 +546,14 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { [0, entry.baseY + doorHeight / 2, frontZ - 0.02], { doorWidth, levelId: entry.id }, ), - createElevatorColliderMesh( - typedElevatorId, - 'landing-door-right', - [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], - [0, entry.baseY + doorHeight / 2, frontZ - 0.02], - { doorWidth, levelId: entry.id, side: 'right' }, + ...doorLeafSides.map((side) => + createElevatorColliderMesh( + typedElevatorId, + side === 'left' ? 'landing-door-left' : 'landing-door-right', + [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, entry.baseY + doorHeight / 2, frontZ - 0.02], + { doorWidth, levelId: entry.id, side }, + ), ), ) } @@ -778,7 +839,18 @@ export const FirstPersonControls = () => { useInteractive.getState().openElevatorDoor(target.id) return } - if (target.levelId) useInteractive.getState().requestElevator(target.id, target.levelId) + if (target.levelId) { + const targetElevatorId = + target.buttonKind === 'landing' + ? resolveElevatorDispatchTarget({ + elevators: useInteractive.getState().elevators, + levelId: target.levelId, + nodes: useScene.getState().nodes, + requestedElevatorId: target.id, + }) + : target.id + useInteractive.getState().requestElevator(targetElevatorId, target.levelId) + } return } @@ -1004,7 +1076,12 @@ export const FirstPersonControls = () => { mesh.visible = false continue } - localX = getElevatorDoorLeafX(side ?? 'left', doorWidth ?? node.doorWidth, runtime.doorOpen) + localX = getElevatorDoorLeafX( + side ?? 'left', + doorWidth ?? node.doorWidth, + runtime.doorOpen, + node.doorStyle, + ) mesh.visible = true } else if (isCabDoorGate) { if (!runtime) { @@ -1014,7 +1091,12 @@ export const FirstPersonControls = () => { mesh.visible = runtime.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD } else if (isDoorCollider) { const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0 - localX = getElevatorDoorLeafX(side ?? 'left', doorWidth ?? node.doorWidth, doorOpen) + localX = getElevatorDoorLeafX( + side ?? 'left', + doorWidth ?? node.doorWidth, + doorOpen, + node.doorStyle, + ) mesh.visible = true } else if (isLandingDoorGate) { const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0 @@ -1045,6 +1127,7 @@ export const FirstPersonControls = () => { const activeRide = ridingElevatorRef.current let nextRide: { cabHeight: number + cabCenterZ: number carY: number doorOpen: number elevatorId: AnyNodeId @@ -1076,12 +1159,13 @@ export const FirstPersonControls = () => { elevatorLocalEyePosition.copy(camera.position) object.worldToLocal(elevatorLocalEyePosition) - const halfWidth = Math.max(node.width, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING - const halfDepth = Math.max(node.depth, 0.8) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const halfWidth = getElevatorCabWidth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const halfDepth = getElevatorCabDepth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const cabCenterZ = getElevatorCabCenterZ(node) const cabHeight = Math.max(node.cabHeight, 1.4) const insideFootprint = Math.abs(elevatorLocalEyePosition.x) <= Math.max(halfWidth, 0.24) && - Math.abs(elevatorLocalEyePosition.z) <= Math.max(halfDepth, 0.24) + Math.abs(elevatorLocalEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) const insideCabHeight = elevatorLocalEyePosition.y >= runtime.carY + 0.35 && elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7 @@ -1094,6 +1178,7 @@ export const FirstPersonControls = () => { if ((insideFootprint && insideCabHeight) || continuingRide) { nextRide = { cabHeight, + cabCenterZ, carY: runtime.carY, doorOpen: runtime.doorOpen, elevatorId: typedElevatorId, @@ -1151,8 +1236,8 @@ export const FirstPersonControls = () => { Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x), ) const clampedZ = Math.max( - -nextRide.halfDepth, - Math.min(nextRide.halfDepth, elevatorLocalControllerPosition.z), + nextRide.cabCenterZ - nextRide.halfDepth, + Math.min(nextRide.cabCenterZ + nextRide.halfDepth, elevatorLocalControllerPosition.z), ) if ( diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 3ba8d9e0e..8c04eb728 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -40,6 +40,7 @@ import { sampleWallCenterline, sceneRegistry, useInteractive, + useLiveNodeOverrides, useLiveTransforms, useScene, type WallNode, @@ -346,6 +347,21 @@ type PendingFenceDragState = { startClientY: number } +type ElevatorResizeHandle = + | 'width-negative' + | 'width-positive' + | 'depth-negative' + | 'depth-positive' + +type ElevatorResizeDragState = { + center: Point2D + elevatorId: ElevatorNode['id'] + handle: ElevatorResizeHandle + pointerId: number + rotation: number + shaftWallThickness: number +} + const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const type GuideCorner = (typeof GUIDE_CORNERS)[number] @@ -634,16 +650,38 @@ type FloorplanColumnEntry = { polygon: Point2D[] } +type FloorplanElevatorServedLevel = { + id: LevelNode['id'] + isCurrent: boolean + isDisabled: boolean + isQueued: boolean + isServiceOnly: boolean + isTarget: boolean + label: string +} + type FloorplanElevatorEntry = { + cabCenterLocalY: number + cabDepth: number + cabWidth: number center: Point2D + doorStyle: ElevatorNode['doorStyle'] + doorWidth: number elevator: ElevatorNode frontEdge: FloorplanLineSegment frontNormal: Point2D isCarOnLevel: boolean isQueuedLevel: boolean isTargetLevel: boolean + outerHalfDepth: number + outerHalfWidth: number points: string polygon: Point2D[] + rotation: number + servedLevels: FloorplanElevatorServedLevel[] + shaftDepth: number + shaftWallThickness: number + shaftWidth: number } type ReferenceFloorData = { @@ -822,6 +860,18 @@ function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max) } +function roundPlanMeters(value: number) { + return Math.round(value * 100) / 100 +} + +function getElevatorResizeAxis(handle: ElevatorResizeHandle) { + return handle.startsWith('width') ? 'width' : 'depth' +} + +function getElevatorResizeSign(handle: ElevatorResizeHandle) { + return handle.endsWith('positive') ? 1 : -1 +} + function getSelectionModifierKeys(event?: { metaKey?: boolean; ctrlKey?: boolean }) { return { meta: Boolean(event?.metaKey), @@ -6029,6 +6079,10 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({ isDeleteMode, onElevatorHoverChange, onElevatorHoverEnter, + onElevatorPointerDown, + onElevatorResizePointerDown, + onElevatorResizePointerMove, + onElevatorResizePointerUp, onElevatorSelect, palette, selectedIdSet, @@ -6041,6 +6095,17 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({ isDeleteMode: boolean onElevatorHoverChange: (elevatorId: ElevatorNode['id'] | null) => void onElevatorHoverEnter: (elevatorId: ElevatorNode['id']) => void + onElevatorPointerDown: ( + elevatorId: ElevatorNode['id'], + event: ReactPointerEvent, + ) => void + onElevatorResizePointerDown: ( + entry: FloorplanElevatorEntry, + handle: ElevatorResizeHandle, + event: ReactPointerEvent, + ) => void + onElevatorResizePointerMove: (event: ReactPointerEvent) => void + onElevatorResizePointerUp: (event: ReactPointerEvent) => void onElevatorSelect: (elevator: ElevatorNode, event: ReactMouseEvent) => void palette: FloorplanPalette selectedIdSet: ReadonlySet @@ -6060,37 +6125,100 @@ const FloorplanElevatorLayer = memo(function FloorplanElevatorLayer({ const isDeleteHovered = isDeleteMode && isHovered const isActive = isSelected || isHighlighted const showChrome = isActive || isHovered - const fill = isDeleteHovered + const isGlassShaft = elevator.shaftStyle === 'glass' + const shaftShellFill = isDeleteHovered ? palette.deleteFill : isActive ? `url(#${wallSelectionHatchId})` - : '#dbeafe' - const fillOpacity = isDeleteHovered ? 0.38 : isActive ? 0.84 : isHovered ? 0.78 : 0.64 + : isGlassShaft + ? '#dff6ff' + : '#e5e7eb' + const shaftClearFill = isGlassShaft ? '#ecfeff' : '#f8fafc' + const shaftShellOpacity = isDeleteHovered ? 0.38 : isActive ? 0.9 : isHovered ? 0.86 : 0.76 const stroke = isDeleteHovered ? palette.deleteStroke : isActive ? palette.selectedStroke : isHovered ? palette.wallHoverStroke - : '#2563eb' + : isGlassShaft + ? '#0891b2' + : '#475569' const doorStroke = isDeleteHovered ? palette.deleteStroke : isActive ? palette.selectedStroke - : '#0284c7' + : '#0369a1' const centerX = toSvgX(entry.center.x) const centerY = toSvgY(entry.center.y) - const frontCenter = { - x: (entry.frontEdge.start.x + entry.frontEdge.end.x) / 2, - y: (entry.frontEdge.start.y + entry.frontEdge.end.y) / 2, - } - const doorIndicatorEnd = { - x: frontCenter.x + entry.frontNormal.x * 0.34, - y: frontCenter.y + entry.frontNormal.y * 0.34, - } + const rotationDeg = (-entry.rotation * 180) / Math.PI + const shaftWidth = entry.outerHalfWidth * 2 + const shaftDepth = entry.outerHalfDepth * 2 + const shaftClearX = -entry.shaftWidth / 2 + const shaftClearY = -entry.shaftDepth / 2 + const cabX = -entry.cabWidth / 2 + const cabY = entry.cabCenterLocalY - entry.cabDepth / 2 + const frontLocalY = -entry.outerHalfDepth + const doorHalfWidth = entry.doorWidth / 2 + const doorTrackY = frontLocalY - 0.075 + const callStationX = Math.min(entry.outerHalfWidth - 0.12, doorHalfWidth + 0.2) + const callStationY = frontLocalY - 0.16 + const cabFill = entry.isCarOnLevel + ? '#dcfce7' + : entry.isTargetLevel || entry.isQueuedLevel + ? '#e0f2fe' + : '#f8fafc' + const cabStroke = entry.isCarOnLevel + ? '#16a34a' + : entry.isTargetLevel || entry.isQueuedLevel + ? '#0ea5e9' + : '#64748b' const showCarMarker = entry.isCarOnLevel || entry.isTargetLevel || entry.isQueuedLevel const carFill = entry.isCarOnLevel ? '#22c55e' : '#ffffff' const carStroke = entry.isCarOnLevel ? '#15803d' : '#0ea5e9' + const resizeHandles = [ + { + cursor: 'ew-resize', + handle: 'width-negative' as const, + localX: -entry.outerHalfWidth, + localY: 0, + }, + { + cursor: 'ew-resize', + handle: 'width-positive' as const, + localX: entry.outerHalfWidth, + localY: 0, + }, + { + cursor: 'ns-resize', + handle: 'depth-negative' as const, + localX: 0, + localY: -entry.outerHalfDepth, + }, + { + cursor: 'ns-resize', + handle: 'depth-positive' as const, + localX: 0, + localY: entry.outerHalfDepth, + }, + ].map((handle) => { + const [offsetX, offsetY] = rotatePlanVector(handle.localX, handle.localY, entry.rotation) + return { + ...handle, + x: entry.center.x + offsetX, + y: entry.center.y + offsetY, + } + }) + const rangeStep = 0.18 + const rangeHeight = Math.max(0, (entry.servedLevels.length - 1) * rangeStep) + const [rangeOffsetX, rangeOffsetY] = rotatePlanVector( + entry.outerHalfWidth + 0.38, + 0, + entry.rotation, + ) + const rangeX = entry.center.x + rangeOffsetX + const rangeTopY = entry.center.y + rangeOffsetY - rangeHeight / 2 + const rangeBottomY = entry.center.y + rangeOffsetY + rangeHeight / 2 return ( ) : null} - - - - {showCarMarker ? ( + transform={`translate(${centerX} ${centerY}) rotate(${rotationDeg})`} + > + + + + + + + + {entry.doorStyle === 'center-opening' ? ( + <> + + + + ) : ( + + )} + - ) : null} + {showCarMarker ? ( + + ) : null} + { + if (event.button === 0) { + onElevatorPointerDown(elevator.id, event) + } + } + : undefined + } points={entry.points} pointerEvents={canSelectElevators ? 'all' : 'none'} style={canSelectElevators ? { cursor: EDITOR_CURSOR } : undefined} > {elevator.name || 'Elevator'} + {isSelected && entry.servedLevels.length > 1 ? ( + + + {entry.servedLevels.map((level, index) => { + const y = rangeBottomY - index * rangeStep + const isUnavailable = level.isDisabled || level.isServiceOnly + const markerFill = level.isCurrent + ? '#22c55e' + : level.isTarget || level.isQueued + ? '#38bdf8' + : isUnavailable + ? '#94a3b8' + : '#ffffff' + const markerStroke = isUnavailable ? '#64748b' : '#0369a1' + + return ( + + + + {index + 1} + + + ) + })} + + ) : null} + {isSelected && canSelectElevators && !isDeleteMode + ? resizeHandles.map((handle) => ( + + onElevatorResizePointerDown(entry, handle.handle, event) + } + onPointerMove={onElevatorResizePointerMove} + onPointerUp={onElevatorResizePointerUp} + r={0.075} + stroke="#0284c7" + strokeWidth="1.7" + style={{ cursor: handle.cursor }} + vectorEffect="non-scaling-stroke" + /> + )) + : null} ) })} @@ -7821,6 +8133,8 @@ export function FloorplanPanel() { const [hoveredSpawnId, setHoveredSpawnId] = useState(null) const [hoveredStairId, setHoveredStairId] = useState(null) const [hoveredElevatorId, setHoveredElevatorId] = useState(null) + const [elevatorResizeDragState, setElevatorResizeDragState] = + useState(null) const [hoveredZoneId, setHoveredZoneId] = useState(null) const [hoveredEndpointId, setHoveredEndpointId] = useState(null) const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState(null) @@ -7868,6 +8182,29 @@ export function FloorplanPanel() { [elevatorIds], ), ) + const elevatorLiveOverrideKey = useLiveNodeOverrides( + useCallback( + (state) => + elevatorIds + .map((elevatorId) => { + const overrides = state.overrides.get(elevatorId) + if (!overrides) { + return `${elevatorId}:` + } + + return [ + elevatorId, + overrides.width ?? '', + overrides.depth ?? '', + overrides.shaftWidth ?? '', + overrides.shaftDepth ?? '', + overrides.shaftWallThickness ?? '', + ].join(':') + }) + .join('|'), + [elevatorIds], + ), + ) const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState(null) const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0) const [isPanning, setIsPanning] = useState(false) @@ -8495,6 +8832,11 @@ export function FloorplanPanel() { }) }, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision]) const floorplanElevatorEntries = useMemo(() => { + // These keys subscribe the memo to imperative floorplan stores read with getState(). + void elevatorLiveOverrideKey + void elevatorRuntimeKey + void movingFloorplanNodeRevision + if (!levelNode) { return [] } @@ -8503,17 +8845,39 @@ export function FloorplanPanel() { const interactiveElevators = useInteractive.getState().elevators return elevators.flatMap((elevator) => { - const serviceLevelIds = resolveElevatorServiceLevelIds(elevator, nodes) + const liveOverrides = useLiveNodeOverrides.getState().get(elevator.id) + const displayElevator = liveOverrides + ? ({ ...elevator, ...liveOverrides } as ElevatorNode) + : elevator + const serviceLevelIds = resolveElevatorServiceLevelIds(displayElevator, nodes) if (!serviceLevelIds.includes(levelNode.id)) { return [] } - const live = useLiveTransforms.getState().get(elevator.id) - const position = live?.position ?? elevator.position - const rotation = live?.rotation ?? elevator.rotation + const live = useLiveTransforms.getState().get(displayElevator.id) + const position = live?.position ?? displayElevator.position + const rotation = live?.rotation ?? displayElevator.rotation const center = { x: position[0], y: position[2] } - const halfWidth = Math.max(0.1, elevator.width / 2) - const halfDepth = Math.max(0.1, elevator.depth / 2) + const wallThickness = Math.max(displayElevator.shaftWallThickness ?? 0.09, 0.04) + const cabWidth = Math.max(displayElevator.width, 0.8) + const cabDepth = Math.max(displayElevator.depth, 0.8) + const shaftWidth = Math.max( + displayElevator.shaftWidth ?? displayElevator.width, + cabWidth, + 0.8, + ) + const shaftDepth = Math.max( + displayElevator.shaftDepth ?? displayElevator.depth, + cabDepth, + 0.8, + ) + const doorWidth = Math.min( + Math.max(displayElevator.doorWidth, 0.45), + cabWidth - 0.18, + shaftWidth - 0.18, + ) + const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness) + const halfDepth = Math.max(0.1, shaftDepth / 2 + wallThickness) const footprintCorners: Array = [ [-halfWidth, -halfDepth], [halfWidth, -halfDepth], @@ -8533,12 +8897,37 @@ export function FloorplanPanel() { return [] } const [frontNormalX, frontNormalY] = rotatePlanVector(0, -1, rotation) - const runtime = interactiveElevators[elevator.id] + const runtime = interactiveElevators[displayElevator.id] + const disabledLevelIds = new Set(displayElevator.disabledLevelIds ?? []) + const serviceOnlyLevelIds = new Set(displayElevator.serviceOnlyLevelIds ?? []) + const servedLevels = serviceLevelIds.flatMap((levelId) => { + const level = nodes[levelId as AnyNodeId] + if (level?.type !== 'level') { + return [] + } + + return [ + { + id: level.id, + isCurrent: runtime?.currentLevelId === level.id, + isDisabled: disabledLevelIds.has(level.id), + isQueued: runtime?.queue.includes(level.id) ?? false, + isServiceOnly: serviceOnlyLevelIds.has(level.id), + isTarget: runtime?.targetLevelId === level.id, + label: level.name || `L${level.level}`, + }, + ] + }) return [ { + cabCenterLocalY: -shaftDepth / 2 + cabDepth / 2, + cabDepth, + cabWidth, center, - elevator, + doorStyle: displayElevator.doorStyle ?? 'center-opening', + doorWidth, + elevator: displayElevator, frontEdge: { start: frontStart, end: frontEnd, @@ -8550,12 +8939,25 @@ export function FloorplanPanel() { isCarOnLevel: runtime?.currentLevelId === levelNode.id, isQueuedLevel: runtime?.queue.includes(levelNode.id) ?? false, isTargetLevel: runtime?.targetLevelId === levelNode.id, + outerHalfDepth: halfDepth, + outerHalfWidth: halfWidth, points: formatPolygonPoints(polygon), polygon, + rotation, + servedLevels, + shaftDepth, + shaftWallThickness: wallThickness, + shaftWidth, }, ] }) - }, [elevatorRuntimeKey, elevators, levelNode, movingFloorplanNodeRevision]) + }, [ + elevatorLiveOverrideKey, + elevatorRuntimeKey, + elevators, + levelNode, + movingFloorplanNodeRevision, + ]) const referenceFloorLevel = useMemo(() => { if (!(showReferenceFloor && levelNode)) { return null @@ -8915,6 +9317,13 @@ export function FloorplanPanel() { return floorplanSpawnEntries.find(({ spawn }) => spawn.id === selectedIds[0]) ?? null }, [floorplanSpawnEntries, selectedIds]) + const selectedElevatorEntry = useMemo(() => { + if (selectedIds.length !== 1) { + return null + } + + return floorplanElevatorEntries.find(({ elevator }) => elevator.id === selectedIds[0]) ?? null + }, [floorplanElevatorEntries, selectedIds]) const selectedItemClearanceMeasurements = useMemo(() => { if (!selectedItemEntry) { return [] as LinearMeasurementOverlay[] @@ -9249,6 +9658,7 @@ export function FloorplanPanel() { const isFenceMoveActive = movingNode?.type === 'fence' const isWallMoveActive = movingNode?.type === 'wall' const isSpawnMoveActive = movingNode?.type === 'spawn' + const isElevatorMoveActive = movingNode?.type === 'elevator' const isWallCurveActive = curvingWall?.type === 'wall' const isFenceCurveActive = curvingFence?.type === 'fence' const isFenceEndpointMoveActive = movingFenceEndpoint !== null @@ -9268,6 +9678,7 @@ export function FloorplanPanel() { isFenceMoveActive || isWallMoveActive || isSpawnMoveActive || + isElevatorMoveActive || isWallCurveActive || isFenceCurveActive || isFenceEndpointMoveActive || @@ -10328,6 +10739,18 @@ export function FloorplanPanel() { floorplanSceneRotationDeg, ) }, [floorplanSceneRotationDeg, selectedSpawnEntry, surfaceSize, viewBox]) + const selectedElevatorActionMenuPosition = useMemo( + () => + selectedElevatorEntry + ? getFloorplanActionMenuPosition( + selectedElevatorEntry.polygon, + viewBox, + surfaceSize, + floorplanSceneRotationDeg, + ) + : null, + [floorplanSceneRotationDeg, selectedElevatorEntry, surfaceSize, viewBox], + ) const selectedSlabActionMenuPosition = useMemo(() => { if (slabHoleMoveDraft) { return null @@ -10899,6 +11322,115 @@ export function FloorplanPanel() { }, [getSvgPointFromClientPoint, buildingRotationY], ) + + const previewElevatorResize = useCallback( + (dragState: ElevatorResizeDragState, planPoint: WallPlanPoint) => { + const localDeltaX = planPoint[0] - dragState.center.x + const localDeltaY = planPoint[1] - dragState.center.y + const [localX, localY] = rotatePlanVector(localDeltaX, localDeltaY, -dragState.rotation) + const axis = getElevatorResizeAxis(dragState.handle) + const sign = getElevatorResizeSign(dragState.handle) + const localDistance = sign * (axis === 'width' ? localX : localY) + const nextOuterSize = Math.max(0.1, localDistance) * 2 + + if (axis === 'width') { + const nextShaftWidth = roundPlanMeters( + Math.max(0.8, nextOuterSize - dragState.shaftWallThickness * 2), + ) + const nextCabWidth = nextShaftWidth + useLiveNodeOverrides + .getState() + .set(dragState.elevatorId, { shaftWidth: nextShaftWidth, width: nextCabWidth }) + setCursorPoint(planPoint) + return { shaftWidth: nextShaftWidth, width: nextCabWidth } satisfies Partial + } + + const nextShaftDepth = roundPlanMeters( + Math.max(0.8, nextOuterSize - dragState.shaftWallThickness * 2), + ) + const nextCabDepth = nextShaftDepth + useLiveNodeOverrides + .getState() + .set(dragState.elevatorId, { depth: nextCabDepth, shaftDepth: nextShaftDepth }) + setCursorPoint(planPoint) + return { depth: nextCabDepth, shaftDepth: nextShaftDepth } satisfies Partial + }, + [], + ) + + const handleElevatorResizePointerDown = useCallback( + ( + entry: FloorplanElevatorEntry, + handle: ElevatorResizeHandle, + event: ReactPointerEvent, + ) => { + if (event.button !== 0 || mode !== 'select') { + return + } + + event.preventDefault() + event.stopPropagation() + event.currentTarget.setPointerCapture(event.pointerId) + setHoveredElevatorId(null) + setSelection({ selectedIds: [entry.elevator.id] }) + + setElevatorResizeDragState({ + center: entry.center, + elevatorId: entry.elevator.id, + handle, + pointerId: event.pointerId, + rotation: entry.rotation, + shaftWallThickness: entry.shaftWallThickness, + }) + }, + [mode, setSelection], + ) + + const handleElevatorResizePointerMove = useCallback( + (event: ReactPointerEvent) => { + const dragState = elevatorResizeDragState + if (!dragState || dragState.pointerId !== event.pointerId) { + return + } + + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + if (!planPoint) { + return + } + + event.preventDefault() + event.stopPropagation() + previewElevatorResize(dragState, planPoint) + }, + [elevatorResizeDragState, getPlanPointFromClientPoint, previewElevatorResize], + ) + + const handleElevatorResizePointerUp = useCallback( + (event: ReactPointerEvent) => { + const dragState = elevatorResizeDragState + if (!dragState || dragState.pointerId !== event.pointerId) { + return + } + + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + const updates = planPoint ? previewElevatorResize(dragState, planPoint) : {} + + event.preventDefault() + event.stopPropagation() + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + + useLiveNodeOverrides.getState().clear(dragState.elevatorId) + if (Object.keys(updates).length > 0) { + updateNode(dragState.elevatorId as AnyNodeId, updates) + } + setElevatorResizeDragState(null) + setCursorPoint(null) + }, + [elevatorResizeDragState, getPlanPointFromClientPoint, previewElevatorResize, updateNode], + ) + useEffect(() => { siteBoundaryDraftRef.current = siteBoundaryDraft }, [siteBoundaryDraft]) @@ -12916,6 +13448,10 @@ export function FloorplanPanel() { return } + if (elevatorResizeDragState?.pointerId === event.pointerId) { + return + } + if (wallEndpointDragRef.current?.pointerId === event.pointerId) { return } @@ -13152,6 +13688,7 @@ export function FloorplanPanel() { ceilingHoleMoveDraft, ceilingHoleVertexDragState, ceilingVertexDragState, + elevatorResizeDragState, siteVertexDragState, slabHoleMoveDraft, slabHoleVertexDragState, @@ -14181,6 +14718,36 @@ export function FloorplanPanel() { }, [emitFloorplanNodeClick], ) + const handleElevatorPointerDown = useCallback( + (elevatorId: ElevatorNode['id'], event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + const elevator = selectedElevatorEntry?.elevator + if (!elevator || elevator.id !== elevatorId) { + return + } + + event.preventDefault() + event.stopPropagation() + + const suppressClick = (clickEvent: MouseEvent) => { + clickEvent.stopImmediatePropagation() + clickEvent.preventDefault() + window.removeEventListener('click', suppressClick, true) + } + window.addEventListener('click', suppressClick, true) + requestAnimationFrame(() => { + window.removeEventListener('click', suppressClick, true) + }) + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(elevator) + setSelection({ selectedIds: [] }) + }, + [selectedElevatorEntry, setMovingNode, setSelection], + ) const handleZoneLabelClick = useCallback( (zoneId: ZoneNodeType['id'], _event: ReactMouseEvent) => { const currentZoneId = useViewer.getState().selection.zoneId @@ -14280,6 +14847,36 @@ export function FloorplanPanel() { }, [deleteNode, selectedSpawnEntry, setSelection], ) + const handleSelectedElevatorMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const elevator = selectedElevatorEntry?.elevator + if (!elevator) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(elevator) + setSelection({ selectedIds: [] }) + }, + [selectedElevatorEntry, setMovingNode, setSelection], + ) + const handleSelectedElevatorDelete = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const elevator = selectedElevatorEntry?.elevator + if (!elevator) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(elevator.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedElevatorEntry, setSelection], + ) const handleItemPointerDown = useCallback( (itemId: ItemNode['id'], event: ReactPointerEvent) => { if (event.button !== 0) { @@ -16025,6 +16622,7 @@ export function FloorplanPanel() { hasFloorplanCursorIndicator && !panStateRef.current && !guideInteractionRef.current && + !elevatorResizeDragState && !wallEndpointDragRef.current && !ceilingVertexDragState && !ceilingHoleMoveDraft && @@ -16061,6 +16659,7 @@ export function FloorplanPanel() { ceilingVertexDragState, ceilingHoleMoveDraft, ceilingHoleVertexDragState, + elevatorResizeDragState, siteVertexDragState, slabHoleMoveDraft, slabHoleVertexDragState, @@ -16535,6 +17134,11 @@ export function FloorplanPanel() { /> )} = [ + { label: 'Center opening', value: 'center-opening' }, + { label: 'Single left', value: 'single-left' }, + { label: 'Single right', value: 'single-right' }, +] + +const DOOR_PANEL_STYLE_OPTIONS: Array<{ + label: string + value: ElevatorNode['doorPanelStyle'] +}> = [ + { label: 'Glass frame', value: 'glass-frame' }, + { label: 'Solid panel', value: 'solid-panel' }, + { label: 'Segmented panel', value: 'segmented-panel' }, +] + +const SHAFT_STYLE_OPTIONS: Array<{ + label: string + value: ElevatorNode['shaftStyle'] +}> = [ + { label: 'Solid', value: 'solid' }, + { label: 'Glass', value: 'glass' }, +] function roundMeters(value: number) { return Math.round(value * 100) / 100 } +function getResolvedShaftWidth(node: ElevatorNode) { + return Math.max(node.shaftWidth ?? node.width, node.width, 0.8) +} + +function getResolvedShaftDepth(node: ElevatorNode) { + return Math.max(node.shaftDepth ?? node.depth, node.depth, 0.8) +} + +function getResolvedShaftWallThickness(node: ElevatorNode) { + return Math.max(node.shaftWallThickness ?? 0.09, 0.04) +} + function radiansToDegrees(radians: number) { return Math.round((radians * 180) / Math.PI) } @@ -288,11 +336,53 @@ export function ElevatorPanel() { const requestLevel = useCallback( (levelId: LevelNode['id']) => { if (!node) return + if ((node.disabledLevelIds ?? []).includes(levelId)) return useInteractive.getState().requestElevator(node.id as AnyNodeId, levelId as AnyNodeId) }, [node], ) + const toggleLevelAccess = useCallback( + (field: ElevatorAccessField, levelId: LevelNode['id']) => { + if (!node) return + const disabledIds = new Set(node.disabledLevelIds ?? []) + const serviceOnlyIds = new Set(node.serviceOnlyLevelIds ?? []) + const targetSet = field === 'disabledLevelIds' ? disabledIds : serviceOnlyIds + + if (targetSet.has(levelId)) { + targetSet.delete(levelId) + } else { + targetSet.add(levelId) + } + + if (field === 'disabledLevelIds' && disabledIds.has(levelId)) { + serviceOnlyIds.delete(levelId) + } + if (field === 'serviceOnlyLevelIds' && serviceOnlyIds.has(levelId)) { + disabledIds.delete(levelId) + } + + const nextServiceLevels = getServiceLevels( + levels, + getResolvedFromLevelId(node, levels), + getResolvedToLevelId(node, levels, getResolvedFromLevelId(node, levels)), + ) + const nextDefaultLevelId = + node.defaultLevelId && !disabledIds.has(node.defaultLevelId) + ? node.defaultLevelId + : (nextServiceLevels.find((level) => !disabledIds.has(level.id))?.id ?? + nextServiceLevels[0]?.id ?? + null) + + handleUpdate({ + defaultLevelId: nextDefaultLevelId, + disabledLevelIds: Array.from(disabledIds), + serviceOnlyLevelIds: Array.from(serviceOnlyIds), + }) + }, + [handleUpdate, levels, node], + ) + const handleServiceBoundaryChange = useCallback( (field: 'fromLevelId' | 'toLevelId', levelId: string) => { if (!node) return @@ -336,10 +426,22 @@ export function ElevatorPanel() { const displayPosition = liveTransform?.position ?? displayNode.position const displayRotation = liveTransform?.rotation ?? displayNode.rotation const displayRotationDegrees = radiansToDegrees(displayRotation) + const displayShaftWidth = getResolvedShaftWidth(displayNode) + const displayShaftDepth = getResolvedShaftDepth(displayNode) + const displayShaftWallThickness = getResolvedShaftWallThickness(displayNode) const fromLevelId = getResolvedFromLevelId(node, levels) const toLevelId = getResolvedToLevelId(node, levels, fromLevelId) const servedLevels = getServiceLevels(levels, fromLevelId, toLevelId) - const defaultLevelOptions = servedLevels.length > 0 ? servedLevels : levels + const servedLevelIdSet = new Set(servedLevels.map((level) => level.id)) + const disabledLevelIds = new Set( + (node.disabledLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)), + ) + const serviceOnlyLevelIds = new Set( + (node.serviceOnlyLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)), + ) + const enabledServedLevels = servedLevels.filter((level) => !disabledLevelIds.has(level.id)) + const defaultLevelOptions = + enabledServedLevels.length > 0 ? enabledServedLevels : servedLevels.length > 0 ? servedLevels : levels const selectedDefaultLevelId = defaultLevelOptions.some( (level) => level.id === node.defaultLevelId, ) @@ -520,7 +622,102 @@ export function ElevatorPanel() { /> + +
+
+ Shaft Style +
+ +
+ previewMetric('shaftWidth', Math.max(value, displayNode.width))} + onCommit={(value) => commitMetric('shaftWidth', Math.max(value, displayNode.width))} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={displayShaftWidth} + /> + previewMetric('shaftDepth', Math.max(value, displayNode.depth))} + onCommit={(value) => commitMetric('shaftDepth', Math.max(value, displayNode.depth))} + precision={2} + restoreOnCommit={false} + step={0.05} + unit="m" + value={displayShaftDepth} + /> + previewMetric('shaftWallThickness', value)} + onCommit={(value) => commitMetric('shaftWallThickness', value)} + precision={2} + restoreOnCommit={false} + step={0.01} + unit="m" + value={displayShaftWallThickness} + /> +
+ +
+
+ Opening Style +
+ +
+
+
+ Door Type +
+ +
+ +
+ {servedLevels.map((level) => { + const isDisabled = disabledLevelIds.has(level.id) + const isServiceOnly = serviceOnlyLevelIds.has(level.id) + + return ( +
+ + {level.name || `Level ${level.level}`} + +
+ + +
+
+ ) + })} +
+
+
{servedLevels.map((level) => { const isActive = activeLevelId === level.id const stopOrder = destinationOrderByLevelId.get(level.id) + const isDisabled = disabledLevelIds.has(level.id) + const isServiceOnly = serviceOnlyLevelIds.has(level.id) return ( ) diff --git a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx index 136159f90..088fe259f 100644 --- a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx +++ b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx @@ -1,6 +1,7 @@ import { type AnyNodeId, type ElevatorNode, + resolveElevatorDispatchTarget, useInteractive, useLiveNodeOverrides, useLiveTransforms, @@ -29,6 +30,7 @@ const CAB_COLOR = '#d7dde5' const GLASS_COLOR = '#f8fafc' const DOOR_COLOR = '#8e98a6' const PANEL_COLOR = '#1f2937' +const DEFAULT_SHAFT_WALL_THICKNESS = 0.09 type Vector3Tuple = [number, number, number] @@ -39,6 +41,11 @@ const BUTTON_RING_GEOMETRY = new TorusGeometry(1.12, 0.12, 8, 24) const LABEL_MATRIX_DUMMY = new Object3D() const SHAFT_TOP_FRAME_CLEARANCE = 0.006 +type ElevatorDoorSide = 'left' | 'right' +type ElevatorDoorPanelStyleValue = ElevatorNode['doorPanelStyle'] +type ElevatorDoorStyleValue = ElevatorNode['doorStyle'] +type ElevatorShaftStyleValue = ElevatorNode['shaftStyle'] + const SHAFT_WALL_MATERIAL = new MeshStandardMaterial({ color: SHAFT_WALL_COLOR, metalness: 0.08, @@ -64,6 +71,11 @@ const DOOR_MATERIAL = new MeshStandardMaterial({ metalness: 0.34, roughness: 0.34, }) +const DOOR_GROOVE_MATERIAL = new MeshStandardMaterial({ + color: '#5f6978', + metalness: 0.28, + roughness: 0.42, +}) const GLASS_MATERIAL = new MeshStandardMaterial({ color: GLASS_COLOR, depthWrite: false, @@ -132,6 +144,11 @@ const BUTTON_FACE_MATERIALS = { metalness: 0.22, roughness: 0.3, }), + disabled: new MeshStandardMaterial({ + color: '#475569', + metalness: 0.12, + roughness: 0.52, + }), } const BUTTON_RING_MATERIALS = { active: new MeshStandardMaterial({ @@ -153,6 +170,11 @@ const BUTTON_RING_MATERIALS = { metalness: 0.48, roughness: 0.28, }), + disabled: new MeshStandardMaterial({ + color: '#334155', + metalness: 0.28, + roughness: 0.5, + }), } const BUTTON_GLOW_MATERIALS = { active: new MeshStandardMaterial({ @@ -183,6 +205,11 @@ const BUTTON_LABEL_MATERIALS = { metalness: 0.12, roughness: 0.34, }), + disabled: new MeshStandardMaterial({ + color: '#94a3b8', + metalness: 0.08, + roughness: 0.5, + }), } const QUEUE_STRIP_MATERIALS = { queued: new MeshStandardMaterial({ @@ -490,6 +517,7 @@ function ElevatorMeshButton({ action = 'request-level', active, buttonKind, + disabled = false, elevatorId, faceSign = -1, glyph, @@ -502,6 +530,7 @@ function ElevatorMeshButton({ action?: ElevatorButtonAction active: boolean buttonKind: 'cab' | 'landing' + disabled?: boolean elevatorId: AnyNodeId faceSign?: -1 | 1 glyph?: 'door-open' @@ -511,34 +540,51 @@ function ElevatorMeshButton({ queued: boolean radius?: number }) { - const state = active ? 'active' : queued ? 'queued' : 'idle' + const state = disabled ? 'disabled' : active ? 'active' : queued ? 'queued' : 'idle' const depth = active ? 0.028 : 0.04 const faceZ = faceSign * (depth / 2 + 0.004) - const labelMaterial = active || queued ? BUTTON_LABEL_MATERIALS.lit : BUTTON_LABEL_MATERIALS.idle + const labelMaterial = disabled + ? BUTTON_LABEL_MATERIALS.disabled + : active || queued + ? BUTTON_LABEL_MATERIALS.lit + : BUTTON_LABEL_MATERIALS.idle const userData = useMemo( () => ({ elevatorButton: { action, + disabled, elevatorId, kind: buttonKind, levelId, }, }), - [action, buttonKind, elevatorId, levelId], + [action, buttonKind, disabled, elevatorId, levelId], ) const press = (event: ThreeEvent) => { if (event.button !== 0) return + if (disabled) return if (action === 'open-door') { useInteractive.getState().openElevatorDoor(elevatorId) return } - if (levelId) useInteractive.getState().requestElevator(elevatorId, levelId) + if (levelId) { + const targetElevatorId = + buttonKind === 'landing' + ? resolveElevatorDispatchTarget({ + elevators: useInteractive.getState().elevators, + levelId, + nodes: useScene.getState().nodes, + requestedElevatorId: elevatorId, + }) + : elevatorId + useInteractive.getState().requestElevator(targetElevatorId, levelId) + } } return ( - {(active || queued) && ( + {!disabled && (active || queued) && ( (null) - const direction = side === 'left' ? -1 : 1 - const getLeafX = (openAmount: number) => direction * (width / 4 + openAmount * width * 0.34) - const leafWidth = Math.max(width / 2 - 0.018, 0.12) + const getLeafX = (openAmount: number) => getElevatorDoorLeafX(side, width, openAmount, doorStyle) + const leafWidth = getElevatorDoorLeafWidth(width, doorStyle) + const resolvedPanelStyle = getResolvedDoorPanelStyle(doorPanelStyle) const railHeight = Math.min(0.09, Math.max(0.055, height * 0.04)) const stileWidth = Math.min(0.07, Math.max(0.04, leafWidth * 0.18)) const glassWidth = Math.max(leafWidth - stileWidth * 2.2, 0.03) const glassHeight = Math.max(height - railHeight * 3, 0.2) + const panelInsetWidth = Math.max(leafWidth - 0.12, 0.05) + const panelInsetHeight = Math.max(height - 0.26, 0.2) + const segmentCount = 4 + const segmentSpacing = panelInsetHeight / segmentCount useFrame(() => { if (!(animated && ref.current)) return @@ -631,43 +757,130 @@ function DoorLeaf({ return ( - - - - - + {resolvedPanelStyle === 'glass-frame' ? ( + <> + + + + + + + ) : ( + <> + + + {resolvedPanelStyle === 'segmented-panel' + ? Array.from({ length: segmentCount - 1 }).map((_, index) => ( + + )) + : null} + + + + )} ) } +function ElevatorDoorLeaves({ + animated, + doorOpen, + doorPanelStyle, + doorStyle, + height, + width, + y, + z, +}: { + animated?: + | { + elevatorId: AnyNodeId + kind: 'cab' + } + | { + elevatorId: AnyNodeId + kind: 'landing' + levelId: AnyNodeId + } + doorOpen: number + doorPanelStyle: ElevatorDoorPanelStyleValue + doorStyle: ElevatorDoorStyleValue + height: number + width: number + y: number + z: number +}) { + return ( + <> + {getElevatorDoorLeafSides(doorStyle).map((side) => ( + + ))} + + ) +} + function LandingDoorFrame({ doorHeight, doorWidth, @@ -749,6 +962,8 @@ function LandingDoorFrame({ function LandingDoor({ animated, + doorPanelStyle, + doorStyle, elevatorId, doorOpen, doorHeight, @@ -758,6 +973,8 @@ function LandingDoor({ z, }: { animated: boolean + doorPanelStyle: ElevatorDoorPanelStyleValue + doorStyle: ElevatorDoorStyleValue elevatorId: AnyNodeId doorOpen: number doorHeight: number @@ -767,26 +984,16 @@ function LandingDoor({ z: number }) { return ( - <> - - - + ) } @@ -856,13 +1063,24 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { cabRef.current.position.y = runtime.carY }, 2.6) - const shaftWidth = Math.max(renderNode.width, 0.8) - const shaftDepth = Math.max(renderNode.depth, 0.8) + const cabWidth = getElevatorCabWidth(renderNode) + const cabDepth = getElevatorCabDepth(renderNode) + const shaftWidth = getElevatorShaftWidth(renderNode, cabWidth) + const shaftDepth = getElevatorShaftDepth(renderNode, cabDepth) const cabHeight = Math.max(renderNode.cabHeight, 1.4) - const doorWidth = Math.min(Math.max(renderNode.doorWidth, 0.45), shaftWidth - 0.18) + const shaftWallThickness = getElevatorShaftWallThickness(renderNode) + const doorWidth = Math.min( + Math.max(renderNode.doorWidth, 0.45), + cabWidth - 0.18, + shaftWidth - 0.18, + ) const doorHeight = Math.min(Math.max(renderNode.doorHeight, 1.2), cabHeight - 0.1) + const doorPanelStyle = getResolvedDoorPanelStyle(renderNode.doorPanelStyle) + const doorStyle = getResolvedDoorStyle(renderNode.doorStyle) + const shaftStyle = getResolvedShaftStyle(renderNode.shaftStyle) + const shaftShellMaterial = shaftStyle === 'glass' ? GLASS_MATERIAL : SHAFT_SIDE_MATERIAL + const shaftTopMaterial = shaftStyle === 'glass' ? SHAFT_TRIM_MATERIAL : SHAFT_SIDE_MATERIAL const shaftHeight = Math.max(totalHeight, cabHeight + 0.3) - const shaftWallThickness = 0.09 const shaftBodyHeight = Math.max(shaftHeight - shaftWallThickness, 0.01) const shaftBodyCenterY = shaftBaseY + shaftBodyHeight / 2 const shaftTopCapBottomY = shaftBaseY + shaftHeight - shaftWallThickness @@ -906,6 +1124,14 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { runtimeStatus?.queue, runtimeStatus?.targetLevelId, ]) + const disabledLevelIds = useMemo( + () => new Set(renderNode.disabledLevelIds ?? []), + [renderNode.disabledLevelIds], + ) + const serviceOnlyLevelIds = useMemo( + () => new Set(renderNode.serviceOnlyLevelIds ?? []), + [renderNode.serviceOnlyLevelIds], + ) const doorOpen = runtimeSnapshot?.doorOpen ?? 0 const doorOpenButtonActive = doorOpen > 0.12 || @@ -916,8 +1142,9 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { const frontWallZ = -shaftDepth / 2 - shaftWallThickness / 2 const frontZ = frontWallZ - shaftWallThickness / 2 - 0.018 const landingPanelX = Math.min(shaftWidth / 2 - 0.16, doorWidth / 2 + 0.18) - const cabPanelX = shaftWidth / 2 - 0.075 - const cabPanelZ = -shaftDepth / 2 + 0.36 + const cabCenterZ = -shaftDepth / 2 + cabDepth / 2 + const cabPanelX = cabWidth / 2 - 0.075 + const cabPanelZ = cabCenterZ - cabDepth / 2 + 0.36 const cabButtonColumns = entries.length > 1 ? 2 : 1 const cabButtonRows = Math.max(1, Math.ceil(entries.length / cabButtonColumns)) const cabButtonSpacingX = 0.14 @@ -956,28 +1183,28 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { > { - - { {entries.map((entry, index) => { const column = index % cabButtonColumns const row = Math.floor(index / cabButtonColumns) + const isDisabledLevel = disabledLevelIds.has(entry.id) const x = cabFloorButtonOffsetX + (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX const y = (row - (cabButtonRows - 1) / 2) * cabButtonSpacingY return ( ) })} @@ -1101,7 +1322,9 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { {entrySpans.map(({ entry, levelTopY }) => { const isCurrentLevel = activeLevelId === entry.id - const isQueuedLevel = queuedLevelIds.has(entry.id) + const isDisabledLevel = disabledLevelIds.has(entry.id) + const isServiceOnlyLevel = serviceOnlyLevelIds.has(entry.id) + const isQueuedLevel = !isDisabledLevel && queuedLevelIds.has(entry.id) const isPendingLevel = pendingLevelId === entry.id const showLandingReadout = isCurrentLevel || isPendingLevel || isQueuedLevel @@ -1117,6 +1340,8 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { /> { scale={[0.18, 0.42, 0.04]} /> 0.5} + active={!isDisabledLevel && !isServiceOnlyLevel && isCurrentLevel && doorOpen > 0.5} buttonKind="landing" + disabled={isDisabledLevel || isServiceOnlyLevel} elevatorId={elevatorId} levelId={entry.id as AnyNodeId} position={[0, 0.06, -0.045]} From 92a98461938ddb4314825e61e3eeb6781db8dcff Mon Sep 17 00:00:00 2001 From: sudhir Date: Sun, 10 May 2026 12:38:44 +0530 Subject: [PATCH 5/9] Refactor elevator systems across core, viewer, and editor --- packages/core/src/index.ts | 1 + .../elevator/elevator-opening-system.tsx | 4 +- .../tools/elevator/elevator-tool.tsx | 55 ++++++----- .../tools/elevator/move-elevator-tool.tsx | 18 +++- .../src/components/tools/item/move-tool.tsx | 7 +- .../src/components/tools/tool-manager.tsx | 27 +++++- .../renderers/elevator/elevator-renderer.tsx | 90 +++++++----------- .../viewer/src/components/viewer/index.tsx | 4 +- .../elevator/elevator-animation-system.tsx | 7 ++ .../elevator/elevator-interaction-system.tsx | 91 +++++++++++++++++++ 10 files changed, 213 insertions(+), 91 deletions(-) rename packages/{viewer => core}/src/systems/elevator/elevator-opening-system.tsx (90%) create mode 100644 packages/viewer/src/systems/elevator/elevator-interaction-system.tsx diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b5996efd0..fcc7dd298 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -87,6 +87,7 @@ export { default as useLiveTransforms, type LiveTransform } from './store/use-li export { clearSceneHistory, default as useScene } from './store/use-scene' export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync' +export { ElevatorOpeningSystem } from './systems/elevator/elevator-opening-system' export { resolveElevatorBuildingLevels, resolveElevatorServiceLevelIds, diff --git a/packages/viewer/src/systems/elevator/elevator-opening-system.tsx b/packages/core/src/systems/elevator/elevator-opening-system.tsx similarity index 90% rename from packages/viewer/src/systems/elevator/elevator-opening-system.tsx rename to packages/core/src/systems/elevator/elevator-opening-system.tsx index 559f9a3b9..5f8c6344b 100644 --- a/packages/viewer/src/systems/elevator/elevator-opening-system.tsx +++ b/packages/core/src/systems/elevator/elevator-opening-system.tsx @@ -1,5 +1,7 @@ -import { type AnyNode, syncAutoElevatorOpenings, useScene } from '@pascal-app/core' import { useEffect, useRef } from 'react' +import type { AnyNode } from '../../schema' +import useScene from '../../store/use-scene' +import { syncAutoElevatorOpenings } from './elevator-opening-sync' function isOpeningRelevantNode(node: AnyNode | undefined) { return ( diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx index d8e876363..a57020788 100644 --- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -7,7 +7,6 @@ import { type LevelNode, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { resolveElevatorSupportY } from '../../../lib/elevator-support' @@ -26,8 +25,16 @@ import { const GRID_OFFSET = 0.02 -function resolveCurrentBuildingId(): BuildingNode['id'] | null { - const { buildingId, levelId } = useViewer.getState().selection +type ElevatorToolProps = { + buildingId: BuildingNode['id'] | null + levelId: LevelNode['id'] | null + onPlaced?: (elevatorId: AnyNodeId, buildingId: BuildingNode['id']) => void +} + +function resolveCurrentBuildingId( + buildingId: BuildingNode['id'] | null, + levelId: LevelNode['id'] | null, +): BuildingNode['id'] | null { if (buildingId) return buildingId as BuildingNode['id'] if (!levelId) return null @@ -39,12 +46,14 @@ function resolveCurrentBuildingId(): BuildingNode['id'] | null { return null } -function resolveDefaultServiceRange(buildingId: BuildingNode['id']): { +function resolveDefaultServiceRange( + buildingId: BuildingNode['id'], + selectedLevelId: LevelNode['id'] | null, +): { defaultLevelId: LevelNode['id'] | null fromLevelId: LevelNode['id'] | null toLevelId: LevelNode['id'] | null } { - const { levelId } = useViewer.getState().selection const nodes = useScene.getState().nodes const building = nodes[buildingId as AnyNodeId] if (building?.type !== 'building') { @@ -55,7 +64,7 @@ function resolveDefaultServiceRange(buildingId: BuildingNode['id']): { .map((childId) => nodes[childId as AnyNodeId]) .filter((node): node is LevelNode => node?.type === 'level') .sort((left, right) => left.level - right.level) - const selectedLevelIndex = levels.findIndex((level) => level.id === levelId) + const selectedLevelIndex = levels.findIndex((level) => level.id === selectedLevelId) const fromIndex = selectedLevelIndex >= 0 ? selectedLevelIndex : 0 const fromLevel = levels[fromIndex] const toLevel = levels[Math.min(fromIndex + 1, levels.length - 1)] ?? fromLevel @@ -77,13 +86,15 @@ function createElevatorPreviewGeometry(): THREE.BufferGeometry { function commitElevatorPlacement( buildingId: BuildingNode['id'], + selectedLevelId: LevelNode['id'] | null, x: number, z: number, rotation: number, + onPlaced: ElevatorToolProps['onPlaced'], ): void { const { createNode, nodes } = useScene.getState() const elevatorCount = Object.values(nodes).filter((node) => node.type === 'elevator').length - const serviceRange = resolveDefaultServiceRange(buildingId) + const serviceRange = resolveDefaultServiceRange(buildingId, selectedLevelId) const supportY = resolveElevatorSupportY({ buildingId, preferredLevelId: serviceRange.fromLevelId ?? serviceRange.defaultLevelId, @@ -107,30 +118,19 @@ function commitElevatorPlacement( }) createNode(elevator, buildingId) - useViewer.getState().setSelection({ buildingId, selectedIds: [elevator.id] }) + onPlaced?.(elevator.id as AnyNodeId, buildingId) sfxEmitter.emit('sfx:structure-build') } -export const ElevatorTool: React.FC = () => { +export const ElevatorTool: React.FC = ({ buildingId, levelId, onPlaced }) => { const cursorRef = useRef(null) const previewRef = useRef(null) const rotationRef = useRef(0) const previousGridPosRef = useRef<[number, number] | null>(null) - const buildingId = useViewer((state) => state.selection.buildingId) - const levelId = useViewer((state) => state.selection.levelId) const previewGeometry = useMemo(() => createElevatorPreviewGeometry(), []) useEffect(() => { - const currentBuildingId = - (buildingId as BuildingNode['id'] | null) ?? - (levelId - ? (() => { - const level = useScene.getState().nodes[levelId as AnyNodeId] - return level?.type === 'level' && level.parentId - ? (level.parentId as BuildingNode['id']) - : null - })() - : null) + const currentBuildingId = resolveCurrentBuildingId(buildingId, levelId) if (!currentBuildingId) return rotationRef.current = 0 @@ -160,12 +160,19 @@ export const ElevatorTool: React.FC = () => { } const onGridClick = (event: GridEvent) => { - const latestBuildingId = resolveCurrentBuildingId() + const latestBuildingId = resolveCurrentBuildingId(buildingId, levelId) if (!latestBuildingId) return const gridX = Math.round(event.localPosition[0] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2 - commitElevatorPlacement(latestBuildingId, gridX, gridZ, rotationRef.current) + commitElevatorPlacement( + latestBuildingId, + levelId, + gridX, + gridZ, + rotationRef.current, + onPlaced, + ) } const onKeyDown = (event: KeyboardEvent) => { @@ -195,7 +202,7 @@ export const ElevatorTool: React.FC = () => { emitter.off('grid:click', onGridClick) window.removeEventListener('keydown', onKeyDown) } - }, [buildingId, levelId]) + }, [buildingId, levelId, onPlaced]) return ( diff --git a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx index 046bf43a7..2322c6dcb 100644 --- a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx @@ -10,7 +10,6 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { resolveElevatorSupportY } from '../../../lib/elevator-support' @@ -29,7 +28,14 @@ function stripMoveMetadata(metadata: ElevatorNode['metadata']) { return nextMeta as ElevatorNode['metadata'] } -export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) { +export function MoveElevatorTool({ + node: movingNode, + onCommitted, +}: { + node: ElevatorNode + onCommitted?: (nodeId: AnyNodeId) => void +}) { + const onCommittedRef = useRef(onCommitted) const previousGridPosRef = useRef<[number, number] | null>(null) const previewPositionRef = useRef([ movingNode.position[0], @@ -46,6 +52,10 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) { useEditor.getState().setMovingNode(null) }, []) + useEffect(() => { + onCommittedRef.current = onCommitted + }, [onCommitted]) + useEffect(() => { useScene.temporal.getState().pause() const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id @@ -145,7 +155,7 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) { rotation: pendingRotation, metadata: committedMeta, }) - useViewer.getState().setSelection({ selectedIds: [movingNodeId] }) + onCommittedRef.current?.(movingNodeId as AnyNodeId) } else if (movingNode.parentId) { const elevator = ElevatorNodeSchema.parse({ ...movingNode, @@ -155,7 +165,7 @@ export function MoveElevatorTool({ node: movingNode }: { node: ElevatorNode }) { metadata: committedMeta, }) useScene.getState().createNode(elevator, movingNode.parentId as AnyNodeId) - useViewer.getState().setSelection({ selectedIds: [elevator.id] }) + onCommittedRef.current?.(elevator.id as AnyNodeId) } sfxEmitter.emit('sfx:item-place') diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 1db6a11f9..19948110b 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -1,4 +1,5 @@ import type { + AnyNodeId, BuildingNode, CeilingNode, ColumnNode, @@ -93,15 +94,17 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { } export const MoveTool: React.FC<{ + onNodeMoved?: (nodeId: AnyNodeId) => void onSpawnMoved?: (nodeId: SpawnNode['id']) => void -}> = ({ onSpawnMoved }) => { +}> = ({ onNodeMoved, onSpawnMoved }) => { const movingNode = useEditor((state) => state.movingNode) if (!movingNode) return null if (movingNode.type === 'building') return if (movingNode.type === 'door') return - if (movingNode.type === 'elevator') return + if (movingNode.type === 'elevator') + return if (movingNode.type === 'window') return if (movingNode.type === 'fence') return if (movingNode.type === 'ceiling') return diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 600e22428..9f726fee6 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -42,7 +42,6 @@ const tools: Record>> = { slab: SlabTool, ceiling: CeilingTool, roof: RoofTool, - elevator: ElevatorTool, stair: StairTool, door: DoorTool, item: ItemTool, @@ -132,12 +131,20 @@ export const ToolManager: React.FC = () => { const handlePlacedNodeSelected = (nodeId: AnyNodeId) => { setSelection({ selectedIds: [nodeId] }) } + const handlePlacedElevatorSelected = ( + nodeId: AnyNodeId, + elevatorBuildingId: BuildingNode['id'], + ) => { + setSelection({ buildingId: elevatorBuildingId, selectedIds: [nodeId] }) + } return ( <> {showSiteBoundaryEditor && } {/* World-space tools: site boundary and building movement operate in world coordinates */} - {movingNode?.type === 'building' && } + {movingNode?.type === 'building' && ( + + )} {/* Building-local group: all other tools are relative to the selected building. Cursor visuals set positions in building-local space; this group applies the @@ -162,7 +169,10 @@ export const ToolManager: React.FC = () => { {curvingWall && } {curvingFence && } {movingNode && movingNode.type !== 'building' && ( - + )} {!movingNode && showBuildTool && tool === 'spawn' && ( @@ -170,7 +180,16 @@ export const ToolManager: React.FC = () => { {!movingNode && showBuildTool && tool === 'column' && ( )} - {!movingNode && BuildToolComponent && tool !== 'column' && } + {!movingNode && showBuildTool && tool === 'elevator' && ( + + )} + {!movingNode && BuildToolComponent && tool !== 'column' && tool !== 'elevator' && ( + + )} ) diff --git a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx index 088fe259f..083455dd8 100644 --- a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx +++ b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx @@ -1,15 +1,15 @@ import { + type AnyNode, type AnyNodeId, type ElevatorNode, - resolveElevatorDispatchTarget, useInteractive, useLiveNodeOverrides, useLiveTransforms, useRegistry, useScene, } from '@pascal-app/core' -import { type ThreeEvent, useFrame } from '@react-three/fiber' -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { useFrame } from '@react-three/fiber' +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { BoxGeometry, CylinderGeometry, @@ -561,29 +561,8 @@ function ElevatorMeshButton({ [action, buttonKind, disabled, elevatorId, levelId], ) - const press = (event: ThreeEvent) => { - if (event.button !== 0) return - if (disabled) return - if (action === 'open-door') { - useInteractive.getState().openElevatorDoor(elevatorId) - return - } - if (levelId) { - const targetElevatorId = - buttonKind === 'landing' - ? resolveElevatorDispatchTarget({ - elevators: useInteractive.getState().elevators, - levelId, - nodes: useScene.getState().nodes, - requestedElevatorId: elevatorId, - }) - : elevatorId - useInteractive.getState().requestElevator(targetElevatorId, levelId) - } - } - return ( - + {!disabled && (active || queued) && ( ['nodes'], +): Record { + const result: Record = {} + const building = elevator.parentId ? nodes[elevator.parentId as AnyNodeId] : null + if (building?.type !== 'building') return result as Record + + result[building.id] = building + + for (const childId of building.children) { + const level = nodes[childId as AnyNodeId] + if (level?.type !== 'level') continue + + result[level.id] = level + for (const levelChildId of level.children) { + const child = nodes[levelChildId as AnyNodeId] + if (child?.type === 'ceiling' || child?.type === 'wall') { + result[child.id] = child + } + } + } + + return result as Record +} + function DoorLeaf({ animated, doorOpen, @@ -1000,7 +1005,6 @@ function LandingDoor({ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { const ref = useRef(null!) const cabRef = useRef(null) - const nodes = useScene((state) => state.nodes) const handlers = useNodeEvents(node, 'elevator') const liveOverrides = useLiveNodeOverrides((state) => state.get(node.id)) const liveTransform = useLiveTransforms((state) => state.get(node.id)) @@ -1008,12 +1012,15 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { () => (liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node), [liveOverrides, node], ) + const levelContextNodes = useScene( + useShallow((state) => getElevatorLevelContextNodes(renderNode, state.nodes)), + ) useRegistry(node.id, 'elevator', ref) const { entries, defaultEntry, shaftBaseY, totalHeight } = useMemo( - () => resolveElevatorLevels(renderNode, nodes), - [renderNode, nodes], + () => resolveElevatorLevels(renderNode, levelContextNodes), + [renderNode, levelContextNodes], ) const elevatorId = node.id as AnyNodeId const runtimeStatus = useInteractive( @@ -1029,33 +1036,6 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { }), ) - useEffect(() => { - if (!defaultEntry) return - - const elevatorId = node.id as AnyNodeId - const interactive = useInteractive.getState() - const current = interactive.elevators[elevatorId] - if (!current) { - interactive.initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY) - } else if (!entries.some((entry) => entry.id === current.currentLevelId)) { - interactive.setElevatorState(elevatorId, { - carY: defaultEntry.baseY, - currentLevelId: defaultEntry.id as AnyNodeId, - doorOpen: 0, - phase: 'idle', - phaseStartedAt: null, - queue: [], - targetLevelId: null, - }) - } - }, [defaultEntry, entries, node.id]) - - useEffect(() => { - return () => { - useInteractive.getState().removeElevator(elevatorId) - } - }, [elevatorId]) - useFrame(() => { if (!cabRef.current) return const runtime = useInteractive.getState().elevators[elevatorId] diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 0ae36bf1b..d0b7fdadf 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,5 +1,6 @@ 'use client' +import { ElevatorOpeningSystem } from '@pascal-app/core' import { Bvh } from '@react-three/drei' import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' @@ -9,7 +10,7 @@ import { CeilingSystem } from '../../systems/ceiling/ceiling-system' import { DoorAnimationSystem } from '../../systems/door/door-animation-system' import { DoorSystem } from '../../systems/door/door-system' import { ElevatorAnimationSystem } from '../../systems/elevator/elevator-animation-system' -import { ElevatorOpeningSystem } from '../../systems/elevator/elevator-opening-system' +import { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-system' import { FenceSystem } from '../../systems/fence/fence-system' import { GuideSystem } from '../../systems/guide/guide-system' import { ItemSystem } from '../../systems/item/item-system' @@ -208,6 +209,7 @@ const Viewer: React.FC = ({ + diff --git a/packages/viewer/src/systems/elevator/elevator-animation-system.tsx b/packages/viewer/src/systems/elevator/elevator-animation-system.tsx index 49be4c84f..f512579ef 100644 --- a/packages/viewer/src/systems/elevator/elevator-animation-system.tsx +++ b/packages/viewer/src/systems/elevator/elevator-animation-system.tsx @@ -22,6 +22,13 @@ export function ElevatorAnimationSystem() { const nodes = useScene.getState().nodes const now = clock.getElapsedTime() * 1000 + for (const elevatorId of Object.keys(interactive.elevators)) { + const typedElevatorId = elevatorId as AnyNodeId + if (nodes[typedElevatorId]?.type !== 'elevator') { + interactive.removeElevator(typedElevatorId) + } + } + for (const elevatorId of sceneRegistry.byType.elevator) { const typedElevatorId = elevatorId as AnyNodeId const node = nodes[typedElevatorId] diff --git a/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx new file mode 100644 index 000000000..447259fb9 --- /dev/null +++ b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx @@ -0,0 +1,91 @@ +import { + type AnyNodeId, + resolveElevatorDispatchTarget, + useInteractive, + useScene, +} from '@pascal-app/core' +import { useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { type Object3D, Raycaster, Vector2 } from 'three' + +type ElevatorButtonAction = 'open-door' | 'request-level' + +type ElevatorButtonUserData = { + action: ElevatorButtonAction + disabled: boolean + elevatorId: AnyNodeId + kind: 'cab' | 'landing' + levelId?: AnyNodeId +} + +function getElevatorButtonData(object: Object3D): ElevatorButtonUserData | null { + let current: Object3D | null = object + + while (current) { + const data = (current.userData as { elevatorButton?: ElevatorButtonUserData }).elevatorButton + if (data) return data + current = current.parent + } + + return null +} + +export function ElevatorInteractionSystem() { + const camera = useThree((state) => state.camera) + const gl = useThree((state) => state.gl) + const scene = useThree((state) => state.scene) + const raycasterRef = useRef(new Raycaster()) + const pointerRef = useRef(new Vector2()) + + useEffect(() => { + const canvas = gl.domElement + + const handlePointerDown = (event: PointerEvent) => { + if (event.button !== 0) return + + const rect = canvas.getBoundingClientRect() + const pointer = pointerRef.current + pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 + pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 + + const raycaster = raycasterRef.current + raycaster.setFromCamera(pointer, camera) + + const button = raycaster + .intersectObjects(scene.children, true) + .map((intersection) => getElevatorButtonData(intersection.object)) + .find((data): data is ElevatorButtonUserData => data !== null) + + if (!button || button.disabled) return + + event.preventDefault() + event.stopPropagation() + + if (button.action === 'open-door') { + useInteractive.getState().openElevatorDoor(button.elevatorId) + return + } + + if (!button.levelId) return + + const targetElevatorId = + button.kind === 'landing' + ? resolveElevatorDispatchTarget({ + elevators: useInteractive.getState().elevators, + levelId: button.levelId, + nodes: useScene.getState().nodes, + requestedElevatorId: button.elevatorId, + }) + : button.elevatorId + + useInteractive.getState().requestElevator(targetElevatorId, button.levelId) + } + + canvas.addEventListener('pointerdown', handlePointerDown, true) + return () => { + canvas.removeEventListener('pointerdown', handlePointerDown, true) + } + }, [camera, gl, scene]) + + return null +} From 0fd6e3d144ed78e290b0d12865baeebe4a58730a Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 12 May 2026 11:21:03 +0530 Subject: [PATCH 6/9] Fix mirrored elevator indicators and arrows --- .../renderers/elevator/elevator-renderer.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx index 083455dd8..8aa0815e6 100644 --- a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx +++ b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx @@ -294,11 +294,13 @@ function BoxPrimitive({ } function MeshButtonLabel({ + faceSign = 1, label, material, position, scale, }: { + faceSign?: -1 | 1 label: string material: MeshStandardMaterial position: [number, number, number] @@ -315,7 +317,7 @@ function MeshButtonLabel({ const props = SEGMENT_PROPS[segment] return { position: [ - startX + charIndex * spacing + props.position[0] * scale, + faceSign * (startX + charIndex * spacing + props.position[0] * scale), props.position[1] * scale, props.position[2], ] as Vector3Tuple, @@ -323,7 +325,7 @@ function MeshButtonLabel({ } }), ) - }, [label, scale]) + }, [faceSign, label, scale]) const applyInstanceMatrices = useCallback( (mesh: InstancedMesh) => { @@ -381,7 +383,7 @@ function ElevatorDirectionGlyph({ ) } - const ySign = direction === 'up' ? 1 : -1 + const ySign = direction === 'up' ? -1 : 1 return ( @@ -594,6 +597,7 @@ function ElevatorMeshButton({ /> {label && ( Date: Wed, 13 May 2026 01:56:06 +0530 Subject: [PATCH 7/9] Refactor elevator runtime into core --- packages/core/src/index.ts | 29 ++ packages/core/src/store/use-interactive.ts | 49 ---- .../src/systems/elevator/elevator-geometry.ts | 92 ++++++ .../elevator/elevator-runtime-system.tsx | 10 + .../systems/elevator/elevator-runtime.test.ts | 79 ++++++ .../src/systems/elevator/elevator-runtime.ts | 265 ++++++++++++++++++ .../src/systems/elevator/elevator-service.ts | 85 +++++- .../editor/first-person-controls.tsx | 82 +----- .../components/ui/panels/elevator-panel.tsx | 3 +- .../renderers/elevator/elevator-renderer.tsx | 89 +----- .../viewer/src/components/viewer/index.tsx | 5 +- .../elevator/elevator-animation-system.tsx | 162 ----------- .../elevator/elevator-interaction-system.tsx | 6 +- .../src/systems/elevator/elevator-utils.ts | 68 ----- 14 files changed, 595 insertions(+), 429 deletions(-) create mode 100644 packages/core/src/systems/elevator/elevator-geometry.ts create mode 100644 packages/core/src/systems/elevator/elevator-runtime-system.tsx create mode 100644 packages/core/src/systems/elevator/elevator-runtime.test.ts create mode 100644 packages/core/src/systems/elevator/elevator-runtime.ts delete mode 100644 packages/viewer/src/systems/elevator/elevator-animation-system.tsx delete mode 100644 packages/viewer/src/systems/elevator/elevator-utils.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aa94614df..9d50139f4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -88,10 +88,39 @@ export { export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { clearSceneHistory, default as useScene } from './store/use-scene' export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' +export { + getElevatorCabCenterZ, + getElevatorCabDepth, + getElevatorCabWidth, + getElevatorDoorLeafSides, + getElevatorDoorLeafWidth, + getElevatorDoorLeafX, + getElevatorShaftDepth, + getElevatorShaftWallThickness, + getElevatorShaftWidth, + getResolvedElevatorDoorPanelStyle, + getResolvedElevatorDoorStyle, + getResolvedElevatorShaftStyle, + type ElevatorDoorSide, +} from './systems/elevator/elevator-geometry' export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync' export { ElevatorOpeningSystem } from './systems/elevator/elevator-opening-system' export { + createElevatorInteractiveState, + openElevatorDoor, + openElevatorDoorState, + queueElevatorRequest, + requestElevatorLevel, + stepElevatorRuntimeState, + stepElevatorRuntimes, +} from './systems/elevator/elevator-runtime' +export { ElevatorRuntimeSystem } from './systems/elevator/elevator-runtime-system' +export { + DEFAULT_ELEVATOR_LEVEL_HEIGHT, + type ElevatorLevelEntry, + getElevatorLevelHeight, resolveElevatorBuildingLevels, + resolveElevatorLevels, resolveElevatorServiceLevelIds, resolveElevatorServiceLevels, } from './systems/elevator/elevator-service' diff --git a/packages/core/src/store/use-interactive.ts b/packages/core/src/store/use-interactive.ts index 843545577..22997afc0 100644 --- a/packages/core/src/store/use-interactive.ts +++ b/packages/core/src/store/use-interactive.ts @@ -95,12 +95,6 @@ type InteractiveStore = { /** Initialize an elevator's runtime state from its default served level. */ initElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId, carY: number) => void - /** Queue a request for an elevator to travel to a level. */ - requestElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId) => void - - /** Open the elevator doors at the current level when the car is not moving. */ - openElevatorDoor: (elevatorId: AnyNodeId) => void - /** Merge runtime elevator state. */ setElevatorState: (elevatorId: AnyNodeId, value: Partial) => void @@ -252,49 +246,6 @@ export const useInteractive = create((set, get) => ({ })) }, - requestElevator: (elevatorId, levelId) => { - set((state) => { - const elevator = state.elevators[elevatorId] ?? { - currentLevelId: null, - targetLevelId: null, - carY: 0, - doorOpen: 0, - phase: 'idle' as const, - phaseStartedAt: null, - queue: [], - } - const isAlreadyQueued = elevator.queue.includes(levelId) || elevator.targetLevelId === levelId - - return { - elevators: { - ...state.elevators, - [elevatorId]: { - ...elevator, - queue: isAlreadyQueued ? elevator.queue : [...elevator.queue, levelId], - }, - }, - } - }) - }, - - openElevatorDoor: (elevatorId) => { - set((state) => { - const elevator = state.elevators[elevatorId] - if (!elevator?.currentLevelId || elevator.phase === 'moving') return state - - return { - elevators: { - ...state.elevators, - [elevatorId]: { - ...elevator, - phase: 'opening', - phaseStartedAt: null, - }, - }, - } - }) - }, - setElevatorState: (elevatorId, value) => { set((state) => { const current = state.elevators[elevatorId] diff --git a/packages/core/src/systems/elevator/elevator-geometry.ts b/packages/core/src/systems/elevator/elevator-geometry.ts new file mode 100644 index 000000000..c08b296bf --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-geometry.ts @@ -0,0 +1,92 @@ +import type { + ElevatorDoorPanelStyle, + ElevatorDoorStyle, + ElevatorNode, + ElevatorShaftStyle, +} from '../../schema' + +export type ElevatorDoorSide = 'left' | 'right' + +const DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS = 0.09 + +export function getResolvedElevatorDoorStyle( + doorStyle: ElevatorNode['doorStyle'] | undefined, +): ElevatorDoorStyle { + return doorStyle ?? 'center-opening' +} + +export function getResolvedElevatorDoorPanelStyle( + doorPanelStyle: ElevatorNode['doorPanelStyle'] | undefined, +): ElevatorDoorPanelStyle { + return doorPanelStyle ?? 'glass-frame' +} + +export function getResolvedElevatorShaftStyle( + shaftStyle: ElevatorNode['shaftStyle'] | undefined, +): ElevatorShaftStyle { + return shaftStyle ?? 'solid' +} + +export function getElevatorDoorLeafSides( + doorStyle: ElevatorNode['doorStyle'] | undefined, +): ElevatorDoorSide[] { + const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle) + if (resolvedDoorStyle === 'single-left') return ['left'] + if (resolvedDoorStyle === 'single-right') return ['right'] + return ['left', 'right'] +} + +export function getElevatorDoorLeafX( + side: ElevatorDoorSide, + openingWidth: number, + doorOpen: number, + doorStyle: ElevatorNode['doorStyle'] | undefined, +) { + const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle) + if (resolvedDoorStyle === 'center-opening') { + const direction = side === 'left' ? -1 : 1 + return direction * (openingWidth / 4 + doorOpen * openingWidth * 0.34) + } + + const direction = resolvedDoorStyle === 'single-left' ? -1 : 1 + return direction * doorOpen * openingWidth * 0.68 +} + +export function getElevatorDoorLeafWidth( + openingWidth: number, + doorStyle: ElevatorNode['doorStyle'] | undefined, +) { + return getResolvedElevatorDoorStyle(doorStyle) === 'center-opening' + ? Math.max(openingWidth / 2 - 0.018, 0.12) + : Math.max(openingWidth - 0.018, 0.18) +} + +export function getElevatorCabWidth(node: ElevatorNode) { + return Math.max(node.width, 0.8) +} + +export function getElevatorCabDepth(node: ElevatorNode) { + return Math.max(node.depth, 0.8) +} + +export function getElevatorShaftWallThickness(node: ElevatorNode) { + return Math.max(node.shaftWallThickness ?? DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS, 0.04) +} + +export function getElevatorShaftWidth( + node: ElevatorNode, + cabWidth = getElevatorCabWidth(node), +) { + return Math.max(node.shaftWidth ?? cabWidth, cabWidth, 0.8) +} + +export function getElevatorShaftDepth( + node: ElevatorNode, + cabDepth = getElevatorCabDepth(node), +) { + return Math.max(node.shaftDepth ?? cabDepth, cabDepth, 0.8) +} + +export function getElevatorCabCenterZ(node: ElevatorNode) { + return -getElevatorShaftDepth(node) / 2 + getElevatorCabDepth(node) / 2 +} diff --git a/packages/core/src/systems/elevator/elevator-runtime-system.tsx b/packages/core/src/systems/elevator/elevator-runtime-system.tsx new file mode 100644 index 000000000..91c897a23 --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-runtime-system.tsx @@ -0,0 +1,10 @@ +import { useFrame } from '@react-three/fiber' +import { stepElevatorRuntimes } from './elevator-runtime' + +export function ElevatorRuntimeSystem() { + useFrame(({ clock }, delta) => { + stepElevatorRuntimes(clock.getElapsedTime() * 1000, delta) + }, 2) + + return null +} diff --git a/packages/core/src/systems/elevator/elevator-runtime.test.ts b/packages/core/src/systems/elevator/elevator-runtime.test.ts new file mode 100644 index 000000000..959dd681a --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-runtime.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNodeId } from '../../schema' +import { ElevatorNode } from '../../schema' +import { + createElevatorInteractiveState, + openElevatorDoorState, + queueElevatorRequest, + stepElevatorRuntimeState, +} from './elevator-runtime' +import type { ElevatorLevelEntry } from './elevator-service' + +const groundLevelId = 'level_ground' as AnyNodeId +const upperLevelId = 'level_upper' as AnyNodeId + +const entries: ElevatorLevelEntry[] = [ + { id: groundLevelId as ElevatorLevelEntry['id'], label: '0', baseY: 0 }, + { id: upperLevelId as ElevatorLevelEntry['id'], label: '1', baseY: 2.5 }, +] + +const elevator = ElevatorNode.parse({ + speed: 10, + doorDurationMs: 100, + dwellMs: 0, +}) + +describe('elevator runtime helpers', () => { + test('queues level requests without duplicating the target', () => { + const state = createElevatorInteractiveState(groundLevelId, 0) + const queued = queueElevatorRequest(state, upperLevelId) + const duplicated = queueElevatorRequest(queued, upperLevelId) + + expect(queued.queue).toEqual([upperLevelId]) + expect(duplicated.queue).toEqual([upperLevelId]) + }) + + test('opens doors only when the elevator is not moving', () => { + const idle = createElevatorInteractiveState(groundLevelId, 0) + const moving = { ...idle, phase: 'moving' as const } + + expect(openElevatorDoorState(idle).phase).toBe('opening') + expect(openElevatorDoorState(moving)).toBe(moving) + }) + + test('moves to a queued level and clears the served request on arrival', () => { + const queued = queueElevatorRequest(createElevatorInteractiveState(groundLevelId, 0), upperLevelId) + const moving = stepElevatorRuntimeState({ + defaultEntry: entries[0]!, + delta: 0.016, + elevator, + entries, + now: 0, + state: queued, + }) + + const arrived = stepElevatorRuntimeState({ + defaultEntry: entries[0]!, + delta: 1, + elevator, + entries, + now: 100, + state: moving, + }) + + const open = stepElevatorRuntimeState({ + defaultEntry: entries[0]!, + delta: 1, + elevator, + entries, + now: 200, + state: arrived, + }) + + expect(moving.phase).toBe('moving') + expect(arrived.currentLevelId).toBe(upperLevelId) + expect(arrived.phase).toBe('opening') + expect(open.phase).toBe('open') + expect(open.queue).toEqual([]) + }) +}) diff --git a/packages/core/src/systems/elevator/elevator-runtime.ts b/packages/core/src/systems/elevator/elevator-runtime.ts new file mode 100644 index 000000000..c75a30bc9 --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-runtime.ts @@ -0,0 +1,265 @@ +import type { AnyNode, AnyNodeId, ElevatorNode } from '../../schema' +import { type ElevatorInteractiveState, useInteractive } from '../../store/use-interactive' +import useScene from '../../store/use-scene' +import { resolveElevatorLevels, type ElevatorLevelEntry } from './elevator-service' + +const EPSILON = 0.001 + +function moveToward(current: number, target: number, maxDelta: number) { + const delta = target - current + if (Math.abs(delta) <= maxDelta) return target + return current + Math.sign(delta) * maxDelta +} + +export function createElevatorInteractiveState( + levelId: AnyNodeId, + carY: number, +): ElevatorInteractiveState { + return { + currentLevelId: levelId, + targetLevelId: null, + carY, + doorOpen: 0, + phase: 'idle', + phaseStartedAt: null, + queue: [], + } +} + +function getInitialElevatorState( + elevatorId: AnyNodeId, + nodes: Record, +): ElevatorInteractiveState | null { + const node = nodes[elevatorId] + if (node?.type !== 'elevator') return null + + const { defaultEntry } = resolveElevatorLevels(node, nodes) + if (!defaultEntry) return null + + return createElevatorInteractiveState(defaultEntry.id as AnyNodeId, defaultEntry.baseY) +} + +function ensureElevatorState( + elevatorId: AnyNodeId, + nodes: Record, +): ElevatorInteractiveState | null { + const interactive = useInteractive.getState() + const existing = interactive.elevators[elevatorId] + if (existing) return existing + + const initial = getInitialElevatorState(elevatorId, nodes) + if (!initial) return null + + interactive.initElevator(elevatorId, initial.currentLevelId as AnyNodeId, initial.carY) + return initial +} + +export function queueElevatorRequest( + state: ElevatorInteractiveState, + levelId: AnyNodeId, +): ElevatorInteractiveState { + const isAlreadyQueued = state.queue.includes(levelId) || state.targetLevelId === levelId + if (isAlreadyQueued) return state + + return { + ...state, + queue: [...state.queue, levelId], + } +} + +export function openElevatorDoorState( + state: ElevatorInteractiveState, +): ElevatorInteractiveState { + if (!state.currentLevelId || state.phase === 'moving') return state + + return { + ...state, + phase: 'opening', + phaseStartedAt: null, + } +} + +export function requestElevatorLevel(elevatorId: AnyNodeId, levelId: AnyNodeId) { + const nodes = useScene.getState().nodes + const current = ensureElevatorState(elevatorId, nodes) + if (!current) return + + const next = queueElevatorRequest(current, levelId) + if (next === current) return + + useInteractive.getState().setElevatorState(elevatorId, next) +} + +export function openElevatorDoor(elevatorId: AnyNodeId) { + const nodes = useScene.getState().nodes + const current = ensureElevatorState(elevatorId, nodes) + if (!current) return + + const next = openElevatorDoorState(current) + if (next === current) return + + useInteractive.getState().setElevatorState(elevatorId, next) +} + +export function stepElevatorRuntimeState({ + defaultEntry, + delta, + elevator, + entries, + now, + state, +}: { + defaultEntry: ElevatorLevelEntry + delta: number + elevator: ElevatorNode + entries: ElevatorLevelEntry[] + now: number + state: ElevatorInteractiveState +}): ElevatorInteractiveState { + const currentEntry = entries.find((entry) => entry.id === state.currentLevelId) ?? defaultEntry + if (currentEntry.id !== state.currentLevelId) { + return { + ...state, + currentLevelId: currentEntry.id as AnyNodeId, + carY: currentEntry.baseY, + targetLevelId: null, + phase: 'idle', + phaseStartedAt: null, + queue: [], + doorOpen: 0, + } + } + + const targetEntry = state.targetLevelId + ? entries.find((entry) => entry.id === state.targetLevelId) + : state.queue[0] + ? entries.find((entry) => entry.id === state.queue[0]) + : null + + const doorDurationMs = Math.max(elevator.doorDurationMs ?? 900, 1) + const doorStep = (delta * 1000) / doorDurationMs + + switch (state.phase) { + case 'idle': { + const nextLevelId = state.queue[0] ?? null + if (!nextLevelId) { + if (state.doorOpen > EPSILON) { + return { + ...state, + doorOpen: Math.max(0, state.doorOpen - doorStep), + } + } + return state + } + + return { + ...state, + targetLevelId: nextLevelId, + phase: + state.doorOpen > EPSILON + ? 'closing' + : nextLevelId === state.currentLevelId + ? 'opening' + : 'moving', + phaseStartedAt: now, + } + } + + case 'closing': { + const doorOpen = Math.max(0, state.doorOpen - doorStep) + return { + ...state, + doorOpen, + phase: doorOpen <= EPSILON ? (state.targetLevelId ? 'moving' : 'idle') : 'closing', + phaseStartedAt: doorOpen <= EPSILON ? now : state.phaseStartedAt, + } + } + + case 'moving': { + if (!targetEntry) { + return { + ...state, + targetLevelId: null, + phase: 'idle', + queue: [], + } + } + + const speed = Math.max(elevator.speed ?? 2.2, 0.1) + const nextY = moveToward(state.carY, targetEntry.baseY, speed * delta) + const arrived = Math.abs(nextY - targetEntry.baseY) <= EPSILON + return { + ...state, + carY: nextY, + currentLevelId: arrived ? (targetEntry.id as AnyNodeId) : state.currentLevelId, + phase: arrived ? 'opening' : 'moving', + phaseStartedAt: arrived ? now : state.phaseStartedAt, + } + } + + case 'opening': { + const doorOpen = Math.min(1, state.doorOpen + doorStep) + return { + ...state, + doorOpen, + phase: doorOpen >= 1 - EPSILON ? 'open' : 'opening', + phaseStartedAt: doorOpen >= 1 - EPSILON ? now : state.phaseStartedAt, + targetLevelId: doorOpen >= 1 - EPSILON ? null : state.targetLevelId, + queue: + doorOpen >= 1 - EPSILON && state.queue[0] === state.currentLevelId + ? state.queue.slice(1) + : state.queue, + } + } + + case 'open': { + const elapsed = now - (state.phaseStartedAt ?? now) + if (elapsed < Math.max(elevator.dwellMs ?? 1400, 0)) return state + + return { + ...state, + phase: 'closing', + phaseStartedAt: now, + targetLevelId: state.queue[0] ?? null, + } + } + } +} + +export function stepElevatorRuntimes(now: number, delta: number) { + const nodes = useScene.getState().nodes + const interactive = useInteractive.getState() + + for (const elevatorId of Object.keys(interactive.elevators)) { + const typedElevatorId = elevatorId as AnyNodeId + if (nodes[typedElevatorId]?.type !== 'elevator') { + interactive.removeElevator(typedElevatorId) + } + } + + for (const node of Object.values(nodes)) { + if (node.type !== 'elevator') continue + + const elevatorId = node.id as AnyNodeId + const { entries, defaultEntry } = resolveElevatorLevels(node, nodes) + if (!defaultEntry) continue + + const state = useInteractive.getState().elevators[elevatorId] + if (!state) { + useInteractive.getState().initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY) + continue + } + + const next = stepElevatorRuntimeState({ + defaultEntry, + delta, + elevator: node, + entries, + now, + state, + }) + if (next !== state) { + useInteractive.getState().setElevatorState(elevatorId, next) + } + } +} diff --git a/packages/core/src/systems/elevator/elevator-service.ts b/packages/core/src/systems/elevator/elevator-service.ts index c775d7089..59798da5d 100644 --- a/packages/core/src/systems/elevator/elevator-service.ts +++ b/packages/core/src/systems/elevator/elevator-service.ts @@ -1,4 +1,12 @@ -import type { AnyNode, AnyNodeId, ElevatorNode, LevelNode } from '../../schema' +import type { AnyNode, AnyNodeId, CeilingNode, ElevatorNode, LevelNode, WallNode } from '../../schema' + +export const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 + +export type ElevatorLevelEntry = { + id: LevelNode['id'] + label: string + baseY: number +} function getBuildingLevels(elevator: ElevatorNode, nodes: Record): LevelNode[] { const building = @@ -65,3 +73,78 @@ export function resolveElevatorServiceLevels( return levels.slice(minIndex, maxIndex + 1) } + +export function getElevatorLevelHeight(levelId: string, nodes: Record): number { + const level = nodes[levelId as AnyNodeId] as LevelNode | undefined + if (!level || level.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT + + let maxTop = 0 + + for (const childId of level.children) { + const child = nodes[childId as AnyNodeId] + if (!child) continue + + if (child.type === 'ceiling') { + const height = (child as CeilingNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT + if (height > maxTop) maxTop = height + } else if (child.type === 'wall') { + const height = (child as WallNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT + if (height > maxTop) maxTop = height + } + } + + return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT +} + +export function resolveElevatorLevels( + elevator: ElevatorNode, + nodes: Record, +): { + entries: ElevatorLevelEntry[] + defaultEntry: ElevatorLevelEntry | null + shaftBaseY: number + shaftTopY: number + totalHeight: number +} { + const allLevels = resolveElevatorBuildingLevels(elevator, nodes) + + const baseYByLevelId = new Map() + let cumulativeY = 0 + for (const level of allLevels) { + baseYByLevelId.set(level.id, cumulativeY) + cumulativeY += getElevatorLevelHeight(level.id, nodes) + } + + const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) + const entries = serviceLevels.map((level) => ({ + id: level.id, + label: String(level.level), + baseY: baseYByLevelId.get(level.id) ?? 0, + })) + + const defaultEntry = + entries.find((entry) => entry.id === elevator.defaultLevelId) ?? + entries.find((entry) => entry.id === elevator.fromLevelId) ?? + entries[0] ?? + null + const firstServedLevel = serviceLevels[0] ?? null + const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null + const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0 + const lastServedIndex = lastServedLevel + ? allLevels.findIndex((level) => level.id === lastServedLevel.id) + : -1 + const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null + const shaftTopY = nextLevel + ? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY) + : lastServedLevel + ? cumulativeY + : elevator.cabHeight + 0.3 + + return { + entries, + defaultEntry, + shaftBaseY, + shaftTopY, + totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3), + } +} diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 02e7a01c6..2b0074fde 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -5,10 +5,23 @@ import { type AnyNode, type AnyNodeId, type ElevatorNode, + type ElevatorDoorSide, emitter, + getElevatorCabCenterZ, + getElevatorCabDepth, + getElevatorCabWidth, + getElevatorDoorLeafSides, + getElevatorDoorLeafWidth, + getElevatorDoorLeafX, + getElevatorShaftDepth, + getElevatorShaftWallThickness, + getElevatorShaftWidth, + getResolvedElevatorDoorStyle, + openElevatorDoor, resolveElevatorBuildingLevels, resolveElevatorDispatchTarget, resolveElevatorServiceLevels, + requestElevatorLevel, sceneRegistry, useInteractive, useScene, @@ -62,7 +75,6 @@ const DOOR_LEAF_INTERACTION_DEPTH = 0.08 const ELEVATOR_RIDE_HORIZONTAL_PADDING = 0.18 const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14 const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08 -const ELEVATOR_COLLIDER_WALL_THICKNESS = 0.16 const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 @@ -133,9 +145,6 @@ type ElevatorColliderUserData = { side?: ElevatorDoorSide } -type ElevatorDoorSide = 'left' | 'right' -type ElevatorDoorStyleValue = ElevatorNode['doorStyle'] - type ElevatorColliderMesh = Mesh & { userData: Mesh['userData'] & ElevatorColliderUserData } @@ -217,67 +226,6 @@ function getInteractableTargetKey(target: FirstPersonInteractableTarget | null) : `${target.type}:${target.id}` } -function getResolvedElevatorDoorStyle( - doorStyle: ElevatorDoorStyleValue | undefined, -): ElevatorDoorStyleValue { - return doorStyle ?? 'center-opening' -} - -function getElevatorDoorLeafSides( - doorStyle: ElevatorDoorStyleValue | undefined, -): ElevatorDoorSide[] { - const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle) - if (resolvedDoorStyle === 'single-left') return ['left'] - if (resolvedDoorStyle === 'single-right') return ['right'] - return ['left', 'right'] -} - -function getElevatorDoorLeafWidth(width: number, doorStyle: ElevatorDoorStyleValue | undefined) { - return getResolvedElevatorDoorStyle(doorStyle) === 'center-opening' - ? Math.max(width / 2 - 0.018, 0.12) - : Math.max(width - 0.018, 0.18) -} - -function getElevatorDoorLeafX( - side: ElevatorDoorSide, - width: number, - doorOpen: number, - doorStyle: ElevatorDoorStyleValue | undefined, -) { - const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle) - if (resolvedDoorStyle === 'center-opening') { - const direction = side === 'left' ? -1 : 1 - return direction * (width / 4 + doorOpen * width * 0.34) - } - - const direction = resolvedDoorStyle === 'single-left' ? -1 : 1 - return direction * doorOpen * width * 0.68 -} - -function getElevatorCabWidth(elevator: ElevatorNode) { - return Math.max(elevator.width, 0.8) -} - -function getElevatorCabDepth(elevator: ElevatorNode) { - return Math.max(elevator.depth, 0.8) -} - -function getElevatorShaftWallThickness(elevator: ElevatorNode) { - return Math.max(elevator.shaftWallThickness ?? ELEVATOR_COLLIDER_WALL_THICKNESS, 0.04) -} - -function getElevatorShaftWidth(elevator: ElevatorNode, cabWidth = getElevatorCabWidth(elevator)) { - return Math.max(elevator.shaftWidth ?? cabWidth, cabWidth, 0.8) -} - -function getElevatorShaftDepth(elevator: ElevatorNode, cabDepth = getElevatorCabDepth(elevator)) { - return Math.max(elevator.shaftDepth ?? cabDepth, cabDepth, 0.8) -} - -function getElevatorCabCenterZ(elevator: ElevatorNode) { - return -getElevatorShaftDepth(elevator) / 2 + getElevatorCabDepth(elevator) / 2 -} - function isDynamicElevatorCollider(kind: ElevatorColliderKind) { return kind.startsWith('cab-') || kind.startsWith('landing-door') } @@ -836,7 +784,7 @@ export const FirstPersonControls = () => { } } if (target.action === 'open-door') { - useInteractive.getState().openElevatorDoor(target.id) + openElevatorDoor(target.id) return } if (target.levelId) { @@ -849,7 +797,7 @@ export const FirstPersonControls = () => { requestedElevatorId: target.id, }) : target.id - useInteractive.getState().requestElevator(targetElevatorId, target.levelId) + requestElevatorLevel(targetElevatorId, target.levelId) } return } diff --git a/packages/editor/src/components/ui/panels/elevator-panel.tsx b/packages/editor/src/components/ui/panels/elevator-panel.tsx index 1e95f00d7..02bbc4aa1 100644 --- a/packages/editor/src/components/ui/panels/elevator-panel.tsx +++ b/packages/editor/src/components/ui/panels/elevator-panel.tsx @@ -6,6 +6,7 @@ import { type ElevatorNode, ElevatorNode as ElevatorNodeSchema, type LevelNode, + requestElevatorLevel, useInteractive, useLiveNodeOverrides, useLiveTransforms, @@ -337,7 +338,7 @@ export function ElevatorPanel() { (levelId: LevelNode['id']) => { if (!node) return if ((node.disabledLevelIds ?? []).includes(levelId)) return - useInteractive.getState().requestElevator(node.id as AnyNodeId, levelId as AnyNodeId) + requestElevatorLevel(node.id as AnyNodeId, levelId as AnyNodeId) }, [node], ) diff --git a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx index 8aa0815e6..facfe5c82 100644 --- a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx +++ b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx @@ -1,7 +1,20 @@ import { type AnyNode, type AnyNodeId, + type ElevatorDoorSide, type ElevatorNode, + getElevatorCabDepth, + getElevatorCabWidth, + getElevatorDoorLeafSides, + getElevatorDoorLeafWidth, + getElevatorDoorLeafX, + getElevatorShaftDepth, + getElevatorShaftWallThickness, + getElevatorShaftWidth, + getResolvedElevatorDoorPanelStyle as getResolvedDoorPanelStyle, + getResolvedElevatorDoorStyle as getResolvedDoorStyle, + getResolvedElevatorShaftStyle as getResolvedShaftStyle, + resolveElevatorLevels, useInteractive, useLiveNodeOverrides, useLiveTransforms, @@ -21,7 +34,6 @@ import { } from 'three' import { useShallow } from 'zustand/react/shallow' import { useNodeEvents } from '../../../hooks/use-node-events' -import { resolveElevatorLevels } from '../../../systems/elevator/elevator-utils' const SHAFT_WALL_COLOR = '#d7dce4' const SHAFT_SIDE_COLOR = '#4b5563' @@ -30,7 +42,6 @@ const CAB_COLOR = '#d7dde5' const GLASS_COLOR = '#f8fafc' const DOOR_COLOR = '#8e98a6' const PANEL_COLOR = '#1f2937' -const DEFAULT_SHAFT_WALL_THICKNESS = 0.09 type Vector3Tuple = [number, number, number] @@ -41,10 +52,8 @@ const BUTTON_RING_GEOMETRY = new TorusGeometry(1.12, 0.12, 8, 24) const LABEL_MATRIX_DUMMY = new Object3D() const SHAFT_TOP_FRAME_CLEARANCE = 0.006 -type ElevatorDoorSide = 'left' | 'right' type ElevatorDoorPanelStyleValue = ElevatorNode['doorPanelStyle'] type ElevatorDoorStyleValue = ElevatorNode['doorStyle'] -type ElevatorShaftStyleValue = ElevatorNode['shaftStyle'] const SHAFT_WALL_MATERIAL = new MeshStandardMaterial({ color: SHAFT_WALL_COLOR, @@ -611,78 +620,6 @@ function ElevatorMeshButton({ ) } -function getResolvedDoorStyle( - doorStyle: ElevatorDoorStyleValue | undefined, -): ElevatorDoorStyleValue { - return doorStyle ?? 'center-opening' -} - -function getResolvedDoorPanelStyle( - doorPanelStyle: ElevatorDoorPanelStyleValue | undefined, -): ElevatorDoorPanelStyleValue { - return doorPanelStyle ?? 'glass-frame' -} - -function getResolvedShaftStyle( - shaftStyle: ElevatorShaftStyleValue | undefined, -): ElevatorShaftStyleValue { - return shaftStyle ?? 'solid' -} - -function getElevatorDoorLeafSides( - doorStyle: ElevatorDoorStyleValue | undefined, -): ElevatorDoorSide[] { - const resolvedDoorStyle = getResolvedDoorStyle(doorStyle) - if (resolvedDoorStyle === 'single-left') return ['left'] - if (resolvedDoorStyle === 'single-right') return ['right'] - return ['left', 'right'] -} - -function getElevatorDoorLeafX( - side: ElevatorDoorSide, - openingWidth: number, - doorOpen: number, - doorStyle: ElevatorDoorStyleValue | undefined, -) { - const resolvedDoorStyle = getResolvedDoorStyle(doorStyle) - if (resolvedDoorStyle === 'center-opening') { - const direction = side === 'left' ? -1 : 1 - return direction * (openingWidth / 4 + doorOpen * openingWidth * 0.34) - } - - const direction = resolvedDoorStyle === 'single-left' ? -1 : 1 - return direction * doorOpen * openingWidth * 0.68 -} - -function getElevatorDoorLeafWidth( - openingWidth: number, - doorStyle: ElevatorDoorStyleValue | undefined, -) { - return getResolvedDoorStyle(doorStyle) === 'center-opening' - ? Math.max(openingWidth / 2 - 0.018, 0.12) - : Math.max(openingWidth - 0.018, 0.18) -} - -function getElevatorCabWidth(node: ElevatorNode) { - return Math.max(node.width, 0.8) -} - -function getElevatorCabDepth(node: ElevatorNode) { - return Math.max(node.depth, 0.8) -} - -function getElevatorShaftWallThickness(node: ElevatorNode) { - return Math.max(node.shaftWallThickness ?? DEFAULT_SHAFT_WALL_THICKNESS, 0.04) -} - -function getElevatorShaftWidth(node: ElevatorNode, cabWidth = getElevatorCabWidth(node)) { - return Math.max(node.shaftWidth ?? cabWidth, cabWidth, 0.8) -} - -function getElevatorShaftDepth(node: ElevatorNode, cabDepth = getElevatorCabDepth(node)) { - return Math.max(node.shaftDepth ?? cabDepth, cabDepth, 0.8) -} - function getElevatorLevelContextNodes( elevator: ElevatorNode, nodes: ReturnType['nodes'], diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 1c8404fd6..b620ad9d2 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { ElevatorOpeningSystem } from '@pascal-app/core' +import { ElevatorOpeningSystem, ElevatorRuntimeSystem } from '@pascal-app/core' import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three/webgpu' @@ -8,7 +8,6 @@ import useViewer from '../../store/use-viewer' import { CeilingSystem } from '../../systems/ceiling/ceiling-system' import { DoorAnimationSystem } from '../../systems/door/door-animation-system' import { DoorSystem } from '../../systems/door/door-system' -import { ElevatorAnimationSystem } from '../../systems/elevator/elevator-animation-system' import { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-system' import { FenceSystem } from '../../systems/fence/fence-system' import { GuideSystem } from '../../systems/guide/guide-system' @@ -225,7 +224,7 @@ const Viewer: React.FC = ({ {/* Core systems */} - + diff --git a/packages/viewer/src/systems/elevator/elevator-animation-system.tsx b/packages/viewer/src/systems/elevator/elevator-animation-system.tsx deleted file mode 100644 index f512579ef..000000000 --- a/packages/viewer/src/systems/elevator/elevator-animation-system.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { - type AnyNodeId, - type ElevatorNode, - sceneRegistry, - useInteractive, - useScene, -} from '@pascal-app/core' -import { useFrame } from '@react-three/fiber' -import { resolveElevatorLevels } from './elevator-utils' - -const EPSILON = 0.001 - -function moveToward(current: number, target: number, maxDelta: number) { - const delta = target - current - if (Math.abs(delta) <= maxDelta) return target - return current + Math.sign(delta) * maxDelta -} - -export function ElevatorAnimationSystem() { - useFrame(({ clock }, delta) => { - const interactive = useInteractive.getState() - const nodes = useScene.getState().nodes - const now = clock.getElapsedTime() * 1000 - - for (const elevatorId of Object.keys(interactive.elevators)) { - const typedElevatorId = elevatorId as AnyNodeId - if (nodes[typedElevatorId]?.type !== 'elevator') { - interactive.removeElevator(typedElevatorId) - } - } - - for (const elevatorId of sceneRegistry.byType.elevator) { - const typedElevatorId = elevatorId as AnyNodeId - const node = nodes[typedElevatorId] - if (node?.type !== 'elevator') { - interactive.removeElevator(typedElevatorId) - continue - } - - const elevator = node as ElevatorNode - const { entries, defaultEntry } = resolveElevatorLevels(elevator, nodes) - if (!defaultEntry) continue - - const state = interactive.elevators[typedElevatorId] - if (!state) { - interactive.initElevator(typedElevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY) - continue - } - - const currentEntry = - entries.find((entry) => entry.id === state.currentLevelId) ?? defaultEntry - if (currentEntry.id !== state.currentLevelId) { - interactive.setElevatorState(typedElevatorId, { - currentLevelId: currentEntry.id as AnyNodeId, - carY: currentEntry.baseY, - targetLevelId: null, - phase: 'idle', - phaseStartedAt: null, - queue: [], - doorOpen: 0, - }) - continue - } - - const targetEntry = state.targetLevelId - ? entries.find((entry) => entry.id === state.targetLevelId) - : state.queue[0] - ? entries.find((entry) => entry.id === state.queue[0]) - : null - - const doorDurationMs = Math.max(elevator.doorDurationMs ?? 900, 1) - const doorStep = (delta * 1000) / doorDurationMs - - switch (state.phase) { - case 'idle': { - const nextLevelId = state.queue[0] ?? null - if (!nextLevelId) { - if (state.doorOpen > EPSILON) { - interactive.setElevatorState(typedElevatorId, { - doorOpen: Math.max(0, state.doorOpen - doorStep), - }) - } - break - } - - interactive.setElevatorState(typedElevatorId, { - targetLevelId: nextLevelId, - phase: - state.doorOpen > EPSILON - ? 'closing' - : nextLevelId === state.currentLevelId - ? 'opening' - : 'moving', - phaseStartedAt: now, - }) - break - } - - case 'closing': { - const doorOpen = Math.max(0, state.doorOpen - doorStep) - interactive.setElevatorState(typedElevatorId, { - doorOpen, - phase: doorOpen <= EPSILON ? (state.targetLevelId ? 'moving' : 'idle') : 'closing', - phaseStartedAt: doorOpen <= EPSILON ? now : state.phaseStartedAt, - }) - break - } - - case 'moving': { - if (!targetEntry) { - interactive.setElevatorState(typedElevatorId, { - targetLevelId: null, - phase: 'idle', - queue: [], - }) - break - } - - const speed = Math.max(elevator.speed ?? 2.2, 0.1) - const nextY = moveToward(state.carY, targetEntry.baseY, speed * delta) - const arrived = Math.abs(nextY - targetEntry.baseY) <= EPSILON - interactive.setElevatorState(typedElevatorId, { - carY: nextY, - currentLevelId: arrived ? (targetEntry.id as AnyNodeId) : state.currentLevelId, - phase: arrived ? 'opening' : 'moving', - phaseStartedAt: arrived ? now : state.phaseStartedAt, - }) - break - } - - case 'opening': { - const doorOpen = Math.min(1, state.doorOpen + doorStep) - interactive.setElevatorState(typedElevatorId, { - doorOpen, - phase: doorOpen >= 1 - EPSILON ? 'open' : 'opening', - phaseStartedAt: doorOpen >= 1 - EPSILON ? now : state.phaseStartedAt, - targetLevelId: doorOpen >= 1 - EPSILON ? null : state.targetLevelId, - queue: - doorOpen >= 1 - EPSILON && state.queue[0] === state.currentLevelId - ? state.queue.slice(1) - : state.queue, - }) - break - } - - case 'open': { - const elapsed = now - (state.phaseStartedAt ?? now) - if (elapsed < Math.max(elevator.dwellMs ?? 1400, 0)) break - - interactive.setElevatorState(typedElevatorId, { - phase: 'closing', - phaseStartedAt: now, - targetLevelId: state.queue[0] ?? null, - }) - break - } - } - } - }, 2) - - return null -} diff --git a/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx index 447259fb9..ded3d4999 100644 --- a/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx +++ b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx @@ -1,6 +1,8 @@ import { type AnyNodeId, + openElevatorDoor, resolveElevatorDispatchTarget, + requestElevatorLevel, useInteractive, useScene, } from '@pascal-app/core' @@ -62,7 +64,7 @@ export function ElevatorInteractionSystem() { event.stopPropagation() if (button.action === 'open-door') { - useInteractive.getState().openElevatorDoor(button.elevatorId) + openElevatorDoor(button.elevatorId) return } @@ -78,7 +80,7 @@ export function ElevatorInteractionSystem() { }) : button.elevatorId - useInteractive.getState().requestElevator(targetElevatorId, button.levelId) + requestElevatorLevel(targetElevatorId, button.levelId) } canvas.addEventListener('pointerdown', handlePointerDown, true) diff --git a/packages/viewer/src/systems/elevator/elevator-utils.ts b/packages/viewer/src/systems/elevator/elevator-utils.ts deleted file mode 100644 index 4c0c03fa8..000000000 --- a/packages/viewer/src/systems/elevator/elevator-utils.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - resolveElevatorBuildingLevels, - resolveElevatorServiceLevels, - type AnyNode, - type AnyNodeId, - type ElevatorNode, - type LevelNode, -} from '@pascal-app/core' -import { getLevelHeight } from '../level/level-utils' - -export type ElevatorLevelEntry = { - id: LevelNode['id'] - label: string - baseY: number -} - -export function resolveElevatorLevels( - elevator: ElevatorNode, - nodes: Record, -): { - entries: ElevatorLevelEntry[] - defaultEntry: ElevatorLevelEntry | null - shaftBaseY: number - shaftTopY: number - totalHeight: number -} { - const allLevels = resolveElevatorBuildingLevels(elevator, nodes) - - const baseYByLevelId = new Map() - let cumulativeY = 0 - for (const level of allLevels) { - baseYByLevelId.set(level.id, cumulativeY) - cumulativeY += getLevelHeight(level.id, nodes) - } - - const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) - const entries = serviceLevels.map((level) => ({ - id: level.id, - label: String(level.level), - baseY: baseYByLevelId.get(level.id) ?? 0, - })) - - const defaultEntry = - entries.find((entry) => entry.id === elevator.defaultLevelId) ?? - entries.find((entry) => entry.id === elevator.fromLevelId) ?? - entries[0] ?? - null - const firstServedLevel = serviceLevels[0] ?? null - const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null - const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0 - const lastServedIndex = lastServedLevel - ? allLevels.findIndex((level) => level.id === lastServedLevel.id) - : -1 - const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null - const shaftTopY = nextLevel - ? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY) - : lastServedLevel - ? cumulativeY - : elevator.cabHeight + 0.3 - - return { - entries, - defaultEntry, - shaftBaseY, - shaftTopY, - totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3), - } -} From ce69433a1a9e97fcb8903f007eebb073c9968e9f Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 13 May 2026 22:28:19 +0530 Subject: [PATCH 8/9] Refine elevator sync and viewer rebuild handling --- .../src/schema/nodes/surface-hole-metadata.ts | 1 + .../systems/elevator/elevator-opening-sync.ts | 15 ++++---- .../src/systems/stair/stair-opening-sync.ts | 7 +--- .../components/editor/selection-manager.tsx | 1 + .../tools/elevator/elevator-tool.tsx | 29 ++++++--------- .../tools/elevator/move-elevator-tool.tsx | 23 +++++++++--- packages/editor/src/lib/elevator-support.ts | 36 ++++++++++++++++--- .../src/components/viewer/post-processing.tsx | 4 ++- 8 files changed, 76 insertions(+), 40 deletions(-) diff --git a/packages/core/src/schema/nodes/surface-hole-metadata.ts b/packages/core/src/schema/nodes/surface-hole-metadata.ts index bb223876b..a3d560e83 100644 --- a/packages/core/src/schema/nodes/surface-hole-metadata.ts +++ b/packages/core/src/schema/nodes/surface-hole-metadata.ts @@ -1,6 +1,7 @@ import { z } from 'zod' export const SurfaceHoleMetadata = z.object({ + // Stair/elevator auto-openings use stairId/elevatorId so sync can replace only its own holes. source: z.enum(['manual', 'stair', 'elevator']).default('manual'), stairId: z.string().optional(), elevatorId: z.string().optional(), diff --git a/packages/core/src/systems/elevator/elevator-opening-sync.ts b/packages/core/src/systems/elevator/elevator-opening-sync.ts index c02d1ee8e..9fe4adf91 100644 --- a/packages/core/src/systems/elevator/elevator-opening-sync.ts +++ b/packages/core/src/systems/elevator/elevator-opening-sync.ts @@ -1,15 +1,16 @@ import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' -import type { AnyNode, AnyNodeId, CeilingNode, ElevatorNode, SlabNode } from '../../schema' +import type { + AnyNode, + AnyNodeId, + CeilingNode, + ElevatorNode, + SlabNode, + SurfaceHoleMetadata, +} from '../../schema' import { resolveElevatorServiceLevels } from './elevator-service' type Point2D = [number, number] -type SurfaceHoleMetadata = { - source: 'manual' | 'stair' | 'elevator' - elevatorId?: string - stairId?: string -} - const ELEVATOR_OPENING_PADDING = 0.08 const DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS = 0.09 diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index 71680e0af..6f4c9f7ae 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -6,17 +6,12 @@ import type { SlabNode, StairNode, StairSegmentNode, + SurfaceHoleMetadata, } from '../../schema' import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' type Point2D = [number, number] -type SurfaceHoleMetadata = { - source: 'manual' | 'stair' | 'elevator' - elevatorId?: string - stairId?: string -} - type SegmentTransform = { position: [number, number, number] rotation: number diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 1927ed51a..81a6059cc 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -56,6 +56,7 @@ import useEditor, { import { boxSelectHandled } from '../tools/select/box-select-tool' const isNodeInCurrentLevel = (node: AnyNode): boolean => { + // Elevators are building-scoped, so they stay selectable across level filters. if (node.type === 'elevator') return true const currentLevelId = useViewer.getState().selection.levelId if (!currentLevelId) return true // No level selected, allow all diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx index a57020788..409628f27 100644 --- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -9,7 +9,7 @@ import { } from '@pascal-app/core' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' -import { resolveElevatorSupportY } from '../../../lib/elevator-support' +import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' import { @@ -31,21 +31,6 @@ type ElevatorToolProps = { onPlaced?: (elevatorId: AnyNodeId, buildingId: BuildingNode['id']) => void } -function resolveCurrentBuildingId( - buildingId: BuildingNode['id'] | null, - levelId: LevelNode['id'] | null, -): BuildingNode['id'] | null { - if (buildingId) return buildingId as BuildingNode['id'] - if (!levelId) return null - - const level = useScene.getState().nodes[levelId as AnyNodeId] - if (level?.type === 'level' && level.parentId) { - return level.parentId as BuildingNode['id'] - } - - return null -} - function resolveDefaultServiceRange( buildingId: BuildingNode['id'], selectedLevelId: LevelNode['id'] | null, @@ -130,7 +115,11 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, const previewGeometry = useMemo(() => createElevatorPreviewGeometry(), []) useEffect(() => { - const currentBuildingId = resolveCurrentBuildingId(buildingId, levelId) + const currentBuildingId = resolveCurrentBuildingId({ + buildingId, + levelId, + nodes: useScene.getState().nodes, + }) if (!currentBuildingId) return rotationRef.current = 0 @@ -160,7 +149,11 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, } const onGridClick = (event: GridEvent) => { - const latestBuildingId = resolveCurrentBuildingId(buildingId, levelId) + const latestBuildingId = resolveCurrentBuildingId({ + buildingId, + levelId, + nodes: useScene.getState().nodes, + }) if (!latestBuildingId) return const gridX = Math.round(event.localPosition[0] * 2) / 2 diff --git a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx index 2322c6dcb..5a82db0ca 100644 --- a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx @@ -6,6 +6,8 @@ import { emitter, type GridEvent, type LevelNode, + pauseSceneHistory, + resumeSceneHistory, sceneRegistry, useLiveTransforms, useScene, @@ -36,6 +38,7 @@ export function MoveElevatorTool({ onCommitted?: (nodeId: AnyNodeId) => void }) { const onCommittedRef = useRef(onCommitted) + const historyPausedRef = useRef(false) const previousGridPosRef = useRef<[number, number] | null>(null) const previewPositionRef = useRef([ movingNode.position[0], @@ -57,7 +60,19 @@ export function MoveElevatorTool({ }, [onCommitted]) useEffect(() => { - useScene.temporal.getState().pause() + const pauseHistory = () => { + const temporal = useScene.temporal.getState() + if (historyPausedRef.current || !temporal.isTracking) return + pauseSceneHistory(useScene) + historyPausedRef.current = true + } + const resumeHistory = () => { + if (!historyPausedRef.current) return + resumeSceneHistory(useScene) + historyPausedRef.current = false + } + + pauseHistory() const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id const meta = @@ -148,7 +163,7 @@ export function MoveElevatorTool({ wasCommitted = true clearPreview() - useScene.temporal.getState().resume() + resumeHistory() if (movingNodeId && useScene.getState().nodes[movingNodeId as AnyNodeId]) { useScene.getState().updateNode(movingNodeId as AnyNodeId, { position: nextPosition, @@ -188,7 +203,7 @@ export function MoveElevatorTool({ } } resetObject(original.position, original.rotation) - useScene.temporal.getState().resume() + resumeHistory() markToolCancelConsumed() exitMoveMode() } @@ -226,7 +241,7 @@ export function MoveElevatorTool({ }) resetObject(original.position, original.rotation) } - useScene.temporal.getState().resume() + resumeHistory() emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) diff --git a/packages/editor/src/lib/elevator-support.ts b/packages/editor/src/lib/elevator-support.ts index fc8745627..711f2594b 100644 --- a/packages/editor/src/lib/elevator-support.ts +++ b/packages/editor/src/lib/elevator-support.ts @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + type BuildingNode, type ElevatorNode, type LevelNode, spatialGridManager, @@ -21,6 +22,30 @@ function getBuildingLevels( .sort((left, right) => left.level - right.level) } +export function resolveCurrentBuildingId({ + buildingId, + levelId, + nodes, +}: { + buildingId: BuildingNode['id'] | null + levelId: LevelNode['id'] | null + nodes: Record +}): BuildingNode['id'] | null { + if (buildingId) return buildingId + if (!levelId) return null + + const level = nodes[levelId as AnyNodeId] + if ( + level?.type === 'level' && + level.parentId && + nodes[level.parentId as AnyNodeId]?.type === 'building' + ) { + return level.parentId as BuildingNode['id'] + } + + return null +} + export function resolveElevatorSupportLevelId({ buildingId, preferredLevelId, @@ -29,13 +54,16 @@ export function resolveElevatorSupportLevelId({ preferredLevelId?: string | null }): LevelNode['id'] | null { const nodes = useScene.getState().nodes + const preferred = preferredLevelId ? nodes[preferredLevelId as AnyNodeId] : undefined const levels = getBuildingLevels(buildingId, nodes) - if (levels.length === 0) return null - - const preferred = preferredLevelId + const preferredInBuilding = preferredLevelId ? levels.find((level) => level.id === preferredLevelId) : undefined - return preferred?.id ?? levels[0]?.id ?? null + + if (preferredInBuilding) return preferredInBuilding.id + if (levels.length === 0) return preferred?.type === 'level' ? preferred.id : null + + return levels[0]?.id ?? null } export function resolveElevatorSupportY({ diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index a64c1e2e6..63d070394 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -116,6 +116,7 @@ const PostProcessingPasses = ({ // Subscribe to projectId so the pipeline rebuilds on project switch const projectId = useViewer((s) => s.projectId) + const lastProjectIdRef = useRef(projectId) // Bump this to force a pipeline rebuild (used by retry logic) const [pipelineVersion, setPipelineVersion] = useState(0) @@ -131,7 +132,8 @@ const PostProcessingPasses = ({ // Reset retry state when project changes useEffect(() => { - void projectId + if (lastProjectIdRef.current === projectId) return + lastProjectIdRef.current = projectId retryCountRef.current = 0 if (rebuildTimeoutRef.current !== null) { clearTimeout(rebuildTimeoutRef.current) From 1ffa3d2d734d831184b429ab72bd069858df2d85 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 13 May 2026 22:45:58 +0530 Subject: [PATCH 9/9] Restore item controls and guard placement asset bounds --- packages/editor/src/components/editor/index.tsx | 2 +- .../editor/src/components/tools/item/item-tool.tsx | 12 +++++++++--- .../tools/item/use-placement-coordinator.tsx | 8 ++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 8515223cb..9b7aa1766 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -607,7 +607,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!isFirstPersonMode && } - {isFirstPersonMode && } + ) }) diff --git a/packages/editor/src/components/tools/item/item-tool.tsx b/packages/editor/src/components/tools/item/item-tool.tsx index 28589ec5e..6bb1ffaeb 100644 --- a/packages/editor/src/components/tools/item/item-tool.tsx +++ b/packages/editor/src/components/tools/item/item-tool.tsx @@ -1,10 +1,10 @@ +import type { AssetInput } from '@pascal-app/core' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { useDraftNode } from './use-draft-node' import { usePlacementCoordinator } from './use-placement-coordinator' -export const ItemTool: React.FC = () => { - const selectedItem = useEditor((state) => state.selectedItem) +function ItemPlacementContent({ selectedItem }: { selectedItem: AssetInput }) { const draftNode = useDraftNode() const cursor = usePlacementCoordinator({ @@ -21,6 +21,12 @@ export const ItemTool: React.FC = () => { }, }) - if (!selectedItem) return null return <>{cursor} } + +export const ItemTool: React.FC = () => { + const selectedItem = useEditor((state) => state.selectedItem) + + if (!selectedItem) return null + return +} diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index fcc477672..fdafe3635 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -116,10 +116,10 @@ function expandBoundsToGrid( function getFallbackPreviewBounds( item: import('@pascal-app/core').ItemNode | null, - asset: AssetInput, - attachTo: AssetInput['attachTo'], + asset: AssetInput | null | undefined, + attachTo: AssetInput['attachTo'] | null | undefined, ): PreviewBounds { - const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS) + const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS) return { min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2], max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2], @@ -1341,7 +1341,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep) const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0 const initialDimensionBounds = expandBoundsToGrid( - getFallbackPreviewBounds(initialDraft, config.asset!, initialAttachTo), + getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo), initialAttachTo, gridSnapStep, )