From e9e508bb29e8e8ded8900ca9c784a3a05e8f9261 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 15:56:37 -0400 Subject: [PATCH 1/8] feat(editor): export selectZoneOrRoom, frameViewerNode and PLUGINS_VIEW_ID for hosts A host surface that names scene nodes (the hosted chat's chips) selects and shows them the way the editor's own lists do: - selectZoneOrRoom: a zone by the Rooms list's rule, an enclosed room as the room, anything else as the zone. A host could only set selection.zoneId, which opened a room as a bare zone. - frameViewerNode: the navigation's framing, the node's saved view else its box in the scene. A host computing its own box missed saved views and a moved or turned building. - PLUGINS_VIEW_ID: the plugin directory's view, for openEditorView. Co-Authored-By: Claude Opus 5.5 --- packages/editor/src/index.tsx | 3 +++ wiki/architecture/selection-managers.md | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 045f347ac2..c138e4f587 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -87,6 +87,7 @@ export { type SnapshotCameraData, ThumbnailGenerator, } from './components/editor/thumbnail-generator' +export { PLUGINS_VIEW_ID } from './components/editor/views/use-editor-views' export { WallMoveSideHandles } from './components/editor/wall-move-side-handles' export { useFloorplanRender } from './components/editor-2d/floorplan-render-context' export { FloorplanDimensionRenderer } from './components/editor-2d/renderers/floorplan-dimension-renderer' @@ -774,6 +775,7 @@ export { captureElementActionOrigin, completeElementAction, type ElementActionOrigin, + selectZoneOrRoom, } from './lib/room-zone-routing' export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor, getSavedSceneDocument } from './lib/scene' @@ -853,6 +855,7 @@ export { applyViewerHover, framedLookAt, frameViewerCamera, + frameViewerNode, useViewerFloorDisplay, type ViewerPick, type ViewerSource, diff --git a/wiki/architecture/selection-managers.md b/wiki/architecture/selection-managers.md index 3ed385c6b4..dc7446a869 100644 --- a/wiki/architecture/selection-managers.md +++ b/wiki/architecture/selection-managers.md @@ -122,7 +122,9 @@ detail chip with the whole label, over the canvas's flags, and the pills it cove Escape closes it. Only a pill takes a click (or Enter or Space), and it goes through the same zone-area rule as a zone's fill or label in the plan (`hoverZoneArea` / `clickZoneArea` in `lib/room-zone-routing.ts`): a room room-first, a modifier or a zone that bounds no room as the -zone (its outline, its panel and outline editor). Zones show tinted volumes only while a unit's +zone (its outline, its panel and outline editor). A list that names a zone, the Rooms list or a +host's chat reference, selects it through `selectZoneOrRoom` (exported for hosts) by the same rule. +Zones show tinted volumes only while a unit's membership is painted; otherwise a zone looks like a room. A new zone is written with a palette colour picked from its id (`newZone` / `zoneColorForSeed` in core), a captured room from its capture's id, unless its creator chose one; From 5caa26e338ad161ed261c0908a3e3f0510490381 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 19:20:48 -0400 Subject: [PATCH 2/8] feat(editor): with no 3D view on screen, the plan frames a node itself A frame request (camera-controls:frame, from the navigation or a host's "show me this node") moves the 3D camera, and the plan follows its pose. With the plan alone on screen nothing carries that pose over, so a node selected from a list stayed wherever it was, often off screen. The plan now answers the request itself while no 3D view is visible: it centres on the node's box (or its saved view's target) at the current turn, wide enough for the box at any rotation and never closer than 4 m. With a 3D view on screen nothing changes. Co-Authored-By: Claude Opus 5.5 --- .../src/components/editor/floorplan-panel.tsx | 43 ++++++++++++++++++- wiki/architecture/selection-managers.md | 11 ++--- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 9f95e5efad..a053b58097 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -5,6 +5,7 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + type CameraControlFrameEvent, type CeilingNode, CeilingNode as CeilingNodeSchema, type ColumnNode, @@ -165,7 +166,13 @@ import useInteractionScope, { import usePlacementPreview from '../../store/use-placement-preview' import { expandSessionSelectionForNode } from '../../store/use-session-groups' import { useStairBuildPreview } from '../../store/use-stair-build-preview' -import { activeViewLayout, isViewVisible, VIEW_2D, visibleScene } from '../../store/view-layout' +import { + activeViewLayout, + isViewVisible, + VIEW_2D, + VIEW_3D, + visibleScene, +} from '../../store/view-layout' import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer' import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay' import { FloorplanGroupActionMenu } from '../editor-2d/floorplan-group-action-menu' @@ -257,6 +264,10 @@ import { useFloorplanSceneData } from './use-floorplan-scene-data' import { sceneSpan } from './views/scene-region' const FALLBACK_VIEW_SIZE = 12 +// A framed node's diagonal spans half the plan's width, so it fits a landscape pane at any turn; +// the view never closes in to less than a small room's width. +const FLOORPLAN_FRAME_MARGIN = 2 +const FLOORPLAN_FRAME_MIN_WIDTH = 4 const FLOORPLAN_PADDING = 2 const MIN_VIEWPORT_WIDTH_RATIO = 0.08 const MAX_VIEWPORT_WIDTH_RATIO = 40 @@ -6561,6 +6572,36 @@ export function FloorplanPanel({ [receiveFloorplanNavigationPose], ) + // A frame request ("show me this node") moves the 3D camera and the plan follows its pose; with no + // 3D view on screen no pose reaches the plan, so it centres on the node itself. + useEffect(() => { + const centreOn = (x: number, z: number, viewWidth?: number) => + smoothFloorplanNavigationView( + worldToFloorplanLocalPoint(x, z, buildingPosition, buildingRotationY), + latestFloorplanUserRotationDegRef.current, + viewWidth, + ) + const frame = ({ bounds, pose }: CameraControlFrameEvent) => { + if (!isFloorplanOpenRef.current || isViewVisible(useEditor.getState(), VIEW_3D)) return + if (pose) { + centreOn(pose.target[0], pose.target[2]) + } else if (bounds) { + const [minX, , minZ] = bounds.min + const [maxX, , maxZ] = bounds.max + centreOn( + (minX + maxX) / 2, + (minZ + maxZ) / 2, + Math.max( + Math.hypot(maxX - minX, maxZ - minZ) * FLOORPLAN_FRAME_MARGIN, + FLOORPLAN_FRAME_MIN_WIDTH, + ), + ) + } + } + emitter.on('camera-controls:frame', frame) + return () => emitter.off('camera-controls:frame', frame) + }, [buildingPosition, buildingRotationY, smoothFloorplanNavigationView]) + useEffect(() => { const receiveStoredNavigationPose = (pose: NavigationSyncPose | null) => { if (pose?.source === '2d') { diff --git a/wiki/architecture/selection-managers.md b/wiki/architecture/selection-managers.md index dc7446a869..e63c7ad546 100644 --- a/wiki/architecture/selection-managers.md +++ b/wiki/architecture/selection-managers.md @@ -70,8 +70,10 @@ The navigation (`ViewerSceneHeader`, given a `ViewerSource`) is the breadcrumb room › element, where an element shows the room it stands in without selecting it — and the floor list with, under the current floor, its Units, Rooms and Zones (empty sections left out). A crumb, a floor or a row selects and frames (`camera-controls:frame`: the node's saved view, else its box at -about 45° along the current heading); the canvas never frames. `ViewerRooms` draws the editor's room -pills (same visibility rule, same declutter) and the editor's room highlight for the selected or +about 45° along the current heading); the canvas never frames. Hosts frame through the same +`frameViewerNode`. The plan follows the framed camera; with no 3D view on screen it answers the +request itself, centred on the box (or the saved view's target) at the current turn. +`ViewerRooms` draws the editor's room pills (same visibility rule, same declutter) and the editor's room highlight for the selected or hovered room and a focused unit's rooms. `useViewerFloorDisplay` hides the levels above the current floor outside the walkthrough. @@ -124,9 +126,8 @@ the same zone-area rule as a zone's fill or label in the plan (`hoverZoneArea` / in `lib/room-zone-routing.ts`): a room room-first, a modifier or a zone that bounds no room as the zone (its outline, its panel and outline editor). A list that names a zone, the Rooms list or a host's chat reference, selects it through `selectZoneOrRoom` (exported for hosts) by the same rule. -Zones show tinted volumes only while a unit's -membership is painted; otherwise a zone looks like a room. A new zone is written with a palette -colour picked from its id (`newZone` / `zoneColorForSeed` in core), a captured room from its capture's +Zones show tinted volumes only while a unit's membership is painted; otherwise a zone looks like a +room. A new zone is written with a palette colour picked from its id (`newZone` / `zoneColorForSeed` in core), a captured room from its capture's id, unless its creator chose one; existing zones keep their stored colour. From 2071bd4509ef49be6ff5b032ca9323a52ba0d650 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 13 Oct 2026 00:02:19 -0400 Subject: [PATCH 3/8] feat(editor): export enterUnitFocus for hosts A host's reference to a unit focuses it the way the site panel does, instead of putting the unit in the selection. Co-Authored-By: Claude Opus 5.5 --- packages/editor/src/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index c138e4f587..ef25418b06 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -844,6 +844,7 @@ export { terrainPointInsideSite, } from './lib/terrain-sculpt' export { isTypingTarget } from './lib/typing-target' +export { enterUnitFocus } from './lib/units' export { exportSceneToUsdz, type UsdzExportOptions } from './lib/usdz-export' export { useLinearDisplay } from './lib/use-linear-display' // `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/ From f1f3e897e0a4e00dff2816dc6e6fb3a622df5bdc Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 13 Oct 2026 00:02:19 -0400 Subject: [PATCH 4/8] fix(editor): the plan frames a node once its floor has rendered, fitted to the pane - With no 3D view on screen, a frame request now waits for the selection it came with to render. A node on another floor or building is placed with that building's transform, after the floor change has reset the view, instead of the previous building's. - The node's box is fitted to the pane at the current turn and aspect (its rotated corners against both dimensions), not twice its diagonal. - A frame request carries the node's box as well as its saved view; the 3D camera still prefers the saved view, the plan frames the box. - Under reduced motion, framing jumps to its pose in 3D and in the plan. - selection-managers.md re-wrapped and updated. Co-Authored-By: Claude Opus 5.5 --- .../src/components/editor/floorplan-panel.tsx | 85 +++++++++++-------- packages/editor/src/lib/viewer-selection.ts | 24 ++++-- wiki/architecture/selection-managers.md | 17 ++-- 3 files changed, 77 insertions(+), 49 deletions(-) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index a053b58097..75de4cf521 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -135,6 +135,7 @@ import { zoneAtLevelPoint, } from '../../lib/units' import { cn } from '../../lib/utils' +import { framingAnimates } from '../../lib/viewer-selection' import { getWallDrawVariant } from '../../lib/wall-draw-variant' import { addWallPolygonDraftCorner, @@ -264,10 +265,6 @@ import { useFloorplanSceneData } from './use-floorplan-scene-data' import { sceneSpan } from './views/scene-region' const FALLBACK_VIEW_SIZE = 12 -// A framed node's diagonal spans half the plan's width, so it fits a landscape pane at any turn; -// the view never closes in to less than a small room's width. -const FLOORPLAN_FRAME_MARGIN = 2 -const FLOORPLAN_FRAME_MIN_WIDTH = 4 const FLOORPLAN_PADDING = 2 const MIN_VIEWPORT_WIDTH_RATIO = 0.08 const MAX_VIEWPORT_WIDTH_RATIO = 40 @@ -6572,36 +6569,6 @@ export function FloorplanPanel({ [receiveFloorplanNavigationPose], ) - // A frame request ("show me this node") moves the 3D camera and the plan follows its pose; with no - // 3D view on screen no pose reaches the plan, so it centres on the node itself. - useEffect(() => { - const centreOn = (x: number, z: number, viewWidth?: number) => - smoothFloorplanNavigationView( - worldToFloorplanLocalPoint(x, z, buildingPosition, buildingRotationY), - latestFloorplanUserRotationDegRef.current, - viewWidth, - ) - const frame = ({ bounds, pose }: CameraControlFrameEvent) => { - if (!isFloorplanOpenRef.current || isViewVisible(useEditor.getState(), VIEW_3D)) return - if (pose) { - centreOn(pose.target[0], pose.target[2]) - } else if (bounds) { - const [minX, , minZ] = bounds.min - const [maxX, , maxZ] = bounds.max - centreOn( - (minX + maxX) / 2, - (minZ + maxZ) / 2, - Math.max( - Math.hypot(maxX - minX, maxZ - minZ) * FLOORPLAN_FRAME_MARGIN, - FLOORPLAN_FRAME_MIN_WIDTH, - ), - ) - } - } - emitter.on('camera-controls:frame', frame) - return () => emitter.off('camera-controls:frame', frame) - }, [buildingPosition, buildingRotationY, smoothFloorplanNavigationView]) - useEffect(() => { const receiveStoredNavigationPose = (pose: NavigationSyncPose | null) => { if (pose?.source === '2d') { @@ -6724,6 +6691,56 @@ export function FloorplanPanel({ stopFloorplanViewAnimation, ]) + // A frame request ("show me this node") moves the 3D camera and the plan follows its pose; with no + // 3D view on screen no pose reaches the plan, so it frames the node itself. The request waits for + // the selection it came with to render: a node on another floor or in another building is placed + // with that building's transform, after the floor change has reset the view. + const [pendingFrame, setPendingFrame] = useState(null) + useEffect(() => { + const frame = (event: CameraControlFrameEvent) => { + if (isFloorplanOpenRef.current && !isViewVisible(useEditor.getState(), VIEW_3D)) + setPendingFrame(event) + } + emitter.on('camera-controls:frame', frame) + return () => emitter.off('camera-controls:frame', frame) + }, []) + + useEffect(() => { + if (!pendingFrame) return + setPendingFrame(null) + const userRotationDeg = latestFloorplanUserRotationDegRef.current + const toLocal = (x: number, z: number) => + worldToFloorplanLocalPoint(x, z, buildingPosition, buildingRotationY) + const { bounds, pose } = pendingFrame + let centre: SvgPoint + let width: number | undefined + if (bounds) { + // The box's corners as the plan shows them now, so it fits the pane at any turn and aspect. + const sceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - buildingRotationDeg + const shown = [bounds.min[0], bounds.max[0]].flatMap((x) => + [bounds.min[2], bounds.max[2]].map((z) => rotateSvgPoint(toLocal(x, z), sceneRotationDeg)), + ) + const xs = shown.map((point) => point.x) + const ys = shown.map((point) => point.y) + centre = toLocal((bounds.min[0] + bounds.max[0]) / 2, (bounds.min[2] + bounds.max[2]) / 2) + width = Math.max( + Math.max(...xs) - Math.min(...xs) + FLOORPLAN_PADDING * 2, + (Math.max(...ys) - Math.min(...ys) + FLOORPLAN_PADDING * 2) * svgAspectRatio, + ) + } else if (pose) { + // A saved view with nothing to measure: its target, at the zoom the person has. + centre = toLocal(pose.target[0], pose.target[2]) + } else return + applyFloorplanNavigationView(centre, userRotationDeg, width, { smooth: framingAnimates() }) + }, [ + applyFloorplanNavigationView, + buildingPosition, + buildingRotationDeg, + buildingRotationY, + pendingFrame, + svgAspectRatio, + ]) + const viewBox = useMemo(() => { const currentViewport = viewport ?? fittedViewport const width = currentViewport.width diff --git a/packages/editor/src/lib/viewer-selection.ts b/packages/editor/src/lib/viewer-selection.ts index d75e0e3a53..ec7d047661 100644 --- a/packages/editor/src/lib/viewer-selection.ts +++ b/packages/editor/src/lib/viewer-selection.ts @@ -344,14 +344,13 @@ function expandByZone(target: Box3, zone: ZoneNode, source: ViewerSource) { } } -/** The frame request for a node: its saved view, else the box it spans. */ +/** The frame request for a node: the box it spans, and its saved view when it has one. */ function viewerFrameEvent(id: string, source: ViewerSource): CameraControlFrameEvent | null { const node = source.nodes[id] if (!node) return null const saved = node.camera - if (saved?.position && saved.target) { - return { bounds: null, pose: { position: saved.position, target: saved.target } } - } + const pose = + saved?.position && saved.target ? { position: saved.position, target: saved.target } : null box.makeEmpty() if (node.type === 'zone') expandByZone(box, node, source) else if (node.type === 'unit') { @@ -363,8 +362,16 @@ function viewerFrameEvent(id: string, source: ViewerSource): CameraControlFrameE const object = source.objectFor(id) if (object) box.setFromObject(object) } - if (box.isEmpty()) return null - return { bounds: { min: box.min.toArray(), max: box.max.toArray() } } + const bounds = box.isEmpty() ? null : { min: box.min.toArray(), max: box.max.toArray() } + if (pose) return { bounds, pose } + return bounds ? { bounds } : null +} + +/** Framing glides to its pose, or jumps there when the person asks for reduced motion. */ +export function framingAnimates(): boolean { + return !( + typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches + ) } export function frameViewerNode(id: string, source: ViewerSource) { @@ -426,13 +433,14 @@ export function frameViewerCamera( : undefined, ) if (!pose) return - control.setLookAt(...pose.position, ...pose.target, true) + const animate = framingAnimates() + control.setLookAt(...pose.position, ...pose.target, animate) if ((camera as OrthographicCamera).isOrthographicCamera && event.bounds && !event.pose) { box.min.set(...event.bounds.min) box.max.set(...event.bounds.max) box.getBoundingSphere(frameSphere) frameSphere.radius = Math.max(frameSphere.radius * 1.3, 1) - control.fitToSphere(frameSphere, true) + control.fitToSphere(frameSphere, animate) } } diff --git a/wiki/architecture/selection-managers.md b/wiki/architecture/selection-managers.md index e63c7ad546..6a69d54cf3 100644 --- a/wiki/architecture/selection-managers.md +++ b/wiki/architecture/selection-managers.md @@ -71,11 +71,14 @@ room › element, where an element shows the room it stands in without selecting list with, under the current floor, its Units, Rooms and Zones (empty sections left out). A crumb, a floor or a row selects and frames (`camera-controls:frame`: the node's saved view, else its box at about 45° along the current heading); the canvas never frames. Hosts frame through the same -`frameViewerNode`. The plan follows the framed camera; with no 3D view on screen it answers the -request itself, centred on the box (or the saved view's target) at the current turn. -`ViewerRooms` draws the editor's room pills (same visibility rule, same declutter) and the editor's room highlight for the selected or -hovered room and a focused unit's rooms. `useViewerFloorDisplay` hides the levels above the current -floor outside the walkthrough. +`frameViewerNode`, and a unit through `enterUnitFocus`, as the site panel does. Framing jumps +instead of gliding under reduced motion. The plan follows the framed camera; with no 3D view on +screen it answers the request itself, once the selection that came with it has rendered (so a node +on another floor or building is placed with that building's transform), fitting the node's box to +the pane at the current turn and aspect, or centring a saved view's target at the current zoom. +`ViewerRooms` draws the editor's room pills (same visibility rule, same declutter) and the editor's +room highlight for the selected or hovered room and a focused unit's rooms. `useViewerFloorDisplay` +hides the levels above the current floor outside the walkthrough. Preview borrows the shared store: `setPreviewMode(true)` saves the editor's `selection` and `focusedUnitId` and opens Preview on the editor's floor with nothing else selected; @@ -127,8 +130,8 @@ in `lib/room-zone-routing.ts`): a room room-first, a modifier or a zone that bou zone (its outline, its panel and outline editor). A list that names a zone, the Rooms list or a host's chat reference, selects it through `selectZoneOrRoom` (exported for hosts) by the same rule. Zones show tinted volumes only while a unit's membership is painted; otherwise a zone looks like a -room. A new zone is written with a palette colour picked from its id (`newZone` / `zoneColorForSeed` in core), a captured room from its capture's -id, unless its creator chose one; +room. A new zone is written with a palette colour picked from its id (`newZone` / +`zoneColorForSeed` in core), a captured room from its capture's id, unless its creator chose one; existing zones keep their stored colour. Inside the building no click changes the phase. Site is entered from the Scene panel's site header From 93cfb6238c0d5135cafebd113e4d69bc26e4ade6 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 13 Oct 2026 12:04:29 -0400 Subject: [PATCH 5/8] fix(editor): the plan and the room outline light what a chat chip points at A host's chat lights nodes through the viewer's `pointedIds` (Point and ask's chips, a step's card, and now every reference in a message), but only the 3D outline read it. - The plan draws a pointed node as it draws a hovered one, so a chat light shows in 2D as well as 3D. - A single pointed zone shows the room outline a hovered zone does. With zones hidden a zone draws nothing of its own, so the point outline had nothing to trace. - point-and-ask.md says so. Co-Authored-By: Claude Opus 5.5 --- .../editor-2d/renderers/floorplan-registry-layer.tsx | 5 ++++- packages/editor/src/components/editor/room-highlight.tsx | 8 ++++++-- wiki/architecture/point-and-ask.md | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 4ceda9a676..6c8eb02b46 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -2058,7 +2058,10 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ const phase = useEditor((state) => state.phase) const hoverTargetId = phase === 'site' ? (resolveCanvasBuildingId(node, nodes) ?? selectionProxyId) : selectionProxyId - const hovered = useViewer((state) => state.hoveredId === hoverTargetId) + // What a chat chip points at (`pointedIds`) lights as a hover does, as it does in 3D. + const hovered = useViewer( + (state) => state.hoveredId === hoverTargetId || state.pointedIds.includes(hoverTargetId), + ) const setHoveredId = useViewer((state) => state.setHoveredId) const referencedAnnotationRole = useViewer((state) => floorplanEntryReferencedAnnotationRole(node, nodes, new Set(state.selection.selectedIds)), diff --git a/packages/editor/src/components/editor/room-highlight.tsx b/packages/editor/src/components/editor/room-highlight.tsx index 989bd0e99d..28f4592075 100644 --- a/packages/editor/src/components/editor/room-highlight.tsx +++ b/packages/editor/src/components/editor/room-highlight.tsx @@ -28,10 +28,14 @@ import { RoomControls2D, RoomControls3D } from './room-controls' /** * A zone reached as a zone (hovered or selected through its area or pill, not - * as a room) shows the room's outline, never a tinted volume. + * as a room, or the one a chat chip points at) shows the room's outline, never + * a tinted volume. With zones hidden, a pointed zone has nothing else to light. */ function useZoneOutline() { const hoveredId = useViewer((state) => state.hoveredId) + const pointedId = useViewer((state) => + state.pointedIds.length === 1 ? state.pointedIds[0] : null, + ) const selectedId = useViewer( (state) => state.selection.zoneId ?? @@ -39,7 +43,7 @@ function useZoneOutline() { ) const unitFocused = useViewer((state) => !!state.focusedUnitId) const zone = useScene((state) => { - for (const id of [hoveredId, selectedId]) { + for (const id of [hoveredId, pointedId, selectedId]) { const node = id ? state.nodes[id as AnyNodeId] : undefined if (node?.type === 'zone') return node } diff --git a/wiki/architecture/point-and-ask.md b/wiki/architecture/point-and-ask.md index 24f5576ee0..f19385502c 100644 --- a/wiki/architecture/point-and-ask.md +++ b/wiki/architecture/point-and-ask.md @@ -25,7 +25,7 @@ One shape for every surface (core `agent-operations/point-context.ts`, types onl ## Hover and pick -`resolvePointPick` (`lib/point-ask/pick.ts`, pure) uses the editor's own predicate, so the hover shows what the click sends: a wall, floor or ceiling picks its room first (the room already in the bubble lets its surfaces pick); openings, items, stairs and columns pick directly; a roof picks its segment; **Alt** goes one level deeper at once; an **exterior** face picks the wall, because from outside "this" means the facade. The mode reads the viewer's node events (only the innermost kind's event, then `stopPropagation`: a pointer move is also emitted for the level, the building and the site). The hover outline is the viewer's `point` hover style (`hoverHighlightMode: 'point'`); a chat chip hover uses `useViewer.setPointedIds`, a separate slot so a collaborator's highlight is never overwritten. +`resolvePointPick` (`lib/point-ask/pick.ts`, pure) uses the editor's own predicate, so the hover shows what the click sends: a wall, floor or ceiling picks its room first (the room already in the bubble lets its surfaces pick); openings, items, stairs and columns pick directly; a roof picks its segment; **Alt** goes one level deeper at once; an **exterior** face picks the wall, because from outside "this" means the facade. The mode reads the viewer's node events (only the innermost kind's event, then `stopPropagation`: a pointer move is also emitted for the level, the building and the site). The hover outline is the viewer's `point` hover style (`hoverHighlightMode: 'point'`); a chat chip hover uses `useViewer.setPointedIds`, a separate slot so a collaborator's highlight is never overwritten. The plan draws pointed nodes as it draws a hovered one, and a single pointed zone shows the room outline a hovered zone does (`room-highlight.tsx`): with zones hidden it has nothing else to light. ## Press, click, region From 0610e66a9e3c6487ddb0a76aabbb7837dddea44c Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 13 Oct 2026 12:49:42 -0400 Subject: [PATCH 6/8] fix(editor): a plan frame on another floor glides in from the whole floor The floor change resets the plan's view before the frame applies, so the glide started at its own destination: the plan showed the whole floor for a frame, then snapped to the node. It now starts from the floor's fitted view. Co-Authored-By: Claude Opus 5.5 --- packages/editor/src/components/editor/floorplan-panel.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 75de4cf521..6d35980051 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -6731,6 +6731,8 @@ export function FloorplanPanel({ // A saved view with nothing to measure: its target, at the zoom the person has. centre = toLocal(pose.target[0], pose.target[2]) } else return + // A floor change has just reset the view to the whole floor: glide from there, not from nothing. + latestViewportRef.current ??= latestFittedViewportRef.current applyFloorplanNavigationView(centre, userRotationDeg, width, { smooth: framingAnimates() }) }, [ applyFloorplanNavigationView, From c8f12d51c9e84df874587572f3db83aa4a3350b8 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 13 Oct 2026 13:47:11 -0400 Subject: [PATCH 7/8] fix(editor): a room a chat points at shows over the room kept selected The room highlight kept a selected or hovered room before the pointed zone, so with one room selected, hovering a chat's reference to another room lit nothing when zones are hidden, in 3D or in the plan. A pointed zone now shows over a kept room, as the editor's own hover does; the kept room returns when the pointing ends, and a room pointed while selected keeps its highlight. Co-Authored-By: Claude Opus 5.5 --- .../src/components/editor/room-highlight.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/components/editor/room-highlight.tsx b/packages/editor/src/components/editor/room-highlight.tsx index 28f4592075..eb50d1f747 100644 --- a/packages/editor/src/components/editor/room-highlight.tsx +++ b/packages/editor/src/components/editor/room-highlight.tsx @@ -31,11 +31,8 @@ import { RoomControls2D, RoomControls3D } from './room-controls' * as a room, or the one a chat chip points at) shows the room's outline, never * a tinted volume. With zones hidden, a pointed zone has nothing else to light. */ -function useZoneOutline() { +function useZoneOutline(pointedId: string | undefined) { const hoveredId = useViewer((state) => state.hoveredId) - const pointedId = useViewer((state) => - state.pointedIds.length === 1 ? state.pointedIds[0] : null, - ) const selectedId = useViewer( (state) => state.selection.zoneId ?? @@ -57,13 +54,20 @@ function useZoneOutline() { function useRoomHighlight() { const room = useHighlightedRoom() - const zoneOutline = useZoneOutline() + const pointedId = useViewer((state) => + state.pointedIds.length === 1 ? state.pointedIds[0] : undefined, + ) + const zoneOutline = useZoneOutline(pointedId) const visible = useInteractionScope( (state) => resolveOverlayPolicy(state.scope).conflictingControls === 'shown', ) // No phase gate: site never hovers or keeps a room (`roomPickingEnabled`). const enabled = useEditor((state) => state.mode === 'select') - return enabled && visible ? (room ?? zoneOutline) : null + if (!(enabled && visible)) return null + // A zone a chat points at shows over a kept room: it is what the person is reaching for. + if (zoneOutline && zoneOutline.zoneId === pointedId && room?.zoneId !== pointedId) + return zoneOutline + return room ?? zoneOutline } const noRaycast = () => {} From 111612eea153437b36c6c3667e20cc1613f82288 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 13 Oct 2026 14:11:19 -0400 Subject: [PATCH 8/8] fix(editor): the plan lights the node a chat points at, not only what its hover lights A plan entry lit a pointed node by its hover target: the selection proxy, or the building in the site phase. A chat points at the node itself, as the 3D outline draws it, so a pointed cabinet in a run or a wall in the site phase stayed unlit in the plan. An entry now lights when its own node is pointed, as well as its hover target. Co-Authored-By: Claude Opus 5.5 --- .../editor-2d/renderers/floorplan-registry-layer.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 6c8eb02b46..0b2fb6c0e8 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -2058,9 +2058,13 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ const phase = useEditor((state) => state.phase) const hoverTargetId = phase === 'site' ? (resolveCanvasBuildingId(node, nodes) ?? selectionProxyId) : selectionProxyId - // What a chat chip points at (`pointedIds`) lights as a hover does, as it does in 3D. + // What a chat chip points at (`pointedIds`) lights as a hover does, as it does in 3D: the node + // itself, or what a hover on it would light. const hovered = useViewer( - (state) => state.hoveredId === hoverTargetId || state.pointedIds.includes(hoverTargetId), + (state) => + state.hoveredId === hoverTargetId || + state.pointedIds.includes(nodeId) || + state.pointedIds.includes(hoverTargetId), ) const setHoveredId = useViewer((state) => state.setHoveredId) const referencedAnnotationRole = useViewer((state) =>