feat(editor): placement & interaction overhaul — FSM spine, bug tracks, perf
Implements plans/editor-placement-interaction-overhaul.md: an authoritative interaction-scope state machine plus the catalogued placement/interaction fixes, and split-view floor-plan performance. - Interaction-scope spine (lib/interaction/* + store/use-interaction-scope), driven from central useEditor setters; overlay scoping (zone labels, context badges, floating action menu) reads resolveOverlayPolicy. - Bug tracks A/B/D/E/F/G/H: handle/cutout raycast, footprint validity, auto-slab loop, ceiling hosting, B-key tool desync, 2D drop offset, per-frame jank. - Snapping modes (grid/lines/angles/off) + contextual HUD chips; modifier model (Shift=cycle, Alt=free place, Ctrl=grid step). - Item move now tracks the cursor 1:1 (was a laggy per-frame lerp); handle rig hides during a whole-node move; rotate gizmo advertises Shift=free rotation in the HUD and hides the move cross while rotating. - Floor-plan perf: pause live reactivity while in 3D-only view; per-node geometry cache so only changed nodes rebuild on a drag; hoist wall miters to a once-per-pass ctx.levelData (O(N^2) -> O(N) on wall/opening drags). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b2f1a8432e
commit
f773e6b8c5
@@ -48,6 +48,7 @@ export function FloorplanRegistryActionMenu() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
|
||||
const movingNode = useEditor((s) => s.movingNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
||||
// Gate on floorplan hover so this 2D menu never coexists with the 3D
|
||||
// FloatingActionMenu in split view — that menu hides while the floorplan
|
||||
// is hovered, so this one must only show then. Mirrors the legacy
|
||||
@@ -141,6 +142,11 @@ export function FloorplanRegistryActionMenu() {
|
||||
const handleMove = () => {
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node as never)
|
||||
// 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture.
|
||||
// Mark the origin (after `setMovingNode`, which resets it to null) so
|
||||
// `ToolManager` keeps the 3D affordance mover from also adopting the node
|
||||
// and reverting it on unmount. Mirrors the orange move-dot path.
|
||||
setMovingNodeOrigin('2d')
|
||||
// Match the legacy 3D `floating-action-menu`: clear selection so
|
||||
// selection-gated affordances unmount during the drag. Specifically
|
||||
// the slab / ceiling boundary editor (`ToolManager` shows it when
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
type GeometryContext,
|
||||
isRegistryMovable,
|
||||
kindsWithFloorplanScope,
|
||||
type LiveNodeOverrides,
|
||||
type LiveTransform,
|
||||
nodeRegistry,
|
||||
pauseSceneHistory,
|
||||
resolveBuildingForLevel,
|
||||
@@ -122,6 +124,46 @@ type RotationOverlayState = {
|
||||
sweep: number
|
||||
}
|
||||
|
||||
type FloorplanEntry = {
|
||||
id: AnyNodeId
|
||||
node: AnyNode
|
||||
base: FloorplanGeometry | null
|
||||
overlay: FloorplanGeometry | null
|
||||
selected: boolean
|
||||
highlighted: boolean
|
||||
}
|
||||
|
||||
type NodeDeps = {
|
||||
node: AnyNode
|
||||
live: LiveTransform | undefined
|
||||
selected: boolean
|
||||
highlighted: boolean
|
||||
hovered: boolean
|
||||
moving: boolean
|
||||
palette: FloorplanPalette | undefined
|
||||
siblingEpoch: number
|
||||
committedNodes: Record<string, AnyNode> | null
|
||||
interactiveElevators: unknown
|
||||
}
|
||||
|
||||
type CacheEntry = {
|
||||
deps: NodeDeps
|
||||
base: FloorplanGeometry | null
|
||||
overlay: FloorplanGeometry | null
|
||||
node: AnyNode
|
||||
}
|
||||
|
||||
type FloorplanContextOverrides = {
|
||||
children: AnyNode[]
|
||||
siblings: AnyNode[]
|
||||
parent: AnyNode | null
|
||||
}
|
||||
|
||||
type FloorplanLevelDataHook = (args: {
|
||||
siblings: ReadonlyArray<AnyNode>
|
||||
nodes: Record<string, AnyNode>
|
||||
}) => unknown
|
||||
|
||||
function snapshotNode(node: AnyNode): NodeSnapshot {
|
||||
// Shallow-clone every non-id, non-type field. Arrays / vec tuples are
|
||||
// deep-cloned to detach from the live store reference.
|
||||
@@ -137,6 +179,16 @@ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
|
||||
return snapshots.map((s) => ({ id: s.id, data: s.data }))
|
||||
}
|
||||
|
||||
// Stable empty sentinels. While the floor plan is hidden (3D-only view) the
|
||||
// live-* selectors return these instead of the real maps, so the per-pointer
|
||||
// drag publishes (usePlacementCoordinator → useLiveTransforms / the rotate
|
||||
// gizmo → useLiveNodeOverrides) no longer re-render this layer and its hundreds
|
||||
// of geometry children. The same reference each call keeps zustand from
|
||||
// detecting a change; committed scene edits still flow through `useScene`, so
|
||||
// the plan is current the instant the view is shown again.
|
||||
const EMPTY_LIVE_TRANSFORMS: Map<string, LiveTransform> = new Map()
|
||||
const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
|
||||
|
||||
export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const selectedLevelId = useViewer((s) => s.selection.levelId)
|
||||
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
||||
@@ -191,6 +243,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const renderCtx = useFloorplanRender()
|
||||
const movingNode = useEditor((s) => s.movingNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
||||
// Door / window placement (both build and move) needs the SVG's
|
||||
// background click handler to run — it finds the closest wall via
|
||||
// `findClosestWallPoint` and emits `wall:click` for the door / window
|
||||
@@ -215,17 +268,28 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
structureLayer !== 'zones' &&
|
||||
!movingNode &&
|
||||
!movingFenceEndpoint
|
||||
// While the floor plan is not on screen (pure 3D view) it must not react to
|
||||
// the per-pointer drag publishes below — re-rendering this layer + its
|
||||
// hundreds of geometry children every move is what tanks 3D-drag framerate
|
||||
// even though nothing 2D is visible. Gating the live-* subscriptions freezes
|
||||
// them to a stable empty map while hidden; committed edits still arrive via
|
||||
// `useScene`, so the plan is current the moment the view is shown.
|
||||
const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
|
||||
// Subscribe to the live-transforms map ref so the layer re-renders
|
||||
// whenever a 3D mover publishes a per-frame position (see
|
||||
// `usePlacementCoordinator`). Without this the 2D floor plan only
|
||||
// updates after 3D commit — the 3D drag would look frozen in 2D.
|
||||
const liveTransforms = useLiveTransforms((s) => s.transforms)
|
||||
const liveTransforms = useLiveTransforms((s) =>
|
||||
floorplanVisible ? s.transforms : EMPTY_LIVE_TRANSFORMS,
|
||||
)
|
||||
// Same reactivity hook for elevator runtime state — `useInteractive`
|
||||
// tracks the current / fallback level + cab travel, `useLiveNode
|
||||
// Overrides` carries live-edit overrides from the inspector. Builders
|
||||
// read both via `getState()` inside `def.floorplan`; subscribing here
|
||||
// is what forces the layer to re-render when they change.
|
||||
const liveOverrides = useLiveNodeOverrides((s) => s.overrides)
|
||||
const liveOverrides = useLiveNodeOverrides((s) =>
|
||||
floorplanVisible ? s.overrides : EMPTY_LIVE_OVERRIDES,
|
||||
)
|
||||
const interactiveElevators = useInteractive((s) => s.elevators)
|
||||
|
||||
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
@@ -239,6 +303,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const [hoveredHandleId, setHoveredHandleId] = useState<string | null>(null)
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||
const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
|
||||
const geometryCacheRef = useRef<Map<string, CacheEntry>>(new Map())
|
||||
const siblingEpochInputsRef = useRef<unknown[]>([])
|
||||
const siblingEpochRef = useRef(0)
|
||||
|
||||
const applyEntrySelection = useCallback(
|
||||
(id: AnyNodeId, shouldToggle: boolean) => {
|
||||
@@ -467,117 +534,223 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
// tree the builder returns. Builders don't need to know about the
|
||||
// partition.
|
||||
const entries = useMemo(() => {
|
||||
// Some builders read elevator runtime state imperatively; this keeps the memo subscribed.
|
||||
void interactiveElevators
|
||||
const previousCache = geometryCacheRef.current
|
||||
const nextCache = new Map<string, CacheEntry>()
|
||||
if (!levelId) {
|
||||
geometryCacheRef.current = nextCache
|
||||
return []
|
||||
}
|
||||
|
||||
if (!levelId) return []
|
||||
const out: {
|
||||
id: AnyNodeId
|
||||
node: AnyNode
|
||||
base: FloorplanGeometry | null
|
||||
overlay: FloorplanGeometry | null
|
||||
selected: boolean
|
||||
highlighted: boolean
|
||||
}[] = []
|
||||
// The sibling epoch bumps whenever a sibling-affecting node's LIVE state
|
||||
// changes (a wall/door/window/gutter being dragged or live-edited). Only
|
||||
// flagged kinds feed it, so dragging or rotating a plain item — which also
|
||||
// publishes to liveTransforms / liveOverrides — leaves it stable and the
|
||||
// hundreds of wall/door geometries stay cached. Committed structural edits
|
||||
// are covered separately by keying flagged kinds on the `nodes` ref.
|
||||
const siblingEpochInputs: unknown[] = []
|
||||
for (const [id, live] of liveTransforms) {
|
||||
const node = nodes[id as AnyNodeId]
|
||||
if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) {
|
||||
siblingEpochInputs.push(live)
|
||||
}
|
||||
}
|
||||
for (const [id, override] of liveOverrides) {
|
||||
const node = nodes[id as AnyNodeId]
|
||||
if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) {
|
||||
siblingEpochInputs.push(override)
|
||||
}
|
||||
}
|
||||
if (!depsValueEqual(siblingEpochInputsRef.current, siblingEpochInputs)) {
|
||||
siblingEpochRef.current += 1
|
||||
siblingEpochInputsRef.current = siblingEpochInputs
|
||||
}
|
||||
const siblingEpoch = siblingEpochRef.current
|
||||
const out: FloorplanEntry[] = []
|
||||
const levelDataByType = new Map<string, unknown>()
|
||||
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
|
||||
|
||||
const collectLevelDataKind = (id: AnyNodeId) => {
|
||||
const node = nodes[id]
|
||||
if (!node) return
|
||||
const def = nodeRegistry.get(node.type)
|
||||
if (def?.computeFloorplanLevelData) {
|
||||
const ids = levelNodeIdsByType.get(node.type)
|
||||
if (ids) ids.push(id)
|
||||
else levelNodeIdsByType.set(node.type, [id])
|
||||
}
|
||||
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
||||
if (Array.isArray(childIds)) {
|
||||
for (const cid of childIds) collectLevelDataKind(cid)
|
||||
}
|
||||
}
|
||||
|
||||
collectLevelDataKind(levelId as AnyNodeId)
|
||||
|
||||
for (const [type, ids] of levelNodeIdsByType) {
|
||||
const def = nodeRegistry.get(type)
|
||||
if (!def?.computeFloorplanLevelData) continue
|
||||
const computeLevelData = def.computeFloorplanLevelData as FloorplanLevelDataHook
|
||||
const sampleId = ids[0]
|
||||
if (!sampleId) continue
|
||||
const contextNodes = def.floorplanSiblingOverrides
|
||||
? def.floorplanSiblingOverrides({ nodeId: sampleId, nodes, liveOverrides })
|
||||
: nodes
|
||||
const siblings: AnyNode[] = []
|
||||
for (const id of ids) {
|
||||
const sibling = contextNodes[id]
|
||||
if (sibling?.type === type) siblings.push(sibling)
|
||||
}
|
||||
levelDataByType.set(type, computeLevelData({ siblings, nodes: contextNodes }))
|
||||
}
|
||||
|
||||
const buildEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
|
||||
const def = nodeRegistry.get(node.type)
|
||||
const builder = def?.floorplan
|
||||
if (!builder) return
|
||||
const selected = selectedIdSet.has(id)
|
||||
const highlighted = highlightedIdSet.has(id)
|
||||
const hovered = hoveredId === id
|
||||
const moving = movingNode?.id === id
|
||||
const live = liveTransforms.get(id)
|
||||
const dependsOnSiblingInputs = !!(
|
||||
def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides
|
||||
)
|
||||
const deps: NodeDeps = {
|
||||
node,
|
||||
live,
|
||||
selected,
|
||||
highlighted,
|
||||
hovered,
|
||||
moving,
|
||||
palette: renderCtx?.palette,
|
||||
siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0,
|
||||
// Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
|
||||
// COMMITTED state via `ctx`, so a committed edit to a sibling/child that
|
||||
// doesn't change this node's own ref must still invalidate it. The
|
||||
// `nodes` ref is stable during a live drag (only commits replace it), so
|
||||
// this preserves the live-drag cache win while matching the old
|
||||
// rebuild-on-every-commit correctness. Self-contained kinds key on their
|
||||
// own `node` ref only.
|
||||
committedNodes: dependsOnSiblingInputs ? nodes : null,
|
||||
// Elevator builders read runtime state imperatively, so every kind's
|
||||
// cache key includes the rare-changing ref conservatively.
|
||||
interactiveElevators,
|
||||
}
|
||||
const cached = previousCache.get(id)
|
||||
if (cached && nodeDepsEqual(cached.deps, deps)) {
|
||||
nextCache.set(id, cached)
|
||||
if (cached.base || cached.overlay) {
|
||||
out.push({
|
||||
id,
|
||||
node: cached.node,
|
||||
base: cached.base,
|
||||
overlay: cached.overlay,
|
||||
selected,
|
||||
highlighted,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const applyLiveTransform = (sourceNode: AnyNode): AnyNode => {
|
||||
if (!live) return sourceNode
|
||||
const hasPosition = Array.isArray((sourceNode as { position?: unknown }).position)
|
||||
if (sourceNode.type === 'door' || sourceNode.type === 'window') {
|
||||
// Door / window movers publish WALL-LOCAL live transforms
|
||||
// ([along-wall x, sill y, 0], wall-local Y rotation) — see
|
||||
// wiki/architecture/tools.md. The mover only writes
|
||||
// `useScene.updateNode` on a wall CHANGE, so a same-wall slide
|
||||
// updates the 3D mesh imperatively but never the scene node —
|
||||
// without applying the live transform here the 2D symbol stays
|
||||
// frozen while the cursor slides. Merge the wall-local position +
|
||||
// rotation onto the node but KEEP `parentId` (the wall) so
|
||||
// `buildDoorFloorplan` still resolves `ctx.parent` and draws the
|
||||
// real swing-arc / pane symbol at the live spot.
|
||||
const r = (sourceNode as { rotation?: unknown }).rotation
|
||||
return {
|
||||
...sourceNode,
|
||||
position: live.position,
|
||||
rotation: Array.isArray(r)
|
||||
? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
|
||||
: r,
|
||||
} as AnyNode
|
||||
}
|
||||
if ((def.capabilities?.floorPlaced || def.floorplanScope === 'building') && hasPosition) {
|
||||
return applyPositionLiveTransform(sourceNode, live)
|
||||
}
|
||||
if (
|
||||
sourceNode.type === 'slab' ||
|
||||
sourceNode.type === 'ceiling' ||
|
||||
sourceNode.type === 'zone'
|
||||
) {
|
||||
const dx = live.position[0]
|
||||
const dz = live.position[2]
|
||||
if (dx === 0 && dz === 0) return sourceNode
|
||||
const surface = sourceNode as {
|
||||
polygon: Array<[number, number]>
|
||||
holes?: Array<Array<[number, number]>>
|
||||
}
|
||||
return {
|
||||
...sourceNode,
|
||||
polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
||||
holes: (surface.holes ?? []).map((h) =>
|
||||
h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
||||
),
|
||||
} as AnyNode
|
||||
}
|
||||
return sourceNode
|
||||
}
|
||||
|
||||
const contextNodes = def.floorplanSiblingOverrides
|
||||
? def.floorplanSiblingOverrides({ nodeId: id, nodes, liveOverrides })
|
||||
: nodes
|
||||
const sourceNode = contextNodes !== nodes ? (contextNodes[id] ?? node) : node
|
||||
const effectiveNode = applyLiveTransform(sourceNode)
|
||||
const viewState = {
|
||||
selected,
|
||||
highlighted,
|
||||
hovered,
|
||||
moving,
|
||||
palette: renderCtx?.palette,
|
||||
}
|
||||
const ctx: GeometryContext = ctxOverrides
|
||||
? {
|
||||
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined =>
|
||||
contextNodes[rid] as N | undefined,
|
||||
children: ctxOverrides.children,
|
||||
siblings: ctxOverrides.siblings,
|
||||
parent: ctxOverrides.parent,
|
||||
levelData: levelDataByType.get(node.type),
|
||||
viewState: renderCtx?.palette
|
||||
? {
|
||||
selected,
|
||||
highlighted,
|
||||
hovered,
|
||||
moving,
|
||||
palette: renderCtx.palette,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: buildContext(effectiveNode, contextNodes, viewState, levelDataByType.get(node.type))
|
||||
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
||||
effectiveNode,
|
||||
ctx,
|
||||
)
|
||||
const { base, overlay } = geometry
|
||||
? splitFloorplanOverlay(geometry)
|
||||
: { base: null, overlay: null }
|
||||
const entry: CacheEntry = { deps, base, overlay, node: effectiveNode }
|
||||
nextCache.set(id, entry)
|
||||
if (base || overlay) {
|
||||
out.push({ id, node: effectiveNode, base, overlay, selected, highlighted })
|
||||
}
|
||||
}
|
||||
|
||||
const visit = (id: AnyNodeId) => {
|
||||
const node = nodes[id]
|
||||
if (!node) return
|
||||
if ((node as { visible?: boolean }).visible === false) return
|
||||
const def = nodeRegistry.get(node.type)
|
||||
const builder = def?.floorplan
|
||||
if (builder) {
|
||||
const selected = selectedIdSet.has(id)
|
||||
const highlighted = highlightedIdSet.has(id)
|
||||
const hovered = hoveredId === id
|
||||
const moving = movingNode?.id === id
|
||||
// Live-transform override — when a mover is publishing per-frame
|
||||
// position/rotation, render that here instead of the committed
|
||||
// scene state. Without this the 2D floor plan would only update
|
||||
// after commit, making the drag look frozen.
|
||||
//
|
||||
// The live-transform contract varies per kind (see
|
||||
// wiki/architecture/tools.md "useLiveTransforms contract is
|
||||
// per-kind, not generic"); position-carrying floor-placed kinds
|
||||
// publish canonical X/Z, while slab / ceiling publish a polygon
|
||||
// translation delta.
|
||||
const live = liveTransforms.get(id)
|
||||
let effectiveNode: AnyNode = node
|
||||
if (live) {
|
||||
const floorPlaced = def?.capabilities?.floorPlaced
|
||||
const hasPosition = Array.isArray((node as { position?: unknown }).position)
|
||||
if (node.type === 'door' || node.type === 'window') {
|
||||
// Door / window movers publish WALL-LOCAL live transforms
|
||||
// ([along-wall x, sill y, 0], wall-local Y rotation) — see
|
||||
// wiki/architecture/tools.md. The mover only writes
|
||||
// `useScene.updateNode` on a wall CHANGE, so a same-wall slide
|
||||
// updates the 3D mesh imperatively but never the scene node —
|
||||
// without applying the live transform here the 2D symbol stays
|
||||
// frozen while the cursor slides. Merge the wall-local position +
|
||||
// rotation onto the node but KEEP `parentId` (the wall) so
|
||||
// `buildDoorFloorplan` still resolves `ctx.parent` and draws the
|
||||
// real swing-arc / pane symbol at the live spot.
|
||||
const r = (node as { rotation?: unknown }).rotation
|
||||
effectiveNode = {
|
||||
...node,
|
||||
position: live.position,
|
||||
rotation: Array.isArray(r)
|
||||
? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
|
||||
: r,
|
||||
} as AnyNode
|
||||
} else if (floorPlaced && hasPosition) {
|
||||
effectiveNode = applyPositionLiveTransform(node, live)
|
||||
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
|
||||
const dx = live.position[0]
|
||||
const dz = live.position[2]
|
||||
if (dx !== 0 || dz !== 0) {
|
||||
const surface = node as {
|
||||
polygon: Array<[number, number]>
|
||||
holes?: Array<Array<[number, number]>>
|
||||
}
|
||||
effectiveNode = {
|
||||
...node,
|
||||
polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
||||
holes: (surface.holes ?? []).map((h) =>
|
||||
h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
||||
),
|
||||
} as AnyNode
|
||||
}
|
||||
}
|
||||
}
|
||||
// Live-edit overrides: kinds whose `def.floorplan` builder
|
||||
// reads cross-sibling data (wall miters, …) declare a
|
||||
// `def.floorplanSiblingOverrides` hook that projects the
|
||||
// override map into a merged `nodes` snapshot. The merged
|
||||
// copy feeds `buildContext` so `ctx.siblings` reflects the
|
||||
// live cursor positions, and replaces `effectiveNode` so the
|
||||
// kind's own override lands too (covers the case where the
|
||||
// node being rendered is itself the dragged one). Kinds
|
||||
// without the hook hand the raw `nodes` through — most
|
||||
// previews are self-contained.
|
||||
const contextNodes = def?.floorplanSiblingOverrides
|
||||
? def.floorplanSiblingOverrides({ nodeId: id, nodes, liveOverrides })
|
||||
: nodes
|
||||
if (contextNodes !== nodes) {
|
||||
const merged = contextNodes[id]
|
||||
if (merged) effectiveNode = merged
|
||||
}
|
||||
const ctx = buildContext(effectiveNode, contextNodes, {
|
||||
selected,
|
||||
highlighted,
|
||||
hovered,
|
||||
moving,
|
||||
palette: renderCtx?.palette,
|
||||
})
|
||||
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
||||
effectiveNode,
|
||||
ctx,
|
||||
)
|
||||
if (geometry) {
|
||||
const { base, overlay } = splitFloorplanOverlay(geometry)
|
||||
out.push({ id, node: effectiveNode, base, overlay, selected, highlighted })
|
||||
}
|
||||
}
|
||||
buildEntry(id, node)
|
||||
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
||||
if (Array.isArray(childIds)) {
|
||||
for (const cid of childIds) visit(cid)
|
||||
@@ -607,50 +780,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const parentId = (node as { parentId?: AnyNodeId | null }).parentId
|
||||
if (parentId !== activeBuildingId) continue
|
||||
const cid = id as AnyNodeId
|
||||
const def = nodeRegistry.get(node.type)
|
||||
const builder = def?.floorplan
|
||||
if (!builder) continue
|
||||
const selected = selectedIdSet.has(cid)
|
||||
const highlighted = highlightedIdSet.has(cid)
|
||||
const hovered = hoveredId === cid
|
||||
const moving = movingNode?.id === cid
|
||||
const live = liveTransforms.get(cid)
|
||||
const hasPosition = Array.isArray((node as { position?: unknown }).position)
|
||||
let effectiveNode: AnyNode =
|
||||
live && hasPosition ? applyPositionLiveTransform(node, live) : node
|
||||
const contextNodes = def?.floorplanSiblingOverrides
|
||||
? def.floorplanSiblingOverrides({ nodeId: cid, nodes, liveOverrides })
|
||||
: nodes
|
||||
if (contextNodes !== nodes) {
|
||||
const merged = contextNodes[cid]
|
||||
if (merged) {
|
||||
effectiveNode = live && hasPosition ? applyPositionLiveTransform(merged, live) : merged
|
||||
}
|
||||
}
|
||||
const ctx: GeometryContext = {
|
||||
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined =>
|
||||
contextNodes[rid] as N | undefined,
|
||||
buildEntry(cid, node, {
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: activeLevelNode,
|
||||
viewState: renderCtx?.palette
|
||||
? {
|
||||
selected,
|
||||
highlighted,
|
||||
hovered,
|
||||
moving,
|
||||
palette: renderCtx.palette,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
||||
effectiveNode,
|
||||
ctx,
|
||||
)
|
||||
if (geometry) {
|
||||
const { base, overlay } = splitFloorplanOverlay(geometry)
|
||||
out.push({ id: cid, node: effectiveNode, base, overlay, selected, highlighted })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,6 +796,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
// DFS visit order (stable sort) so siblings keep their relative
|
||||
// priority.
|
||||
out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
|
||||
geometryCacheRef.current = nextCache
|
||||
return out
|
||||
}, [
|
||||
levelId,
|
||||
@@ -991,6 +1126,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
suppressBoxSelectForPointer(event)
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node as never)
|
||||
// Claim 2D ownership of this move at the source. `setMovingNode`
|
||||
// resets the origin to null, so this must follow it. It gates the
|
||||
// 3D affordance mover (`ToolManager`) off entirely: without it the
|
||||
// 3D `MoveItemTool` would also mount, `adopt()` the same node, and
|
||||
// restore its adopt-time (original) position from its unmount
|
||||
// `destroy()` — snapping a committed 2D move back to its start.
|
||||
setMovingNodeOrigin('2d')
|
||||
}}
|
||||
palette={palette}
|
||||
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
||||
@@ -1947,6 +2089,7 @@ function buildContext(
|
||||
moving: boolean
|
||||
palette: FloorplanPalette | undefined
|
||||
},
|
||||
levelData?: unknown,
|
||||
): GeometryContext {
|
||||
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
|
||||
|
||||
@@ -1979,6 +2122,7 @@ function buildContext(
|
||||
children,
|
||||
siblings,
|
||||
parent,
|
||||
levelData,
|
||||
viewState: viewState.palette
|
||||
? {
|
||||
selected: viewState.selected,
|
||||
@@ -2071,6 +2215,37 @@ function splitFloorplanOverlay(g: FloorplanGeometry): {
|
||||
return { base: g, overlay: null }
|
||||
}
|
||||
|
||||
function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
|
||||
const keys: Array<keyof NodeDeps> = [
|
||||
'node',
|
||||
'live',
|
||||
'selected',
|
||||
'highlighted',
|
||||
'hovered',
|
||||
'moving',
|
||||
'palette',
|
||||
'siblingEpoch',
|
||||
'committedNodes',
|
||||
'interactiveElevators',
|
||||
]
|
||||
for (const key of keys) {
|
||||
if (!depsValueEqual(a[key], b[key])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function depsValueEqual(a: unknown, b: unknown): boolean {
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b)) return false
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!Object.is(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return Object.is(a, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Z-order bucket for floor-plan rendering. Lower rank = painted first =
|
||||
* sits under everything with a higher rank. SVG renders in document
|
||||
|
||||
@@ -35,10 +35,12 @@ import { Html } from '@react-three/drei'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
|
||||
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
||||
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
||||
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import useInteractionScope from '../../store/use-interaction-scope'
|
||||
import { formatMeasurement, MeasurementPill } from './measurement-pill'
|
||||
import { NodeActionMenu } from './node-action-menu'
|
||||
|
||||
@@ -137,6 +139,11 @@ function getAttributeVersion(
|
||||
: 0
|
||||
}
|
||||
|
||||
// Pooled scratch for the per-frame anchor recompute (see useFrame below) so a
|
||||
// dragged node doesn't allocate a fresh Box3 + Vector3 every frame.
|
||||
const _anchorBox = new THREE.Box3()
|
||||
const _anchorCenter = new THREE.Vector3()
|
||||
|
||||
function getObjectGeometryKey(object: THREE.Object3D): string {
|
||||
const parts: string[] = []
|
||||
object.traverse((child) => {
|
||||
@@ -218,6 +225,10 @@ export function FloatingActionMenu() {
|
||||
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
|
||||
// R/T rotation axis for kinds with full 3D orientation (duct fittings).
|
||||
const rotationAxis = useEditor((s) => s.rotationAxis)
|
||||
// The floating action menu is an action-conflicting control: hard-hidden
|
||||
// during any active interaction so it never competes with the live action.
|
||||
const scope = useInteractionScope((s) => s.scope)
|
||||
const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
|
||||
|
||||
const groupRef = useRef<THREE.Group>(null)
|
||||
const menuScaleRef = useRef<HTMLDivElement>(null)
|
||||
@@ -329,23 +340,44 @@ export function FloatingActionMenu() {
|
||||
// mid-resize). A spinning child changes the head's matrix, not the
|
||||
// registered group's, so it never triggers a recompute → the menu
|
||||
// holds still.
|
||||
// Cheapest guards first: a selection swap, the object's own world
|
||||
// transform changing (true every frame during a drag), a live override,
|
||||
// or an active handle drag all force a recompute on their own — so skip
|
||||
// the geometry traversal (`getObjectGeometryKey` walks the whole subtree
|
||||
// reading attribute versions) until none of them fired and a
|
||||
// geometry-only change is the only thing left that could move the anchor.
|
||||
const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null
|
||||
const dragActive = activeHandleDrag?.nodeId === selectedId
|
||||
const effectiveNode = getEffectiveNode(node)
|
||||
const geometryKey = getObjectGeometryKey(obj)
|
||||
const selectionChanged =
|
||||
lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
|
||||
const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld)
|
||||
const geometryChanged = lastAnchorKeyRef.current.geometryKey !== geometryKey
|
||||
|
||||
if (selectionChanged || matrixChanged || geometryChanged || overrideActive || dragActive) {
|
||||
let geometryKey = lastAnchorKeyRef.current.geometryKey
|
||||
let needsRecompute = selectionChanged || matrixChanged || overrideActive || dragActive
|
||||
// Only when nothing cheaper fired do we pay for the subtree traversal —
|
||||
// a geometry-only change is the lone remaining trigger. When a cheaper
|
||||
// guard already forced a recompute the stored key is reused; the matrix
|
||||
// (or override/drag) keeps recomputing the anchor every frame, so a
|
||||
// geometry edit mid-drag is absorbed, and the next idle frame refreshes
|
||||
// the key against the live geometry.
|
||||
if (!needsRecompute) {
|
||||
geometryKey = getObjectGeometryKey(obj)
|
||||
if (geometryKey !== lastAnchorKeyRef.current.geometryKey) needsRecompute = true
|
||||
}
|
||||
|
||||
if (needsRecompute) {
|
||||
const effectiveNode = getEffectiveNode(node)
|
||||
if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) {
|
||||
const box = new THREE.Box3().setFromObject(obj)
|
||||
if (!box.isEmpty()) {
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
_anchorBox.setFromObject(obj)
|
||||
if (!_anchorBox.isEmpty()) {
|
||||
_anchorBox.getCenter(_anchorCenter)
|
||||
// Position above the object. Per-type offsets clear each kind's
|
||||
// in-world chrome (height-resize arrows, measurement labels).
|
||||
anchorRef.current.set(center.x, box.max.y + getMenuYOffset(effectiveNode), center.z)
|
||||
anchorRef.current.set(
|
||||
_anchorCenter.x,
|
||||
_anchorBox.max.y + getMenuYOffset(effectiveNode),
|
||||
_anchorCenter.z,
|
||||
)
|
||||
hasAnchorRef.current = true
|
||||
}
|
||||
} else {
|
||||
@@ -624,7 +656,8 @@ export function FloatingActionMenu() {
|
||||
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
|
||||
movingWallEndpoint ||
|
||||
movingFenceEndpoint ||
|
||||
curvingFence
|
||||
curvingFence ||
|
||||
menuStepBack
|
||||
)
|
||||
return null
|
||||
|
||||
|
||||
@@ -75,9 +75,11 @@ import {
|
||||
buildFloorplanItemEntry,
|
||||
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
|
||||
collectLevelDescendants,
|
||||
floorplanLocalToWorldPoint,
|
||||
getFloorplanWall as getSharedFloorplanWall,
|
||||
rotatePlanVector as rotateSharedPlanVector,
|
||||
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
||||
worldToFloorplanLocalPoint,
|
||||
} from '../../lib/floorplan'
|
||||
import { guideEmitter } from '../../lib/guide-events'
|
||||
import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
|
||||
@@ -87,7 +89,11 @@ import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
|
||||
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
|
||||
import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor'
|
||||
import useEditor, {
|
||||
isAngleSnapActive,
|
||||
isMagneticSnapActive,
|
||||
selectSiteFloorplanContext,
|
||||
} from '../../store/use-editor'
|
||||
import usePlacementPreview from '../../store/use-placement-preview'
|
||||
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
|
||||
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
||||
@@ -1793,39 +1799,6 @@ function cameraAzimuthFromFloorplanRotation(rotationDeg: number) {
|
||||
return degreesToRadians(rotationDeg + FLOORPLAN_VIEW_ROTATION_DEG)
|
||||
}
|
||||
|
||||
function floorplanLocalToWorldPoint(
|
||||
point: SvgPoint | WallPlanPoint,
|
||||
buildingPosition: readonly [number, number, number],
|
||||
buildingRotationY: number,
|
||||
): { x: number; z: number } {
|
||||
const localX = Array.isArray(point) ? point[0] : point.x
|
||||
const localY = Array.isArray(point) ? point[1] : point.y
|
||||
const cos = Math.cos(buildingRotationY)
|
||||
const sin = Math.sin(buildingRotationY)
|
||||
|
||||
return {
|
||||
x: buildingPosition[0] + localX * cos + localY * sin,
|
||||
z: buildingPosition[2] - localX * sin + localY * cos,
|
||||
}
|
||||
}
|
||||
|
||||
function worldToFloorplanLocalPoint(
|
||||
worldX: number,
|
||||
worldZ: number,
|
||||
buildingPosition: readonly [number, number, number],
|
||||
buildingRotationY: number,
|
||||
): SvgPoint {
|
||||
const dx = worldX - buildingPosition[0]
|
||||
const dz = worldZ - buildingPosition[2]
|
||||
const cos = Math.cos(buildingRotationY)
|
||||
const sin = Math.sin(buildingRotationY)
|
||||
|
||||
return {
|
||||
x: dx * cos - dz * sin,
|
||||
y: dx * sin + dz * cos,
|
||||
}
|
||||
}
|
||||
|
||||
function projectSvgPointToSurface(
|
||||
svgPoint: SvgPoint,
|
||||
viewBox: { minX: number; minY: number; width: number; height: number },
|
||||
@@ -7678,7 +7651,7 @@ export function FloorplanPanel({
|
||||
walls,
|
||||
ignoreWallIds: [dragState.wallId],
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||
magnetic: !bypassSnap && isMagneticSnapActive(),
|
||||
})
|
||||
const snappedPoint = snapResult.point
|
||||
// Magnetic beacon at the endpoint when it locked onto existing geometry.
|
||||
@@ -8537,30 +8510,30 @@ export function FloorplanPanel({
|
||||
}
|
||||
|
||||
if (isFenceBuildActive) {
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
|
||||
// Figma alignment — same endpoint-wins precedence as the wall branch.
|
||||
// While a draft is open the segment locks to 15° rays from its start
|
||||
// unless Shift is held; Shift bypasses grid, magnetic, angle, and
|
||||
// alignment snap.
|
||||
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap
|
||||
// While a draft is open the segment locks to 15° rays from its start.
|
||||
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
||||
// there is no Shift hold-to-bypass. Alt still bypasses Figma alignment.
|
||||
const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
|
||||
const fenceSnapped = snapFenceDraftPoint({
|
||||
point: planPoint,
|
||||
walls,
|
||||
fences,
|
||||
start: fenceDraftStart ?? undefined,
|
||||
angleSnap: fenceAngleSnap,
|
||||
bypassSnap,
|
||||
magnetic: isMagneticSnapActive(),
|
||||
})
|
||||
const fenceGridBase = bypassSnap ? planPoint : snapWallPointToGrid(planPoint)
|
||||
const fenceGridBase = snapWallPointToGrid(planPoint)
|
||||
const fenceLocked =
|
||||
!bypassSnap &&
|
||||
(fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1])
|
||||
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
|
||||
let snappedPoint = fenceSnapped
|
||||
if (fenceLocked || fenceAngleSnap) useAlignmentGuides.getState().clear()
|
||||
else
|
||||
snappedPoint = alignFloorplanDraftPoint(fenceSnapped, {
|
||||
bypass: event.altKey || bypassSnap,
|
||||
// Alignment is a line snap (pulls onto existing corners/edges) —
|
||||
// suppress it whenever magnetic snap is off (`'off'` / `'angles'`).
|
||||
bypass: event.altKey || !isMagneticSnapActive(),
|
||||
})
|
||||
|
||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||
@@ -8738,18 +8711,16 @@ export function FloorplanPanel({
|
||||
}
|
||||
|
||||
// Wall draft: grid + magnetic snap, then Figma-style alignment.
|
||||
// While a draft is open the segment locks to 15° rays from its
|
||||
// start unless Shift is held. Shift bypasses grid, magnetic, angle,
|
||||
// and alignment snap.
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
const wallAngleSnap = draftStart !== null && !bypassSnap
|
||||
// While a draft is open the segment locks to 15° rays from its start.
|
||||
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
||||
// there is no Shift hold-to-bypass. Alt still bypasses Figma alignment.
|
||||
const wallAngleSnap = draftStart !== null && isAngleSnapActive()
|
||||
const wallSnap = snapWallDraftPointDetailed({
|
||||
point: planPoint,
|
||||
walls,
|
||||
start: draftStart ?? undefined,
|
||||
angleSnap: wallAngleSnap,
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
||||
magnetic: isMagneticSnapActive(),
|
||||
})
|
||||
const wallSnapped = wallSnap.point
|
||||
// Locked onto existing geometry (corner / midpoint / crossing / edge) →
|
||||
@@ -8761,7 +8732,9 @@ export function FloorplanPanel({
|
||||
} else {
|
||||
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
|
||||
applySnap: !wallAngleSnap,
|
||||
bypass: event.altKey || bypassSnap,
|
||||
// Alignment is a line snap (pulls onto existing corners/edges) —
|
||||
// suppress it whenever magnetic snap is off (`'off'` / `'angles'`).
|
||||
bypass: event.altKey || !isMagneticSnapActive(),
|
||||
})
|
||||
}
|
||||
useWallSnapIndicator
|
||||
@@ -8780,8 +8753,9 @@ export function FloorplanPanel({
|
||||
|
||||
setDraftEnd((previousEnd) => {
|
||||
if (
|
||||
!bypassSnap &&
|
||||
(!previousEnd || previousEnd[0] !== snappedPoint[0] || previousEnd[1] !== snappedPoint[1])
|
||||
!previousEnd ||
|
||||
previousEnd[0] !== snappedPoint[0] ||
|
||||
previousEnd[1] !== snappedPoint[1]
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
@@ -9044,7 +9018,7 @@ export function FloorplanPanel({
|
||||
angleSnap?: boolean
|
||||
bypassSnap?: boolean
|
||||
step?: number
|
||||
}) => snapWallDraftPoint({ ...args, magnetic: useEditor.getState().magneticSnap }),
|
||||
}) => snapWallDraftPoint({ ...args, magnetic: isMagneticSnapActive() }),
|
||||
[],
|
||||
)
|
||||
const { handleBackgroundPlacementClick } = useFloorplanBackgroundPlacement({
|
||||
|
||||
@@ -269,15 +269,27 @@ export function createArrowHitAreaGeometry() {
|
||||
return geometry
|
||||
}
|
||||
|
||||
// The move cross is a plus, not a disk. A disk-shaped hit area fills the four
|
||||
// corner gaps between the arms, so a neighbouring node sitting next to the
|
||||
// selected node (a lamp by a door, a slab beside a wall) gets swallowed by the
|
||||
// invisible grip and can't be picked. Wrap the visible arms instead: two flat
|
||||
// arm boxes (length/width + margin) merged into a plus, leaving the corners
|
||||
// empty so co-located neighbours stay selectable while the grip stays grabbable.
|
||||
function createMoveCrossHitAreaGeometry() {
|
||||
const geometry = new CylinderGeometry(
|
||||
MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
|
||||
MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
|
||||
HIT_AREA_THICKNESS,
|
||||
32,
|
||||
)
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
const armLength = (MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN) * 2
|
||||
const armWidth = (MOVE_CROSS_HEAD_HALF_WIDTH + HIT_AREA_MARGIN) * 2
|
||||
const armX = new BoxGeometry(armLength, HIT_AREA_THICKNESS, armWidth)
|
||||
const armZ = new BoxGeometry(armWidth, HIT_AREA_THICKNESS, armLength)
|
||||
const merged = mergeGeometries([armX, armZ], false)
|
||||
if (!merged) {
|
||||
armZ.dispose()
|
||||
armX.computeBoundingSphere()
|
||||
return armX
|
||||
}
|
||||
armX.dispose()
|
||||
armZ.dispose()
|
||||
merged.computeBoundingSphere()
|
||||
return merged
|
||||
}
|
||||
|
||||
export function createRotateArrowHitAreaGeometry() {
|
||||
|
||||
@@ -43,6 +43,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
|
||||
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
import { ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
|
||||
import { createEditorApi } from '../../lib/editor-api'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
||||
@@ -177,7 +178,6 @@ export function NodeArrowHandles() {
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
||||
const movingNode = useEditor((state) => state.movingNode)
|
||||
const placementDragMode = useEditor((state) => state.placementDragMode)
|
||||
// Endpoint / curve drags reshape the selected wall or fence; hide its
|
||||
// resize arrows for the duration so they don't clutter (or get blocked
|
||||
// by) the drag's own cursor + dimension overlays. Mirrors the same guard
|
||||
@@ -203,9 +203,6 @@ export function NodeArrowHandles() {
|
||||
() => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
|
||||
[rawNode, liveOverride],
|
||||
)
|
||||
const isOwnPressDragMove =
|
||||
placementDragMode && movingNode !== null && selectedId !== null && movingNode.id === selectedId
|
||||
|
||||
const def = node ? nodeRegistry.get(node.type) : null
|
||||
const descriptors = useMemo(() => {
|
||||
if (!(node && def?.handles)) return null
|
||||
@@ -218,7 +215,11 @@ export function NodeArrowHandles() {
|
||||
Boolean(node && descriptors?.length) &&
|
||||
!isFloorplanHovered &&
|
||||
mode !== 'delete' &&
|
||||
(!movingNode || isOwnPressDragMove) &&
|
||||
// Any whole-node move (placement or press-drag) hides the rig: the item is
|
||||
// following the cursor, so its rotate/resize handles would only clutter and
|
||||
// draw stray selection rays. The active handle-drag scope (resize/rotate)
|
||||
// sets `activeHandleDrag`, not `movingNode`, so those are unaffected.
|
||||
!movingNode &&
|
||||
!movingWallEndpoint &&
|
||||
!movingFenceEndpoint &&
|
||||
!curvingWall &&
|
||||
@@ -398,22 +399,30 @@ function NodeArrowHandlesForNode({
|
||||
// resize that re-centres the mesh) must NOT fire for the non-active arrows
|
||||
// here, or they'd lag behind the moving item.
|
||||
const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate'
|
||||
// While a rotate gizmo is mid-drag, drop the opposite-side move cross: you
|
||||
// can't move and rotate at once, so it only clutters the rotation.
|
||||
const activeDescriptor = activeIndex !== null ? descriptors[activeIndex] : undefined
|
||||
const activeIsRotate =
|
||||
!!activeDescriptor && 'shape' in activeDescriptor && activeDescriptor.shape === 'rotate'
|
||||
|
||||
const arrows = descriptors.map((descriptor, index) => (
|
||||
<ArrowHandle
|
||||
activeIndex={activeIndex}
|
||||
descriptor={descriptor}
|
||||
dragControls={dragControls}
|
||||
handleIndex={index}
|
||||
// Descriptors come from a per-node-kind static list, so index is a
|
||||
// stable identity within this node's selection cycle.
|
||||
key={index}
|
||||
liveNode={node}
|
||||
preDragNode={preDragNode}
|
||||
rideObject={arrowFrame}
|
||||
suppressFreeze={activeIsTranslate}
|
||||
/>
|
||||
))
|
||||
const arrows = descriptors.map((descriptor, index) => {
|
||||
if (activeIsRotate && 'shape' in descriptor && descriptor.shape === 'move-cross') return null
|
||||
return (
|
||||
<ArrowHandle
|
||||
activeIndex={activeIndex}
|
||||
descriptor={descriptor}
|
||||
dragControls={dragControls}
|
||||
handleIndex={index}
|
||||
// Descriptors come from a per-node-kind static list, so index is a
|
||||
// stable identity within this node's selection cycle.
|
||||
key={index}
|
||||
liveNode={node}
|
||||
preDragNode={preDragNode}
|
||||
rideObject={arrowFrame}
|
||||
suppressFreeze={activeIsTranslate}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return createPortal(
|
||||
<group ref={outerRef}>
|
||||
@@ -1135,8 +1144,21 @@ function ArcArrow({
|
||||
}
|
||||
const initialAngle = angleOf(hitWorld)
|
||||
|
||||
// Advertise the rotate interaction so the contextual HUD can surface the
|
||||
// Shift = free-rotation toggle (the angle-step bypass below). Resize
|
||||
// handles route a measurement label here; rotate gets a sentinel label so
|
||||
// the HUD shows the rotate hint, not a dimension pill.
|
||||
if (isRotateShape) {
|
||||
useEditor
|
||||
.getState()
|
||||
.setActiveHandleDrag({ nodeId: node.id, label: ROTATE_HANDLE_DRAG_LABEL })
|
||||
}
|
||||
|
||||
return {
|
||||
onEnd: () => setRotationDelta(null),
|
||||
onEnd: () => {
|
||||
setRotationDelta(null)
|
||||
if (isRotateShape) useEditor.getState().setActiveHandleDrag(null)
|
||||
},
|
||||
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
|
||||
const hit = new Vector3()
|
||||
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
|
||||
|
||||
@@ -6,10 +6,11 @@ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
|
||||
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
||||
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||
import { isAngleSnapActive, isMagneticSnapActive } from '../../store/use-editor'
|
||||
import usePlacementPreview from '../../store/use-placement-preview'
|
||||
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
|
||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
|
||||
import { getSegmentGridStep, type WallPlanPoint } from '../tools/wall/wall-drafting'
|
||||
|
||||
type UseFloorplanBackgroundPlacementArgs = {
|
||||
activePolygonDraftPoints: WallPlanPoint[]
|
||||
@@ -212,8 +213,8 @@ export function useFloorplanBackgroundPlacement({
|
||||
// start unless Shift is held; Shift bypasses grid, magnetic,
|
||||
// angle, and alignment snap. `gridSnap` keeps the regular snap
|
||||
// on the world XZ grid even when the building is rotated.
|
||||
const fenceStep = WALL_GRID_STEP
|
||||
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap
|
||||
const fenceStep = getSegmentGridStep()
|
||||
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap && isAngleSnapActive()
|
||||
const fenceSnapped = snapFenceDraftPoint({
|
||||
point: planPoint,
|
||||
walls,
|
||||
@@ -221,6 +222,7 @@ export function useFloorplanBackgroundPlacement({
|
||||
start: fenceDraftStart ?? undefined,
|
||||
angleSnap: fenceAngleSnap,
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && isMagneticSnapActive(),
|
||||
gridSnap: (p) => worldGridSnap(p, fenceStep),
|
||||
})
|
||||
const fenceGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, fenceStep)
|
||||
@@ -230,7 +232,9 @@ export function useFloorplanBackgroundPlacement({
|
||||
const snappedPoint =
|
||||
fenceLocked || fenceAngleSnap
|
||||
? fenceSnapped
|
||||
: alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey || bypassSnap })
|
||||
: alignFloorplanDraftPoint(fenceSnapped, {
|
||||
bypass: event.altKey || bypassSnap || !isMagneticSnapActive(),
|
||||
})
|
||||
|
||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||
setCursorPoint(snappedPoint)
|
||||
@@ -321,8 +325,8 @@ export function useFloorplanBackgroundPlacement({
|
||||
// start unless Shift is held; Shift bypasses grid, magnetic,
|
||||
// angle, and alignment snap. `gridSnap` keeps the regular snap
|
||||
// on the world XZ grid even when the building is rotated.
|
||||
const wallStep = WALL_GRID_STEP
|
||||
const wallAngleSnap = draftStart !== null && !bypassSnap
|
||||
const wallStep = getSegmentGridStep()
|
||||
const wallAngleSnap = draftStart !== null && !bypassSnap && isAngleSnapActive()
|
||||
const wallSnapped = snapWallDraftPoint({
|
||||
point: planPoint,
|
||||
walls,
|
||||
@@ -340,7 +344,10 @@ export function useFloorplanBackgroundPlacement({
|
||||
} else {
|
||||
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
|
||||
applySnap: !wallAngleSnap,
|
||||
bypass: event.altKey || bypassSnap,
|
||||
// Figma alignment pulls the endpoint onto existing wall corners /
|
||||
// edges, so it is a line snap — suppress it whenever magnetic snap
|
||||
// is off (`'off'` / `'angles'`), matching the wall-geometry snap.
|
||||
bypass: event.altKey || bypassSnap || !isMagneticSnapActive(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import { Check, Pencil } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import useInteractionScope from '../../../store/use-interaction-scope'
|
||||
|
||||
// ─── Per-zone label editor ────────────────────────────────────────────────────
|
||||
|
||||
@@ -19,6 +21,10 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
||||
const selectedZoneId = useViewer((s) => s.selection.zoneId)
|
||||
const hoveredId = useViewer((s) => s.hoveredId)
|
||||
const mode = useEditor((s) => s.mode)
|
||||
// During an active interaction the zone label is a context badge that steps
|
||||
// back: faded + non-interactive so it can't be hovered/clicked mid-action.
|
||||
const scope = useInteractionScope((s) => s.scope)
|
||||
const labelStepBack = resolveOverlayPolicy(scope).contextBadges === 'faded'
|
||||
const isSelected = selectedZoneId === zoneId
|
||||
const isDeleteHovered = mode === 'delete' && hoveredId === zoneId
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -149,7 +155,8 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
||||
fontSize: 14,
|
||||
fontFamily: 'sans-serif',
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'auto',
|
||||
pointerEvents: labelStepBack ? 'none' : 'auto',
|
||||
opacity: labelStepBack ? 0.4 : undefined,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
|
||||
@@ -3,7 +3,9 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { type Group, MathUtils, type Mesh } from 'three'
|
||||
import type { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import useInteractionScope from '../../../store/use-interaction-scope'
|
||||
|
||||
// Disable raycasting on zone geometry so clicks pass through to items underneath.
|
||||
// Zone selection in the editor is handled exclusively via the HTML label overlay.
|
||||
@@ -20,6 +22,11 @@ export const ZoneSystem = () => {
|
||||
// geometry or the HTML zone tags in the framed shot.
|
||||
const isCaptureMode = useEditor.getState().isCaptureMode
|
||||
|
||||
// During any active interaction zone labels step back entirely — they are
|
||||
// not a primary editing concern and would distract / invite misclicks.
|
||||
const zoneLabelsHidden =
|
||||
resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
|
||||
|
||||
const zoneGeometryVisible = structureLayer === 'zones'
|
||||
const zones = sceneRegistry.byType.zone || new Set()
|
||||
const nodes = useScene.getState().nodes
|
||||
@@ -84,7 +91,8 @@ export const ZoneSystem = () => {
|
||||
|
||||
// Labels: visible on the current level (regardless of mode), but never
|
||||
// during snapshot capture.
|
||||
const showLabel = !isCaptureMode && !!selectedLevelId && isOnSelectedLevel
|
||||
const showLabel =
|
||||
!isCaptureMode && !zoneLabelsHidden && !!selectedLevelId && isOnSelectedLevel
|
||||
const labelOpacity = showLabel ? '1' : '0'
|
||||
const labelEl = document.getElementById(`${zoneId}-label`)
|
||||
if (labelEl && labelEl.style.opacity !== labelOpacity) {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { type AssetInput, isObject } from '@pascal-app/core'
|
||||
import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three'
|
||||
import { resolveSnapFlags } from '../../../lib/snapping-mode'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
|
||||
// Sentinel returned when the active snapping mode disables grid snapping.
|
||||
// The snap helpers below treat any `step <= 0` as "no grid snap" and pass the
|
||||
// raw value through. When grid snapping is enabled (the default `'grid'` mode)
|
||||
// this returns the user's `gridSnapStep` exactly as before — so the default
|
||||
// path is byte-identical to the pre-mode behaviour.
|
||||
function getGridSnapStep(): number {
|
||||
return useEditor.getState().gridSnapStep
|
||||
const state = useEditor.getState()
|
||||
return resolveSnapFlags(state.snappingMode).grid ? state.gridSnapStep : 0
|
||||
}
|
||||
|
||||
function positiveModulo(value: number, divisor: number): number {
|
||||
@@ -14,6 +21,7 @@ function positiveModulo(value: number, divisor: number): number {
|
||||
* Snaps a position to the active grid step, aligning item edges to grid lines.
|
||||
*/
|
||||
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
|
||||
if (step <= 0) return position
|
||||
const halfDim = dimension / 2
|
||||
const offset = positiveModulo(halfDim, step)
|
||||
return Math.round((position - offset) / step) * step + offset
|
||||
@@ -23,6 +31,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
|
||||
* Snap a value to the active grid step (used for wall-local positions).
|
||||
*/
|
||||
export function snapToHalf(value: number, step = getGridSnapStep()): number {
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
@@ -30,6 +39,7 @@ export function snapToHalf(value: number, step = getGridSnapStep()): number {
|
||||
* Round a value up to the next multiple of `step`, with a minimum of `step`.
|
||||
*/
|
||||
export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
|
||||
if (step <= 0) return value
|
||||
return Math.max(step, Math.ceil(value / step) * step)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
canHostOnTop,
|
||||
clampRectToRoofWallFace,
|
||||
getRoofSegmentWallFace,
|
||||
getScaledDimensions,
|
||||
@@ -64,6 +65,7 @@ function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
|
||||
}
|
||||
|
||||
function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
|
||||
if (!canHostOnTop(surfaceItem)) return null
|
||||
if (isLowProfileItemSurface(surfaceItem)) return null
|
||||
if (!isUpwardItemSurfaceHit(event)) return null
|
||||
|
||||
@@ -113,7 +115,7 @@ export const floorStrategy = {
|
||||
// is rotated; then project the world point back into building-local
|
||||
// for storage. Without this, a rotated building drags placement off
|
||||
// the world grid.
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const bypassSnap = event.nativeEvent?.altKey === true
|
||||
const [x, z] = bypassSnap
|
||||
? [event.localPosition[0], event.localPosition[2]]
|
||||
: snapWorldXZForActiveBuilding(
|
||||
@@ -202,7 +204,7 @@ export const wallStrategy = {
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const bypassSnap = event.nativeEvent?.altKey === true
|
||||
const x = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0])
|
||||
const y = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1])
|
||||
const z = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
|
||||
@@ -266,7 +268,7 @@ export const wallStrategy = {
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const bypassSnap = event.nativeEvent?.altKey === true
|
||||
const snappedX = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0])
|
||||
const snappedY = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1])
|
||||
const snappedZ = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
|
||||
@@ -393,14 +395,14 @@ type RoofWallTarget = {
|
||||
* `wall-side` items mount on the outer surface, `wall` items center in
|
||||
* the wall thickness.
|
||||
*
|
||||
* `shiftFree` mirrors the wall flow's Shift override (stubbed
|
||||
* `freePlace` mirrors the wall flow's Alt override (stubbed
|
||||
* validators): the profile clamp is skipped, so the rect may overhang
|
||||
* the face edges — placement follows the snapped cursor as-is.
|
||||
*/
|
||||
function resolveRoofWallTarget(
|
||||
ctx: PlacementContext,
|
||||
event: RoofEvent,
|
||||
shiftFree = false,
|
||||
freePlace = false,
|
||||
): RoofWallTarget | null {
|
||||
const attachTo = ctx.asset.attachTo
|
||||
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
|
||||
@@ -414,10 +416,10 @@ function resolveRoofWallTarget(
|
||||
const dims = getGridAlignedDimensions(rawDims, attachTo)
|
||||
const [width, height] = dims
|
||||
|
||||
const u = shiftFree ? hit.u : snapToHalf(hit.u)
|
||||
const centerV = (shiftFree ? hit.v : snapToHalf(hit.v)) + height / 2
|
||||
const fitted = shiftFree ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
|
||||
if (!fitted && !shiftFree) return null
|
||||
const u = freePlace ? hit.u : snapToHalf(hit.u)
|
||||
const centerV = (freePlace ? hit.v : snapToHalf(hit.v)) + height / 2
|
||||
const fitted = freePlace ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
|
||||
if (!fitted && !freePlace) return null
|
||||
const finalU = fitted?.u ?? u
|
||||
const finalV = fitted?.v ?? centerV
|
||||
|
||||
@@ -483,8 +485,8 @@ export const roofWallStrategy = {
|
||||
* face. Returns null when the item doesn't wall-attach or the pointer
|
||||
* isn't over a placeable face.
|
||||
*/
|
||||
enter(ctx: PlacementContext, event: RoofEvent, shiftFree = false): TransitionResult | null {
|
||||
const target = resolveRoofWallTarget(ctx, event, shiftFree)
|
||||
enter(ctx: PlacementContext, event: RoofEvent, freePlace = false): TransitionResult | null {
|
||||
const target = resolveRoofWallTarget(ctx, event, freePlace)
|
||||
if (!target) return null
|
||||
|
||||
return {
|
||||
@@ -511,11 +513,11 @@ export const roofWallStrategy = {
|
||||
* segment transitions inside one roof never re-fire roof:enter) or to
|
||||
* no placeable face.
|
||||
*/
|
||||
move(ctx: PlacementContext, event: RoofEvent, shiftFree = false): PlacementResult | null {
|
||||
move(ctx: PlacementContext, event: RoofEvent, freePlace = false): PlacementResult | null {
|
||||
if (ctx.state.surface !== 'roof-wall') return null
|
||||
if (!ctx.draftItem) return null
|
||||
|
||||
const target = resolveRoofWallTarget(ctx, event, shiftFree)
|
||||
const target = resolveRoofWallTarget(ctx, event, freePlace)
|
||||
if (!target) return null
|
||||
if (target.segment.id !== ctx.state.roofSegmentId) return null
|
||||
|
||||
@@ -538,12 +540,12 @@ export const roofWallStrategy = {
|
||||
/**
|
||||
* Handle roof:click — commit placement on the segment wall face.
|
||||
*/
|
||||
click(ctx: PlacementContext, _event: RoofEvent, shiftFree = false): CommitResult | null {
|
||||
click(ctx: PlacementContext, _event: RoofEvent, freePlace = false): CommitResult | null {
|
||||
if (ctx.state.surface !== 'roof-wall') return null
|
||||
if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null
|
||||
// Shift mirrors the wall flow's stubbed validators: skip profile-fit
|
||||
// Alt mirrors the wall flow's stubbed validators: skip profile-fit
|
||||
// and overlap checks entirely.
|
||||
if (!shiftFree && !canPlaceOnRoofWall(ctx)) return null
|
||||
if (!freePlace && !canPlaceOnRoofWall(ctx)) return null
|
||||
|
||||
return {
|
||||
nodeUpdate: {
|
||||
@@ -615,7 +617,7 @@ export const ceilingStrategy = {
|
||||
|
||||
// Ceiling items are stored in ceiling-local coordinates, so snapping must
|
||||
// use the ceiling hit's local position rather than world position.
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const bypassSnap = event.nativeEvent?.altKey === true
|
||||
const x = bypassSnap
|
||||
? event.localPosition[0]
|
||||
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||||
@@ -654,7 +656,7 @@ export const ceilingStrategy = {
|
||||
const rotY = ctx.draftItem.rotation?.[1] ?? 0
|
||||
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
||||
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const bypassSnap = event.nativeEvent?.altKey === true
|
||||
const x = bypassSnap
|
||||
? event.localPosition[0]
|
||||
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||||
@@ -771,7 +773,7 @@ export const itemSurfaceStrategy = {
|
||||
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
|
||||
if (surfaceHeight === null) return null
|
||||
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const bypassSnap = event.nativeEvent?.altKey === true
|
||||
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
||||
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
||||
const y = surfaceHeight
|
||||
@@ -823,7 +825,7 @@ export const itemSurfaceStrategy = {
|
||||
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
|
||||
if (surfaceHeight === null) return null
|
||||
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const bypassSnap = event.nativeEvent?.altKey === true
|
||||
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
||||
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
||||
const y = surfaceHeight
|
||||
@@ -924,7 +926,7 @@ export const shelfSurfaceStrategy = {
|
||||
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
|
||||
if (rowY === null) return null
|
||||
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const bypassSnap = event.nativeEvent?.altKey === true
|
||||
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
||||
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
||||
|
||||
@@ -969,7 +971,7 @@ export const shelfSurfaceStrategy = {
|
||||
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
|
||||
if (rowY === null) return null
|
||||
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const bypassSnap = event.nativeEvent?.altKey === true
|
||||
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
||||
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
||||
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
|
||||
|
||||
@@ -44,7 +44,7 @@ import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { formatLinearMeasurement } from '../../../lib/measurements'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import useEditor, { isMagneticSnapActive } from '../../../store/use-editor'
|
||||
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
||||
import {
|
||||
createLineGeometry,
|
||||
@@ -221,7 +221,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
shelfId: null,
|
||||
},
|
||||
)
|
||||
const shiftFreeRef = useRef(false)
|
||||
const altFreeRef = useRef(false)
|
||||
const previewBoundsSignatureRef = useRef<string | null>(null)
|
||||
// Goes true the first time a 3D pointer event drives this coordinator.
|
||||
// The per-frame mesh-position lerp below is only useful for that path;
|
||||
@@ -441,7 +441,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
})
|
||||
|
||||
const getActiveValidators = () =>
|
||||
shiftFreeRef.current
|
||||
altFreeRef.current
|
||||
? {
|
||||
canPlaceOnFloor: () => ({ valid: true }),
|
||||
canPlaceOnWall: () => ({ valid: true }),
|
||||
@@ -450,7 +450,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
: validators
|
||||
|
||||
const revalidate = (): boolean => {
|
||||
const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators)
|
||||
const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
|
||||
const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
|
||||
edgeMaterial.color.setHex(color)
|
||||
basePlaneMaterial.color.setHex(color)
|
||||
@@ -610,8 +610,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// Floor grab-offset: the item tracks the grabbed point instead of snapping
|
||||
// its origin under the cursor. `floorStrategy.move` snaps on the WORLD grid
|
||||
// (`event.position`) on its default path and only reads `event.localPosition`
|
||||
// under Shift, so both frames must carry the offset; the world point is
|
||||
// derived from the corrected local one so the two stay consistent.
|
||||
// under Alt (free place), so both frames must carry the offset; the world
|
||||
// point is derived from the corrected local one so the two stay consistent.
|
||||
const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
|
||||
if (relativeFloorStart === null) return event
|
||||
const rawX = event.localPosition[0]
|
||||
@@ -773,12 +773,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// item's edge, snap and publish a guide. The guide connects to the
|
||||
// nearest real corner of the candidate (resolver tie-break), so the dot
|
||||
// always sits on an actual point. The delta is applied to BOTH the grid
|
||||
// and cursor positions below. Alt bypasses alignment; Shift bypasses all snap.
|
||||
// and cursor positions below. Alt (free place) bypasses all snap; the
|
||||
// active snapping mode governs whether alignment runs at all ('off' /
|
||||
// 'angles' disable magnetic alignment, matching the wall/fence flow).
|
||||
const draft = draftNode.current
|
||||
let alignX = 0
|
||||
let alignZ = 0
|
||||
const bypassSnap = floorEvent.nativeEvent?.shiftKey === true
|
||||
const bypassAlign = floorEvent.nativeEvent?.altKey === true || bypassSnap
|
||||
const freePlace = floorEvent.nativeEvent?.altKey === true
|
||||
const bypassAlign = freePlace || !isMagneticSnapActive()
|
||||
if (!bypassAlign && draft) {
|
||||
alignmentCandidates ??= collectAlignmentAnchors(
|
||||
useScene.getState().nodes,
|
||||
@@ -812,7 +814,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
// Play snap sound when grid position changes
|
||||
if (
|
||||
!bypassSnap &&
|
||||
!freePlace &&
|
||||
previousGridPos &&
|
||||
(gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
|
||||
) {
|
||||
@@ -997,7 +999,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
gridPosition.current.z !== result.gridPosition[2]
|
||||
|
||||
// Play snap sound when grid position changes
|
||||
if (event.nativeEvent?.shiftKey !== true && posChanged) {
|
||||
if (event.nativeEvent?.altKey !== true && posChanged) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
@@ -1121,7 +1123,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// re-enters whenever the strategy reports a segment change.
|
||||
|
||||
const enterRoofWall = (event: RoofEvent): boolean => {
|
||||
const result = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current)
|
||||
const result = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
|
||||
if (!result) return false
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -1152,7 +1154,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
return
|
||||
}
|
||||
|
||||
const result = roofWallStrategy.move(ctx, event, shiftFreeRef.current)
|
||||
const result = roofWallStrategy.move(ctx, event, altFreeRef.current)
|
||||
if (!result) {
|
||||
// Different segment under the pointer (or no placeable face) —
|
||||
// try a fresh enter; a null resolve leaves the draft where it is.
|
||||
@@ -1167,7 +1169,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
gridPosition.current.y !== result.gridPosition[1] ||
|
||||
gridPosition.current.z !== result.gridPosition[2]
|
||||
|
||||
if (!shiftFreeRef.current && posChanged) {
|
||||
if (!altFreeRef.current && posChanged) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
@@ -1210,7 +1212,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
}
|
||||
|
||||
const onRoofWallClick = (event: RoofEvent) => {
|
||||
const result = roofWallStrategy.click(getContext(), event, shiftFreeRef.current)
|
||||
const result = roofWallStrategy.click(getContext(), event, altFreeRef.current)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -1220,7 +1222,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
draftNode.commit(result.nodeUpdate)
|
||||
|
||||
if (configRef.current.onCommitted()) {
|
||||
const enterResult = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current)
|
||||
const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
|
||||
if (enterResult) {
|
||||
applyTransition(enterResult)
|
||||
} else {
|
||||
@@ -1261,7 +1263,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const bypassSnap = event.nativeEvent?.altKey === true
|
||||
const wx = bypassSnap ? buildingLocalPoint.x : Math.round(buildingLocalPoint.x * 2) / 2
|
||||
const wz = bypassSnap ? buildingLocalPoint.z : Math.round(buildingLocalPoint.z * 2) / 2
|
||||
const floorPos: [number, number, number] = [wx, 0, wz]
|
||||
@@ -1598,7 +1600,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
gridPosition.current.y !== result.gridPosition[1] ||
|
||||
gridPosition.current.z !== result.gridPosition[2]
|
||||
|
||||
if (event.nativeEvent?.shiftKey !== true && posChanged) {
|
||||
if (event.nativeEvent?.altKey !== true && posChanged) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
@@ -1793,8 +1795,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// items (use-keyboard.ts) so the ghost/duplicate rotates the same way.
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftFreeRef.current = true
|
||||
if (event.key === 'Alt') {
|
||||
altFreeRef.current = true
|
||||
revalidate()
|
||||
return
|
||||
}
|
||||
@@ -1908,8 +1910,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
}
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftFreeRef.current = false
|
||||
if (event.key === 'Alt') {
|
||||
altFreeRef.current = false
|
||||
revalidate()
|
||||
}
|
||||
}
|
||||
@@ -1997,6 +1999,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
emitter.on('shelf:move', onShelfMove)
|
||||
emitter.on('shelf:click', onShelfClick)
|
||||
emitter.on('shelf:leave', onShelfLeave)
|
||||
|
||||
// A floor placement commits at the tracked floor cursor (`gridPosition`),
|
||||
// which keeps following the floor even when the click ray lands on a wall
|
||||
// (grid:move uses a separate ground-plane raycast). Without this, a commit
|
||||
// click whose ray hits a wall fires only `wall:click` — whose handler
|
||||
// declines for a floor item — and the click is silently eaten (the user
|
||||
// has to click again until the ray happens to clear the wall). Route every
|
||||
// surface click to the floor commit too; `floorStrategy.click` guards on
|
||||
// `surface === 'floor'` (and a non-attach draft), so it no-ops while the
|
||||
// draft is actually resting on that surface.
|
||||
const commitFloorOnSurfaceClick = (event: { stopPropagation: () => void }) => {
|
||||
if (placementState.current.surface !== 'floor') return
|
||||
onGridClick(event as unknown as GridEvent)
|
||||
}
|
||||
emitter.on('wall:click', commitFloorOnSurfaceClick as never)
|
||||
emitter.on('item:click', commitFloorOnSurfaceClick as never)
|
||||
emitter.on('ceiling:click', commitFloorOnSurfaceClick as never)
|
||||
emitter.on('roof:click', commitFloorOnSurfaceClick as never)
|
||||
emitter.on('shelf:click', commitFloorOnSurfaceClick as never)
|
||||
if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
|
||||
|
||||
return () => {
|
||||
@@ -2032,6 +2053,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
emitter.off('shelf:move', onShelfMove)
|
||||
emitter.off('shelf:click', onShelfClick)
|
||||
emitter.off('shelf:leave', onShelfLeave)
|
||||
emitter.off('wall:click', commitFloorOnSurfaceClick as never)
|
||||
emitter.off('item:click', commitFloorOnSurfaceClick as never)
|
||||
emitter.off('ceiling:click', commitFloorOnSurfaceClick as never)
|
||||
emitter.off('roof:click', commitFloorOnSurfaceClick as never)
|
||||
emitter.off('shelf:click', commitFloorOnSurfaceClick as never)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
@@ -2114,7 +2140,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// Restore the draft mesh's raycast when the coordinator unmounts (tool change).
|
||||
useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
|
||||
|
||||
useFrame((_, delta) => {
|
||||
useFrame(() => {
|
||||
if (!asset) {
|
||||
reconcileDraftRaycast(null)
|
||||
return
|
||||
@@ -2145,12 +2171,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
mesh.visible = true
|
||||
|
||||
if (placementState.current.surface === 'floor') {
|
||||
const distance = mesh.position.distanceToSquared(gridPosition.current)
|
||||
if (distance > 1) {
|
||||
mesh.position.copy(gridPosition.current)
|
||||
} else {
|
||||
mesh.position.lerp(gridPosition.current, delta * 20)
|
||||
}
|
||||
// Track the cursor 1:1. An earlier per-frame lerp (delta*20) made an
|
||||
// active move visibly trail the cursor and — combined with React
|
||||
// re-renders momentarily pulling the mesh back toward its committed
|
||||
// position — read as a laggy snap-back on every move. Copying each frame
|
||||
// locks placement/move to the cursor and overrides any stray reset
|
||||
// within a single frame, so it feels precise instead of dragging.
|
||||
mesh.position.copy(gridPosition.current)
|
||||
|
||||
// Adjust Y for slab elevation (floor items on top of slabs)
|
||||
if (!asset.attachTo) {
|
||||
|
||||
@@ -30,7 +30,8 @@ import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement
|
||||
import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
|
||||
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { resolveSnapFlags } from '../../../lib/snapping-mode'
|
||||
import useEditor, { isMagneticSnapActive } from '../../../store/use-editor'
|
||||
import { swallowNextClick } from '../../editor/node-arrow-handles'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { DragBoundingBox } from '../shared/drag-bounding-box'
|
||||
@@ -41,7 +42,9 @@ import { PlacementBox } from '../shared/placement-box'
|
||||
/** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
|
||||
* / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
|
||||
const snapToGridStep = (value: number) => {
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
const state = useEditor.getState()
|
||||
if (!resolveSnapFlags(state.snappingMode).grid) return value
|
||||
const step = state.gridSnapStep
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
@@ -244,10 +247,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
// a register collar drops onto a duct run end. Reads `def.ports` through
|
||||
// the core registry, so it stays layer-clean (no @pascal-app/nodes import).
|
||||
const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null
|
||||
// Mirrors of `valid` / Shift for the event handlers inside the effect, which
|
||||
// Mirrors of `valid` / Alt for the event handlers inside the effect, which
|
||||
// can't read React state without stale closures.
|
||||
const validRef = useRef(true)
|
||||
const shiftRef = useRef(false)
|
||||
const altRef = useRef(false)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
@@ -259,7 +262,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
dragAnchorRef.current = null
|
||||
hasMovedRef.current = false
|
||||
rotationRef.current = originalRotationY
|
||||
shiftRef.current = false
|
||||
altRef.current = false
|
||||
validRef.current = true
|
||||
// Re-sync the box transform to the (possibly new) node. `node` changes
|
||||
// without this component remounting whenever a positioned preset re-arms a
|
||||
@@ -335,12 +338,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
|
||||
|
||||
// Re-run the floor-collision check at the live cursor + rotation and push
|
||||
// the result to the box colour. Shift forces a valid (green) override so
|
||||
// the user can drop on top of an existing item on purpose. Only shelves
|
||||
// show the box, so this no-ops for every other movable kind.
|
||||
// the result to the box colour. Alt (free place) forces a valid (green)
|
||||
// override so the user can drop on top of an existing item on purpose. Only
|
||||
// shelves show the box, so this no-ops for every other movable kind.
|
||||
const recomputeValidity = () => {
|
||||
if (!boxDimensions) return
|
||||
if (shiftRef.current) {
|
||||
if (altRef.current) {
|
||||
validRef.current = true
|
||||
setValid(true)
|
||||
return
|
||||
@@ -417,7 +420,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
original: [originalPosition[0], originalPosition[2]],
|
||||
anchor: dragAnchorRef.current,
|
||||
mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
|
||||
snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep,
|
||||
snap: event.nativeEvent?.altKey === true ? (value) => value : snapToGridStep,
|
||||
})
|
||||
dragAnchorRef.current = resolved.anchor
|
||||
let [x, z] = resolved.point
|
||||
@@ -426,8 +429,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
// moving item's edge lines up (on X or Z) with another item's edge,
|
||||
// snap and publish a guide. The guide connects to the nearest real
|
||||
// corner of the candidate (resolver tie-break), so the dot always sits
|
||||
// on an actual point. Alt bypasses alignment; Shift bypasses all snap.
|
||||
const bypass = event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true
|
||||
// on an actual point. Alt (free place) bypasses all snap; the active
|
||||
// snapping mode governs whether magnetic alignment runs at all.
|
||||
const freePlace = event.nativeEvent?.altKey === true
|
||||
const bypass = freePlace || !isMagneticSnapActive()
|
||||
if (!bypass && alignmentCandidates.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
moving: movingFootprintAnchors(node, x, z, rotationRef.current),
|
||||
@@ -488,7 +493,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
previewConnectivity(position, rotationRef.current)
|
||||
|
||||
const prev = previousSnapRef.current
|
||||
if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== x || prev[1] !== z)) {
|
||||
if (!freePlace && (!prev || prev[0] !== x || prev[1] !== z)) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
previousSnapRef.current = [x, z]
|
||||
}
|
||||
@@ -524,9 +529,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
// deliberate drop. Prevents preset re-arm from double-placing.
|
||||
if (!hasMovedRef.current) return
|
||||
// Refuse a drop on an invalid (red) footprint, matching the GLB item
|
||||
// tool — unless Shift is held to force placement. Other kinds carry no
|
||||
// validity box (`validRef` stays true), so they're never blocked.
|
||||
if (!validRef.current && !shiftRef.current) return
|
||||
// tool — unless Alt (free place) is held to force placement. Other kinds
|
||||
// carry no validity box (`validRef` stays true), so they're never blocked.
|
||||
if (!validRef.current && !altRef.current) return
|
||||
const position: [number, number, number] = [...lastCursorRef.current]
|
||||
|
||||
const rotation = toCommitRotation(rotationRef.current)
|
||||
@@ -624,10 +629,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
// item placement keys (and the "Rotate" hints the move HUD shows). Applied
|
||||
// imperatively + mirrored to the live transform; committed on drop.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
// Hold Shift to force placement on an invalid (red) footprint, matching
|
||||
// the GLB item tool. Recolour the box to green while held.
|
||||
if (e.key === 'Shift') {
|
||||
shiftRef.current = true
|
||||
// Hold Alt (free place) to force placement on an invalid (red) footprint,
|
||||
// matching the GLB item tool. Recolour the box to green while held.
|
||||
if (e.key === 'Alt') {
|
||||
altRef.current = true
|
||||
recomputeValidity()
|
||||
return
|
||||
}
|
||||
@@ -659,8 +664,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
recomputeValidity()
|
||||
}
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
shiftRef.current = false
|
||||
if (e.key === 'Alt') {
|
||||
altRef.current = false
|
||||
recomputeValidity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { Box3, type Camera, type Object3D, Vector3 } from 'three'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import useInteractionScope from '../../../store/use-interaction-scope'
|
||||
import {
|
||||
clearBoxSelectHandled,
|
||||
isBoxSelectPointerSuppressed,
|
||||
@@ -191,6 +192,12 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
||||
const currentClientXRef = useRef(0)
|
||||
const currentClientYRef = useRef(0)
|
||||
const spaceDownRef = useRef(false)
|
||||
// rAF throttle for the expensive marquee preview pass. pointermove can fire
|
||||
// several times per animation frame; the per-node AABB projection in
|
||||
// `collectNodeIdsInScreenRect` only needs to run once per frame. We stash the
|
||||
// latest clamped rect and process it inside the rAF callback.
|
||||
const previewRafRef = useRef<number | null>(null)
|
||||
const pendingPreviewRectRef = useRef<ScreenRect | null>(null)
|
||||
|
||||
const syncPreviewSelectedIds = useCallback(
|
||||
(nextIds: string[]) => {
|
||||
@@ -206,6 +213,11 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
||||
pointerDownRef.current = false
|
||||
isDraggingRef.current = false
|
||||
pointerIdRef.current = null
|
||||
if (previewRafRef.current !== null) {
|
||||
cancelAnimationFrame(previewRafRef.current)
|
||||
previewRafRef.current = null
|
||||
}
|
||||
pendingPreviewRectRef.current = null
|
||||
hideScreenRectangleSelectionElement(elementRef.current)
|
||||
syncPreviewSelectedIds([])
|
||||
|
||||
@@ -213,6 +225,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
||||
useViewer.getState().setInputDragging(false)
|
||||
ownsInputDraggingRef.current = false
|
||||
}
|
||||
useInteractionScope.getState().endIf((s) => s.kind === 'box-select')
|
||||
}, [syncPreviewSelectedIds])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -263,6 +276,14 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const canvas = gl.domElement
|
||||
|
||||
const flushPreview = () => {
|
||||
previewRafRef.current = null
|
||||
const rect = pendingPreviewRectRef.current
|
||||
if (!rect) return
|
||||
pendingPreviewRectRef.current = null
|
||||
syncPreviewSelectedIds(collectNodeIdsInScreenRect(rect, camera, canvas))
|
||||
}
|
||||
|
||||
const updateDrag = (event: PointerEvent) => {
|
||||
if (!pointerDownRef.current) return
|
||||
if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return
|
||||
@@ -291,6 +312,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
||||
isDraggingRef.current = true
|
||||
ownsInputDraggingRef.current = true
|
||||
useViewer.getState().setInputDragging(true)
|
||||
useInteractionScope.getState().begin({ kind: 'box-select' })
|
||||
markBoxSelectHandled()
|
||||
try {
|
||||
canvas.setPointerCapture(event.pointerId)
|
||||
@@ -311,13 +333,22 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
||||
screenRectFromDomRect(canvas.getBoundingClientRect()),
|
||||
)
|
||||
if (!clampedRect) {
|
||||
if (previewRafRef.current !== null) {
|
||||
cancelAnimationFrame(previewRafRef.current)
|
||||
previewRafRef.current = null
|
||||
}
|
||||
pendingPreviewRectRef.current = null
|
||||
hideScreenRectangleSelectionElement(elementRef.current)
|
||||
syncPreviewSelectedIds([])
|
||||
return
|
||||
}
|
||||
|
||||
updateScreenRectangleSelectionElement(elementRef.current!, clampedRect)
|
||||
syncPreviewSelectedIds(collectNodeIdsInScreenRect(clampedRect, camera, canvas))
|
||||
// Coalesce the per-node AABB projection to one run per animation frame.
|
||||
pendingPreviewRectRef.current = clampedRect
|
||||
if (previewRafRef.current === null) {
|
||||
previewRafRef.current = requestAnimationFrame(flushPreview)
|
||||
}
|
||||
}
|
||||
|
||||
const finishDrag = (event: PointerEvent) => {
|
||||
|
||||
@@ -56,6 +56,7 @@ export const ToolManager: React.FC = () => {
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const tool = useEditor((state) => state.tool)
|
||||
const movingNode = useEditor((state) => state.movingNode)
|
||||
const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin)
|
||||
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
|
||||
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
||||
const curvingWall = useEditor((state) => state.curvingWall)
|
||||
@@ -134,6 +135,16 @@ export const ToolManager: React.FC = () => {
|
||||
// Show build tools when in build mode
|
||||
const showBuildTool = mode === 'build' && tool !== null
|
||||
|
||||
// A move initiated from the 2D floor-plan (orange move-dot) is owned end-to-
|
||||
// end by `FloorplanRegistryMoveOverlay`, which marks the origin `'2d'` at
|
||||
// dot-down. Mounting the 3D affordance mover alongside it would adopt the
|
||||
// same node and, on its unmount, restore the adopt-time position — snapping
|
||||
// the committed 2D move back to its start. Gate the 3D mover off for 2D moves
|
||||
// (the scene writes the overlay makes still mirror into the 3D view). A
|
||||
// 3D-initiated move leaves the origin null until its own commit, so this only
|
||||
// suppresses the 3D tool for genuinely 2D-owned moves.
|
||||
const showMover = movingNode != null && movingNodeOrigin !== '2d'
|
||||
|
||||
// Registry-first: if the active tool's kind has a NodeDefinition with a
|
||||
// tool contribution, the registry-driven tool takes over.
|
||||
const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null
|
||||
@@ -163,7 +174,7 @@ export const ToolManager: React.FC = () => {
|
||||
<>
|
||||
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
||||
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
||||
{movingNode?.type === 'building' && (
|
||||
{showMover && movingNode?.type === 'building' && (
|
||||
<MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} />
|
||||
)}
|
||||
|
||||
@@ -259,7 +270,7 @@ export const ToolManager: React.FC = () => {
|
||||
</Suspense>
|
||||
) : null
|
||||
})()}
|
||||
{movingNode && movingNode.type !== 'building' && (
|
||||
{showMover && movingNode.type !== 'building' && (
|
||||
<MoveTool
|
||||
onNodeMoved={handlePlacedNodeSelected}
|
||||
onSpawnMoved={handlePlacedNodeSelected}
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { resolveSnapFlags } from '../../../lib/snapping-mode'
|
||||
import useEditor, { isMagneticSnapActive } from '../../../store/use-editor'
|
||||
import {
|
||||
distanceSquared,
|
||||
findWallSnapTarget,
|
||||
@@ -51,10 +52,16 @@ type WallSplitIntersection = {
|
||||
}
|
||||
|
||||
export function getSegmentGridStep(): number {
|
||||
return useEditor.getState().gridSnapStep
|
||||
const state = useEditor.getState()
|
||||
// A 0 step means "no grid lattice" — every grid-snap consumer guards on
|
||||
// `step <= 0` and returns the raw value, so disabling grid here suppresses
|
||||
// the lattice for walls, fences, and every node move/affordance that reads
|
||||
// this choke point, without retuning their snap math.
|
||||
return resolveSnapFlags(state.snappingMode).grid ? state.gridSnapStep : 0
|
||||
}
|
||||
|
||||
export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
@@ -404,32 +411,38 @@ export function createWallOnCurrentLevel(
|
||||
let resolvedStart = start
|
||||
let resolvedEnd = end
|
||||
|
||||
const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
|
||||
const splitEnd = splitWallIfNeeded(
|
||||
endIntersection,
|
||||
workingWalls,
|
||||
nodes,
|
||||
createNodes,
|
||||
updateNodes,
|
||||
deleteNode,
|
||||
)
|
||||
if (splitEnd) {
|
||||
workingWalls = splitEnd.walls
|
||||
resolvedEnd = splitEnd.point
|
||||
}
|
||||
// The corner-join / wall-split snap on commit is a magnetic (line) snap, so
|
||||
// it must be gated by the snapping mode like the draft preview is. Without
|
||||
// this gate `'off'` (and `'angles'`) still snapped the committed endpoint to
|
||||
// existing wall geometry — the residual snap the draft path no longer does.
|
||||
if (isMagneticSnapActive()) {
|
||||
const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
|
||||
const splitEnd = splitWallIfNeeded(
|
||||
endIntersection,
|
||||
workingWalls,
|
||||
nodes,
|
||||
createNodes,
|
||||
updateNodes,
|
||||
deleteNode,
|
||||
)
|
||||
if (splitEnd) {
|
||||
workingWalls = splitEnd.walls
|
||||
resolvedEnd = splitEnd.point
|
||||
}
|
||||
|
||||
const startIntersection = findWallIntersection(resolvedStart, workingWalls)
|
||||
const splitStart = splitWallIfNeeded(
|
||||
startIntersection,
|
||||
workingWalls,
|
||||
nodes,
|
||||
createNodes,
|
||||
updateNodes,
|
||||
deleteNode,
|
||||
)
|
||||
if (splitStart) {
|
||||
workingWalls = splitStart.walls
|
||||
resolvedStart = splitStart.point
|
||||
const startIntersection = findWallIntersection(resolvedStart, workingWalls)
|
||||
const splitStart = splitWallIfNeeded(
|
||||
startIntersection,
|
||||
workingWalls,
|
||||
nodes,
|
||||
createNodes,
|
||||
updateNodes,
|
||||
deleteNode,
|
||||
)
|
||||
if (splitStart) {
|
||||
workingWalls = splitStart.walls
|
||||
resolvedStart = splitStart.point
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
|
||||
|
||||
@@ -1,35 +1,129 @@
|
||||
import { Icon } from '@iconify/react'
|
||||
import type { ContextualShortcutHint } from '../../../lib/contextual-help'
|
||||
import { resolveSnapFlags } from '../../../lib/snapping-mode'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor, { type GridSnapStep } from '../../../store/use-editor'
|
||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
|
||||
|
||||
const PILL_CLASS =
|
||||
'flex items-center gap-3 rounded-full border border-border bg-popover/90 py-1.5 pr-1.5 pl-3.5 text-foreground text-[11px] shadow-md shadow-black/10 backdrop-blur-md'
|
||||
|
||||
function ShortcutSequence({ keys }: { keys: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{keys.map((key, index) => (
|
||||
<div className="flex items-center gap-0.5" key={`${key}-${index}`}>
|
||||
<div className="flex items-center gap-1" key={`${key}-${index}`}>
|
||||
{index > 0 ? <span className="text-[9px] text-muted-foreground/70">+</span> : null}
|
||||
<ShortcutToken className="h-5 px-1.5 text-[10px]" value={key} />
|
||||
<ShortcutToken className="h-6 px-1.5 text-[10px]" value={key} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextualHelperPanel({ hints }: { hints: ContextualShortcutHint[] }) {
|
||||
if (hints.length === 0) return null
|
||||
const SNAPPING_MODE_ICONS = {
|
||||
grid: 'lucide:grid-2x2',
|
||||
lines: 'lucide:magnet',
|
||||
angles: 'lucide:triangle',
|
||||
off: 'lucide:ban',
|
||||
} as const
|
||||
|
||||
const SNAPPING_MODE_LABELS = {
|
||||
grid: 'Grid',
|
||||
lines: 'Lines',
|
||||
angles: 'Angles',
|
||||
off: 'Off',
|
||||
} as const
|
||||
|
||||
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
|
||||
|
||||
function nextGridSnapStep(step: GridSnapStep): GridSnapStep {
|
||||
const index = GRID_SNAP_STEPS.indexOf(step)
|
||||
return GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]!
|
||||
}
|
||||
|
||||
// Interactive chip rows: the active interaction's own snapping controls. The
|
||||
// surrounding stack is `pointer-events-none` (passive key hints), so these
|
||||
// pills carve out `pointer-events-auto` to stay clickable.
|
||||
function SnappingChips() {
|
||||
const snappingMode = useEditor((s) => s.snappingMode)
|
||||
const cycleSnappingMode = useEditor((s) => s.cycleSnappingMode)
|
||||
const gridSnapStep = useEditor((s) => s.gridSnapStep)
|
||||
const setGridSnapStep = useEditor((s) => s.setGridSnapStep)
|
||||
|
||||
const gridActive = resolveSnapFlags(snappingMode).grid
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex max-w-[260px] -translate-y-1/2 flex-col gap-1.5 rounded-lg border border-border bg-background/95 px-3 py-2.5 shadow-lg backdrop-blur-md">
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
|
||||
className={`${PILL_CLASS} pointer-events-auto cursor-pointer transition-colors hover:bg-accent`}
|
||||
onClick={() => cycleSnappingMode()}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium">
|
||||
<Icon
|
||||
className="shrink-0"
|
||||
height={13}
|
||||
icon={SNAPPING_MODE_ICONS[snappingMode]}
|
||||
width={13}
|
||||
/>
|
||||
<span className="truncate">Snapping: {SNAPPING_MODE_LABELS[snappingMode]}</span>
|
||||
</span>
|
||||
<ShortcutToken className="h-6 px-1.5 text-[10px]" value="Shift" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Snapping mode — click or press Shift to cycle</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{gridActive ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={`Grid step: ${gridSnapStep.toFixed(2)} m`}
|
||||
className={`${PILL_CLASS} pointer-events-auto cursor-pointer transition-colors hover:bg-accent`}
|
||||
onClick={() => setGridSnapStep(nextGridSnapStep(gridSnapStep))}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
Grid: <span className="tabular-nums">{gridSnapStep.toFixed(2)}</span> m
|
||||
</span>
|
||||
<ShortcutToken className="h-6 px-1.5 text-[10px]" value="Ctrl" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Grid step — click or tap Ctrl to cycle</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextualHelperPanel({
|
||||
hints,
|
||||
showSnapping = false,
|
||||
}: {
|
||||
hints: ContextualShortcutHint[]
|
||||
showSnapping?: boolean
|
||||
}) {
|
||||
if (hints.length === 0 && !showSnapping) return null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex max-w-[260px] -translate-y-1/2 flex-col items-end gap-2">
|
||||
{showSnapping ? <SnappingChips /> : null}
|
||||
{hints.map((hint) => (
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-w-0 grid-cols-1 gap-1 rounded-md text-sm',
|
||||
hint.active && '-mx-1 bg-primary/10 px-1.5 py-1 text-foreground',
|
||||
PILL_CLASS,
|
||||
'w-full justify-between',
|
||||
hint.active && 'border-primary/40 bg-primary/10 text-foreground',
|
||||
)}
|
||||
key={`${hint.keys.join('+')}:${hint.label}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-medium leading-snug">{hint.label}</span>
|
||||
<ShortcutSequence keys={hint.keys} />
|
||||
<span className="min-w-0 text-muted-foreground text-xs leading-snug">{hint.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,11 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useIsMobile } from '../../../hooks/use-mobile'
|
||||
import { resolveSelectModeHelpHints } from '../../../lib/contextual-help'
|
||||
import {
|
||||
ROTATE_HANDLE_DRAG_LABEL,
|
||||
resolveRotateHandleHelpHints,
|
||||
resolveSelectModeHelpHints,
|
||||
} from '../../../lib/contextual-help'
|
||||
import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { BuildingHelper } from './building-helper'
|
||||
@@ -62,6 +66,7 @@ export function HelperManager() {
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const tool = useEditor((s) => s.tool)
|
||||
const movingNode = useEditor((state) => state.movingNode)
|
||||
const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const isMobile = useIsMobile()
|
||||
const modifiers = useActiveModifierKeys()
|
||||
@@ -87,9 +92,16 @@ export function HelperManager() {
|
||||
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
|
||||
if (isMobile) return null
|
||||
|
||||
// Rotating a node via its in-world gizmo: advertise Shift = free rotation,
|
||||
// the same angle-step bypass wall drafting exposes. Takes priority over the
|
||||
// idle select-mode hints since a handle drag is the active interaction.
|
||||
if (activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL) {
|
||||
return <ContextualHelperPanel hints={resolveRotateHandleHelpHints(modifiers.shift)} />
|
||||
}
|
||||
|
||||
if (movingNode) {
|
||||
if (movingNode.type === 'building') return <BuildingHelper showRotate />
|
||||
return <ItemHelper shiftPressed={modifiers.shift} showEsc />
|
||||
return <ItemHelper showEsc />
|
||||
}
|
||||
|
||||
if (mode === 'material-paint') {
|
||||
|
||||
@@ -2,21 +2,18 @@ import { ContextualHelperPanel } from './contextual-helper-panel'
|
||||
|
||||
interface ItemHelperProps {
|
||||
showEsc?: boolean
|
||||
shiftPressed?: boolean
|
||||
}
|
||||
|
||||
export function ItemHelper({ showEsc, shiftPressed = false }: ItemHelperProps) {
|
||||
export function ItemHelper({ showEsc }: ItemHelperProps) {
|
||||
return (
|
||||
<ContextualHelperPanel
|
||||
showSnapping
|
||||
hints={[
|
||||
{ keys: ['Left click'], label: 'Place item' },
|
||||
{ keys: ['R'], label: 'Rotate counterclockwise' },
|
||||
{ keys: ['T'], label: 'Rotate clockwise' },
|
||||
{
|
||||
keys: ['Shift'],
|
||||
label: shiftPressed ? 'Guided constraints bypassed' : 'Free place',
|
||||
active: shiftPressed,
|
||||
},
|
||||
{ keys: ['Shift'], label: 'Cycle snapping mode' },
|
||||
{ keys: ['Alt'], label: 'Free place (no snap)' },
|
||||
{ keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -20,12 +20,19 @@ export function RegisteredToolHelper({
|
||||
if (hints.length === 0) return null
|
||||
return (
|
||||
<ContextualHelperPanel
|
||||
hints={hints.map((hint) => ({
|
||||
keys: [hint.key],
|
||||
label:
|
||||
shiftPressed && hint.key === 'Shift' ? 'Guided constraints bypassed' : hint.label,
|
||||
active: shiftPressed && hint.key === 'Shift',
|
||||
}))}
|
||||
showSnapping
|
||||
hints={hints.map((hint) => {
|
||||
// Shift is a per-kind bypass for item / opening / zone / duct placement
|
||||
// ("Free place", "Free angle", …) — those hints flip to a bypassed
|
||||
// state while held. For wall / fence, Shift now cycles the snapping
|
||||
// mode (no hold-to-bypass), so it must NOT show the bypass treatment.
|
||||
const isBypassHint = hint.key === 'Shift' && hint.label !== 'Cycle snapping mode'
|
||||
return {
|
||||
keys: [hint.key],
|
||||
label: shiftPressed && isBypassHint ? 'Guided constraints bypassed' : hint.label,
|
||||
active: shiftPressed && isBypassHint,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ContextualHelperPanel } from './contextual-helper-panel'
|
||||
export function RoofHelper({ shiftPressed = false }: { shiftPressed?: boolean }) {
|
||||
return (
|
||||
<ContextualHelperPanel
|
||||
showSnapping
|
||||
hints={[
|
||||
{ keys: ['Left click'], label: 'Set corner' },
|
||||
{
|
||||
|
||||
@@ -4,13 +4,18 @@ import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import type { Mesh } from 'three'
|
||||
import { resolveOverlayPolicy } from '../lib/interaction/overlay-policy'
|
||||
import useEditor from '../store/use-editor'
|
||||
import useInteractionScope from '../store/use-interaction-scope'
|
||||
|
||||
export const ViewerZoneSystem = () => {
|
||||
useFrame(() => {
|
||||
const { levelId, zoneId } = useViewer.getState().selection
|
||||
const structureLayer = useEditor.getState().structureLayer
|
||||
const nodes = useScene.getState().nodes
|
||||
// During any active interaction zone labels step back entirely (Sims-light).
|
||||
const zoneLabelsHidden =
|
||||
resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
|
||||
|
||||
sceneRegistry.byType.zone!.forEach((id) => {
|
||||
const obj = sceneRegistry.nodes.get(id)
|
||||
@@ -35,7 +40,7 @@ export const ViewerZoneSystem = () => {
|
||||
})
|
||||
|
||||
// Labels: always visible on the current level (regardless of mode or zone selection)
|
||||
const showLabel = !!levelId && isOnSelectedLevel
|
||||
const showLabel = !zoneLabelsHidden && !!levelId && isOnSelectedLevel
|
||||
const targetOpacity = showLabel ? '1' : '0'
|
||||
const labelEl = document.getElementById(`${id}-label`)
|
||||
if (labelEl && labelEl.style.opacity !== targetOpacity) {
|
||||
|
||||
@@ -40,12 +40,56 @@ export const useKeyboard = ({
|
||||
return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
|
||||
}
|
||||
|
||||
// Shift cycles the snapping mode while a snapping-mode-governed draft is
|
||||
// armed: wall / fence build, item placement (build + item tool), and any
|
||||
// active node move (`movingNode` — covers item 3D moves plus the generic
|
||||
// registry move for shelf / spawn / column / stair). For items, free place
|
||||
// moved to Alt, so Shift is free to cycle here too. Elsewhere Shift keeps
|
||||
// its existing meaning — multi-select in plain select mode (no movingNode),
|
||||
// free-place bypass during opening / zone placement — so this predicate
|
||||
// must NOT fire for those. Door / window moves still use Shift for free
|
||||
// place (out of this overhaul's scope), so they're excluded.
|
||||
const isSnappingCycleContext = () => {
|
||||
const ed = useEditor.getState()
|
||||
const moving = ed.movingNode
|
||||
if (moving != null) return moving.type !== 'door' && moving.type !== 'window'
|
||||
return (
|
||||
ed.mode === 'build' && (ed.tool === 'wall' || ed.tool === 'fence' || ed.tool === 'item')
|
||||
)
|
||||
}
|
||||
|
||||
// A "clean tap" of Ctrl/Meta (pressed and released with NO other key in
|
||||
// between) cycles the grid step — same context as the Shift snapping-mode
|
||||
// cycle. `ctrlTapClean` starts true the moment Ctrl/Meta goes down alone
|
||||
// and is cleared the instant any other key fires, so chords like Ctrl+Z /
|
||||
// Ctrl+C never cycle.
|
||||
let ctrlTapClean = false
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Control' || e.key === 'Meta') {
|
||||
// Only a fresh, modifier-free press starts a clean-tap candidate;
|
||||
// ignore key-repeat and presses already part of a combo.
|
||||
ctrlTapClean = !e.repeat && !e.shiftKey && !e.altKey
|
||||
} else {
|
||||
// Any non-modifier key (or a modifier combined with Ctrl/Meta) breaks
|
||||
// the clean tap.
|
||||
ctrlTapClean = false
|
||||
}
|
||||
|
||||
// Don't handle shortcuts if user is typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Shift' && !e.repeat && isSnappingCycleContext()) {
|
||||
// Cycle the global snapping mode (grid → lines → angles → off).
|
||||
// `'off'` is the snap bypass now, so Shift no longer holds-to-bypass.
|
||||
e.preventDefault()
|
||||
useEditor.getState().cycleSnappingMode()
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
_toolCancelConsumed = false
|
||||
@@ -91,6 +135,9 @@ export const useKeyboard = ({
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('furnish')
|
||||
useEditor.getState().setMode('build')
|
||||
// Set the item tool explicitly so the active tool never inherits a
|
||||
// stale tool from a prior build session.
|
||||
useEditor.getState().setTool('item')
|
||||
useEditor.getState().setActiveSidebarPanel('items')
|
||||
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
|
||||
if (isVersionPreviewMode) return
|
||||
@@ -98,6 +145,8 @@ export const useKeyboard = ({
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setStructureLayer('zones')
|
||||
useEditor.getState().setMode('build')
|
||||
// Set the zone tool explicitly so it never inherits a stale tool.
|
||||
useEditor.getState().setTool('zone')
|
||||
}
|
||||
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
@@ -109,6 +158,9 @@ export const useKeyboard = ({
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
useEditor.getState().setMode('build')
|
||||
// Set the wall tool explicitly so B never inherits a stale tool
|
||||
// (e.g. fence) left over from a prior build session.
|
||||
useEditor.getState().setTool('wall')
|
||||
} else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
|
||||
if (isVersionPreviewMode) return
|
||||
e.preventDefault()
|
||||
@@ -346,8 +398,28 @@ export const useKeyboard = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Control' && e.key !== 'Meta') return
|
||||
const wasClean = ctrlTapClean
|
||||
ctrlTapClean = false
|
||||
if (!wasClean) return
|
||||
// Same scope as the Shift snapping-mode cycle: wall / fence build only,
|
||||
// and never while typing in an input.
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
if (!isSnappingCycleContext()) return
|
||||
// Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05).
|
||||
useEditor.getState().cycleGridSnapStep()
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [disabled, isVersionPreviewMode])
|
||||
|
||||
return null
|
||||
|
||||
@@ -327,7 +327,7 @@ export type {
|
||||
ViewMode,
|
||||
WorkspaceMode,
|
||||
} from './store/use-editor'
|
||||
export { default as useEditor } from './store/use-editor'
|
||||
export { default as useEditor, isAngleSnapActive, isMagneticSnapActive } from './store/use-editor'
|
||||
export {
|
||||
default as useOpeningGuides,
|
||||
type OpeningGuide3D,
|
||||
|
||||
@@ -49,7 +49,10 @@ describe('resolveSelectModeHelpHints', () => {
|
||||
keys: ['Cmd/Ctrl', 'Right click'],
|
||||
label: 'Drag left or right to rotate selected object',
|
||||
})
|
||||
expect(hints).toContainEqual({
|
||||
// The Shift bypass hint is gated to the in-progress direct-move gesture
|
||||
// (Cmd/Ctrl held); on an idle selection it must not appear (Shift there
|
||||
// means multi-select, not bypass).
|
||||
expect(hints).not.toContainEqual({
|
||||
keys: ['Shift'],
|
||||
label: 'Hold to bypass snaps and angle steps',
|
||||
active: false,
|
||||
|
||||
@@ -4,6 +4,25 @@ export type ContextualShortcutHint = {
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
// `activeHandleDrag.label` value a rotate gizmo sets while dragging, so the
|
||||
// contextual HUD can surface the Shift = free-rotation toggle for the duration
|
||||
// (mirrors how wall drafting advertises Shift). Distinct from resize handles,
|
||||
// which route their own measurement label here.
|
||||
export const ROTATE_HANDLE_DRAG_LABEL = 'rotate-handle'
|
||||
|
||||
// Hints shown while a rotate gizmo is mid-drag: Shift bypasses the angle step
|
||||
// (free rotation), the same toggle wall drafting exposes. `active` lights the
|
||||
// pill while Shift is held.
|
||||
export function resolveRotateHandleHelpHints(shiftPressed: boolean): ContextualShortcutHint[] {
|
||||
return [
|
||||
{
|
||||
keys: [SHIFT_KEY],
|
||||
label: shiftPressed ? 'Rotating freely (no angle step)' : 'Hold to rotate freely',
|
||||
active: shiftPressed,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export type SelectModeHelpContext = {
|
||||
selectedCount: number
|
||||
hasMovableSelection: boolean
|
||||
@@ -79,11 +98,16 @@ export function resolveSelectModeHelpHints({
|
||||
}
|
||||
}
|
||||
|
||||
hints.push({
|
||||
keys: [SHIFT_KEY],
|
||||
label: shiftPressed ? 'Guided constraints bypassed' : 'Hold to bypass snaps and angle steps',
|
||||
active: shiftPressed,
|
||||
})
|
||||
// The Shift bypass only applies to an in-progress direct move/rotate
|
||||
// (the Cmd/Ctrl-drag gesture), so only surface it while that modifier is
|
||||
// engaged — not on an idle selection, where Shift means multi-select.
|
||||
if (commandPressed && (hasMovableSelection || hasRotatableSelection)) {
|
||||
hints.push({
|
||||
keys: [SHIFT_KEY],
|
||||
label: shiftPressed ? 'Guided constraints bypassed' : 'Hold to bypass snaps and angle steps',
|
||||
active: shiftPressed,
|
||||
})
|
||||
}
|
||||
|
||||
if (!commandPressed) {
|
||||
hints.push({
|
||||
|
||||
@@ -11,6 +11,46 @@ export function rotatePlanVector(x: number, y: number, rotation: number): [numbe
|
||||
return [x * cos + y * sin, -x * sin + y * cos]
|
||||
}
|
||||
|
||||
// Converts a world X/Z point into the floor-plan-local (building-local)
|
||||
// frame used by the SVG scene `<g>` and every stored node position. The
|
||||
// inverse of `floorplanLocalToWorldPoint`. Shared so the floor-plan panel
|
||||
// and the 2D move overlay resolve the same frame — feeding a world-space
|
||||
// `original` into a local-space cursor solver lands the drop off by the
|
||||
// building's world X/Z (worse for an off-origin building).
|
||||
export function worldToFloorplanLocalPoint(
|
||||
worldX: number,
|
||||
worldZ: number,
|
||||
buildingPosition: readonly [number, number, number],
|
||||
buildingRotationY: number,
|
||||
): Point2D {
|
||||
const dx = worldX - buildingPosition[0]
|
||||
const dz = worldZ - buildingPosition[2]
|
||||
const cos = Math.cos(buildingRotationY)
|
||||
const sin = Math.sin(buildingRotationY)
|
||||
|
||||
return {
|
||||
x: dx * cos - dz * sin,
|
||||
y: dx * sin + dz * cos,
|
||||
}
|
||||
}
|
||||
|
||||
// Inverse of `worldToFloorplanLocalPoint`: floor-plan-local X/Y → world X/Z.
|
||||
export function floorplanLocalToWorldPoint(
|
||||
point: Point2D | [number, number],
|
||||
buildingPosition: readonly [number, number, number],
|
||||
buildingRotationY: number,
|
||||
): { x: number; z: number } {
|
||||
const localX = Array.isArray(point) ? point[0] : point.x
|
||||
const localY = Array.isArray(point) ? point[1] : point.y
|
||||
const cos = Math.cos(buildingRotationY)
|
||||
const sin = Math.sin(buildingRotationY)
|
||||
|
||||
return {
|
||||
x: buildingPosition[0] + localX * cos + localY * sin,
|
||||
z: buildingPosition[2] - localX * sin + localY * cos,
|
||||
}
|
||||
}
|
||||
|
||||
export function getRotatedRectanglePolygon(
|
||||
center: Point2D,
|
||||
width: number,
|
||||
|
||||
@@ -8,6 +8,7 @@ export {
|
||||
export {
|
||||
clampPlanValue,
|
||||
doesPolygonIntersectSelectionBounds,
|
||||
floorplanLocalToWorldPoint,
|
||||
getDistanceToWallSegment,
|
||||
getFloorplanSelectionBounds,
|
||||
getPlanPointDistance,
|
||||
@@ -20,6 +21,7 @@ export {
|
||||
movePlanPointTowards,
|
||||
pointMatchesWallPlanPoint,
|
||||
rotatePlanVector,
|
||||
worldToFloorplanLocalPoint,
|
||||
} from './geometry'
|
||||
export {
|
||||
buildFloorplanItemEntry,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AttachClass,
|
||||
attachClassOf,
|
||||
type HotSetCandidate,
|
||||
isCandidateInHotSet,
|
||||
isPickableForAttach,
|
||||
} from './hot-set'
|
||||
|
||||
const floor: HotSetCandidate = {
|
||||
type: 'level',
|
||||
isFloorLike: true,
|
||||
exposesTop: false,
|
||||
attachClass: 'surface',
|
||||
}
|
||||
const wall: HotSetCandidate = {
|
||||
type: 'wall',
|
||||
isFloorLike: false,
|
||||
exposesTop: false,
|
||||
attachClass: 'surface',
|
||||
}
|
||||
const ceiling: HotSetCandidate = {
|
||||
type: 'ceiling',
|
||||
isFloorLike: false,
|
||||
exposesTop: false,
|
||||
attachClass: 'surface',
|
||||
}
|
||||
const table: HotSetCandidate = {
|
||||
type: 'item',
|
||||
isFloorLike: false,
|
||||
exposesTop: true,
|
||||
attachClass: 'surface',
|
||||
}
|
||||
const wallShelf: HotSetCandidate = {
|
||||
type: 'shelf',
|
||||
isFloorLike: false,
|
||||
exposesTop: true,
|
||||
attachClass: 'wall',
|
||||
}
|
||||
const ceilingFan: HotSetCandidate = {
|
||||
type: 'item',
|
||||
isFloorLike: false,
|
||||
exposesTop: true,
|
||||
attachClass: 'ceiling',
|
||||
}
|
||||
|
||||
describe('attachClassOf', () => {
|
||||
test('wall and wall-side collapse to wall', () => {
|
||||
expect(attachClassOf('wall')).toBe('wall')
|
||||
expect(attachClassOf('wall-side')).toBe('wall')
|
||||
})
|
||||
test('ceiling maps to ceiling', () => {
|
||||
expect(attachClassOf('ceiling')).toBe('ceiling')
|
||||
})
|
||||
test('undefined/null/unknown is surface-resting', () => {
|
||||
expect(attachClassOf(undefined)).toBe('surface')
|
||||
expect(attachClassOf(null)).toBe('surface')
|
||||
expect(attachClassOf('')).toBe('surface')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPickableForAttach — wall-mounted (window)', () => {
|
||||
test('only walls are eligible; floor/ceiling/tops are not', () => {
|
||||
expect(isPickableForAttach('wall', wall)).toBe(true)
|
||||
expect(isPickableForAttach('wall', floor)).toBe(false)
|
||||
expect(isPickableForAttach('wall', ceiling)).toBe(false)
|
||||
expect(isPickableForAttach('wall', table)).toBe(false)
|
||||
expect(isPickableForAttach('wall', wallShelf)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPickableForAttach — ceiling-mounted', () => {
|
||||
test('only ceilings are eligible', () => {
|
||||
expect(isPickableForAttach('ceiling', ceiling)).toBe(true)
|
||||
expect(isPickableForAttach('ceiling', wall)).toBe(false)
|
||||
expect(isPickableForAttach('ceiling', floor)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPickableForAttach — surface-resting (sofa / cactus)', () => {
|
||||
test('floor is always eligible', () => {
|
||||
expect(isPickableForAttach('surface', floor)).toBe(true)
|
||||
})
|
||||
test('host tops (table, wall-shelf top) are eligible', () => {
|
||||
expect(isPickableForAttach('surface', table)).toBe(true)
|
||||
expect(isPickableForAttach('surface', wallShelf)).toBe(true)
|
||||
})
|
||||
test('a wall (no top surface) is not eligible', () => {
|
||||
expect(isPickableForAttach('surface', wall)).toBe(false)
|
||||
})
|
||||
test('a ceiling-mounted host (ceiling fan) is never eligible — Track E', () => {
|
||||
expect(isPickableForAttach('surface', ceilingFan)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCandidateInHotSet — by scope', () => {
|
||||
const surfaceClass: AttachClass = 'surface'
|
||||
test('idle: everything is in the hot-set (selection filtering lives elsewhere)', () => {
|
||||
expect(isCandidateInHotSet({ kind: 'idle' }, null, ceilingFan)).toBe(true)
|
||||
})
|
||||
test('placing a surface item: derives from attach class', () => {
|
||||
const scope = {
|
||||
kind: 'placing' as const,
|
||||
nodeId: 'i1',
|
||||
nodeType: 'item',
|
||||
view: '3d' as const,
|
||||
pressDrag: false,
|
||||
}
|
||||
expect(isCandidateInHotSet(scope, surfaceClass, floor)).toBe(true)
|
||||
expect(isCandidateInHotSet(scope, surfaceClass, ceilingFan)).toBe(false)
|
||||
})
|
||||
test('moving a wall-mounted item: only walls', () => {
|
||||
const scope = {
|
||||
kind: 'moving' as const,
|
||||
nodeId: 'w1',
|
||||
nodeType: 'window',
|
||||
view: '2d' as const,
|
||||
}
|
||||
expect(isCandidateInHotSet(scope, 'wall', wall)).toBe(true)
|
||||
expect(isCandidateInHotSet(scope, 'wall', table)).toBe(false)
|
||||
})
|
||||
test('non-placement active scopes target nothing in the scene', () => {
|
||||
expect(isCandidateInHotSet({ kind: 'box-select' }, null, floor)).toBe(false)
|
||||
expect(
|
||||
isCandidateInHotSet({ kind: 'handle-drag', nodeId: 'x', handle: 'h' }, null, floor),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
// The hot-set: which scene objects are raycast-eligible during an interaction.
|
||||
//
|
||||
// It is never hand-authored per interaction. It falls out of the node's
|
||||
// `asset.attachTo` plus whether a candidate exposes a top surface. "Floor item"
|
||||
// really means surface-resting: it rests on the floor *or* any host's top
|
||||
// surface. Walls and ceilings are the special attach modes. Adding a node kind
|
||||
// = set `attachTo` (or leave blank); the hot-set follows with zero per-kind
|
||||
// wiring.
|
||||
|
||||
import type { InteractionScope } from './scope'
|
||||
|
||||
// What a node attaches to, collapsed to the three classes the hot-set cares
|
||||
// about. `wall-side` is a wall attachment; everything without an explicit
|
||||
// `attachTo` is surface-resting.
|
||||
export type AttachClass = 'wall' | 'ceiling' | 'surface'
|
||||
|
||||
export function attachClassOf(attachTo: string | undefined | null): AttachClass {
|
||||
if (attachTo === 'wall' || attachTo === 'wall-side') return 'wall'
|
||||
if (attachTo === 'ceiling') return 'ceiling'
|
||||
return 'surface'
|
||||
}
|
||||
|
||||
// The metadata the hot-set needs about a candidate host/surface. Derived from
|
||||
// the candidate node + its registry definition by the caller, so this module
|
||||
// stays pure and unit-testable without the scene or registry.
|
||||
export type HotSetCandidate = {
|
||||
type: string
|
||||
// The level floor plane / ground a surface-resting node can always rest on.
|
||||
isFloorLike: boolean
|
||||
// The candidate exposes a usable top surface (registry
|
||||
// `capabilities.surfaces.top`) — a table, a shelf, a slab.
|
||||
exposesTop: boolean
|
||||
// The candidate's own attach class. A ceiling fan is `ceiling`: it hangs from
|
||||
// the ceiling and must never act as a host top (Track E).
|
||||
attachClass: AttachClass
|
||||
}
|
||||
|
||||
// For a node whose attach class is `placed`, is `candidate` a valid
|
||||
// host/surface to pick during placement or move?
|
||||
export function isPickableForAttach(placed: AttachClass, candidate: HotSetCandidate): boolean {
|
||||
if (placed === 'wall') return candidate.type === 'wall'
|
||||
if (placed === 'ceiling') return candidate.type === 'ceiling'
|
||||
// Surface-resting: the floor, or any host that exposes a top surface — but
|
||||
// never a ceiling-mounted host (a floor lamp must not land on a ceiling fan).
|
||||
if (candidate.isFloorLike) return true
|
||||
if (!candidate.exposesTop) return false
|
||||
if (candidate.attachClass === 'ceiling') return false
|
||||
return true
|
||||
}
|
||||
|
||||
// The hot-set predicate for a whole scope. For placing/moving it derives from
|
||||
// the moving node's attach class; for every other active scope nothing in the
|
||||
// scene is a placement target, so the body's own raycast owns the pointer.
|
||||
// `idle` returns true here — selection/phase filtering stays in the selection
|
||||
// manager; this only narrows what an *active* interaction can target.
|
||||
export function isCandidateInHotSet(
|
||||
scope: InteractionScope,
|
||||
placedAttachClass: AttachClass | null,
|
||||
candidate: HotSetCandidate,
|
||||
): boolean {
|
||||
if (scope.kind === 'idle') return true
|
||||
if (scope.kind === 'placing' || scope.kind === 'moving') {
|
||||
if (placedAttachClass === null) return true
|
||||
return isPickableForAttach(placedAttachClass, candidate)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { resolveOverlayPolicy } from './overlay-policy'
|
||||
import type { ActiveInteractionScope } from './scope'
|
||||
|
||||
const ACTIVE_SCOPES: ActiveInteractionScope[] = [
|
||||
{ kind: 'placing', nodeId: 'i1', nodeType: 'item', view: '3d', pressDrag: false },
|
||||
{ kind: 'moving', nodeId: 'i1', nodeType: 'item', view: '2d' },
|
||||
{ kind: 'handle-drag', nodeId: 'w1', handle: 'height' },
|
||||
{ kind: 'drafting', tool: 'wall' },
|
||||
{ kind: 'reshaping', nodeId: 's1', reshape: 'hole', holeIndex: 0 },
|
||||
{ kind: 'box-select' },
|
||||
{ kind: 'painting' },
|
||||
]
|
||||
|
||||
describe('resolveOverlayPolicy', () => {
|
||||
test('idle keeps everything shown and pickable', () => {
|
||||
const p = resolveOverlayPolicy({ kind: 'idle' })
|
||||
expect(p.zoneLabels).toBe('shown')
|
||||
expect(p.contextBadges).toBe('shown')
|
||||
expect(p.conflictingControls).toBe('shown')
|
||||
expect(p.sceneObjectsPickable).toBe(true)
|
||||
})
|
||||
|
||||
test('every active scope hides zone labels, fades badges, hides conflicting controls', () => {
|
||||
for (const scope of ACTIVE_SCOPES) {
|
||||
const p = resolveOverlayPolicy(scope)
|
||||
expect(p.zoneLabels).toBe('hidden')
|
||||
expect(p.contextBadges).toBe('faded')
|
||||
expect(p.conflictingControls).toBe('hidden')
|
||||
expect(p.sceneObjectsPickable).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('active affordances and the contextual HUD always stay interactive', () => {
|
||||
for (const scope of [{ kind: 'idle' } as const, ...ACTIVE_SCOPES]) {
|
||||
const p = resolveOverlayPolicy(scope)
|
||||
expect(p.activeAffordances).toBe('shown')
|
||||
expect(p.contextualHudInteractive).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
// The overlay scope matrix — the "Sims-light" feel. During any non-idle
|
||||
// interaction, two layers behave differently:
|
||||
//
|
||||
// - 3D scene objects stay VISIBLE but become NON-pickable (the hot-set owns
|
||||
// what the active interaction can target). Context is preserved; you just
|
||||
// can't grab the wrong thing.
|
||||
// - DOM/HUD overlays step back, differentiated by how distracting they are:
|
||||
// zone labels -> hidden (not a primary editing concern)
|
||||
// context badges -> faded + pointer-events:none (hover name pills)
|
||||
// other controls -> hard-hidden (other objects' handles, the floating
|
||||
// action menu, conflicting controls)
|
||||
//
|
||||
// The active interaction's own affordances (ghost, snap guides, dimension
|
||||
// labels, the active handle) always stay — "default-off, opt-in for the active
|
||||
// action". The contextual control HUD is exempt from the pointer-events
|
||||
// step-back because it *is* the active interaction's own controls.
|
||||
|
||||
import { type InteractionScope, isActive } from './scope'
|
||||
|
||||
export type OverlayVisibility = 'shown' | 'faded' | 'hidden'
|
||||
|
||||
export type OverlayPolicy = {
|
||||
zoneLabels: OverlayVisibility
|
||||
// Hover name pills / context badges.
|
||||
contextBadges: OverlayVisibility
|
||||
// Other objects' handles + the floating action menu — anything whose action
|
||||
// would conflict with the active interaction.
|
||||
conflictingControls: OverlayVisibility
|
||||
// Non-active scene objects: visible always, pickable only when idle.
|
||||
sceneObjectsPickable: boolean
|
||||
// The active interaction's own ghost/guides/dimension labels/handle. Always
|
||||
// shown; this field exists so consumers can assert the contract.
|
||||
activeAffordances: 'shown'
|
||||
// The contextual control HUD keeps pointer events even while everything else
|
||||
// steps back, because it is the active interaction's own controls.
|
||||
contextualHudInteractive: boolean
|
||||
}
|
||||
|
||||
const IDLE_POLICY: OverlayPolicy = {
|
||||
zoneLabels: 'shown',
|
||||
contextBadges: 'shown',
|
||||
conflictingControls: 'shown',
|
||||
sceneObjectsPickable: true,
|
||||
activeAffordances: 'shown',
|
||||
contextualHudInteractive: true,
|
||||
}
|
||||
|
||||
const ACTIVE_POLICY: OverlayPolicy = {
|
||||
zoneLabels: 'hidden',
|
||||
contextBadges: 'faded',
|
||||
conflictingControls: 'hidden',
|
||||
sceneObjectsPickable: false,
|
||||
activeAffordances: 'shown',
|
||||
contextualHudInteractive: true,
|
||||
}
|
||||
|
||||
export function resolveOverlayPolicy(scope: InteractionScope): OverlayPolicy {
|
||||
return isActive(scope) ? ACTIVE_POLICY : IDLE_POLICY
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// The authoritative description of "what the user is currently doing".
|
||||
//
|
||||
// Before this, that question was answered by re-deriving from 7+ independent
|
||||
// `useEditor` flags (`mode`, `tool`, `movingNode`, `placementDragMode`,
|
||||
// `activeHandleDrag`, `curvingWall`, `curvingFence`, `editingHole`,
|
||||
// `movingWallEndpoint`, `movingFenceEndpoint`, …). Every overlay and pick site
|
||||
// re-derived its behaviour from a different subset, so the flags could drift
|
||||
// into illegal combinations (moving + curving at once; a stale `movingNode`
|
||||
// after a drag ended). Collapsing them into one discriminated union makes those
|
||||
// combinations unrepresentable: a scope is exactly one interaction at a time,
|
||||
// and `idle` carries no interaction payload at all.
|
||||
|
||||
export type InteractionView = '2d' | '3d'
|
||||
|
||||
// Endpoint/curve/hole/boundary edits are all "reshape the selected node" — one
|
||||
// node, one in-flight reshape. Grouping them as sub-states of `reshaping`
|
||||
// (rather than four sibling scopes) keeps the union small while still making
|
||||
// "curving and hole-editing at once" unrepresentable.
|
||||
export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary'
|
||||
|
||||
export type InteractionScope =
|
||||
| { kind: 'idle' }
|
||||
// Placing a fresh node (catalog/preset/build tool). `pressDrag` is the
|
||||
// gizmo press-drag flavour (commit on release) vs click-to-place.
|
||||
| {
|
||||
kind: 'placing'
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
view: InteractionView
|
||||
pressDrag: boolean
|
||||
}
|
||||
// Moving an existing node.
|
||||
| { kind: 'moving'; nodeId: string; nodeType: string; view: InteractionView }
|
||||
// Dragging a resize/translate/rotate handle of a selected node.
|
||||
| { kind: 'handle-drag'; nodeId: string; handle: string }
|
||||
// Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…).
|
||||
| { kind: 'drafting'; tool: string }
|
||||
// Reshaping a selected node's geometry (see ReshapeKind).
|
||||
| { kind: 'reshaping'; nodeId: string; reshape: ReshapeKind; holeIndex?: number }
|
||||
// Marquee selection drag.
|
||||
| { kind: 'box-select' }
|
||||
// Material paint application.
|
||||
| { kind: 'painting' }
|
||||
|
||||
export type InteractionKind = InteractionScope['kind']
|
||||
|
||||
export type ActiveInteractionScope = Exclude<InteractionScope, { kind: 'idle' }>
|
||||
|
||||
export const IDLE_SCOPE: InteractionScope = { kind: 'idle' }
|
||||
|
||||
export function isIdle(scope: InteractionScope): scope is { kind: 'idle' } {
|
||||
return scope.kind === 'idle'
|
||||
}
|
||||
|
||||
export function isActive(scope: InteractionScope): scope is ActiveInteractionScope {
|
||||
return scope.kind !== 'idle'
|
||||
}
|
||||
|
||||
// The node a scope is acting on, if any. Drafting/box-select/painting/idle
|
||||
// target no single existing node.
|
||||
export function scopeNodeId(scope: InteractionScope): string | null {
|
||||
switch (scope.kind) {
|
||||
case 'placing':
|
||||
case 'moving':
|
||||
case 'handle-drag':
|
||||
case 'reshaping':
|
||||
return scope.nodeId
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Selection/hover picking is only meaningful while idle. During any active
|
||||
// interaction the pointer belongs to that interaction's body, not to selecting
|
||||
// a different object — the picking choke point should not route a hover/click
|
||||
// to selection while this is false.
|
||||
export function selectionEnabled(scope: InteractionScope): boolean {
|
||||
return scope.kind === 'idle'
|
||||
}
|
||||
@@ -40,4 +40,61 @@ describe('resolvePlanarCursorPosition', () => {
|
||||
expect(moved.point).toEqual([11, 19])
|
||||
expect(moved.anchor).toEqual([4.1, 6.1])
|
||||
})
|
||||
|
||||
// Track B regression: "off-slab cursor, on-slab footprint stays at center".
|
||||
// When the gizmo is grabbed off the footprint center (e.g. near a slab edge),
|
||||
// the resolved center must track original + cursorDelta and be independent of
|
||||
// the initial grab offset — so a footprint fully inside a slab cannot be
|
||||
// pushed off the edge just because the cursor sample landed off-center.
|
||||
test('relative mode cancels the off-center gizmo grab offset so the committed center is offset-independent', () => {
|
||||
const original: [number, number] = [2, 2]
|
||||
const firstSample: [number, number] = [2.3, 2.3]
|
||||
const cursor: [number, number] = [3.1, 1.6]
|
||||
|
||||
const start = resolvePlanarCursorPosition({
|
||||
cursor: firstSample,
|
||||
original,
|
||||
anchor: null,
|
||||
mode: 'relative',
|
||||
})
|
||||
|
||||
// First sample absorbs the off-center grab: the footprint stays put.
|
||||
expect(start.point).toEqual(original)
|
||||
expect(start.anchor).toEqual(firstSample)
|
||||
|
||||
const moved = resolvePlanarCursorPosition({
|
||||
cursor,
|
||||
original,
|
||||
anchor: start.anchor,
|
||||
mode: 'relative',
|
||||
})
|
||||
|
||||
// Committed center = original + (cursor - firstSample), i.e. the gizmo
|
||||
// offset is cancelled regardless of where on the footprint it was grabbed.
|
||||
const expected: [number, number] = [
|
||||
original[0] + (cursor[0] - firstSample[0]),
|
||||
original[1] + (cursor[1] - firstSample[1]),
|
||||
]
|
||||
expect(moved.point[0]).toBeCloseTo(expected[0])
|
||||
expect(moved.point[1]).toBeCloseTo(expected[1])
|
||||
|
||||
// The result must not depend on the absolute grab offset: grabbing the same
|
||||
// footprint dead-center and moving by the same delta yields the same center.
|
||||
const centerStart = resolvePlanarCursorPosition({
|
||||
cursor: original,
|
||||
original,
|
||||
anchor: null,
|
||||
mode: 'relative',
|
||||
})
|
||||
const delta: [number, number] = [cursor[0] - firstSample[0], cursor[1] - firstSample[1]]
|
||||
const centerMoved = resolvePlanarCursorPosition({
|
||||
cursor: [original[0] + delta[0], original[1] + delta[1]],
|
||||
original,
|
||||
anchor: centerStart.anchor,
|
||||
mode: 'relative',
|
||||
})
|
||||
|
||||
expect(centerMoved.point[0]).toBeCloseTo(moved.point[0])
|
||||
expect(centerMoved.point[1]).toBeCloseTo(moved.point[1])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
DEFAULT_SNAPPING_MODE,
|
||||
nextSnappingMode,
|
||||
resolveSnapFlags,
|
||||
SNAPPING_MODES,
|
||||
} from './snapping-mode'
|
||||
|
||||
describe('resolveSnapFlags', () => {
|
||||
it('default mode is grid', () => {
|
||||
expect(DEFAULT_SNAPPING_MODE).toBe('grid')
|
||||
})
|
||||
|
||||
it("default 'grid' reproduces today's full snapping (grid + magnetic + angles on)", () => {
|
||||
expect(resolveSnapFlags('grid')).toEqual({ grid: true, magnetic: true, angles: true })
|
||||
})
|
||||
|
||||
it("'off' disables grid, magnetic, and angles", () => {
|
||||
expect(resolveSnapFlags('off')).toEqual({ grid: false, magnetic: false, angles: false })
|
||||
})
|
||||
|
||||
it("'lines' keeps magnetic but drops the grid lattice and angle lock", () => {
|
||||
expect(resolveSnapFlags('lines')).toEqual({ grid: false, magnetic: true, angles: false })
|
||||
})
|
||||
|
||||
it("'angles' keeps the angle lock but drops grid and magnetic", () => {
|
||||
expect(resolveSnapFlags('angles')).toEqual({ grid: false, magnetic: false, angles: true })
|
||||
})
|
||||
|
||||
it("'lines' and 'angles' are distinct", () => {
|
||||
expect(resolveSnapFlags('lines')).not.toEqual(resolveSnapFlags('angles'))
|
||||
})
|
||||
|
||||
it('cycles through every mode and wraps', () => {
|
||||
const seen = [DEFAULT_SNAPPING_MODE]
|
||||
let mode = DEFAULT_SNAPPING_MODE
|
||||
for (let i = 0; i < SNAPPING_MODES.length - 1; i += 1) {
|
||||
mode = nextSnappingMode(mode)
|
||||
seen.push(mode)
|
||||
}
|
||||
expect(seen).toEqual(SNAPPING_MODES)
|
||||
expect(nextSnappingMode(mode)).toBe(DEFAULT_SNAPPING_MODE)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Snapping mode is a single global, user-cyclable control that maps onto the
|
||||
* two pre-existing snap knobs (`gridSnapStep` grid snap + `magneticSnap`).
|
||||
* The default `'grid'` resolves to the exact pair the editor shipped with
|
||||
* before this control existed (grid on, magnetic on), so the default path is
|
||||
* behaviourally unchanged — only when a user opts into `'lines'` or `'off'`
|
||||
* does any snap math get suppressed.
|
||||
*/
|
||||
export type SnappingMode = 'grid' | 'lines' | 'angles' | 'off'
|
||||
|
||||
export const SNAPPING_MODES: SnappingMode[] = ['grid', 'lines', 'angles', 'off']
|
||||
|
||||
export const DEFAULT_SNAPPING_MODE: SnappingMode = 'grid'
|
||||
|
||||
export type SnapFlags = {
|
||||
grid: boolean
|
||||
magnetic: boolean
|
||||
angles: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure mapping from the curated mode enum onto the individual snap knobs.
|
||||
*
|
||||
* - `grid` → grid + magnetic + angles (today's default; full snapping).
|
||||
* - `lines` → magnetic only (alignment / wall beacons, no grid lattice, no
|
||||
* angle lock).
|
||||
* - `angles` → angle lock only (15° wall/line rays, no grid lattice, no
|
||||
* magnetic beacons).
|
||||
* - `off` → nothing snaps.
|
||||
*/
|
||||
export function resolveSnapFlags(mode: SnappingMode): SnapFlags {
|
||||
switch (mode) {
|
||||
case 'grid':
|
||||
return { grid: true, magnetic: true, angles: true }
|
||||
case 'lines':
|
||||
return { grid: false, magnetic: true, angles: false }
|
||||
case 'angles':
|
||||
return { grid: false, magnetic: false, angles: true }
|
||||
case 'off':
|
||||
return { grid: false, magnetic: false, angles: false }
|
||||
}
|
||||
}
|
||||
|
||||
const SNAPPING_MODE_LABELS: Record<SnappingMode, string> = {
|
||||
grid: 'Grid',
|
||||
lines: 'Lines',
|
||||
angles: 'Angles',
|
||||
off: 'Off',
|
||||
}
|
||||
|
||||
export function getSnappingModeLabel(mode: SnappingMode): string {
|
||||
return SNAPPING_MODE_LABELS[mode]
|
||||
}
|
||||
|
||||
export function nextSnappingMode(mode: SnappingMode): SnappingMode {
|
||||
const index = SNAPPING_MODES.indexOf(mode)
|
||||
return SNAPPING_MODES[(index + 1) % SNAPPING_MODES.length] ?? DEFAULT_SNAPPING_MODE
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type WallSnapRadii,
|
||||
} from '../components/tools/wall/wall-drafting'
|
||||
import useAlignmentGuides from '../store/use-alignment-guides'
|
||||
import useEditor from '../store/use-editor'
|
||||
import { isMagneticSnapActive } from '../store/use-editor'
|
||||
import useWallSnapIndicator from '../store/use-wall-snap-indicator'
|
||||
|
||||
const SURFACE_SNAP_MOVING_ID = '__surface_snap__'
|
||||
@@ -181,7 +181,7 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
|
||||
const nodes = input.nodes ?? useScene.getState().nodes
|
||||
const walls = getLevelWalls(nodes, input.levelId, input.walls)
|
||||
const fallbackPoint = input.fallbackPoint
|
||||
const magnetic = input.magnetic ?? useEditor.getState().magneticSnap
|
||||
const magnetic = input.magnetic ?? isMagneticSnapActive()
|
||||
|
||||
const wallSnap = snapWallDraftPointDetailed({
|
||||
point: input.rawPoint,
|
||||
|
||||
@@ -40,6 +40,14 @@ import {
|
||||
resolvePaintTargetFromSelection,
|
||||
type SingleSurfaceMaterialRole,
|
||||
} from '../lib/material-paint'
|
||||
import {
|
||||
DEFAULT_SNAPPING_MODE,
|
||||
nextSnappingMode,
|
||||
resolveSnapFlags,
|
||||
SNAPPING_MODES,
|
||||
type SnappingMode,
|
||||
} from '../lib/snapping-mode'
|
||||
import useInteractionScope from './use-interaction-scope'
|
||||
|
||||
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'ai'
|
||||
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
|
||||
@@ -374,11 +382,20 @@ type EditorState = {
|
||||
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
|
||||
gridSnapStep: GridSnapStep
|
||||
setGridSnapStep: (step: GridSnapStep) => void
|
||||
// Cycles the grid step through GRID_SNAP_STEPS (0.5 → 0.25 → 0.1 → 0.05 →
|
||||
// 0.5) and returns the new value. Bound to the measurement-step shortcut.
|
||||
cycleGridSnapStep: () => GridSnapStep
|
||||
// Magnetic snapping while drafting — snaps wall endpoints onto existing
|
||||
// wall corners / wall bodies (the "magnetic" beacon). Independent of grid
|
||||
// snap. On by default; toggled from the Display menu.
|
||||
magneticSnap: boolean
|
||||
setMagneticSnap: (enabled: boolean) => void
|
||||
// Global, user-cyclable snapping mode. Maps onto `gridSnapStep` (grid) and
|
||||
// `magneticSnap` via `resolveSnapFlags`. Default `'grid'` reproduces the
|
||||
// historical behaviour (grid + magnetic on).
|
||||
snappingMode: SnappingMode
|
||||
setSnappingMode: (mode: SnappingMode) => void
|
||||
cycleSnappingMode: () => SnappingMode
|
||||
showReferenceFloor: boolean
|
||||
toggleReferenceFloor: () => void
|
||||
setShowReferenceFloor: (show: boolean) => void
|
||||
@@ -427,6 +444,7 @@ type PersistedEditorLayoutState = Pick<
|
||||
| 'floorplanSelectionTool'
|
||||
| 'gridSnapStep'
|
||||
| 'magneticSnap'
|
||||
| 'snappingMode'
|
||||
| 'showReferenceFloor'
|
||||
| 'referenceFloorOffset'
|
||||
| 'referenceFloorOpacity'
|
||||
@@ -450,6 +468,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
|
||||
floorplanSelectionTool: 'click',
|
||||
gridSnapStep: 0.5,
|
||||
magneticSnap: true,
|
||||
snappingMode: DEFAULT_SNAPPING_MODE,
|
||||
showReferenceFloor: false,
|
||||
referenceFloorOffset: 1,
|
||||
referenceFloorOpacity: 0.35,
|
||||
@@ -568,6 +587,9 @@ function normalizePersistedEditorLayoutState(
|
||||
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
||||
// Default on: only an explicit persisted `false` disables it.
|
||||
magneticSnap: state?.magneticSnap !== false,
|
||||
snappingMode: SNAPPING_MODES.includes(state?.snappingMode as SnappingMode)
|
||||
? (state?.snappingMode as SnappingMode)
|
||||
: DEFAULT_SNAPPING_MODE,
|
||||
showReferenceFloor: state?.showReferenceFloor === true,
|
||||
referenceFloorOffset:
|
||||
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
|
||||
@@ -760,6 +782,10 @@ const useEditor = create<EditorState>()(
|
||||
else if (tool) {
|
||||
set({ tool: null })
|
||||
}
|
||||
|
||||
const scope = useInteractionScope.getState()
|
||||
if (mode === 'material-paint') scope.begin({ kind: 'painting' })
|
||||
else scope.endIf((s) => s.kind === 'painting')
|
||||
},
|
||||
tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
|
||||
setTool: (tool) => set({ tool }),
|
||||
@@ -814,25 +840,68 @@ const useEditor = create<EditorState>()(
|
||||
| null,
|
||||
placementDragMode: false,
|
||||
setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }),
|
||||
setMovingNode: (node) =>
|
||||
set(
|
||||
node === null
|
||||
? // Preserve `movingNodeOrigin` across the clear so the
|
||||
// non-owning side's effect cleanup — which fires after
|
||||
// `setMovingNode(null)` propagates — can still read who
|
||||
// finalised. The next non-null `setMovingNode` resets it.
|
||||
// Always clear the press-drag flag when a move ends.
|
||||
{ movingNode: null, placementDragMode: false }
|
||||
: { movingNode: node, movingNodeOrigin: null },
|
||||
),
|
||||
setMovingNode: (node) => {
|
||||
const scope = useInteractionScope.getState()
|
||||
if (node === null) {
|
||||
scope.endIf((s) => s.kind === 'placing' || s.kind === 'moving')
|
||||
// Preserve `movingNodeOrigin` across the clear so the non-owning
|
||||
// side's effect cleanup — which fires after `setMovingNode(null)`
|
||||
// propagates — can still read who finalised. The next non-null
|
||||
// `setMovingNode` resets it. Always clear the press-drag flag.
|
||||
set({ movingNode: null, placementDragMode: false })
|
||||
return
|
||||
}
|
||||
const isNew = Boolean((node as { metadata?: { isNew?: boolean } }).metadata?.isNew)
|
||||
if (isNew) {
|
||||
scope.begin({
|
||||
kind: 'placing',
|
||||
nodeId: node.id,
|
||||
nodeType: node.type,
|
||||
view: '3d',
|
||||
pressDrag: get().placementDragMode,
|
||||
})
|
||||
} else {
|
||||
scope.begin({ kind: 'moving', nodeId: node.id, nodeType: node.type, view: '3d' })
|
||||
}
|
||||
set({ movingNode: node, movingNodeOrigin: null })
|
||||
},
|
||||
movingNodeOrigin: null as '2d' | '3d' | null,
|
||||
setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }),
|
||||
movingWallEndpoint: null,
|
||||
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
|
||||
setMovingWallEndpoint: (value) => {
|
||||
const scope = useInteractionScope.getState()
|
||||
if (value) scope.begin({ kind: 'reshaping', nodeId: value.wall.id, reshape: 'endpoint' })
|
||||
else {
|
||||
const prev = get().movingWallEndpoint
|
||||
if (prev)
|
||||
scope.endIf(
|
||||
(s) =>
|
||||
s.kind === 'reshaping' && s.reshape === 'endpoint' && s.nodeId === prev.wall.id,
|
||||
)
|
||||
}
|
||||
set({ movingWallEndpoint: value })
|
||||
},
|
||||
movingFenceEndpoint: null,
|
||||
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
|
||||
setMovingFenceEndpoint: (value) => {
|
||||
const scope = useInteractionScope.getState()
|
||||
if (value) scope.begin({ kind: 'reshaping', nodeId: value.fence.id, reshape: 'endpoint' })
|
||||
else {
|
||||
const prev = get().movingFenceEndpoint
|
||||
if (prev)
|
||||
scope.endIf(
|
||||
(s) =>
|
||||
s.kind === 'reshaping' && s.reshape === 'endpoint' && s.nodeId === prev.fence.id,
|
||||
)
|
||||
}
|
||||
set({ movingFenceEndpoint: value })
|
||||
},
|
||||
activeHandleDrag: null,
|
||||
setActiveHandleDrag: (drag) => set({ activeHandleDrag: drag }),
|
||||
setActiveHandleDrag: (drag) => {
|
||||
const scope = useInteractionScope.getState()
|
||||
if (drag) scope.begin({ kind: 'handle-drag', nodeId: drag.nodeId, handle: drag.label })
|
||||
else scope.endIf((s) => s.kind === 'handle-drag')
|
||||
set({ activeHandleDrag: drag })
|
||||
},
|
||||
rotationAxis: 'y',
|
||||
cycleRotationAxis: () => {
|
||||
const order = ['y', 'x', 'z'] as const
|
||||
@@ -841,9 +910,31 @@ const useEditor = create<EditorState>()(
|
||||
return next
|
||||
},
|
||||
curvingWall: null,
|
||||
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
||||
setCurvingWall: (wall) => {
|
||||
const scope = useInteractionScope.getState()
|
||||
if (wall) scope.begin({ kind: 'reshaping', nodeId: wall.id, reshape: 'curve' })
|
||||
else {
|
||||
const prev = get().curvingWall
|
||||
if (prev)
|
||||
scope.endIf(
|
||||
(s) => s.kind === 'reshaping' && s.reshape === 'curve' && s.nodeId === prev.id,
|
||||
)
|
||||
}
|
||||
set({ curvingWall: wall })
|
||||
},
|
||||
curvingFence: null,
|
||||
setCurvingFence: (fence) => set({ curvingFence: fence }),
|
||||
setCurvingFence: (fence) => {
|
||||
const scope = useInteractionScope.getState()
|
||||
if (fence) scope.begin({ kind: 'reshaping', nodeId: fence.id, reshape: 'curve' })
|
||||
else {
|
||||
const prev = get().curvingFence
|
||||
if (prev)
|
||||
scope.endIf(
|
||||
(s) => s.kind === 'reshaping' && s.reshape === 'curve' && s.nodeId === prev.id,
|
||||
)
|
||||
}
|
||||
set({ curvingFence: fence })
|
||||
},
|
||||
selectedMaterialTarget: null,
|
||||
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
|
||||
activePaintMaterial: null,
|
||||
@@ -925,7 +1016,24 @@ const useEditor = create<EditorState>()(
|
||||
spaces: {},
|
||||
setSpaces: (spaces) => set({ spaces }),
|
||||
editingHole: null,
|
||||
setEditingHole: (hole) => set({ editingHole: hole }),
|
||||
setEditingHole: (hole) => {
|
||||
const scope = useInteractionScope.getState()
|
||||
if (hole)
|
||||
scope.begin({
|
||||
kind: 'reshaping',
|
||||
nodeId: hole.nodeId,
|
||||
reshape: 'hole',
|
||||
holeIndex: hole.holeIndex,
|
||||
})
|
||||
else {
|
||||
const prev = get().editingHole
|
||||
if (prev)
|
||||
scope.endIf(
|
||||
(s) => s.kind === 'reshaping' && s.reshape === 'hole' && s.nodeId === prev.nodeId,
|
||||
)
|
||||
}
|
||||
set({ editingHole: hole })
|
||||
},
|
||||
hoveredHole: null,
|
||||
setHoveredHole: (hole) =>
|
||||
set((state) =>
|
||||
@@ -1007,8 +1115,22 @@ const useEditor = create<EditorState>()(
|
||||
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
|
||||
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
||||
setGridSnapStep: (step) => set({ gridSnapStep: step }),
|
||||
cycleGridSnapStep: () => {
|
||||
const current = get().gridSnapStep
|
||||
const index = GRID_SNAP_STEPS.indexOf(current)
|
||||
const next = GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]!
|
||||
set({ gridSnapStep: next })
|
||||
return next
|
||||
},
|
||||
magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap,
|
||||
setMagneticSnap: (enabled) => set({ magneticSnap: enabled }),
|
||||
snappingMode: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.snappingMode,
|
||||
setSnappingMode: (mode) => set({ snappingMode: mode }),
|
||||
cycleSnappingMode: () => {
|
||||
const next = nextSnappingMode(get().snappingMode)
|
||||
set({ snappingMode: next })
|
||||
return next
|
||||
},
|
||||
showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
|
||||
toggleReferenceFloor: () =>
|
||||
set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
|
||||
@@ -1101,6 +1223,7 @@ const useEditor = create<EditorState>()(
|
||||
floorplanSelectionTool: state.floorplanSelectionTool,
|
||||
gridSnapStep: state.gridSnapStep,
|
||||
magneticSnap: state.magneticSnap,
|
||||
snappingMode: state.snappingMode,
|
||||
showReferenceFloor: state.showReferenceFloor,
|
||||
referenceFloorOffset: state.referenceFloorOffset,
|
||||
referenceFloorOpacity: state.referenceFloorOpacity,
|
||||
@@ -1109,4 +1232,28 @@ const useEditor = create<EditorState>()(
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Effective magnetic-snap state: the legacy `magneticSnap` flag AND the
|
||||
* snapping mode's magnetic component. Default mode `'grid'` resolves magnetic
|
||||
* to `true`, so with the default-on `magneticSnap` this returns `true` exactly
|
||||
* as before; only `'off'` (or an explicitly-disabled `magneticSnap`) turns it
|
||||
* off. Read from the smallest magnetic choke points so the mode is honoured
|
||||
* without retuning any snap math.
|
||||
*/
|
||||
export function isMagneticSnapActive(): boolean {
|
||||
const state = useEditor.getState()
|
||||
return state.magneticSnap && resolveSnapFlags(state.snappingMode).magnetic
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective angle-lock state: the snapping mode's angle component. Default mode
|
||||
* `'grid'` resolves angles to `true`, so the 15° draft lock behaves exactly as
|
||||
* before; `'lines'` and `'off'` suppress it. Read from the smallest angle-lock
|
||||
* choke points (wall / fence draft call sites) so the mode is honoured without
|
||||
* retuning any snap math.
|
||||
*/
|
||||
export function isAngleSnapActive(): boolean {
|
||||
return resolveSnapFlags(useEditor.getState().snappingMode).angles
|
||||
}
|
||||
|
||||
export default useEditor
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { isActive, isIdle, scopeNodeId, selectionEnabled } from '../lib/interaction/scope'
|
||||
import useInteractionScope from './use-interaction-scope'
|
||||
|
||||
function reset() {
|
||||
useInteractionScope.getState().end()
|
||||
}
|
||||
afterEach(reset)
|
||||
|
||||
describe('use-interaction-scope state machine', () => {
|
||||
test('starts idle', () => {
|
||||
expect(useInteractionScope.getState().scope.kind).toBe('idle')
|
||||
expect(isIdle(useInteractionScope.getState().scope)).toBe(true)
|
||||
})
|
||||
|
||||
test('begin enters an interaction; end returns to idle atomically', () => {
|
||||
const s = useInteractionScope.getState()
|
||||
s.begin({ kind: 'moving', nodeId: 'item_1', nodeType: 'item', view: '3d' })
|
||||
expect(useInteractionScope.getState().scope).toEqual({
|
||||
kind: 'moving',
|
||||
nodeId: 'item_1',
|
||||
nodeType: 'item',
|
||||
view: '3d',
|
||||
})
|
||||
s.end()
|
||||
// No interaction payload leaks past end — the scope is plain idle, so a
|
||||
// stale nodeId/handle is unrepresentable.
|
||||
expect(useInteractionScope.getState().scope).toEqual({ kind: 'idle' })
|
||||
expect(scopeNodeId(useInteractionScope.getState().scope)).toBeNull()
|
||||
})
|
||||
|
||||
test('begin is single-owner: a new interaction replaces the prior one', () => {
|
||||
const s = useInteractionScope.getState()
|
||||
s.begin({ kind: 'drafting', tool: 'wall' })
|
||||
s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' })
|
||||
const scope = useInteractionScope.getState().scope
|
||||
expect(scope.kind).toBe('handle-drag')
|
||||
// The prior drafting payload is gone — illegal "drafting + handle-drag"
|
||||
// combination is unrepresentable.
|
||||
expect(scopeNodeId(scope)).toBe('wall_1')
|
||||
})
|
||||
|
||||
test('update patches the live payload of the active scope', () => {
|
||||
const s = useInteractionScope.getState()
|
||||
s.begin({ kind: 'placing', nodeId: 'i1', nodeType: 'item', view: '3d', pressDrag: false })
|
||||
s.update({ pressDrag: true })
|
||||
const scope = useInteractionScope.getState().scope
|
||||
expect(scope.kind === 'placing' && scope.pressDrag).toBe(true)
|
||||
})
|
||||
|
||||
test('update is a no-op when idle', () => {
|
||||
useInteractionScope
|
||||
.getState()
|
||||
.update({ kind: 'moving', nodeId: 'x', nodeType: 'item', view: '3d' })
|
||||
expect(useInteractionScope.getState().scope.kind).toBe('idle')
|
||||
})
|
||||
|
||||
test('update cannot change which interaction is running', () => {
|
||||
const s = useInteractionScope.getState()
|
||||
s.begin({ kind: 'moving', nodeId: 'i1', nodeType: 'item', view: '3d' })
|
||||
s.update({ kind: 'placing', nodeId: 'i1', nodeType: 'item', view: '3d', pressDrag: true })
|
||||
expect(useInteractionScope.getState().scope.kind).toBe('moving')
|
||||
})
|
||||
|
||||
test('selectionEnabled only while idle', () => {
|
||||
const s = useInteractionScope.getState()
|
||||
expect(selectionEnabled(useInteractionScope.getState().scope)).toBe(true)
|
||||
s.begin({ kind: 'box-select' })
|
||||
expect(selectionEnabled(useInteractionScope.getState().scope)).toBe(false)
|
||||
expect(isActive(useInteractionScope.getState().scope)).toBe(true)
|
||||
})
|
||||
|
||||
test('end is idempotent', () => {
|
||||
const s = useInteractionScope.getState()
|
||||
s.end()
|
||||
s.end()
|
||||
expect(useInteractionScope.getState().scope.kind).toBe('idle')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
type ActiveInteractionScope,
|
||||
IDLE_SCOPE,
|
||||
type InteractionScope,
|
||||
} from '../lib/interaction/scope'
|
||||
|
||||
// The authoritative interaction state machine. A single owner holds exactly one
|
||||
// scope at a time. `begin` enters an interaction (atomically replacing any prior
|
||||
// one — a single owner, no producer races), `update` narrows the live payload,
|
||||
// and `end` returns to idle atomically so no interaction payload can leak past
|
||||
// the end of its interaction. There is no setter that can leave the store in an
|
||||
// illegal half-state: the only writable shape is `InteractionScope`.
|
||||
|
||||
export type InteractionScopeState = {
|
||||
scope: InteractionScope
|
||||
// Enter an interaction. If one is already active it is ended first, so the
|
||||
// store is always single-owner.
|
||||
begin: (scope: ActiveInteractionScope) => void
|
||||
// Patch the current scope's payload. Ignored when idle, or when the patch's
|
||||
// implied kind differs from the active kind — payload updates must not change
|
||||
// which interaction is running (use `begin` for that).
|
||||
update: (patch: Partial<ActiveInteractionScope>) => void
|
||||
// Return to idle atomically. Both commit and cancel paths call this; the
|
||||
// distinction (write vs revert) lives in the interaction body, not here.
|
||||
end: () => void
|
||||
// Return to idle only if the active scope matches `match`. Used when scope is
|
||||
// driven from independent legacy flag clears, so clearing one flag (e.g. a
|
||||
// fence curve) cannot stomp an unrelated active scope (e.g. a wall move).
|
||||
endIf: (match: (scope: ActiveInteractionScope) => boolean) => void
|
||||
}
|
||||
|
||||
const useInteractionScope = create<InteractionScopeState>((set, get) => ({
|
||||
scope: IDLE_SCOPE,
|
||||
begin: (scope) => set({ scope }),
|
||||
update: (patch) =>
|
||||
set((state) => {
|
||||
if (state.scope.kind === 'idle') return state
|
||||
if ('kind' in patch && patch.kind !== state.scope.kind) return state
|
||||
return { scope: { ...state.scope, ...patch } as InteractionScope }
|
||||
}),
|
||||
end: () => {
|
||||
if (get().scope.kind === 'idle') return
|
||||
set({ scope: IDLE_SCOPE })
|
||||
},
|
||||
endIf: (match) => {
|
||||
const scope = get().scope
|
||||
if (scope.kind === 'idle') return
|
||||
if (match(scope)) set({ scope: IDLE_SCOPE })
|
||||
},
|
||||
}))
|
||||
|
||||
export default useInteractionScope
|
||||
Reference in New Issue
Block a user