From 2aa28228c6894ec4d748d104b1f7e07cac101262 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 30 Sep 2026 09:58:51 -0400 Subject: [PATCH 1/2] feat(editor): door/window/light action-menu buttons with E hint, slab lift on remount, select-building action - doors and windows declare capabilities.mechanism (Open/Close with their own glyphs); E routes through it - ActionMenuButton extracted with pressed state; light toggle and mechanism buttons show the E key - FloorElevationSystem lifts floor-placed meshes when they first mount (Preview remount left them at base Y) - multi-selection panel: Select building when the selection is everything on the level Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VMczFMfWJtZDbBdTARy88m --- packages/core/src/registry/types.ts | 2 + .../components/editor/action-menu-button.tsx | 83 +++++++++++++++++++ .../editor/first-person-controls.tsx | 3 + .../components/editor/node-action-menu.tsx | 72 +--------------- .../editor/registry-action-contributions.tsx | 40 +++++++-- .../src/components/editor/room-controls.tsx | 3 +- .../src/components/editor/room-handles-2d.tsx | 2 +- .../ui/panels/multi-selection-panel.tsx | 42 +++++++++- packages/editor/src/hooks/use-keyboard.ts | 24 +----- packages/editor/src/index.tsx | 12 +++ packages/editor/src/lib/door-interaction.ts | 14 ++++ packages/editor/src/lib/window-interaction.ts | 14 ++++ packages/nodes/src/door/definition.ts | 2 + packages/nodes/src/door/mechanism.ts | 16 ++++ .../src/shared/item-interaction-actions.tsx | 20 ++--- packages/nodes/src/window/definition.ts | 2 + packages/nodes/src/window/mechanism.ts | 20 +++++ .../floor-elevation-system.tsx | 20 ++++- 18 files changed, 270 insertions(+), 121 deletions(-) create mode 100644 packages/editor/src/components/editor/action-menu-button.tsx create mode 100644 packages/nodes/src/door/mechanism.ts create mode 100644 packages/nodes/src/window/mechanism.ts diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 306d16d95b..1dc44ae016 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1983,6 +1983,8 @@ export type MechanismCapability = { set: (node: AnyNode, on: boolean) => void /** Walkthrough wording: `open` parts open and close; `run` parts (the default) turn on and off. */ verb?: 'open' | 'run' + /** Action-menu glyph: openable kinds show their own instead of Play/Stop. */ + icon?: 'door' | 'window' } export type NodeQuickActionIcon = 'add-left' | 'add-right' | 'add' | 'convert' diff --git a/packages/editor/src/components/editor/action-menu-button.tsx b/packages/editor/src/components/editor/action-menu-button.tsx new file mode 100644 index 0000000000..36b722e055 --- /dev/null +++ b/packages/editor/src/components/editor/action-menu-button.tsx @@ -0,0 +1,83 @@ +'use client' + +import type { MouseEventHandler, ReactNode } from 'react' +import { cn } from '../../lib/utils' +import { shortcutDisplayValue } from '../ui/primitives/shortcut-token' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' + +const BUTTON = + 'rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground' +const DESTRUCTIVE = 'hover:bg-destructive/10 hover:text-destructive' +const DISABLED = 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground' + +/** + * One button of an action pill, with the editor's tooltip: its name, and the + * keys that do the same where there are any. A disabled button still shows + * its tooltip (it stays hoverable) so it can say why it is off. + */ +export function ActionMenuButton({ + label, + keys, + onClick, + destructive = false, + disabled = false, + disabledReason, + pressed, + children, +}: { + label: string + /** Keys pressed together, as `ShortcutToken` names them (`Cmd/Ctrl`, `G`). */ + keys?: string[] + onClick?: MouseEventHandler + destructive?: boolean + disabled?: boolean + /** The tooltip while disabled; the label otherwise. */ + disabledReason?: string + /** A toggle's state: sets `aria-pressed` and the active look. */ + pressed?: boolean + children: ReactNode +}) { + return ( + + + + + + {disabled && disabledReason ? disabledReason : label} + {!disabled && keys?.length ? ( + + {keys.map((key) => ( + + {shortcutDisplayValue(key)} + + ))} + + ) : null} + + + ) +} diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index d67ed0de92..1157fe6690 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1074,6 +1074,9 @@ export const FirstPersonControls = () => { closestDistance = hit.distance } } + // Doors and windows have their own walkthrough targets (resolveInteractable*Id). + for (const rawId of sceneRegistry.byType.door ?? []) covered.add(rawId) + for (const rawId of sceneRegistry.byType.window ?? []) covered.add(rawId) for (const id of mechanismTargetIds(covered)) { const object = sceneRegistry.nodes.get(id) const hit = object && proceduralInteractionRaycaster.intersectObject(object, true)[0] diff --git a/packages/editor/src/components/editor/node-action-menu.tsx b/packages/editor/src/components/editor/node-action-menu.tsx index 72c1497e76..ee5fa7d434 100644 --- a/packages/editor/src/components/editor/node-action-menu.tsx +++ b/packages/editor/src/components/editor/node-action-menu.tsx @@ -14,79 +14,9 @@ import { Ungroup, } from 'lucide-react' import type { MouseEventHandler, PointerEventHandler, ReactNode } from 'react' -import { cn } from '../../lib/utils' -import { shortcutDisplayValue } from '../ui/primitives/shortcut-token' -import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' +import { ActionMenuButton } from './action-menu-button' import { RegistryActionContributions } from './registry-action-contributions' -const BUTTON = - 'rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground' -const DESTRUCTIVE = 'hover:bg-destructive/10 hover:text-destructive' -const DISABLED = 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground' - -/** - * One button of an action pill, with the editor's tooltip: its name, and the - * keys that do the same where there are any. A disabled button still shows - * its tooltip (it stays hoverable) so it can say why it is off. - */ -export function ActionMenuButton({ - label, - keys, - onClick, - destructive = false, - disabled = false, - disabledReason, - children, -}: { - label: string - /** Keys pressed together, as `ShortcutToken` names them (`Cmd/Ctrl`, `G`). */ - keys?: string[] - onClick?: MouseEventHandler - destructive?: boolean - disabled?: boolean - /** The tooltip while disabled; the label otherwise. */ - disabledReason?: string - children: ReactNode -}) { - return ( - - - - - - {disabled && disabledReason ? disabledReason : label} - {!disabled && keys?.length ? ( - - {keys.map((key) => ( - - {shortcutDisplayValue(key)} - - ))} - - ) : null} - - - ) -} - type NodeActionMenuProps = { onFind?: MouseEventHandler onAddHole?: MouseEventHandler diff --git a/packages/editor/src/components/editor/registry-action-contributions.tsx b/packages/editor/src/components/editor/registry-action-contributions.tsx index cdc60ce438..c4df35e11c 100644 --- a/packages/editor/src/components/editor/registry-action-contributions.tsx +++ b/packages/editor/src/components/editor/registry-action-contributions.tsx @@ -11,10 +11,11 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Play, Square } from 'lucide-react' +import { DoorClosed, DoorOpen, PanelTopClose, PanelTopOpen, Play, Square } from 'lucide-react' import { type ComponentType, lazy, Suspense } from 'react' import { useShallow } from 'zustand/react/shallow' import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension' +import { ActionMenuButton } from './action-menu-button' type Loader = () => Promise<{ default: ComponentType }> const lazyCache = new WeakMap() @@ -38,6 +39,28 @@ export function selectedMechanismNode( return nodeMechanism(node) ? node : undefined } +function MechanismGlyph({ + icon, + running, +}: { + icon: MechanismCapability['icon'] + running: boolean +}) { + const Glyph = + icon === 'door' + ? running + ? DoorClosed + : DoorOpen + : icon === 'window' + ? running + ? PanelTopClose + : PanelTopOpen + : running + ? Square + : Play + return +} + export function MechanismButton({ node, mechanism, @@ -47,20 +70,19 @@ export function MechanismButton({ mechanism: MechanismCapability running: boolean }) { + const label = mechanism.icon ? (running ? 'Close' : 'Open') : running ? 'Stop' : 'Play' return ( - + + ) } diff --git a/packages/editor/src/components/editor/room-controls.tsx b/packages/editor/src/components/editor/room-controls.tsx index 049b726919..04bbe65394 100644 --- a/packages/editor/src/components/editor/room-controls.tsx +++ b/packages/editor/src/components/editor/room-controls.tsx @@ -66,8 +66,9 @@ import { DRAFT_LABEL_Y_OFFSET, DraftMeasurementLabel, } from '../tools/shared/draft-measurement-label' +import { ActionMenuButton } from './action-menu-button' import { getMenuYOffset } from './floating-action-menu' -import { ActionMenuButton, NodeActionMenu } from './node-action-menu' +import { NodeActionMenu } from './node-action-menu' import { RoomFloorHighlight3D } from './room-floor-highlight' import { RoomHandleDragPreview3D, RoomHandles3D } from './room-handles' import { RoomFloorHeightStepper, useRoomControls } from './room-handles-2d' diff --git a/packages/editor/src/components/editor/room-handles-2d.tsx b/packages/editor/src/components/editor/room-handles-2d.tsx index f7c83036ed..386ab13cc0 100644 --- a/packages/editor/src/components/editor/room-handles-2d.tsx +++ b/packages/editor/src/components/editor/room-handles-2d.tsx @@ -27,8 +27,8 @@ import useInteractionScope from '../../store/use-interaction-scope' import { useFloorplanRender } from '../editor-2d/floorplan-render-context' import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' +import { ActionMenuButton } from './action-menu-button' import { formatMeasurement } from './measurement-pill' -import { ActionMenuButton } from './node-action-menu' /** * The selected room while its controls may show: structure select, nothing diff --git a/packages/editor/src/components/ui/panels/multi-selection-panel.tsx b/packages/editor/src/components/ui/panels/multi-selection-panel.tsx index a35c9c45da..4ea195b9f1 100644 --- a/packages/editor/src/components/ui/panels/multi-selection-panel.tsx +++ b/packages/editor/src/components/ui/panels/multi-selection-panel.tsx @@ -1,10 +1,11 @@ 'use client' -import { type AnyNodeId, useScene } from '@pascal-app/core' +import { type AnyNodeId, resolveBuildingForLevel, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Copy, Group, Trash2, Ungroup } from 'lucide-react' +import { Building2, Copy, Group, Trash2, Ungroup } from 'lucide-react' import { useMemo } from 'react' import { deleteSelection, duplicateSelectionAndPickUp } from '../../editor/group-actions' +import { collectSelectableCandidateIds } from '../../tools/select/select-candidates' import { canCreateSessionGroup, selectionIntersectsSessionGroup, @@ -18,6 +19,25 @@ import { ActionButton, ActionGroup } from '../controls/action-button' import { PanelWrapper } from './panel-wrapper' import { formatSelectionBreakdown } from './selection-breakdown' +/** The building holding the level, when the selection is everything selectable on it. */ +function useWholeLevelBuildingId(selectedIds: readonly string[]) { + const levelId = useViewer((s) => s.selection.levelId) + const nodes = useScene((s) => s.nodes) + return useMemo(() => { + if (!levelId) return null + const candidates = collectSelectableCandidateIds() + if (candidates.length === 0) return null + const selected = new Set(selectedIds) + if (!candidates.every((id) => selected.has(id))) return null + return resolveBuildingForLevel(levelId as AnyNodeId, nodes) ?? null + }, [levelId, nodes, selectedIds]) +} + +/** The building's own selection (no level): its floating pill offers the whole-building Move. */ +function selectBuilding(buildingId: string) { + useViewer.getState().setSelection({ buildingId }) +} + export function MultiSelectionActions() { const selectedIds = useViewer((s) => s.selection.selectedIds) const sessionGroups = useSessionGroups((s) => s.groups) @@ -32,10 +52,22 @@ export function MultiSelectionActions() { [sessionGroups, selectedIds, liveIds], ) + const buildingId = useWholeLevelBuildingId(selectedIds) + return ( - + + {buildingId && ( + } + label="Select building" + onClick={() => selectBuilding(buildingId)} + title="Select the whole building to move all its levels" + /> + )} {showGroup && ( } label="Group" onClick={() => groupCurrentSelection()} @@ -44,6 +76,7 @@ export function MultiSelectionActions() { )} {showUngroup && ( } label="Ungroup" onClick={() => ungroupCurrentSelection()} @@ -51,12 +84,13 @@ export function MultiSelectionActions() { /> )} } label="Duplicate" onClick={() => duplicateSelectionAndPickUp()} /> } label="Delete" onClick={() => deleteSelection()} diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 6b4f7d1b64..df60321342 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -26,7 +26,6 @@ import { import { steppedRotation } from '../components/tools/item/placement-math' import { keyCyclableContinuationContext } from '../lib/continuation' import { resolveDirectManipulationNode } from '../lib/direct-manipulation' -import { toggleDoorOpenState } from '../lib/door-interaction' import { cancelGestures } from '../lib/gesture-lifecycle' import { guideEmitter } from '../lib/guide-events' import { isHistoryShortcut, runRedo, runUndo, shouldCancelDraftOnHistoryJump } from '../lib/history' @@ -39,7 +38,6 @@ import { openSidebarPanel } from '../lib/sidebar-panel' import { activeSiteNode, clampBrushRadius } from '../lib/terrain-sculpt' import { leaveUnitFocus } from '../lib/units' import { selectWallDrawVariant } from '../lib/wall-draw-variant' -import { toggleWindowOpenState } from '../lib/window-interaction' import useDeleteConfirmation from '../store/use-delete-confirmation' import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor' import useInteractionScope, { @@ -743,8 +741,8 @@ export const useKeyboard = ({ } } } else if ((e.key === 'e' || e.key === 'E') && !isVersionPreviewMode) { - // Toggle door / operable-window open/closed state. Moved off R, - // which now flips the opening (side + π rotation). + // E runs the kind's interaction or mechanism (doors and windows open and + // close). Moved off R, which now flips the opening (side + π rotation). const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { const node = useScene.getState().nodes[selectedNodeIds[0]!] @@ -752,24 +750,6 @@ export const useKeyboard = ({ // Registry-driven E interaction. Same shape as the R/T arms. e.preventDefault() sfxEmitter.emit('sfx:item-rotate') - } else if (node?.type === 'door' && node.openingKind !== 'opening') { - e.preventDefault() - toggleDoorOpenState(node.id) - sfxEmitter.emit('sfx:item-rotate') - } else if ( - node?.type === 'window' && - node.openingKind !== 'opening' && - (node.windowType === 'sliding' || - node.windowType === 'casement' || - node.windowType === 'awning' || - node.windowType === 'hopper' || - node.windowType === 'single-hung' || - node.windowType === 'double-hung' || - node.windowType === 'louvered') - ) { - e.preventDefault() - toggleWindowOpenState(node.id) - sfxEmitter.emit('sfx:item-rotate') } } } else if ((e.key === 'Delete' || e.key === 'Backspace') && !isVersionPreviewMode) { diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index ad3455d4ed..b7ec605df2 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -20,6 +20,7 @@ export { export { useViewer } from '@pascal-app/viewer' export type { EditorProps } from './components/editor' export { default as Editor } from './components/editor' +export { ActionMenuButton } from './components/editor/action-menu-button' // Headless component aliases: the implementation files keep their // internal names (`ParametricInspector`, `FloatingActionMenu`) because // they're referenced throughout the editor's own internals; the public @@ -416,6 +417,11 @@ export { nextContinuation, } from './lib/continuation' export { canDirectMoveNode } from './lib/direct-manipulation' +export { + closeDoorOpenState, + getDisplayedDoorValue, + openDoorOpenState, +} from './lib/door-interaction' export { createEditorApi } from './lib/editor-api' export { clearStructuralElevationGuide, @@ -832,6 +838,12 @@ export { startWallPolygonDraft, wallPolygonDraftWalls, } from './lib/wall-polygon-draft' +export { + closeWindowOpenState, + getDisplayedWindowValue, + isOperableWindowType, + openWindowOpenState, +} from './lib/window-interaction' export { getActiveBuildingPose, projectAlignmentGuidesWorldToActiveBuildingLocal, diff --git a/packages/editor/src/lib/door-interaction.ts b/packages/editor/src/lib/door-interaction.ts index 1ad720883d..d95061f047 100644 --- a/packages/editor/src/lib/door-interaction.ts +++ b/packages/editor/src/lib/door-interaction.ts @@ -86,3 +86,17 @@ export function closeDoorOpenState(doorId: AnyNodeId, options?: DoorOpenAnimatio const currentSwingAngle = getDisplayedDoorValue(doorId, 'swingAngle', node.swingAngle) startDoorOpenAnimation(doorId, 'swingAngle', currentSwingAngle, 0, options) } + +export function openDoorOpenState(doorId: AnyNodeId, options?: DoorOpenAnimationOptions) { + const node = useScene.getState().nodes[doorId] + if (node?.type !== 'door' || node.openingKind === 'opening') return + + if (isOperationDoorType(node.doorType)) { + const currentOpenAmount = getDisplayedDoorValue(doorId, 'operationState', node.operationState) + startDoorOpenAnimation(doorId, 'operationState', currentOpenAmount, 1, options) + return + } + + const currentSwingAngle = getDisplayedDoorValue(doorId, 'swingAngle', node.swingAngle) + startDoorOpenAnimation(doorId, 'swingAngle', currentSwingAngle, DOOR_SWING_OPEN_ANGLE, options) +} diff --git a/packages/editor/src/lib/window-interaction.ts b/packages/editor/src/lib/window-interaction.ts index 2ed3ec4dfb..7a9bcdd985 100644 --- a/packages/editor/src/lib/window-interaction.ts +++ b/packages/editor/src/lib/window-interaction.ts @@ -84,3 +84,17 @@ export function closeWindowOpenState(windowId: AnyNodeId, options?: WindowOpenAn const currentOpenAmount = getDisplayedWindowValue(windowId, node.operationState) startWindowOpenAnimation(windowId, 'operationState', currentOpenAmount, 0, options) } + +export function openWindowOpenState(windowId: AnyNodeId, options?: WindowOpenAnimationOptions) { + const node = useScene.getState().nodes[windowId] + if ( + node?.type !== 'window' || + node.openingKind === 'opening' || + !isOperableWindowType(node.windowType) + ) { + return + } + + const currentOpenAmount = getDisplayedWindowValue(windowId, node.operationState) + startWindowOpenAnimation(windowId, 'operationState', currentOpenAmount, 1, options) +} diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 2c53f5ab16..fd63fdaf8d 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -25,6 +25,7 @@ import { scaleHandleHeight } from './door-math' import { buildDoorFloorplan } from './floorplan' import { doorWidthAffordance } from './floorplan-affordances' import { doorFloorplanMoveTarget } from './floorplan-move' +import { doorMechanism } from './mechanism' import { doorPaint } from './paint' import { doorParametrics } from './parametrics' import { DoorNode } from './schema' @@ -258,6 +259,7 @@ export const doorDefinition: NodeDefinition = { // each mesh with its `userData.slotId`; paint writes `node.slots`. slots: () => doorSlots(), paint: doorPaint, + mechanism: doorMechanism, }, parametrics: doorParametrics, diff --git a/packages/nodes/src/door/mechanism.ts b/packages/nodes/src/door/mechanism.ts new file mode 100644 index 0000000000..d261771850 --- /dev/null +++ b/packages/nodes/src/door/mechanism.ts @@ -0,0 +1,16 @@ +import { isOperationDoorType, type MechanismCapability } from '@pascal-app/core' +import { closeDoorOpenState, getDisplayedDoorValue, openDoorOpenState } from '@pascal-app/editor' + +/** A door's leaf: Play opens it and Stop closes it, without touching the saved open state. */ +export const doorMechanism: MechanismCapability = { + verb: 'open', + icon: 'door', + has: (node) => node.type === 'door' && node.openingKind !== 'opening', + isOn: (node) => { + if (node.type !== 'door') return false + return isOperationDoorType(node.doorType) + ? getDisplayedDoorValue(node.id, 'operationState', node.operationState) > 0 + : getDisplayedDoorValue(node.id, 'swingAngle', node.swingAngle) > 0 + }, + set: (node, on) => (on ? openDoorOpenState : closeDoorOpenState)(node.id, { persist: false }), +} diff --git a/packages/nodes/src/shared/item-interaction-actions.tsx b/packages/nodes/src/shared/item-interaction-actions.tsx index 50449ab685..c3fde3b654 100644 --- a/packages/nodes/src/shared/item-interaction-actions.tsx +++ b/packages/nodes/src/shared/item-interaction-actions.tsx @@ -1,13 +1,10 @@ 'use client' -import { type AnyNodeId, useInteractive, useScene } from '@pascal-app/core' +import { type AnyNodeId, nodeMechanism, useInteractive, useScene } from '@pascal-app/core' +import { ActionMenuButton } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Lightbulb, LightbulbOff } from 'lucide-react' import { itemHasLights, itemLightsOn, toggleItemLights } from './item-interactions' -const BUTTON = - 'tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground' -const ACTIVE = 'bg-accent text-foreground' - /** * The light switch for a single selected item or procedural item. Play/Stop * comes from the action menu's `capabilities.mechanism` button. @@ -21,18 +18,17 @@ export default function ItemInteractionActions() { const lit = useInteractive((s) => (node && hasLights ? itemLightsOn(node, s) : false)) if (!(node && hasLights)) return null return ( - + ) } diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 04852b92d1..e900e6a616 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -30,6 +30,7 @@ import { buildWindowContextualDimensions } from './contextual-dimensions' import { buildWindowFloorplan } from './floorplan' import { windowWidthAffordance } from './floorplan-affordances' import { windowFloorplanMoveTarget } from './floorplan-move' +import { windowMechanism } from './mechanism' import { windowPaint } from './paint' import { windowParametrics } from './parametrics' import { WindowNode } from './schema' @@ -309,6 +310,7 @@ export const windowDefinition: NodeDefinition = { // each mesh with its `userData.slotId`; paint writes `node.slots`. slots: () => windowSlots(), paint: windowPaint, + mechanism: windowMechanism, }, parametrics: windowParametrics, diff --git a/packages/nodes/src/window/mechanism.ts b/packages/nodes/src/window/mechanism.ts new file mode 100644 index 0000000000..0532127b5f --- /dev/null +++ b/packages/nodes/src/window/mechanism.ts @@ -0,0 +1,20 @@ +import type { MechanismCapability } from '@pascal-app/core' +import { + closeWindowOpenState, + getDisplayedWindowValue, + isOperableWindowType, + openWindowOpenState, +} from '@pascal-app/editor' + +/** A window's sash: Open and Close preview it without touching the saved open state. */ +export const windowMechanism: MechanismCapability = { + verb: 'open', + icon: 'window', + has: (node) => + node.type === 'window' && + node.openingKind !== 'opening' && + isOperableWindowType(node.windowType), + isOn: (node) => + node.type === 'window' && getDisplayedWindowValue(node.id, node.operationState) > 0, + set: (node, on) => (on ? openWindowOpenState : closeWindowOpenState)(node.id, { persist: false }), +} diff --git a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx index be8284e2c9..4d35018ade 100644 --- a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx +++ b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx @@ -84,6 +84,9 @@ export const FloorElevationSystem = () => { const dirtyNodes = useScene((s) => s.dirtyNodes) const clearDirty = useScene((s) => s.clearDirty) const consumed = useMemo(() => new Set(), []) + // Meshes already lifted once. A mesh mounts at its base Y, and a remount (entering Preview + // builds a fresh viewer tree) raises no dirty mark, so the lift would never be applied. + const mounted = useMemo(() => ({ revision: -1, meshes: new WeakSet() }), []) const preview = useMemo( () => ({ local: new Matrix4(), @@ -143,7 +146,9 @@ export const FloorElevationSystem = () => { // during group drags over elevated slabs). const overrides = useLiveNodeOverrides.getState().overrides const transforms = useLiveTransforms.getState().transforms - if (dirtyNodes.size === 0 && overrides.size === 0 && transforms.size === 0) return + const registryChanged = sceneRegistry.revision !== mounted.revision + if (dirtyNodes.size === 0 && overrides.size === 0 && transforms.size === 0 && !registryChanged) + return const nodes = useScene.getState().nodes const applyLift = (id: AnyNodeId) => { @@ -198,6 +203,19 @@ export const FloorElevationSystem = () => { mesh.position.y = visualPosition[1] } + if (registryChanged) { + mounted.revision = sceneRegistry.revision + for (const [kind, ids] of Object.entries(sceneRegistry.byType)) { + if (!nodeRegistry.get(kind)?.capabilities?.floorPlaced) continue + for (const id of ids) { + const mesh = sceneRegistry.nodes.get(id) + if (!mesh || mounted.meshes.has(mesh)) continue + mounted.meshes.add(mesh) + applyLift(id as AnyNodeId) + } + } + } + dirtyNodes.forEach((id) => { applyLift(id) }) From ba382303d7320ab0b4dc484cf50bd9bd11a6a84f Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 30 Sep 2026 10:41:00 -0400 Subject: [PATCH 2/2] fix(editor): type the selected building id Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VMczFMfWJtZDbBdTARy88m --- .../components/ui/panels/multi-selection-panel.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/components/ui/panels/multi-selection-panel.tsx b/packages/editor/src/components/ui/panels/multi-selection-panel.tsx index 4ea195b9f1..25ff5be7a2 100644 --- a/packages/editor/src/components/ui/panels/multi-selection-panel.tsx +++ b/packages/editor/src/components/ui/panels/multi-selection-panel.tsx @@ -1,6 +1,11 @@ 'use client' -import { type AnyNodeId, resolveBuildingForLevel, useScene } from '@pascal-app/core' +import { + type AnyNodeId, + type BuildingNode, + resolveBuildingForLevel, + useScene, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Building2, Copy, Group, Trash2, Ungroup } from 'lucide-react' import { useMemo } from 'react' @@ -34,8 +39,8 @@ function useWholeLevelBuildingId(selectedIds: readonly string[]) { } /** The building's own selection (no level): its floating pill offers the whole-building Move. */ -function selectBuilding(buildingId: string) { - useViewer.getState().setSelection({ buildingId }) +function selectBuilding(buildingId: AnyNodeId) { + useViewer.getState().setSelection({ buildingId: buildingId as BuildingNode['id'] }) } export function MultiSelectionActions() {