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..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,7 +2058,14 @@ 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: the node + // itself, or what a hover on it would light. + const hovered = useViewer( + (state) => + state.hoveredId === hoverTargetId || + state.pointedIds.includes(nodeId) || + 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/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 9f95e5efad..6d35980051 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, @@ -134,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, @@ -165,7 +167,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' @@ -6683,6 +6691,58 @@ 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 + // 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, + buildingPosition, + buildingRotationDeg, + buildingRotationY, + pendingFrame, + svgAspectRatio, + ]) + const viewBox = useMemo(() => { const currentViewport = viewport ?? fittedViewport const width = currentViewport.width diff --git a/packages/editor/src/components/editor/room-highlight.tsx b/packages/editor/src/components/editor/room-highlight.tsx index 989bd0e99d..eb50d1f747 100644 --- a/packages/editor/src/components/editor/room-highlight.tsx +++ b/packages/editor/src/components/editor/room-highlight.tsx @@ -28,9 +28,10 @@ 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() { +function useZoneOutline(pointedId: string | undefined) { const hoveredId = useViewer((state) => state.hoveredId) const selectedId = useViewer( (state) => @@ -39,7 +40,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 } @@ -53,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 = () => {} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 045f347ac2..ef25418b06 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' @@ -842,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/ @@ -853,6 +856,7 @@ export { applyViewerHover, framedLookAt, frameViewerCamera, + frameViewerNode, useViewerFloorDisplay, type ViewerPick, type ViewerSource, 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/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 diff --git a/wiki/architecture/selection-managers.md b/wiki/architecture/selection-managers.md index 3ed385c6b4..6a69d54cf3 100644 --- a/wiki/architecture/selection-managers.md +++ b/wiki/architecture/selection-managers.md @@ -70,10 +70,15 @@ 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 -hovered room and a focused unit's rooms. `useViewerFloorDisplay` hides the levels above the current -floor outside the walkthrough. +about 45° along the current heading); the canvas never frames. Hosts frame through the same +`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; @@ -122,10 +127,11 @@ 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 -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; +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; existing zones keep their stored colour. Inside the building no click changes the phase. Site is entered from the Scene panel's site header