editor: stabilize floorplan and camera interactions (#545)
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * fix(viewer): keep outlines during camera movement * fix(editor): prevent floorplan clipping during rotation * fix(editor): keep compass rotation in sync Stream live headings in 2D and 3D, and defer restoring the compositor rotation preview until the committed floor-plan state is ready to paint so pointer release cannot snap back. * fix(editor): stabilize floorplan and camera sync * fix floorplan registry scale subscriptions --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f0985df887
commit
7309fc8f1d
@@ -11,4 +11,17 @@ describe('floorplan render context', () => {
|
|||||||
expect(scale.read).toBe(read)
|
expect(scale.read).toBe(read)
|
||||||
expect(read()).toBe(0.01)
|
expect(read()).toBe(0.01)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('notifies selected-handle renderers when a hidden floorplan becomes visible', () => {
|
||||||
|
const scale = createFloorplanRenderScaleReference(30)
|
||||||
|
let renderedCurveHandleRadius = 8 * scale.read()
|
||||||
|
const unsubscribe = scale.subscribe(() => {
|
||||||
|
renderedCurveHandleRadius = 8 * scale.read()
|
||||||
|
})
|
||||||
|
|
||||||
|
scale.update(0.02)
|
||||||
|
|
||||||
|
expect(renderedCurveHandleRadius).toBe(0.16)
|
||||||
|
unsubscribe()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { FloorplanPalette } from '@pascal-app/core'
|
import type { FloorplanPalette } from '@pascal-app/core'
|
||||||
import { createContext, type ReactNode, useContext, useMemo, useRef } from 'react'
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-frame render context shared between the legacy `floorplan-panel.tsx`
|
* Per-frame render context shared between the legacy `floorplan-panel.tsx`
|
||||||
@@ -40,6 +48,7 @@ export type FloorplanStaticRenderContextValue = Omit<
|
|||||||
> & {
|
> & {
|
||||||
getSceneRotationDeg: () => number
|
getSceneRotationDeg: () => number
|
||||||
getUnitsPerPixel: () => number
|
getUnitsPerPixel: () => number
|
||||||
|
subscribeUnitsPerPixel: (listener: () => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const FloorplanStaticRenderContext = createContext<FloorplanStaticRenderContextValue | null>(null)
|
const FloorplanStaticRenderContext = createContext<FloorplanStaticRenderContextValue | null>(null)
|
||||||
@@ -48,6 +57,7 @@ const FloorplanUnitsPerPixelContext = createContext(1)
|
|||||||
|
|
||||||
export type FloorplanRenderScaleReference = {
|
export type FloorplanRenderScaleReference = {
|
||||||
read: () => number
|
read: () => number
|
||||||
|
subscribe: (listener: () => void) => () => void
|
||||||
update: (unitsPerPixel: number) => void
|
update: (unitsPerPixel: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +65,17 @@ export function createFloorplanRenderScaleReference(
|
|||||||
initialUnitsPerPixel: number,
|
initialUnitsPerPixel: number,
|
||||||
): FloorplanRenderScaleReference {
|
): FloorplanRenderScaleReference {
|
||||||
let unitsPerPixel = initialUnitsPerPixel
|
let unitsPerPixel = initialUnitsPerPixel
|
||||||
|
const listeners = new Set<() => void>()
|
||||||
return {
|
return {
|
||||||
read: () => unitsPerPixel,
|
read: () => unitsPerPixel,
|
||||||
|
subscribe: (listener) => {
|
||||||
|
listeners.add(listener)
|
||||||
|
return () => listeners.delete(listener)
|
||||||
|
},
|
||||||
update: (nextUnitsPerPixel) => {
|
update: (nextUnitsPerPixel) => {
|
||||||
|
if (Object.is(unitsPerPixel, nextUnitsPerPixel)) return
|
||||||
unitsPerPixel = nextUnitsPerPixel
|
unitsPerPixel = nextUnitsPerPixel
|
||||||
|
for (const listener of listeners) listener()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,11 +95,20 @@ export function FloorplanRenderProvider({
|
|||||||
if (!renderScaleReference.current) {
|
if (!renderScaleReference.current) {
|
||||||
renderScaleReference.current = createFloorplanRenderScaleReference(unitsPerPixel)
|
renderScaleReference.current = createFloorplanRenderScaleReference(unitsPerPixel)
|
||||||
}
|
}
|
||||||
renderScaleReference.current.update(unitsPerPixel)
|
|
||||||
const getUnitsPerPixel = renderScaleReference.current.read
|
const getUnitsPerPixel = renderScaleReference.current.read
|
||||||
|
const subscribeUnitsPerPixel = renderScaleReference.current.subscribe
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
renderScaleReference.current?.update(unitsPerPixel)
|
||||||
|
}, [unitsPerPixel])
|
||||||
const staticValue = useMemo<FloorplanStaticRenderContextValue>(
|
const staticValue = useMemo<FloorplanStaticRenderContextValue>(
|
||||||
() => ({ palette, hatchPatternId, getSceneRotationDeg, getUnitsPerPixel }),
|
() => ({
|
||||||
[palette, hatchPatternId, getSceneRotationDeg, getUnitsPerPixel],
|
palette,
|
||||||
|
hatchPatternId,
|
||||||
|
getSceneRotationDeg,
|
||||||
|
getUnitsPerPixel,
|
||||||
|
subscribeUnitsPerPixel,
|
||||||
|
}),
|
||||||
|
[palette, hatchPatternId, getSceneRotationDeg, getUnitsPerPixel, subscribeUnitsPerPixel],
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<FloorplanStaticRenderContext.Provider value={staticValue}>
|
<FloorplanStaticRenderContext.Provider value={staticValue}>
|
||||||
@@ -124,6 +150,18 @@ export function useFloorplanStaticRender(): FloorplanStaticRenderContextValue |
|
|||||||
return useContext(FloorplanStaticRenderContext)
|
return useContext(FloorplanStaticRenderContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const subscribeToNoFloorplanScale = () => () => {}
|
||||||
|
const readDefaultFloorplanScale = () => 1
|
||||||
|
|
||||||
|
export function useFloorplanStaticUnitsPerPixel(): number {
|
||||||
|
const staticValue = useContext(FloorplanStaticRenderContext)
|
||||||
|
return useSyncExternalStore(
|
||||||
|
staticValue?.subscribeUnitsPerPixel ?? subscribeToNoFloorplanScale,
|
||||||
|
staticValue?.getUnitsPerPixel ?? readDefaultFloorplanScale,
|
||||||
|
staticValue?.getUnitsPerPixel ?? readDefaultFloorplanScale,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function useFloorplanSceneRotation(): number {
|
export function useFloorplanSceneRotation(): number {
|
||||||
return useContext(FloorplanSceneRotationContext)
|
return useContext(FloorplanSceneRotationContext)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
collectFloorplanDependencyNodes,
|
collectFloorplanDependencyNodes,
|
||||||
collectFloorplanLinkedLevelNodes,
|
collectFloorplanLinkedLevelNodes,
|
||||||
computeAffectedSiblingIds,
|
computeAffectedSiblingIds,
|
||||||
|
floorplanAffordanceReshapeScope,
|
||||||
floorplanHandleDoubleClickAffordance,
|
floorplanHandleDoubleClickAffordance,
|
||||||
InteractiveGeometry,
|
InteractiveGeometry,
|
||||||
isFloorplanOpeningPlacementState,
|
isFloorplanOpeningPlacementState,
|
||||||
@@ -26,6 +27,17 @@ import {
|
|||||||
subscribeFloorplanAffordanceToolCancel,
|
subscribeFloorplanAffordanceToolCancel,
|
||||||
} from './floorplan-registry-layer'
|
} from './floorplan-registry-layer'
|
||||||
|
|
||||||
|
describe('floorplan affordance ownership', () => {
|
||||||
|
test('keeps the wall center curve drag owned by the floorplan dispatcher', () => {
|
||||||
|
expect(floorplanAffordanceReshapeScope('wall-curve', 'wall_1', undefined)).toEqual({
|
||||||
|
kind: 'reshaping',
|
||||||
|
nodeId: 'wall_1',
|
||||||
|
reshape: 'curve',
|
||||||
|
driver: 'floorplan',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
function cabinetRun(id: string, children: string[] = [], parentId: string | null = 'level_test') {
|
function cabinetRun(id: string, children: string[] = [], parentId: string | null = 'level_test') {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import {
|
import {
|
||||||
|
type ComponentProps,
|
||||||
memo,
|
memo,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
@@ -90,7 +91,11 @@ import {
|
|||||||
startFloorplanGroupMove,
|
startFloorplanGroupMove,
|
||||||
startFloorplanGroupRotate,
|
startFloorplanGroupRotate,
|
||||||
} from '../floorplan-group-move'
|
} from '../floorplan-group-move'
|
||||||
import { useFloorplanSceneRotation, useFloorplanStaticRender } from '../floorplan-render-context'
|
import {
|
||||||
|
useFloorplanSceneRotation,
|
||||||
|
useFloorplanStaticRender,
|
||||||
|
useFloorplanStaticUnitsPerPixel,
|
||||||
|
} from '../floorplan-render-context'
|
||||||
import {
|
import {
|
||||||
floorplanAnnotationObstacleMode,
|
floorplanAnnotationObstacleMode,
|
||||||
isFloorplanAnnotationObstacleGeometry,
|
isFloorplanAnnotationObstacleGeometry,
|
||||||
@@ -144,6 +149,13 @@ const DIRECT_DRAG_THRESHOLD_PX = 4
|
|||||||
const DIRECT_ROTATE_EPSILON = 1e-6
|
const DIRECT_ROTATE_EPSILON = 1e-6
|
||||||
const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
|
const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
|
||||||
|
|
||||||
|
const ScaleAwareFloorplanGroupSelectionBox = memo(function ScaleAwareFloorplanGroupSelectionBox(
|
||||||
|
props: Omit<ComponentProps<typeof FloorplanGroupSelectionBox>, 'unitsPerPixel'>,
|
||||||
|
) {
|
||||||
|
const unitsPerPixel = useFloorplanStaticUnitsPerPixel()
|
||||||
|
return <FloorplanGroupSelectionBox {...props} unitsPerPixel={unitsPerPixel} />
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot of node fields captured at drag-start, used by the single-undo
|
* Snapshot of node fields captured at drag-start, used by the single-undo
|
||||||
* dance to revert untracked before re-applying as a single tracked
|
* dance to revert untracked before re-applying as a single tracked
|
||||||
@@ -237,7 +249,7 @@ export function subscribeFloorplanAffordanceToolCancel(
|
|||||||
// affordances return `null` (no polygon/wall snapping chip). Keyed off the
|
// affordances return `null` (no polygon/wall snapping chip). Keyed off the
|
||||||
// affordance name the kinds register (`move-vertex` / `move-edge` / `add-vertex`
|
// affordance name the kinds register (`move-vertex` / `move-edge` / `add-vertex`
|
||||||
// / `curve` / `move-endpoint`).
|
// / `curve` / `move-endpoint`).
|
||||||
function affordanceReshapeScope(
|
export function floorplanAffordanceReshapeScope(
|
||||||
affordance: string,
|
affordance: string,
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
@@ -245,30 +257,30 @@ function affordanceReshapeScope(
|
|||||||
if (affordance.includes('vertex') || affordance.includes('edge')) {
|
if (affordance.includes('vertex') || affordance.includes('edge')) {
|
||||||
const holeIndex = (payload as { holeIndex?: number } | undefined)?.holeIndex
|
const holeIndex = (payload as { holeIndex?: number } | undefined)?.holeIndex
|
||||||
return holeIndex !== undefined
|
return holeIndex !== undefined
|
||||||
? holeEditScope({ nodeId, holeIndex })
|
? holeEditScope({ nodeId, holeIndex, driver: 'floorplan' })
|
||||||
: boundaryReshapeScope(nodeId)
|
: boundaryReshapeScope(nodeId, 'floorplan')
|
||||||
}
|
}
|
||||||
if (affordance.includes('curve')) {
|
if (affordance.includes('curve')) {
|
||||||
return curveReshapeScope(nodeId)
|
return curveReshapeScope(nodeId, 'floorplan')
|
||||||
}
|
}
|
||||||
if (affordance.includes('control-point')) {
|
if (affordance.includes('control-point')) {
|
||||||
const index = (payload as { index?: number } | undefined)?.index ?? 0
|
const index = (payload as { index?: number } | undefined)?.index ?? 0
|
||||||
return controlPointReshapeScope(nodeId, index)
|
return controlPointReshapeScope(nodeId, index, 'floorplan')
|
||||||
}
|
}
|
||||||
if (affordance.includes('tangent')) {
|
if (affordance.includes('tangent')) {
|
||||||
const target = payload as { index?: number; side?: 'in' | 'out' } | undefined
|
const target = payload as { index?: number; side?: 'in' | 'out' } | undefined
|
||||||
return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out')
|
return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out', 'floorplan')
|
||||||
}
|
}
|
||||||
if (affordance.includes('endpoint')) {
|
if (affordance.includes('endpoint')) {
|
||||||
const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end'
|
const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end'
|
||||||
return endpointReshapeScope(nodeId, endpoint)
|
return endpointReshapeScope(nodeId, endpoint, 'floorplan')
|
||||||
}
|
}
|
||||||
// Roof-segment width/depth resize — a no-angle dimension edit, so the
|
// Roof-segment width/depth resize — a no-angle dimension edit, so the
|
||||||
// no-angle 'polygon' snap set (grid / lines / off) via a boundary scope.
|
// no-angle 'polygon' snap set (grid / lines / off) via a boundary scope.
|
||||||
// Matched exactly so a still-legacy `*-resize` affordance on another kind
|
// Matched exactly so a still-legacy `*-resize` affordance on another kind
|
||||||
// doesn't get a chip its snap math can't honour yet.
|
// doesn't get a chip its snap math can't honour yet.
|
||||||
if (affordance === 'roof-segment-resize') {
|
if (affordance === 'roof-segment-resize') {
|
||||||
return boundaryReshapeScope(nodeId)
|
return boundaryReshapeScope(nodeId, 'floorplan')
|
||||||
}
|
}
|
||||||
// 2D corner rotate-arrow (column / elevator / roof-segment / shelf / spawn /
|
// 2D corner rotate-arrow (column / elevator / roof-segment / shelf / spawn /
|
||||||
// stair). Begin the same handle-drag scope the 3D rotate gizmo uses, label-
|
// stair). Begin the same handle-drag scope the 3D rotate gizmo uses, label-
|
||||||
@@ -1092,7 +1104,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
// the right chip during the edit AND `getActiveSnapContext()` resolves the
|
// the right chip during the edit AND `getActiveSnapContext()` resolves the
|
||||||
// polygon / wall mode-set the affordance's snap math reads. Torn down on
|
// polygon / wall mode-set the affordance's snap math reads. Torn down on
|
||||||
// release / cancel below. `null` for resize / rotate (no snapping chip).
|
// release / cancel below. `null` for resize / rotate (no snapping chip).
|
||||||
const reshapeScope = affordanceReshapeScope(affordance, nodeId, payload)
|
const reshapeScope = floorplanAffordanceReshapeScope(affordance, nodeId, payload)
|
||||||
if (reshapeScope) {
|
if (reshapeScope) {
|
||||||
useInteractionScope.getState().begin(reshapeScope)
|
useInteractionScope.getState().begin(reshapeScope)
|
||||||
}
|
}
|
||||||
@@ -1338,7 +1350,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
const entries = floorplanData.entries
|
const entries = floorplanData.entries
|
||||||
if (entries.length === 0) return null
|
if (entries.length === 0) return null
|
||||||
|
|
||||||
const unitsPerPixel = renderCtx?.getUnitsPerPixel() ?? 1
|
|
||||||
const palette = renderCtx?.palette
|
const palette = renderCtx?.palette
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1406,7 +1417,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
unit={unit}
|
unit={unit}
|
||||||
metricNotation={metricNotation}
|
metricNotation={metricNotation}
|
||||||
wallDimensionReference={wallDimensionReference}
|
wallDimensionReference={wallDimensionReference}
|
||||||
unitsPerPixel={unitsPerPixel}
|
|
||||||
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
||||||
ctxOverrides={entry.ctxOverrides}
|
ctxOverrides={entry.ctxOverrides}
|
||||||
/>
|
/>
|
||||||
@@ -1459,7 +1469,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
unit={unit}
|
unit={unit}
|
||||||
metricNotation={metricNotation}
|
metricNotation={metricNotation}
|
||||||
wallDimensionReference={wallDimensionReference}
|
wallDimensionReference={wallDimensionReference}
|
||||||
unitsPerPixel={unitsPerPixel}
|
|
||||||
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
||||||
ctxOverrides={entry.ctxOverrides}
|
ctxOverrides={entry.ctxOverrides}
|
||||||
/>
|
/>
|
||||||
@@ -1469,11 +1478,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
{/* Dashed group bbox — shows what a group drag carries along while a
|
{/* Dashed group bbox — shows what a group drag carries along while a
|
||||||
multi-selection exists, rides the live delta mid-drag, and doubles
|
multi-selection exists, rides the live delta mid-drag, and doubles
|
||||||
as the group's whole-area drag handle. */}
|
as the group's whole-area drag handle. */}
|
||||||
<FloorplanGroupSelectionBox
|
<ScaleAwareFloorplanGroupSelectionBox
|
||||||
onPointerDown={handleGroupBoxPointerDown}
|
onPointerDown={handleGroupBoxPointerDown}
|
||||||
onRotatePointerDown={handleGroupBoxRotatePointerDown}
|
onRotatePointerDown={handleGroupBoxRotatePointerDown}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
unitsPerPixel={unitsPerPixel}
|
|
||||||
/>
|
/>
|
||||||
{/* Transient live-rotation readout — drawn last so the wedge + degree
|
{/* Transient live-rotation readout — drawn last so the wedge + degree
|
||||||
chip sit above all handle chrome while a rotate-arrow is dragged. */}
|
chip sit above all handle chrome while a rotate-arrow is dragged. */}
|
||||||
@@ -1482,7 +1490,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
overlay={rotationOverlay}
|
overlay={rotationOverlay}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
sceneRotationDeg={sceneRotationDeg}
|
sceneRotationDeg={sceneRotationDeg}
|
||||||
unitsPerPixel={unitsPerPixel}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</g>
|
</g>
|
||||||
@@ -1805,7 +1812,6 @@ type FloorplanRegistryEntryProps = {
|
|||||||
unit: 'metric' | 'imperial'
|
unit: 'metric' | 'imperial'
|
||||||
metricNotation: 'meters' | 'millimeters'
|
metricNotation: 'meters' | 'millimeters'
|
||||||
wallDimensionReference: FloorplanWallDimensionReference
|
wallDimensionReference: FloorplanWallDimensionReference
|
||||||
unitsPerPixel: number
|
|
||||||
visibilityRootId: AnyNodeId | undefined
|
visibilityRootId: AnyNodeId | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1847,7 +1853,6 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|||||||
unit,
|
unit,
|
||||||
metricNotation,
|
metricNotation,
|
||||||
wallDimensionReference,
|
wallDimensionReference,
|
||||||
unitsPerPixel,
|
|
||||||
visibilityRootId,
|
visibilityRootId,
|
||||||
}: FloorplanRegistryEntryProps): React.ReactElement | null {
|
}: FloorplanRegistryEntryProps): React.ReactElement | null {
|
||||||
const live = useLiveTransforms((s) => (floorplanVisible ? s.transforms.get(nodeId) : undefined))
|
const live = useLiveTransforms((s) => (floorplanVisible ? s.transforms.get(nodeId) : undefined))
|
||||||
@@ -1997,7 +2002,6 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|||||||
onMoveHandlePointerDown={handleMoveHandlePointerDown}
|
onMoveHandlePointerDown={handleMoveHandlePointerDown}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
sceneRotationDeg={sceneRotationDeg}
|
sceneRotationDeg={sceneRotationDeg}
|
||||||
unitsPerPixel={unitsPerPixel}
|
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
@@ -2270,7 +2274,7 @@ export function getFloorplanLevelData(
|
|||||||
|
|
||||||
type InteractiveGeometryProps = {
|
type InteractiveGeometryProps = {
|
||||||
geometry: FloorplanGeometry
|
geometry: FloorplanGeometry
|
||||||
unitsPerPixel: number
|
unitsPerPixel?: number
|
||||||
palette: FloorplanPalette | undefined
|
palette: FloorplanPalette | undefined
|
||||||
hatchPatternId: string | undefined
|
hatchPatternId: string | undefined
|
||||||
hoveredHandleId: string | null
|
hoveredHandleId: string | null
|
||||||
@@ -2298,7 +2302,7 @@ type InteractiveGeometryProps = {
|
|||||||
|
|
||||||
export const InteractiveGeometry = memo(function InteractiveGeometry({
|
export const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||||
geometry,
|
geometry,
|
||||||
unitsPerPixel,
|
unitsPerPixel: unitsPerPixelOverride,
|
||||||
palette,
|
palette,
|
||||||
hatchPatternId,
|
hatchPatternId,
|
||||||
hoveredHandleId,
|
hoveredHandleId,
|
||||||
@@ -2312,6 +2316,9 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
|
|||||||
onHandlePointerDown,
|
onHandlePointerDown,
|
||||||
onMoveHandlePointerDown,
|
onMoveHandlePointerDown,
|
||||||
}: InteractiveGeometryProps): React.ReactElement {
|
}: InteractiveGeometryProps): React.ReactElement {
|
||||||
|
const liveUnitsPerPixel = useFloorplanStaticUnitsPerPixel()
|
||||||
|
const unitsPerPixel = unitsPerPixelOverride ?? liveUnitsPerPixel
|
||||||
|
|
||||||
return renderInteractive(geometry, 0)
|
return renderInteractive(geometry, 0)
|
||||||
|
|
||||||
function renderInteractive(g: FloorplanGeometry, keyHint: number): React.ReactElement {
|
function renderInteractive(g: FloorplanGeometry, keyHint: number): React.ReactElement {
|
||||||
@@ -3519,7 +3526,7 @@ const ROTATION_WEDGE_SEGMENTS = 48
|
|||||||
export function RotationAngleOverlay({
|
export function RotationAngleOverlay({
|
||||||
overlay,
|
overlay,
|
||||||
palette,
|
palette,
|
||||||
unitsPerPixel,
|
unitsPerPixel: unitsPerPixelOverride,
|
||||||
sceneRotationDeg,
|
sceneRotationDeg,
|
||||||
}: {
|
}: {
|
||||||
overlay: RotationOverlayState
|
overlay: RotationOverlayState
|
||||||
@@ -3527,9 +3534,11 @@ export function RotationAngleOverlay({
|
|||||||
FloorplanPalette,
|
FloorplanPalette,
|
||||||
'measurementLabelBackground' | 'measurementLabelText' | 'measurementStroke'
|
'measurementLabelBackground' | 'measurementLabelText' | 'measurementStroke'
|
||||||
>
|
>
|
||||||
unitsPerPixel: number
|
unitsPerPixel?: number
|
||||||
sceneRotationDeg: number
|
sceneRotationDeg: number
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
const liveUnitsPerPixel = useFloorplanStaticUnitsPerPixel()
|
||||||
|
const unitsPerPixel = unitsPerPixelOverride ?? liveUnitsPerPixel
|
||||||
const { pivot, startAngle, endAngle, radius, sweep } = overlay
|
const { pivot, startAngle, endAngle, radius, sweep } = overlay
|
||||||
const span = endAngle - startAngle
|
const span = endAngle - startAngle
|
||||||
const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_WEDGE_SEGMENTS))
|
const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_WEDGE_SEGMENTS))
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
type CameraPoseApplicationPlan,
|
type CameraPoseApplicationPlan,
|
||||||
normalizeCameraPose,
|
normalizeCameraPose,
|
||||||
planCameraPoseApplication,
|
planCameraPoseApplication,
|
||||||
|
publishInitialCameraPose,
|
||||||
|
releaseCameraPoseEventSuppression,
|
||||||
stepCameraPoseInterpolation,
|
stepCameraPoseInterpolation,
|
||||||
withCameraPoseDistance,
|
withCameraPoseDistance,
|
||||||
} from '../../lib/camera-pose'
|
} from '../../lib/camera-pose'
|
||||||
@@ -609,6 +611,10 @@ export const CustomCameraControls = () => {
|
|||||||
publishCurrentPose()
|
publishCurrentPose()
|
||||||
}, [publishCurrentPose])
|
}, [publishCurrentPose])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
publishInitialCameraPose(publishCurrentPose)
|
||||||
|
}, [publishCurrentPose])
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
if (isFirstPersonMode || !controls.current) return
|
if (isFirstPersonMode || !controls.current) return
|
||||||
|
|
||||||
@@ -645,12 +651,12 @@ export const CustomCameraControls = () => {
|
|||||||
} catch {
|
} catch {
|
||||||
if (activePoseInterpolation.current === activePose) {
|
if (activePoseInterpolation.current === activePose) {
|
||||||
activePoseInterpolation.current = null
|
activePoseInterpolation.current = null
|
||||||
suppressPoseEvents.current = false
|
releaseCameraPoseEventSuppression(suppressPoseEvents, publishCurrentPose)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (step.settled && activePoseInterpolation.current === activePose) {
|
if (step.settled && activePoseInterpolation.current === activePose) {
|
||||||
activePoseInterpolation.current = null
|
activePoseInterpolation.current = null
|
||||||
suppressPoseEvents.current = false
|
releaseCameraPoseEventSuppression(suppressPoseEvents, publishCurrentPose)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import type { CameraPose } from '@pascal-app/core'
|
import type { CameraPose } from '@pascal-app/core'
|
||||||
|
import { publishInitialCameraPose } from '../../lib/camera-pose'
|
||||||
import type { NavigationSyncPose } from '../../store/use-editor'
|
import type { NavigationSyncPose } from '../../store/use-editor'
|
||||||
import {
|
import {
|
||||||
cameraPoseToFloorplanNavigationPose,
|
cameraPoseToFloorplanNavigationPose,
|
||||||
@@ -105,7 +106,7 @@ describe('floorplan camera sync', () => {
|
|||||||
expect(applied[0]?.position[2]).toBeCloseTo(20)
|
expect(applied[0]?.position[2]).toBeCloseTo(20)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('does not publish floor-plan rotation below one degree', () => {
|
test('publishes sub-degree camera rotation for a real-time compass', () => {
|
||||||
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
|
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
|
||||||
const bridge = createFloorplanCameraSyncBridge({
|
const bridge = createFloorplanCameraSyncBridge({
|
||||||
applyCameraPose: () => {},
|
applyCameraPose: () => {},
|
||||||
@@ -114,14 +115,11 @@ describe('floorplan camera sync', () => {
|
|||||||
|
|
||||||
bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
|
bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
|
||||||
bridge.receiveCameraPose(cameraPoseAtAzimuth((0.9 * Math.PI) / 180))
|
bridge.receiveCameraPose(cameraPoseAtAzimuth((0.9 * Math.PI) / 180))
|
||||||
expect(published).toHaveLength(1)
|
|
||||||
|
|
||||||
bridge.receiveCameraPose(cameraPoseAtAzimuth(Math.PI / 180))
|
|
||||||
expect(published).toHaveLength(2)
|
expect(published).toHaveLength(2)
|
||||||
expect(published[1]?.azimuth).toBeCloseTo(Math.PI / 180)
|
expect(published[1]?.azimuth).toBeCloseTo((0.9 * Math.PI) / 180)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('keeps the previous floor-plan angle when a sub-degree rotation arrives with a pan', () => {
|
test('keeps a sub-degree camera rotation when it arrives with a pan', () => {
|
||||||
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
|
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
|
||||||
const bridge = createFloorplanCameraSyncBridge({
|
const bridge = createFloorplanCameraSyncBridge({
|
||||||
applyCameraPose: () => {},
|
applyCameraPose: () => {},
|
||||||
@@ -134,11 +132,11 @@ describe('floorplan camera sync', () => {
|
|||||||
expect(published).toHaveLength(2)
|
expect(published).toHaveLength(2)
|
||||||
expect(published[1]).toMatchObject({
|
expect(published[1]).toMatchObject({
|
||||||
target: [1, 1, 0],
|
target: [1, 1, 0],
|
||||||
azimuth: 0,
|
|
||||||
})
|
})
|
||||||
|
expect(published[1]?.azimuth).toBeCloseTo((0.5 * Math.PI) / 180)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('does no floor-plan synchronization in 3D-only mode and catches up once when visible', () => {
|
test('keeps streaming live camera headings in 3D-only mode', () => {
|
||||||
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
|
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
|
||||||
const applied: CameraPose[] = []
|
const applied: CameraPose[] = []
|
||||||
const bridge = createFloorplanCameraSyncBridge({
|
const bridge = createFloorplanCameraSyncBridge({
|
||||||
@@ -158,11 +156,13 @@ describe('floorplan camera sync', () => {
|
|||||||
viewWidth: 8,
|
viewWidth: 8,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(published).toEqual([])
|
|
||||||
expect(applied).toEqual([])
|
|
||||||
|
|
||||||
bridge.setActive(true)
|
|
||||||
expect(published).toEqual([
|
expect(published).toEqual([
|
||||||
|
{
|
||||||
|
source: '3d',
|
||||||
|
target: cameraPose.target,
|
||||||
|
azimuth: 0,
|
||||||
|
viewWidth: 12,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: '3d',
|
source: '3d',
|
||||||
target: latestPose.target,
|
target: latestPose.target,
|
||||||
@@ -171,9 +171,13 @@ describe('floorplan camera sync', () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
expect(applied).toEqual([])
|
expect(applied).toEqual([])
|
||||||
|
|
||||||
|
bridge.setActive(true)
|
||||||
|
expect(published).toHaveLength(2)
|
||||||
|
expect(applied).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('waits for a generic camera reference before applying an early 2D pose', () => {
|
test('applies 2D-first navigation on initial load without a 3D interaction', () => {
|
||||||
const applied: CameraPose[] = []
|
const applied: CameraPose[] = []
|
||||||
const bridge = createFloorplanCameraSyncBridge({
|
const bridge = createFloorplanCameraSyncBridge({
|
||||||
applyCameraPose: (pose) => applied.push(pose),
|
applyCameraPose: (pose) => applied.push(pose),
|
||||||
@@ -189,7 +193,7 @@ describe('floorplan camera sync', () => {
|
|||||||
})
|
})
|
||||||
expect(applied).toEqual([])
|
expect(applied).toEqual([])
|
||||||
|
|
||||||
bridge.receiveCameraPose(cameraPose)
|
publishInitialCameraPose(() => bridge.receiveCameraPose(cameraPose))
|
||||||
expect(applied).toHaveLength(1)
|
expect(applied).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import useEditor, {
|
|||||||
} from '../../store/use-editor'
|
} from '../../store/use-editor'
|
||||||
|
|
||||||
const POSITION_EPSILON = 0.001
|
const POSITION_EPSILON = 0.001
|
||||||
const AZIMUTH_EPSILON = Math.PI / 180
|
const AZIMUTH_EPSILON = 1e-4
|
||||||
const VIEW_WIDTH_EPSILON = 0.001
|
const VIEW_WIDTH_EPSILON = 0.001
|
||||||
|
|
||||||
type FloorplanNavigationSnapshot = Omit<NavigationSyncPoseInput, 'source'>
|
type FloorplanNavigationSnapshot = Omit<NavigationSyncPoseInput, 'source'>
|
||||||
@@ -151,18 +151,8 @@ export function createFloorplanCameraSyncBridge({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const publishCameraNavigationPose = (pose: CameraPose) => {
|
const publishCameraNavigationPose = (pose: CameraPose) => {
|
||||||
let navigationPose = cameraPoseToFloorplanNavigationPose(pose)
|
const navigationPose = cameraPoseToFloorplanNavigationPose(pose)
|
||||||
if (!navigationPose) return
|
if (!navigationPose) return
|
||||||
if (
|
|
||||||
lastPublishedNavigation &&
|
|
||||||
Math.abs(angleDeltaRadians(lastPublishedNavigation.azimuth, navigationPose.azimuth)) <
|
|
||||||
AZIMUTH_EPSILON
|
|
||||||
) {
|
|
||||||
navigationPose = {
|
|
||||||
...navigationPose,
|
|
||||||
azimuth: lastPublishedNavigation.azimuth,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const nextSnapshot = navigationSnapshot(navigationPose)
|
const nextSnapshot = navigationSnapshot(navigationPose)
|
||||||
if (
|
if (
|
||||||
lastPublishedNavigation &&
|
lastPublishedNavigation &&
|
||||||
@@ -178,8 +168,7 @@ export function createFloorplanCameraSyncBridge({
|
|||||||
return {
|
return {
|
||||||
receiveCameraPose: (pose) => {
|
receiveCameraPose: (pose) => {
|
||||||
latestCameraPose = pose
|
latestCameraPose = pose
|
||||||
if (!active) return
|
if (active && applyPendingNavigationPose()) return
|
||||||
if (applyPendingNavigationPose()) return
|
|
||||||
|
|
||||||
publishCameraNavigationPose(pose)
|
publishCameraNavigationPose(pose)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,10 +4,40 @@ import {
|
|||||||
canZoomFloorplanDuringNavigation,
|
canZoomFloorplanDuringNavigation,
|
||||||
createFloorplanNavigationSyncScheduler,
|
createFloorplanNavigationSyncScheduler,
|
||||||
finalizeFloorplanNavigation,
|
finalizeFloorplanNavigation,
|
||||||
|
flushFloorplanRotationPresentationRestore,
|
||||||
|
getFloorplanRotationOverscanViewBox,
|
||||||
|
queueFloorplanRotationPresentationRestore,
|
||||||
resolveFloorplanPresentationViewBox,
|
resolveFloorplanPresentationViewBox,
|
||||||
|
setFloorplanCompassRotation,
|
||||||
} from './floorplan-navigation-presentation'
|
} from './floorplan-navigation-presentation'
|
||||||
|
|
||||||
describe('floorplan navigation presentation', () => {
|
describe('floorplan navigation presentation', () => {
|
||||||
|
test('keeps the viewport inside the painted floorplan surface throughout rotation', () => {
|
||||||
|
const viewport = { minX: -80, minY: -45, width: 160, height: 90 }
|
||||||
|
const overscan = getFloorplanRotationOverscanViewBox(viewport)
|
||||||
|
const viewportCorners: Array<[number, number]> = [
|
||||||
|
[-80, -45],
|
||||||
|
[80, -45],
|
||||||
|
[80, 45],
|
||||||
|
[-80, 45],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let degrees = 0; degrees < 360; degrees += 1) {
|
||||||
|
const radians = (-degrees * Math.PI) / 180
|
||||||
|
const cos = Math.cos(radians)
|
||||||
|
const sin = Math.sin(radians)
|
||||||
|
|
||||||
|
for (const [x, y] of viewportCorners) {
|
||||||
|
const localX = x * cos - y * sin
|
||||||
|
const localY = x * sin + y * cos
|
||||||
|
expect(localX).toBeGreaterThanOrEqual(overscan.minX - 1e-10)
|
||||||
|
expect(localX).toBeLessThanOrEqual(overscan.minX + overscan.width + 1e-10)
|
||||||
|
expect(localY).toBeGreaterThanOrEqual(overscan.minY - 1e-10)
|
||||||
|
expect(localY).toBeLessThanOrEqual(overscan.minY + overscan.height + 1e-10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('keeps the imperative viewBox authoritative during navigation', () => {
|
test('keeps the imperative viewBox authoritative during navigation', () => {
|
||||||
const reactViewBox = { minX: 0, minY: 0, width: 100, height: 50 }
|
const reactViewBox = { minX: 0, minY: 0, width: 100, height: 50 }
|
||||||
const imperativeViewBox = { minX: 25, minY: 10, width: 40, height: 20 }
|
const imperativeViewBox = { minX: 25, minY: 10, width: 40, height: 20 }
|
||||||
@@ -30,6 +60,42 @@ describe('floorplan navigation presentation', () => {
|
|||||||
expect(canApplyFloorplanNavigationSync(false)).toBe(true)
|
expect(canApplyFloorplanNavigationSync(false)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('updates the compass presentation on every live rotation frame', () => {
|
||||||
|
const compass = { style: { transform: '' } }
|
||||||
|
|
||||||
|
setFloorplanCompassRotation(compass, 0.25)
|
||||||
|
expect(compass.style.transform).toBe('rotate(0.25deg)')
|
||||||
|
|
||||||
|
setFloorplanCompassRotation(compass, 0.5)
|
||||||
|
expect(compass.style.transform).toBe('rotate(0.5deg)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the rotation preview in place until the committed state is ready to paint', () => {
|
||||||
|
const pending = { current: null }
|
||||||
|
const svg = {
|
||||||
|
style: {
|
||||||
|
transform: 'rotate(42deg)',
|
||||||
|
transformOrigin: 'center',
|
||||||
|
willChange: 'transform',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const presentation = {
|
||||||
|
svg,
|
||||||
|
svgStyle: {
|
||||||
|
transform: '',
|
||||||
|
transformOrigin: '',
|
||||||
|
willChange: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
queueFloorplanRotationPresentationRestore(pending, presentation)
|
||||||
|
expect(svg.style.transform).toBe('rotate(42deg)')
|
||||||
|
|
||||||
|
flushFloorplanRotationPresentationRestore(pending)
|
||||||
|
expect(svg.style.transform).toBe('')
|
||||||
|
expect(pending.current).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
test('commits every active navigation channel before teardown', () => {
|
test('commits every active navigation channel before teardown', () => {
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
const rotationState = { angle: 42 }
|
const rotationState = { angle: 42 }
|
||||||
|
|||||||
@@ -5,6 +5,63 @@ export type FloorplanPresentationViewBox = {
|
|||||||
height: number
|
height: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setFloorplanCompassRotation(
|
||||||
|
compass: { style: { transform: string } } | null,
|
||||||
|
rotationDeg: number,
|
||||||
|
): void {
|
||||||
|
if (compass) {
|
||||||
|
compass.style.transform = `rotate(${rotationDeg}deg)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FloorplanRotationPresentation = {
|
||||||
|
svg: {
|
||||||
|
style: {
|
||||||
|
transform: string
|
||||||
|
transformOrigin: string
|
||||||
|
willChange: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svgStyle: {
|
||||||
|
transform: string
|
||||||
|
transformOrigin: string
|
||||||
|
willChange: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueFloorplanRotationPresentationRestore<
|
||||||
|
Presentation extends FloorplanRotationPresentation,
|
||||||
|
>(pending: { current: Presentation | null }, presentation: Presentation): void {
|
||||||
|
pending.current = presentation
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flushFloorplanRotationPresentationRestore<
|
||||||
|
Presentation extends FloorplanRotationPresentation,
|
||||||
|
>(pending: { current: Presentation | null }): void {
|
||||||
|
const presentation = pending.current
|
||||||
|
if (!presentation) return
|
||||||
|
|
||||||
|
presentation.svg.style.transform = presentation.svgStyle.transform
|
||||||
|
presentation.svg.style.transformOrigin = presentation.svgStyle.transformOrigin
|
||||||
|
presentation.svg.style.willChange = presentation.svgStyle.willChange
|
||||||
|
pending.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFloorplanRotationOverscanViewBox(
|
||||||
|
viewBox: FloorplanPresentationViewBox,
|
||||||
|
): FloorplanPresentationViewBox {
|
||||||
|
const size = Math.hypot(viewBox.width, viewBox.height)
|
||||||
|
const centerX = viewBox.minX + viewBox.width / 2
|
||||||
|
const centerY = viewBox.minY + viewBox.height / 2
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: centerX - size / 2,
|
||||||
|
minY: centerY - size / 2,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveFloorplanPresentationViewBox(
|
export function resolveFloorplanPresentationViewBox(
|
||||||
reactViewBox: FloorplanPresentationViewBox,
|
reactViewBox: FloorplanPresentationViewBox,
|
||||||
imperativeViewBox: FloorplanPresentationViewBox | null,
|
imperativeViewBox: FloorplanPresentationViewBox | null,
|
||||||
|
|||||||
@@ -203,7 +203,11 @@ import {
|
|||||||
type FloorplanNavigationSyncScheduler,
|
type FloorplanNavigationSyncScheduler,
|
||||||
type FloorplanPresentationViewBox,
|
type FloorplanPresentationViewBox,
|
||||||
finalizeFloorplanNavigation,
|
finalizeFloorplanNavigation,
|
||||||
|
flushFloorplanRotationPresentationRestore,
|
||||||
|
getFloorplanRotationOverscanViewBox,
|
||||||
|
queueFloorplanRotationPresentationRestore,
|
||||||
resolveFloorplanPresentationViewBox,
|
resolveFloorplanPresentationViewBox,
|
||||||
|
setFloorplanCompassRotation,
|
||||||
} from './floorplan-navigation-presentation'
|
} from './floorplan-navigation-presentation'
|
||||||
import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement'
|
import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement'
|
||||||
import { useFloorplanHitTesting } from './use-floorplan-hit-testing'
|
import { useFloorplanHitTesting } from './use-floorplan-hit-testing'
|
||||||
@@ -358,12 +362,6 @@ type FloorplanNavigationSyncPresentationState = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreFloorplanRotationPresentation(rotationState: FloorplanRotationState) {
|
|
||||||
rotationState.svg.style.transform = rotationState.svgStyle.transform
|
|
||||||
rotationState.svg.style.transformOrigin = rotationState.svgStyle.transformOrigin
|
|
||||||
rotationState.svg.style.willChange = rotationState.svgStyle.willChange
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreFloorplanNavigationSyncPresentation(
|
function restoreFloorplanNavigationSyncPresentation(
|
||||||
presentationState: FloorplanNavigationSyncPresentationState,
|
presentationState: FloorplanNavigationSyncPresentationState,
|
||||||
) {
|
) {
|
||||||
@@ -5343,6 +5341,7 @@ export function FloorplanPanel({
|
|||||||
const floorplanContentRef = useRef<SVGGElement>(null)
|
const floorplanContentRef = useRef<SVGGElement>(null)
|
||||||
const panStateRef = useRef<PanState | null>(null)
|
const panStateRef = useRef<PanState | null>(null)
|
||||||
const floorplanRotationStateRef = useRef<FloorplanRotationState | null>(null)
|
const floorplanRotationStateRef = useRef<FloorplanRotationState | null>(null)
|
||||||
|
const pendingFloorplanRotationRestoreRef = useRef<FloorplanRotationState | null>(null)
|
||||||
const floorplanSpacePanPressedRef = useRef(false)
|
const floorplanSpacePanPressedRef = useRef(false)
|
||||||
const floorplanNavigationClickSuppressedRef = useRef(false)
|
const floorplanNavigationClickSuppressedRef = useRef(false)
|
||||||
const guideInteractionRef = useRef<GuideInteractionState | null>(null)
|
const guideInteractionRef = useRef<GuideInteractionState | null>(null)
|
||||||
@@ -6611,12 +6610,14 @@ export function FloorplanPanel({
|
|||||||
const nextHeight = nextViewport.width / svgAspectRatio
|
const nextHeight = nextViewport.width / svgAspectRatio
|
||||||
const nextMinX = nextViewport.centerX - nextViewport.width / 2
|
const nextMinX = nextViewport.centerX - nextViewport.width / 2
|
||||||
const nextMinY = nextViewport.centerY - nextHeight / 2
|
const nextMinY = nextViewport.centerY - nextHeight / 2
|
||||||
floorplanImperativeViewBoxRef.current = {
|
const nextViewBox = {
|
||||||
minX: nextMinX,
|
minX: nextMinX,
|
||||||
minY: nextMinY,
|
minY: nextMinY,
|
||||||
width: nextViewport.width,
|
width: nextViewport.width,
|
||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
}
|
}
|
||||||
|
const nextPaintViewBox = getFloorplanRotationOverscanViewBox(nextViewBox)
|
||||||
|
floorplanImperativeViewBoxRef.current = nextViewBox
|
||||||
hasUserAdjustedViewportRef.current = true
|
hasUserAdjustedViewportRef.current = true
|
||||||
latestViewportRef.current = nextViewport
|
latestViewportRef.current = nextViewport
|
||||||
svgRef.current?.setAttribute(
|
svgRef.current?.setAttribute(
|
||||||
@@ -6625,10 +6626,10 @@ export function FloorplanPanel({
|
|||||||
)
|
)
|
||||||
const background = floorplanBackgroundRef.current
|
const background = floorplanBackgroundRef.current
|
||||||
if (background) {
|
if (background) {
|
||||||
background.setAttribute('x', String(nextMinX))
|
background.setAttribute('x', String(nextPaintViewBox.minX))
|
||||||
background.setAttribute('y', String(nextMinY))
|
background.setAttribute('y', String(nextPaintViewBox.minY))
|
||||||
background.setAttribute('width', String(nextViewport.width))
|
background.setAttribute('width', String(nextPaintViewBox.width))
|
||||||
background.setAttribute('height', String(nextHeight))
|
background.setAttribute('height', String(nextPaintViewBox.height))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[svgAspectRatio],
|
[svgAspectRatio],
|
||||||
@@ -7134,9 +7135,7 @@ export function FloorplanPanel({
|
|||||||
nextDeg = targetDeg
|
nextDeg = targetDeg
|
||||||
}
|
}
|
||||||
latestFloorplanUserRotationDegRef.current = nextDeg
|
latestFloorplanUserRotationDegRef.current = nextDeg
|
||||||
if (compassNeedleRef.current) {
|
setFloorplanCompassRotation(compassNeedleRef.current, nextDeg)
|
||||||
compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)`
|
|
||||||
}
|
|
||||||
if (nextDeg !== targetDeg) {
|
if (nextDeg !== targetDeg) {
|
||||||
hiddenCompassAnimationRef.current = requestAnimationFrame(tick)
|
hiddenCompassAnimationRef.current = requestAnimationFrame(tick)
|
||||||
}
|
}
|
||||||
@@ -7167,9 +7166,7 @@ export function FloorplanPanel({
|
|||||||
// owns the needle, so any local animation yields to it.
|
// owns the needle, so any local animation yields to it.
|
||||||
cancelHiddenCompassAnimation()
|
cancelHiddenCompassAnimation()
|
||||||
latestFloorplanUserRotationDegRef.current = nextDeg
|
latestFloorplanUserRotationDegRef.current = nextDeg
|
||||||
if (compassNeedleRef.current) {
|
setFloorplanCompassRotation(compassNeedleRef.current, nextDeg)
|
||||||
compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)`
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
animateHiddenCompassNeedle(nextDeg)
|
animateHiddenCompassNeedle(nextDeg)
|
||||||
}
|
}
|
||||||
@@ -7213,8 +7210,11 @@ export function FloorplanPanel({
|
|||||||
// state; this layout effect restores the authoritative ref value before
|
// state; this layout effect restores the authoritative ref value before
|
||||||
// the browser paints so the needle never visibly snaps to a stale angle.
|
// the browser paints so the needle never visibly snaps to a stale angle.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isFloorplanOpen && compassNeedleRef.current) {
|
if (!isFloorplanOpen) {
|
||||||
compassNeedleRef.current.style.transform = `rotate(${latestFloorplanUserRotationDegRef.current}deg)`
|
setFloorplanCompassRotation(
|
||||||
|
compassNeedleRef.current,
|
||||||
|
latestFloorplanUserRotationDegRef.current,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -7328,6 +7328,7 @@ export function FloorplanPanel({
|
|||||||
floorplanImperativeViewBoxRef.current,
|
floorplanImperativeViewBoxRef.current,
|
||||||
floorplanViewportInteractionInProgressRef.current,
|
floorplanViewportInteractionInProgressRef.current,
|
||||||
)
|
)
|
||||||
|
const presentationPaintViewBox = getFloorplanRotationOverscanViewBox(presentationViewBox)
|
||||||
const floorplanWorldUnitsPerPixel = useMemo(() => {
|
const floorplanWorldUnitsPerPixel = useMemo(() => {
|
||||||
const widthUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1)
|
const widthUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1)
|
||||||
const heightUnitsPerPixel = viewBox.height / Math.max(surfaceSize.height, 1)
|
const heightUnitsPerPixel = viewBox.height / Math.max(surfaceSize.height, 1)
|
||||||
@@ -7603,7 +7604,11 @@ export function FloorplanPanel({
|
|||||||
[surfaceSize.width, viewBox.width],
|
[surfaceSize.width, viewBox.width],
|
||||||
)
|
)
|
||||||
const gridBounds = useMemo(
|
const gridBounds = useMemo(
|
||||||
() => getRotatedViewBoxBounds(viewBox, floorplanSceneRotationDeg),
|
() =>
|
||||||
|
getRotatedViewBoxBounds(
|
||||||
|
getFloorplanRotationOverscanViewBox(viewBox),
|
||||||
|
floorplanSceneRotationDeg,
|
||||||
|
),
|
||||||
[floorplanSceneRotationDeg, viewBox],
|
[floorplanSceneRotationDeg, viewBox],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -8177,6 +8182,7 @@ export function FloorplanPanel({
|
|||||||
hasUserAdjustedViewportRef.current = true
|
hasUserAdjustedViewportRef.current = true
|
||||||
latestFloorplanUserRotationDegRef.current = nextUserRotationDeg
|
latestFloorplanUserRotationDegRef.current = nextUserRotationDeg
|
||||||
latestViewportRef.current = nextViewport
|
latestViewportRef.current = nextViewport
|
||||||
|
setFloorplanCompassRotation(compassNeedleRef.current, nextUserRotationDeg)
|
||||||
// Transform the already-painted SVG as one compositor layer. Mutating the
|
// Transform the already-painted SVG as one compositor layer. Mutating the
|
||||||
// scene rotation/viewBox here forces the heavy vector plan to rerasterize.
|
// scene rotation/viewBox here forces the heavy vector plan to rerasterize.
|
||||||
rotationState.svg.style.transform = `rotate(${nextUserRotationDeg - rotationState.initialUserRotationDeg}deg)`
|
rotationState.svg.style.transform = `rotate(${nextUserRotationDeg - rotationState.initialUserRotationDeg}deg)`
|
||||||
@@ -8348,7 +8354,7 @@ export function FloorplanPanel({
|
|||||||
(rotationState: FloorplanRotationState) => {
|
(rotationState: FloorplanRotationState) => {
|
||||||
floorplanViewportInteractionInProgressRef.current = false
|
floorplanViewportInteractionInProgressRef.current = false
|
||||||
floorplanImperativeViewBoxRef.current = null
|
floorplanImperativeViewBoxRef.current = null
|
||||||
restoreFloorplanRotationPresentation(rotationState)
|
queueFloorplanRotationPresentationRestore(pendingFloorplanRotationRestoreRef, rotationState)
|
||||||
setFloorplanUserRotationDeg((current) =>
|
setFloorplanUserRotationDeg((current) =>
|
||||||
current === rotationState.latestUserRotationDeg
|
current === rotationState.latestUserRotationDeg
|
||||||
? current
|
? current
|
||||||
@@ -8368,6 +8374,10 @@ export function FloorplanPanel({
|
|||||||
[publishFloorplanNavigationPose],
|
[publishFloorplanNavigationPose],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
flushFloorplanRotationPresentationRestore(pendingFloorplanRotationRestoreRef)
|
||||||
|
})
|
||||||
|
|
||||||
// Finalize imperative navigation when the floorplan closes so reopening
|
// Finalize imperative navigation when the floorplan closes so reopening
|
||||||
// restores the last visible pose instead of stale React state.
|
// restores the last visible pose instead of stale React state.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -11877,6 +11887,7 @@ export function FloorplanPanel({
|
|||||||
style={{
|
style={{
|
||||||
cursor:
|
cursor:
|
||||||
floorplanNavigationCursor ?? (referenceScaleDraft ? 'crosshair' : EDITOR_CURSOR),
|
floorplanNavigationCursor ?? (referenceScaleDraft ? 'crosshair' : EDITOR_CURSOR),
|
||||||
|
overflow: 'visible',
|
||||||
}}
|
}}
|
||||||
viewBox={`${presentationViewBox.minX} ${presentationViewBox.minY} ${presentationViewBox.width} ${presentationViewBox.height}`}
|
viewBox={`${presentationViewBox.minX} ${presentationViewBox.minY} ${presentationViewBox.width} ${presentationViewBox.height}`}
|
||||||
>
|
>
|
||||||
@@ -11916,11 +11927,11 @@ export function FloorplanPanel({
|
|||||||
</defs>
|
</defs>
|
||||||
<rect
|
<rect
|
||||||
fill={palette.surface}
|
fill={palette.surface}
|
||||||
height={presentationViewBox.height}
|
height={presentationPaintViewBox.height}
|
||||||
ref={floorplanBackgroundRef}
|
ref={floorplanBackgroundRef}
|
||||||
width={presentationViewBox.width}
|
width={presentationPaintViewBox.width}
|
||||||
x={presentationViewBox.minX}
|
x={presentationPaintViewBox.minX}
|
||||||
y={presentationViewBox.minY}
|
y={presentationPaintViewBox.minY}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<g
|
<g
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
useEditingHole,
|
useEditingHole,
|
||||||
useEndpointReshape,
|
useEndpointReshape,
|
||||||
useIsCurveReshape,
|
useIsCurveReshape,
|
||||||
|
useIsFloorplanDrivenReshape,
|
||||||
|
useIsToolDrivenReshape,
|
||||||
useMovingNode,
|
useMovingNode,
|
||||||
useReshapingNode,
|
useReshapingNode,
|
||||||
useTangentReshape,
|
useTangentReshape,
|
||||||
@@ -103,6 +105,8 @@ export const ToolManager: React.FC = () => {
|
|||||||
const controlPointReshape = useControlPointReshape()
|
const controlPointReshape = useControlPointReshape()
|
||||||
const tangentReshape = useTangentReshape()
|
const tangentReshape = useTangentReshape()
|
||||||
const isCurveReshape = useIsCurveReshape()
|
const isCurveReshape = useIsCurveReshape()
|
||||||
|
const isToolDrivenReshape = useIsToolDrivenReshape()
|
||||||
|
const isFloorplanDrivenReshape = useIsFloorplanDrivenReshape()
|
||||||
const reshapingNode = useReshapingNode()
|
const reshapingNode = useReshapingNode()
|
||||||
// The endpoint affordance tool's `target` is kind-specific
|
// The endpoint affordance tool's `target` is kind-specific
|
||||||
// (`{ wall | fence, endpoint }`); rebuild it from the (frozen) reshaped node +
|
// (`{ wall | fence, endpoint }`); rebuild it from the (frozen) reshaped node +
|
||||||
@@ -173,6 +177,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
mode === 'select' &&
|
mode === 'select' &&
|
||||||
isSoleSelection &&
|
isSoleSelection &&
|
||||||
selectedSlabId !== undefined &&
|
selectedSlabId !== undefined &&
|
||||||
|
!isFloorplanDrivenReshape &&
|
||||||
!editingSlabHoleIsManual
|
!editingSlabHoleIsManual
|
||||||
|
|
||||||
// Show slab hole editor when editing a hole on the selected slab
|
// Show slab hole editor when editing a hole on the selected slab
|
||||||
@@ -180,6 +185,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
selectedSlabId !== undefined &&
|
selectedSlabId !== undefined &&
|
||||||
editingHole !== null &&
|
editingHole !== null &&
|
||||||
editingHole.nodeId === selectedSlabId &&
|
editingHole.nodeId === selectedSlabId &&
|
||||||
|
!isFloorplanDrivenReshape &&
|
||||||
editingSlabHoleIsManual
|
editingSlabHoleIsManual
|
||||||
|
|
||||||
// Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)
|
// Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)
|
||||||
@@ -188,13 +194,15 @@ export const ToolManager: React.FC = () => {
|
|||||||
mode === 'select' &&
|
mode === 'select' &&
|
||||||
isSoleSelection &&
|
isSoleSelection &&
|
||||||
selectedCeilingId !== undefined &&
|
selectedCeilingId !== undefined &&
|
||||||
|
!isFloorplanDrivenReshape &&
|
||||||
(!editingHole || editingHole.nodeId !== selectedCeilingId)
|
(!editingHole || editingHole.nodeId !== selectedCeilingId)
|
||||||
|
|
||||||
// Show ceiling hole editor when editing a hole on the selected ceiling
|
// Show ceiling hole editor when editing a hole on the selected ceiling
|
||||||
const showCeilingHoleEditor =
|
const showCeilingHoleEditor =
|
||||||
selectedCeilingId !== undefined &&
|
selectedCeilingId !== undefined &&
|
||||||
editingHole !== null &&
|
editingHole !== null &&
|
||||||
editingHole.nodeId === selectedCeilingId
|
editingHole.nodeId === selectedCeilingId &&
|
||||||
|
!isFloorplanDrivenReshape
|
||||||
|
|
||||||
// Show zone boundary editor when in structure/select mode with a zone selected
|
// Show zone boundary editor when in structure/select mode with a zone selected
|
||||||
// Hide when editing a slab or ceiling to avoid overlapping handles
|
// Hide when editing a slab or ceiling to avoid overlapping handles
|
||||||
@@ -202,6 +210,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
phase === 'structure' &&
|
phase === 'structure' &&
|
||||||
mode === 'select' &&
|
mode === 'select' &&
|
||||||
selectedZoneId !== null &&
|
selectedZoneId !== null &&
|
||||||
|
!isFloorplanDrivenReshape &&
|
||||||
!showSlabBoundaryEditor &&
|
!showSlabBoundaryEditor &&
|
||||||
!showCeilingBoundaryEditor
|
!showCeilingBoundaryEditor
|
||||||
|
|
||||||
@@ -301,7 +310,8 @@ export const ToolManager: React.FC = () => {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
) : null
|
) : null
|
||||||
})()}
|
})()}
|
||||||
{endpointTarget &&
|
{isToolDrivenReshape &&
|
||||||
|
endpointTarget &&
|
||||||
reshapingNode &&
|
reshapingNode &&
|
||||||
(() => {
|
(() => {
|
||||||
const RegistryAffordance = getRegistryAffordanceTool(
|
const RegistryAffordance = getRegistryAffordanceTool(
|
||||||
@@ -314,7 +324,8 @@ export const ToolManager: React.FC = () => {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
) : null
|
) : null
|
||||||
})()}
|
})()}
|
||||||
{isCurveReshape &&
|
{isToolDrivenReshape &&
|
||||||
|
isCurveReshape &&
|
||||||
reshapingNode &&
|
reshapingNode &&
|
||||||
(() => {
|
(() => {
|
||||||
const RegistryAffordance = getRegistryAffordanceTool(reshapingNode.type, 'curve')
|
const RegistryAffordance = getRegistryAffordanceTool(reshapingNode.type, 'curve')
|
||||||
@@ -324,7 +335,8 @@ export const ToolManager: React.FC = () => {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
) : null
|
) : null
|
||||||
})()}
|
})()}
|
||||||
{controlPointTarget &&
|
{isToolDrivenReshape &&
|
||||||
|
controlPointTarget &&
|
||||||
(() => {
|
(() => {
|
||||||
const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-control-point')
|
const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-control-point')
|
||||||
return RegistryAffordance ? (
|
return RegistryAffordance ? (
|
||||||
@@ -333,7 +345,8 @@ export const ToolManager: React.FC = () => {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
) : null
|
) : null
|
||||||
})()}
|
})()}
|
||||||
{tangentTarget &&
|
{isToolDrivenReshape &&
|
||||||
|
tangentTarget &&
|
||||||
(() => {
|
(() => {
|
||||||
const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-tangent')
|
const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-tangent')
|
||||||
return RegistryAffordance ? (
|
return RegistryAffordance ? (
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ describe('createWallOnCurrentLevel', () => {
|
|||||||
useEditor.getState().setSnappingMode('wall', 'lines')
|
useEditor.getState().setSnappingMode('wall', 'lines')
|
||||||
useInteractionScope
|
useInteractionScope
|
||||||
.getState()
|
.getState()
|
||||||
.begin({ kind: 'reshaping', nodeId: 'wall_a', reshape: 'endpoint' })
|
.begin({ kind: 'reshaping', nodeId: 'wall_a', reshape: 'endpoint', driver: 'tool' })
|
||||||
seedLevel([makeWall([0, 0], [4, 0], 'wall_a')])
|
seedLevel([makeWall([0, 0], [4, 0], 'wall_a')])
|
||||||
useScene.temporal.getState().clear()
|
useScene.temporal.getState().clear()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test'
|
|||||||
import {
|
import {
|
||||||
normalizeCameraPose,
|
normalizeCameraPose,
|
||||||
planCameraPoseApplication,
|
planCameraPoseApplication,
|
||||||
|
publishInitialCameraPose,
|
||||||
|
releaseCameraPoseEventSuppression,
|
||||||
stepCameraPoseInterpolation,
|
stepCameraPoseInterpolation,
|
||||||
withCameraPoseDistance,
|
withCameraPoseDistance,
|
||||||
} from './camera-pose'
|
} from './camera-pose'
|
||||||
@@ -163,6 +165,28 @@ describe('camera pose', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('publishes the initial pose before any camera control update event', () => {
|
||||||
|
let publishCount = 0
|
||||||
|
|
||||||
|
publishInitialCameraPose(() => {
|
||||||
|
publishCount += 1
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(publishCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('publishes the settled pose when programmatic interpolation releases suppression', () => {
|
||||||
|
const suppression = { current: true }
|
||||||
|
let publishCount = 0
|
||||||
|
|
||||||
|
releaseCameraPoseEventSuppression(suppression, () => {
|
||||||
|
publishCount += 1
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(suppression.current).toBe(false)
|
||||||
|
expect(publishCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
test('retargets the bounded interpolation owner to the latest pose', () => {
|
test('retargets the bounded interpolation owner to the latest pose', () => {
|
||||||
const first = stepCameraPoseInterpolation(
|
const first = stepCameraPoseInterpolation(
|
||||||
[0, 0, 0],
|
[0, 0, 0],
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ export type CameraPoseInterpolationStep = {
|
|||||||
target: [number, number, number]
|
target: [number, number, number]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function publishInitialCameraPose(publishCurrentPose: () => void): void {
|
||||||
|
publishCurrentPose()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseCameraPoseEventSuppression(
|
||||||
|
suppression: { current: boolean },
|
||||||
|
publishCurrentPose: () => void,
|
||||||
|
): void {
|
||||||
|
suppression.current = false
|
||||||
|
publishCurrentPose()
|
||||||
|
}
|
||||||
|
|
||||||
function finiteVectorTuple(value: unknown): [number, number, number] | null {
|
function finiteVectorTuple(value: unknown): [number, number, number] | null {
|
||||||
if (
|
if (
|
||||||
!(
|
!(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const ACTIVE_SCOPES: ActiveInteractionScope[] = [
|
|||||||
{ kind: 'moving', node: mockNode('i1', 'item'), nodeId: 'i1', nodeType: 'item', view: '2d' },
|
{ kind: 'moving', node: mockNode('i1', 'item'), nodeId: 'i1', nodeType: 'item', view: '2d' },
|
||||||
{ kind: 'handle-drag', nodeId: 'w1', handle: 'height' },
|
{ kind: 'handle-drag', nodeId: 'w1', handle: 'height' },
|
||||||
{ kind: 'drafting', tool: 'wall' },
|
{ kind: 'drafting', tool: 'wall' },
|
||||||
{ kind: 'reshaping', nodeId: 's1', reshape: 'hole', holeIndex: 0 },
|
{ kind: 'reshaping', nodeId: 's1', reshape: 'hole', driver: 'tool', holeIndex: 0 },
|
||||||
{ kind: 'box-select' },
|
{ kind: 'box-select' },
|
||||||
{ kind: 'painting' },
|
{ kind: 'painting' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import type { AnyNode } from '@pascal-app/core'
|
import type { AnyNode } from '@pascal-app/core'
|
||||||
|
|
||||||
export type InteractionView = '2d' | '3d'
|
export type InteractionView = '2d' | '3d'
|
||||||
|
export type ReshapeDriver = 'tool' | 'floorplan'
|
||||||
|
|
||||||
// Endpoint/curve/hole/boundary edits are all "reshape the selected node" — one
|
// Endpoint/curve/hole/boundary edits are all "reshape the selected node" — one
|
||||||
// node, one in-flight reshape. Grouping them as sub-states of `reshaping`
|
// node, one in-flight reshape. Grouping them as sub-states of `reshaping`
|
||||||
@@ -47,6 +48,8 @@ export type InteractionScope =
|
|||||||
kind: 'reshaping'
|
kind: 'reshaping'
|
||||||
nodeId: string
|
nodeId: string
|
||||||
reshape: ReshapeKind
|
reshape: ReshapeKind
|
||||||
|
// Floorplan affordances own their commit and must not mount a second 3D reshape tool.
|
||||||
|
driver: ReshapeDriver
|
||||||
holeIndex?: number
|
holeIndex?: number
|
||||||
endpoint?: 'start' | 'end'
|
endpoint?: 'start' | 'end'
|
||||||
index?: number
|
index?: number
|
||||||
@@ -126,12 +129,14 @@ export function editingHoleInfo(
|
|||||||
export function holeEditScope(target: {
|
export function holeEditScope(target: {
|
||||||
nodeId: string
|
nodeId: string
|
||||||
holeIndex: number
|
holeIndex: number
|
||||||
|
driver?: ReshapeDriver
|
||||||
}): ActiveInteractionScope {
|
}): ActiveInteractionScope {
|
||||||
return {
|
return {
|
||||||
kind: 'reshaping',
|
kind: 'reshaping',
|
||||||
nodeId: target.nodeId,
|
nodeId: target.nodeId,
|
||||||
reshape: 'hole',
|
reshape: 'hole',
|
||||||
holeIndex: target.holeIndex,
|
holeIndex: target.holeIndex,
|
||||||
|
driver: target.driver ?? 'tool',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +147,14 @@ export function isCurveReshape(scope: InteractionScope): boolean {
|
|||||||
return scope.kind === 'reshaping' && scope.reshape === 'curve'
|
return scope.kind === 'reshaping' && scope.reshape === 'curve'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isToolDrivenReshape(scope: InteractionScope): boolean {
|
||||||
|
return scope.kind === 'reshaping' && scope.driver === 'tool'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFloorplanDrivenReshape(scope: InteractionScope): boolean {
|
||||||
|
return scope.kind === 'reshaping' && scope.driver === 'floorplan'
|
||||||
|
}
|
||||||
|
|
||||||
// The legacy `movingWallEndpoint` / `movingFenceEndpoint` flags minus the node
|
// The legacy `movingWallEndpoint` / `movingFenceEndpoint` flags minus the node
|
||||||
// itself (consumers fetch the node from the scene by `nodeId`; it is stable for
|
// itself (consumers fetch the node from the scene by `nodeId`; it is stable for
|
||||||
// the duration of the drag).
|
// the duration of the drag).
|
||||||
@@ -181,31 +194,43 @@ export function reshapingNodeId(scope: InteractionScope): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Builders so producers don't re-spell the discriminator at every call site.
|
// Builders so producers don't re-spell the discriminator at every call site.
|
||||||
export function curveReshapeScope(nodeId: string): ActiveInteractionScope {
|
export function curveReshapeScope(
|
||||||
return { kind: 'reshaping', nodeId, reshape: 'curve' }
|
nodeId: string,
|
||||||
|
driver: ReshapeDriver = 'tool',
|
||||||
|
): ActiveInteractionScope {
|
||||||
|
return { kind: 'reshaping', nodeId, reshape: 'curve', driver }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function endpointReshapeScope(
|
export function endpointReshapeScope(
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
endpoint: 'start' | 'end',
|
endpoint: 'start' | 'end',
|
||||||
|
driver: ReshapeDriver = 'tool',
|
||||||
): ActiveInteractionScope {
|
): ActiveInteractionScope {
|
||||||
return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint }
|
return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint, driver }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function controlPointReshapeScope(nodeId: string, index: number): ActiveInteractionScope {
|
export function controlPointReshapeScope(
|
||||||
return { kind: 'reshaping', nodeId, reshape: 'control-point', index }
|
nodeId: string,
|
||||||
|
index: number,
|
||||||
|
driver: ReshapeDriver = 'tool',
|
||||||
|
): ActiveInteractionScope {
|
||||||
|
return { kind: 'reshaping', nodeId, reshape: 'control-point', index, driver }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tangentReshapeScope(
|
export function tangentReshapeScope(
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
index: number,
|
index: number,
|
||||||
side: 'in' | 'out',
|
side: 'in' | 'out',
|
||||||
|
driver: ReshapeDriver = 'tool',
|
||||||
): ActiveInteractionScope {
|
): ActiveInteractionScope {
|
||||||
return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side }
|
return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side, driver }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping
|
// Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping
|
||||||
// HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen.
|
// HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen.
|
||||||
export function boundaryReshapeScope(nodeId: string): ActiveInteractionScope {
|
export function boundaryReshapeScope(
|
||||||
return { kind: 'reshaping', nodeId, reshape: 'boundary' }
|
nodeId: string,
|
||||||
|
driver: ReshapeDriver = 'tool',
|
||||||
|
): ActiveInteractionScope {
|
||||||
|
return { kind: 'reshaping', nodeId, reshape: 'boundary', driver }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { afterEach, describe, expect, test } from 'bun:test'
|
|||||||
import type { AnyNode } from '@pascal-app/core'
|
import type { AnyNode } from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
type ActiveInteractionScope,
|
type ActiveInteractionScope,
|
||||||
|
curveReshapeScope,
|
||||||
editingHoleInfo,
|
editingHoleInfo,
|
||||||
handleDragInfo,
|
handleDragInfo,
|
||||||
isActive,
|
isActive,
|
||||||
isIdle,
|
isIdle,
|
||||||
|
isToolDrivenReshape,
|
||||||
scopeNodeId,
|
scopeNodeId,
|
||||||
selectionEnabled,
|
selectionEnabled,
|
||||||
} from '../lib/interaction/scope'
|
} from '../lib/interaction/scope'
|
||||||
@@ -137,7 +139,13 @@ describe('derived flag views are leak-free (no parallel flags)', () => {
|
|||||||
|
|
||||||
test('editingHoleInfo mirrors a hole reshape and clears on end', () => {
|
test('editingHoleInfo mirrors a hole reshape and clears on end', () => {
|
||||||
const s = useInteractionScope.getState()
|
const s = useInteractionScope.getState()
|
||||||
s.begin({ kind: 'reshaping', nodeId: 'slab_1', reshape: 'hole', holeIndex: 2 })
|
s.begin({
|
||||||
|
kind: 'reshaping',
|
||||||
|
nodeId: 'slab_1',
|
||||||
|
reshape: 'hole',
|
||||||
|
driver: 'tool',
|
||||||
|
holeIndex: 2,
|
||||||
|
})
|
||||||
expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 2 })
|
expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 2 })
|
||||||
s.end()
|
s.end()
|
||||||
expect(editingHoleInfo(scope())).toBeNull()
|
expect(editingHoleInfo(scope())).toBeNull()
|
||||||
@@ -145,16 +153,27 @@ describe('derived flag views are leak-free (no parallel flags)', () => {
|
|||||||
|
|
||||||
test('a non-hole reshape never reads as an editing hole', () => {
|
test('a non-hole reshape never reads as an editing hole', () => {
|
||||||
const s = useInteractionScope.getState()
|
const s = useInteractionScope.getState()
|
||||||
s.begin({ kind: 'reshaping', nodeId: 'wall_1', reshape: 'curve' })
|
s.begin({ kind: 'reshaping', nodeId: 'wall_1', reshape: 'curve', driver: 'tool' })
|
||||||
expect(editingHoleInfo(scope())).toBeNull()
|
expect(editingHoleInfo(scope())).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('a floorplan-owned curve drag does not activate the registry reshape tool', () => {
|
||||||
|
expect(isToolDrivenReshape(curveReshapeScope('wall_1', 'floorplan'))).toBe(false)
|
||||||
|
expect(isToolDrivenReshape(curveReshapeScope('wall_1', 'tool'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test('switching interactions never leaks the prior derived view', () => {
|
test('switching interactions never leaks the prior derived view', () => {
|
||||||
const s = useInteractionScope.getState()
|
const s = useInteractionScope.getState()
|
||||||
s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' })
|
s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' })
|
||||||
// Single-owner replacement: the handle-drag view must vanish the instant a
|
// Single-owner replacement: the handle-drag view must vanish the instant a
|
||||||
// different interaction begins — the two cannot be simultaneously active.
|
// different interaction begins — the two cannot be simultaneously active.
|
||||||
s.begin({ kind: 'reshaping', nodeId: 'slab_1', reshape: 'hole', holeIndex: 0 })
|
s.begin({
|
||||||
|
kind: 'reshaping',
|
||||||
|
nodeId: 'slab_1',
|
||||||
|
reshape: 'hole',
|
||||||
|
driver: 'tool',
|
||||||
|
holeIndex: 0,
|
||||||
|
})
|
||||||
expect(handleDragInfo(scope())).toBeNull()
|
expect(handleDragInfo(scope())).toBeNull()
|
||||||
expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 0 })
|
expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 0 })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
IDLE_SCOPE,
|
IDLE_SCOPE,
|
||||||
type InteractionScope,
|
type InteractionScope,
|
||||||
isCurveReshape,
|
isCurveReshape,
|
||||||
|
isFloorplanDrivenReshape,
|
||||||
|
isToolDrivenReshape,
|
||||||
movingNodeOf,
|
movingNodeOf,
|
||||||
reshapingNodeId,
|
reshapingNodeId,
|
||||||
tangentReshapeInfo,
|
tangentReshapeInfo,
|
||||||
@@ -84,6 +86,12 @@ export const getIsCurveReshape = (): boolean => isCurveReshape(useInteractionSco
|
|||||||
// recovered by reading the reshaped node's type from `useReshapingNode`.
|
// recovered by reading the reshaped node's type from `useReshapingNode`.
|
||||||
export const useIsCurveReshape = (): boolean => useInteractionScope((s) => isCurveReshape(s.scope))
|
export const useIsCurveReshape = (): boolean => useInteractionScope((s) => isCurveReshape(s.scope))
|
||||||
|
|
||||||
|
export const useIsToolDrivenReshape = (): boolean =>
|
||||||
|
useInteractionScope((s) => isToolDrivenReshape(s.scope))
|
||||||
|
|
||||||
|
export const useIsFloorplanDrivenReshape = (): boolean =>
|
||||||
|
useInteractionScope((s) => isFloorplanDrivenReshape(s.scope))
|
||||||
|
|
||||||
// Replaces the legacy `movingWallEndpoint` / `movingFenceEndpoint` payloads,
|
// Replaces the legacy `movingWallEndpoint` / `movingFenceEndpoint` payloads,
|
||||||
// minus the node (fetch it from `useReshapingNode`).
|
// minus the node (fetch it from `useReshapingNode`).
|
||||||
export const useEndpointReshape = (): { nodeId: string; endpoint: 'start' | 'end' } | null =>
|
export const useEndpointReshape = (): { nodeId: string; endpoint: 'start' | 'end' } | null =>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { afterEach, describe, expect, test } from 'bun:test'
|
||||||
|
import { type AnyNodeId, useLiveNodeOverrides, useScene, WallNode } from '@pascal-app/core'
|
||||||
|
import { wallCurveAffordance } from './floorplan-affordances'
|
||||||
|
|
||||||
|
globalThis.requestAnimationFrame ??= (callback) => {
|
||||||
|
callback(0)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
globalThis.cancelAnimationFrame ??= () => {}
|
||||||
|
|
||||||
|
const modifiers = {
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('wall center curve handle release', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
useLiveNodeOverrides.getState().clearAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('persists the previewed curve offset after commit clears the override', () => {
|
||||||
|
const wall = WallNode.parse({
|
||||||
|
id: 'wall_curve-release',
|
||||||
|
parentId: null,
|
||||||
|
start: [0, 0],
|
||||||
|
end: [4, 0],
|
||||||
|
})
|
||||||
|
useScene.setState({ nodes: { [wall.id]: wall } as never })
|
||||||
|
|
||||||
|
const session = wallCurveAffordance.start({
|
||||||
|
node: wall,
|
||||||
|
payload: { wallId: wall.id },
|
||||||
|
nodes: useScene.getState().nodes,
|
||||||
|
initialPlanPoint: [2, 0],
|
||||||
|
gridSnapStep: 0.1,
|
||||||
|
})
|
||||||
|
session.apply({ planPoint: [2, 1], modifiers })
|
||||||
|
|
||||||
|
expect((useScene.getState().nodes[wall.id] as typeof wall).curveOffset ?? 0).toBe(0)
|
||||||
|
expect(useLiveNodeOverrides.getState().get(wall.id as AnyNodeId)?.curveOffset).not.toBe(0)
|
||||||
|
|
||||||
|
expect(session.canCommit()).toBe(true)
|
||||||
|
session.commit?.()
|
||||||
|
|
||||||
|
expect((useScene.getState().nodes[wall.id] as typeof wall).curveOffset).not.toBe(0)
|
||||||
|
expect(useLiveNodeOverrides.getState().get(wall.id as AnyNodeId)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -526,7 +526,6 @@ const PostProcessingPasses = ({
|
|||||||
let visualAlpha = contentAlpha
|
let visualAlpha = contentAlpha
|
||||||
if (outlineEnabled) {
|
if (outlineEnabled) {
|
||||||
const outlineNode = mergedOutline(scene, camera, {
|
const outlineNode = mergedOutline(scene, camera, {
|
||||||
enabled: () => !useViewer.getState().cameraDragging,
|
|
||||||
primaryObjects: outliner.selectedObjects,
|
primaryObjects: outliner.selectedObjects,
|
||||||
secondaryObjects: outliner.hoveredObjects,
|
secondaryObjects: outliner.hoveredObjects,
|
||||||
primaryEdgeThickness: uniform(1),
|
primaryEdgeThickness: uniform(1),
|
||||||
|
|||||||
@@ -5,18 +5,20 @@ import { Object3D, PerspectiveCamera, Scene } from 'three'
|
|||||||
import { mergedOutline } from './merged-outline-node'
|
import { mergedOutline } from './merged-outline-node'
|
||||||
|
|
||||||
describe('merged outline rendering', () => {
|
describe('merged outline rendering', () => {
|
||||||
test('skips outline work while the pass is disabled', () => {
|
test('keeps selected outlines active during camera interaction', () => {
|
||||||
const outline = mergedOutline(new Scene(), new PerspectiveCamera(), {
|
const cameraInteractionActive = true
|
||||||
enabled: () => false,
|
const params = {
|
||||||
|
enabled: () => !cameraInteractionActive,
|
||||||
primaryObjects: [new Object3D()],
|
primaryObjects: [new Object3D()],
|
||||||
})
|
}
|
||||||
|
const outline = mergedOutline(new Scene(), new PerspectiveCamera(), params)
|
||||||
const frame = {
|
const frame = {
|
||||||
get renderer(): never {
|
get renderer(): never {
|
||||||
throw new Error('outline renderer should not be touched')
|
throw new Error('outline renderer was reached')
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(() => outline.updateBefore(frame)).not.toThrow()
|
expect(() => outline.updateBefore(frame)).toThrow('outline renderer was reached')
|
||||||
outline.dispose()
|
outline.dispose()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ export class MergedOutlineNode extends TempNode {
|
|||||||
primaryEdgeGlowNode: any
|
primaryEdgeGlowNode: any
|
||||||
secondaryEdgeGlowNode: any
|
secondaryEdgeGlowNode: any
|
||||||
downSampleRatio: number
|
downSampleRatio: number
|
||||||
enabled: () => boolean
|
|
||||||
updateBeforeType: string
|
updateBeforeType: string
|
||||||
|
|
||||||
private readonly _depthRT: RenderTarget
|
private readonly _depthRT: RenderTarget
|
||||||
@@ -190,7 +189,6 @@ export class MergedOutlineNode extends TempNode {
|
|||||||
primaryEdgeGlow?: any
|
primaryEdgeGlow?: any
|
||||||
secondaryEdgeGlow?: any
|
secondaryEdgeGlow?: any
|
||||||
downSampleRatio?: number
|
downSampleRatio?: number
|
||||||
enabled?: () => boolean
|
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
super('vec4')
|
super('vec4')
|
||||||
@@ -203,7 +201,6 @@ export class MergedOutlineNode extends TempNode {
|
|||||||
primaryEdgeGlow = float(0),
|
primaryEdgeGlow = float(0),
|
||||||
secondaryEdgeGlow = float(0),
|
secondaryEdgeGlow = float(0),
|
||||||
downSampleRatio = 2,
|
downSampleRatio = 2,
|
||||||
enabled = () => true,
|
|
||||||
} = params
|
} = params
|
||||||
|
|
||||||
this.scene = scene
|
this.scene = scene
|
||||||
@@ -215,7 +212,6 @@ export class MergedOutlineNode extends TempNode {
|
|||||||
this.primaryEdgeGlowNode = nodeObject(primaryEdgeGlow)
|
this.primaryEdgeGlowNode = nodeObject(primaryEdgeGlow)
|
||||||
this.secondaryEdgeGlowNode = nodeObject(secondaryEdgeGlow)
|
this.secondaryEdgeGlowNode = nodeObject(secondaryEdgeGlow)
|
||||||
this.downSampleRatio = downSampleRatio
|
this.downSampleRatio = downSampleRatio
|
||||||
this.enabled = enabled
|
|
||||||
this.updateBeforeType = NodeUpdateType.FRAME
|
this.updateBeforeType = NodeUpdateType.FRAME
|
||||||
|
|
||||||
this._depthRT = new RenderTarget()
|
this._depthRT = new RenderTarget()
|
||||||
@@ -305,9 +301,8 @@ export class MergedOutlineNode extends TempNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateBefore(frame: any) {
|
updateBefore(frame: any) {
|
||||||
const enabled = this.enabled()
|
const hasPrimary = this.primaryObjects.length > 0
|
||||||
const hasPrimary = enabled && this.primaryObjects.length > 0
|
const hasSecondary = this.secondaryObjects.length > 0
|
||||||
const hasSecondary = enabled && this.secondaryObjects.length > 0
|
|
||||||
const hasAny = hasPrimary || hasSecondary
|
const hasAny = hasPrimary || hasSecondary
|
||||||
|
|
||||||
// Fast-path: nothing to render and nothing was rendered last frame either,
|
// Fast-path: nothing to render and nothing was rendered last frame either,
|
||||||
|
|||||||
@@ -22,22 +22,28 @@ scope is exactly one interaction at a time, and `idle` carries no payload.
|
|||||||
| `kind` | Payload | What |
|
| `kind` | Payload | What |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `idle` | — | Nothing in flight. The only state where selection/hover picking is meaningful. |
|
| `idle` | — | Nothing in flight. The only state where selection/hover picking is meaningful. |
|
||||||
| `placing` | `nodeId`, `nodeType`, `view`, `pressDrag` | Placing a fresh node (catalog/preset/build tool). `pressDrag` = gizmo press-drag (commit on release) vs click-to-place. |
|
| `placing` | `node`, `nodeId`, `nodeType`, `view`, `pressDrag` | Placing a fresh node (catalog/preset/build tool). `node` carries the not-yet-committed draft; `pressDrag` = gizmo press-drag (commit on release) vs click-to-place. |
|
||||||
| `moving` | `nodeId`, `nodeType`, `view` | Moving an existing node. |
|
| `moving` | `node`, `nodeId`, `nodeType`, `view` | Moving an existing node. |
|
||||||
| `handle-drag` | `nodeId`, `handle` | Dragging a resize/translate/rotate handle of a selected node. |
|
| `handle-drag` | `nodeId`, `handle` | Dragging a resize/translate/rotate handle of a selected node. |
|
||||||
| `drafting` | `tool` | Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…). |
|
| `drafting` | `tool` | Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…). |
|
||||||
| `reshaping` | `nodeId`, `reshape`, `holeIndex?` | Reshaping a selected node's geometry. `reshape` is `curve \| hole \| endpoint \| boundary`. |
|
| `reshaping` | `nodeId`, `reshape`, `driver`, `holeIndex?`, `endpoint?`, `index?`, `side?` | Reshaping a selected node's geometry. `driver` identifies the interaction body that owns preview and commit. |
|
||||||
| `box-select` | — | Marquee selection drag. |
|
| `box-select` | — | Marquee selection drag. |
|
||||||
| `painting` | — | Material paint application. |
|
| `painting` | — | Material paint application. |
|
||||||
|
|
||||||
`reshaping` groups endpoint/curve/hole/boundary edits as sub-states of one scope
|
`reshaping` groups endpoint/curve/hole/boundary/control-point/tangent edits as
|
||||||
(rather than four sibling kinds) — there is one node and one in-flight reshape,
|
sub-states of one scope — there is one node and one in-flight reshape, so
|
||||||
so "curving and hole-editing at once" stays unrepresentable. `view` is `'2d' | '3d'`.
|
"curving and hole-editing at once" stays unrepresentable. Its `driver` is
|
||||||
|
`'tool' | 'floorplan'`: framework tools own tool-driven interactions, while a
|
||||||
|
floor-plan affordance owns the complete preview/commit lifecycle of a
|
||||||
|
floorplan-driven interaction. The driver prevents both interaction bodies from
|
||||||
|
mounting for the same gesture. Placing and moving use `view: '2d' | '3d'`.
|
||||||
|
|
||||||
### Helpers
|
### Helpers
|
||||||
|
|
||||||
- `isIdle(scope)` / `isActive(scope)` — `idle` vs anything else (`ActiveInteractionScope`).
|
- `isIdle(scope)` / `isActive(scope)` — `idle` vs anything else (`ActiveInteractionScope`).
|
||||||
- `scopeNodeId(scope)` — the node a scope acts on, or `null`. `drafting`/`box-select`/`painting`/`idle` target no single existing node.
|
- `scopeNodeId(scope)` — the node a scope acts on, or `null`. `drafting`/`box-select`/`painting`/`idle` target no single existing node.
|
||||||
|
- `isToolDrivenReshape(scope)` / `isFloorplanDrivenReshape(scope)` — narrow
|
||||||
|
reshape ownership so only the matching interaction body mounts.
|
||||||
- `selectionEnabled(scope)` — true only while `idle`. During any active interaction the pointer belongs to that interaction's body, not to selecting a different object; the picking choke point must not route a hover/click to selection while this is false.
|
- `selectionEnabled(scope)` — true only while `idle`. During any active interaction the pointer belongs to that interaction's body, not to selecting a different object; the picking choke point must not route a hover/click to selection while this is false.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -176,6 +182,10 @@ independent flag clear can't stomp an unrelated scope) to keep the scope in sync
|
|||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- **One owner, one scope.** Only `useInteractionScope` writes the scope, and only via `begin`/`update`/`end`/`endIf`. Never reconstruct interaction state from a private combination of flags.
|
- **One owner, one scope.** Only `useInteractionScope` writes the scope, and only via `begin`/`update`/`end`/`endIf`. Never reconstruct interaction state from a private combination of flags.
|
||||||
|
- **One reshape driver.** A floorplan-driven reshape is previewed and committed
|
||||||
|
by its floor-plan affordance; a tool-driven reshape is owned by the framework
|
||||||
|
tool. Gate interaction bodies with the driver so both cannot act on one
|
||||||
|
gesture.
|
||||||
- **`end` is atomic and payload-free.** Never leave a `nodeId`/payload behind on idle; commit-vs-revert logic belongs in the interaction body before `end`.
|
- **`end` is atomic and payload-free.** Never leave a `nodeId`/payload behind on idle; commit-vs-revert logic belongs in the interaction body before `end`.
|
||||||
- **`update` cannot change `kind`.** Switching interactions is a `begin`, not a patch.
|
- **`update` cannot change `kind`.** Switching interactions is a `begin`, not a patch.
|
||||||
- **Hot-set and overlay policy are pure derivations of the scope** (and, for the hot-set, the candidate metadata). Don't branch overlay/picking behaviour on legacy flags — branch on the scope.
|
- **Hot-set and overlay policy are pure derivations of the scope** (and, for the hot-set, the candidate metadata). Don't branch overlay/picking behaviour on legacy flags — branch on the scope.
|
||||||
|
|||||||
Reference in New Issue
Block a user