diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 37d4ac5e..840c0946 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -108,6 +108,7 @@ export type { SelectableConfig, SlotDeclaration, SnapPointKind, + SnapProfile, SnappableConfig, SnapServicesLike, SurfacePoint, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index c5d1ad5e..a1a77b87 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -221,6 +221,13 @@ export type ToolHint = { key: string /** Description of what the input does. Sentence case. */ label: string + /** + * Only show this hint once the in-progress draft has at least this many + * vertices (reads `useEditor.draftVertexCount`). Lets a polygon tool's + * "Finish" hint appear only when finishing is actually possible (≥ 3 points), + * so the HUD reflects reality. Omit for always-shown hints. + */ + minDraftVertices?: number } export type FloorplanGeometry = @@ -710,6 +717,15 @@ export type SurfaceRole = /** Role a kind plays in a duct / pipe / lineset distribution system. */ export type DistributionRole = 'run' | 'fitting' | 'terminal' | 'equipment' +/** + * A kind's snapping profile (see `NodeDefinition.snapProfile`). + * - `'item'` free object (furniture/fixtures): lines-default, no grid lattice, no angle. + * - `'structural'` walls / fences / slabs / ceilings / roofs / zones: grid-default, and an + * angle lock while *setting direction* (drafting a run/polygon, dragging an endpoint or a + * polygon vertex). A plain translate or a curve of a structural node has no angle. + */ +export type SnapProfile = 'item' | 'structural' + export type NodeDefinition> = { kind: string schemaVersion: number @@ -958,6 +974,18 @@ export type NodeDefinition> = { */ toolHints?: ToolHint[] + /** + * Which snapping profile this kind uses, so the editor's contextual snapping + * HUD + snap math + force-place affordance are node-declared rather than + * switched on the kind name (`'item'` free object vs `'structural'` wall/slab/ + * surface — see `SnapProfile`). The angle lock is derived from the *action* + * (setting direction), not declared here. Also gates the "force place" hint: + * structural kinds don't collision-reject, so they don't show it. + * Omit it for kinds whose placement/move tools haven't moved onto the unified + * snapping model yet — they get no snapping chip (no Shift-cycle) until they do. + */ + snapProfile?: SnapProfile + /** * Optional translucent preview of the node — used by the move tool to * show where the node will land, and by the placement tool's cursor. @@ -1267,6 +1295,13 @@ export type SlotDeclaration = { } export type PaintCapability = { + /** + * Opt this kind into the painter's `room` application scope: a paint click + * spreads to every same-kind node bounding the clicked node's room (walls and + * slabs). The room geometry is resolved by the editor from `Space.polygon`; + * this flag only declares that the kind participates. + */ + roomScope?: boolean /** * Resolve which logical surface the user clicked. Returns `null` * when the face shouldn't be painted (e.g. interior slot exposed diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 1458e239..c6475bf7 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -2,15 +2,11 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, - type CeilingNode, - type ColumnNode, createSceneApi, emitter, - type FenceNode, type GridEvent, getEffectiveRoofSurfaceMaterial, getEffectiveSegmentSurfaceMaterial, - getMaterialPresetByRef, getRoofSegmentSurfaceY, getSelectableKinds, type ItemNode, @@ -22,11 +18,7 @@ import { type RoofSegmentEvent, type RoofSegmentNode, resolveLevelId, - resolveMaterial, - type ShelfNode, - type SlabNode, type StairEvent, - type StairNode, type StairSegmentEvent, type StairSurfaceMaterialRole, sceneRegistry, @@ -35,12 +27,9 @@ import { } from '@pascal-app/core' import { - applyMaterialPresetToMaterials, createMaterial, createMaterialFromPresetRef, getRoofMaterialArray, - getStairBodyMaterials, - getStairRailingMaterial, useViewer, } from '@pascal-app/viewer' import { useCallback, useEffect, useRef } from 'react' @@ -56,11 +45,17 @@ import { type ActivePaintMaterial, buildRoofSegmentSurfaceMaterialPatch, buildRoofSurfaceMaterialPatch, - buildSingleSurfaceMaterialPatch, - buildStairSurfaceMaterialPatch, hasActivePaintMaterial, resolveActivePaintMaterialFromSelection, } from '../../lib/material-paint' +import { + availablePaintScopes, + commitPaintScopeFanout, + nodeSlotRoles, + type PaintHoverInfo, + resolvePaintScopeTargets, + slotDisplayLabel, +} from '../../lib/paint-scope' import { resolveNodeSelectionTarget, resolveSelectedIdsForNodeClick, @@ -114,6 +109,9 @@ type PaintInteraction = { hoverMode: HoverHighlightMode hoveredId: AnyNodeId preview: (() => PaintPreviewCleanup | null) | null + // What the paint HUD chip should show for this hover (scopes + labels), or + // null when the surface isn't paintable. + paintHover: PaintHoverInfo | null } interface SelectionStrategy { @@ -240,6 +238,28 @@ function getRegisteredMesh(nodeId: string): Mesh | null { return object && (object as Mesh).isMesh ? (object as Mesh) : null } +// Every distinct slot role on a node, read off the registered mesh subtree's +// `userData.slotId` tags (a tag may be a single role or an array, one per +// material group). The mesh-derived fallback behind `nodeSlotRoles` for kinds +// whose slots come from a GLB (items) rather than a `capabilities.slots` +// declaration; returns `[]` when the subtree isn't mounted. +function meshSlotRoles(node: AnyNode): string[] { + const root = getRegisteredNodeObject(node.id) + if (!root) return [] + const roles = new Set() + root.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + const tag = (mesh.userData as { slotId?: string | null | (string | null)[] }).slotId + if (Array.isArray(tag)) { + for (const entry of tag) if (typeof entry === 'string') roles.add(entry) + } else if (typeof tag === 'string') { + roles.add(tag) + } + }) + return [...roles] +} + const roofSelectionWorldPoint = new Vector3() function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null { @@ -309,20 +329,6 @@ function previewCursor(cursor: string): PaintPreviewCleanup { } } -function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null { - const shading = useViewer.getState().shading - - if (material.materialPreset) { - return createMaterialFromPresetRef(material.materialPreset, shading) - } - - if (material.material) { - return createMaterial(material.material, shading) - } - - return null -} - function applyRoofPaintPreview( node: RoofNode, role: 'top' | 'edge' | 'wall', @@ -388,164 +394,6 @@ function applyRoofSegmentPaintPreview( return previewMeshMaterial(mesh, arr) } -function applyStairPaintPreview( - node: StairNode, - role: StairSurfaceMaterialRole, - material: ActivePaintMaterial, -): PaintPreviewCleanup | null { - const root = getRegisteredNodeObject(node.id) - if (!root) return null - - const previewNode = { - ...node, - ...buildStairSurfaceMaterialPatch(node, role, material.material, material.materialPreset), - } - const shading = useViewer.getState().shading - const bodyMaterials = getStairBodyMaterials(previewNode, shading) - const railingMaterial = getStairRailingMaterial(previewNode, shading) - const restores: PaintPreviewCleanup[] = [] - - root.traverse((object) => { - if (!(object as Mesh).isMesh) return - const mesh = object as Mesh - if (mesh.name.startsWith('stair-railing')) { - restores.push(previewMeshMaterial(mesh, railingMaterial)) - return - } - if (Array.isArray(mesh.material) && mesh.material.length === 2) { - restores.push(previewMeshMaterial(mesh, bodyMaterials)) - return - } - if (mesh.name === 'merged-stair') { - restores.push(previewMeshMaterial(mesh, bodyMaterials)) - return - } - if (mesh.name.startsWith('stair-side')) { - restores.push(previewMeshMaterial(mesh, bodyMaterials[1])) - } - }) - - if (restores.length === 0) return null - - return () => { - for (let index = restores.length - 1; index >= 0; index -= 1) { - restores[index]?.() - } - } -} - -function applySingleSurfacePaintPreview( - node: FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode, - material: ActivePaintMaterial, -): PaintPreviewCleanup | null { - if (node.type === 'ceiling') { - const root = getRegisteredMesh(node.id) - const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined - if (!(root && overlay)) return null - - const previewColor = - getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ?? - resolveMaterial(material.material).color ?? - '#999999' - - const previousRootMaterial = root.material - const previousOverlayMaterial = overlay.material - const rootPreviewMaterial = Array.isArray(previousRootMaterial) - ? previousRootMaterial.map((entry) => entry.clone()) - : previousRootMaterial.clone() - const overlayPreviewMaterial = Array.isArray(previousOverlayMaterial) - ? previousOverlayMaterial.map((entry) => entry.clone()) - : previousOverlayMaterial.clone() - - const applyColor = (input: Material | Material[]) => { - const materials = Array.isArray(input) ? input : [input] - for (const entry of materials) { - const materialWithColor = entry as Material & { color?: Color; needsUpdate?: boolean } - if (materialWithColor.color instanceof Color) { - materialWithColor.color = new Color(previewColor) - } - materialWithColor.needsUpdate = true - } - } - - applyColor(rootPreviewMaterial) - applyColor(overlayPreviewMaterial) - root.material = rootPreviewMaterial - overlay.material = overlayPreviewMaterial - - return () => { - root.material = previousRootMaterial - overlay.material = previousOverlayMaterial - } - } - - const registeredObject = getRegisteredNodeObject(node.id) - const mesh = - registeredObject && (registeredObject as Mesh).isMesh ? (registeredObject as Mesh) : null - - const previewMaterial = getSingleSurfacePreviewMaterial(material) - if (!previewMaterial) return null - - if (node.type === 'column') { - if (!registeredObject) return null - const restores: PaintPreviewCleanup[] = [] - - registeredObject.traverse((object) => { - if (!(object as Mesh).isMesh) return - restores.push(previewMeshMaterial(object as Mesh, previewMaterial)) - }) - - if (restores.length === 0) return null - return () => { - for (let index = restores.length - 1; index >= 0; index -= 1) { - restores[index]?.() - } - } - } - - if (node.type === 'shelf') { - // Shelf registers a `` (not a Mesh) with `useRegistry`, so we walk - // the subtree and preview-swap every child mesh — same approach `column` - // uses. (The roof vents previously shared this arm; they now route through - // their `capabilities.paint` dispatcher.) - if (!registeredObject) return null - const restores: PaintPreviewCleanup[] = [] - registeredObject.traverse((object) => { - if (!(object as Mesh).isMesh) return - restores.push(previewMeshMaterial(object as Mesh, previewMaterial)) - }) - if (restores.length === 0) return null - return () => { - for (let index = restores.length - 1; index >= 0; index -= 1) { - restores[index]?.() - } - } - } - - if (!mesh) return null - - if (node.type === 'slab') { - const slabMaterial = previewMaterial.clone() - applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset)) - const previewMeshMaterialInput = slabMaterial as Material & { - alphaMap?: unknown - depthWrite?: boolean - needsUpdate?: boolean - opacity?: number - side?: number - transparent?: boolean - } - previewMeshMaterialInput.transparent = false - previewMeshMaterialInput.opacity = 1 - previewMeshMaterialInput.alphaMap = null - previewMeshMaterialInput.depthWrite = true - previewMeshMaterialInput.needsUpdate = true - return previewMeshMaterial(mesh, slabMaterial) - } - - return previewMeshMaterial(mesh, previewMaterial) -} - // Chimney + dormer paint dispatch lives on their NodeDefinition's // `capabilities.paint` (see packages/nodes/src/{chimney,dormer}/ // paint.ts). The generic registry-driven arm in this file consults @@ -878,6 +726,9 @@ export const SelectionManager = () => { if (movingNode || isCurveReshape) return let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null + // The last hover event, replayed when the application scope cycles so the + // preview + chip update under a stationary cursor (Shift fires no pointer move). + let lastEnterEvent: NodeEvent | null = null const clearActivePreview = () => { activePreview?.restore() @@ -939,13 +790,52 @@ export const SelectionManager = () => { ray: event.nativeEvent.ray, }) const compatible = role !== null && paintEnabled + // Derive the node's slots (declared, else mesh tags) once — drives both + // the chip's available scopes and the whole-object fan-out. + const slotRoles = compatible && role ? nodeSlotRoles(node, meshSlotRoles) : [] + // Resolve the application-scope fan-out once (this surface / whole object + // / all matching / room). The scope is part of the key so cycling it + // (Shift) re-keys the interaction → the preview re-applies for the new + // spread instead of being deduped to the single-surface preview. + const scope = useEditor.getState().paintScope + const scopeTargets = + compatible && role + ? resolvePaintScopeTargets({ + node, + role, + scope, + nodes: useScene.getState().nodes, + spaces: useEditor.getState().spaces, + slotRolesOf: () => slotRoles, + }) + : [] return { - key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`, + key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}:${scope}`, hoveredId: node.id as AnyNodeId, hoverMode: compatible ? 'paint-ready' : 'paint-disabled', + paintHover: + compatible && role + ? { + scopes: availablePaintScopes({ node, slotRoles }), + slotLabel: slotDisplayLabel(node, role), + nodeNoun: node.type, + } + : null, apply: compatible && role ? () => { + // Spread targets are all the same slot-model kind, so one + // batched commit writes them in a single undo step; the + // single-surface case keeps the kind's own commit (covers + // non-slot kinds too). + if (scopeTargets.length > 1) { + commitPaintScopeFanout( + scopeTargets, + paintSpec.material, + paintSpec.materialPreset, + ) + return + } const args = { node, role, @@ -967,15 +857,33 @@ export const SelectionManager = () => { preview: compatible && role ? () => { - const root = getRegisteredNodeObject(node.id) - if (!root) return null - return paintCap.applyPreview({ - node, - role, - material: paintSpec.material, - materialPreset: paintSpec.materialPreset, - root, - }) + // Preview every surface the click would paint, so room / + // whole-item / all-matching show the full spread, not just the + // hovered surface. Each target is the same kind, so its own + // paint capability builds the preview; restores combine. + const restores: PaintPreviewCleanup[] = [] + const sceneNodes = useScene.getState().nodes + for (const target of scopeTargets) { + const targetNode = sceneNodes[target.nodeId] + const targetRoot = getRegisteredNodeObject(target.nodeId) + const targetCap = targetNode + ? nodeRegistry.get(targetNode.type)?.capabilities?.paint + : null + if (!(targetNode && targetRoot && targetCap)) continue + const restore = targetCap.applyPreview({ + node: targetNode, + role: target.role, + material: paintSpec.material, + materialPreset: paintSpec.materialPreset, + root: targetRoot, + }) + if (restore) restores.push(restore) + } + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) + restores[index]?.() + } } : () => previewCursor('not-allowed'), } @@ -1004,6 +912,16 @@ export const SelectionManager = () => { }:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`, hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId, hoverMode: compatible ? 'paint-ready' : 'paint-disabled', + // Roof isn't on the slot model (role-specific fields, custom commit), + // so it offers only the single surface — but still labels it. + paintHover: + compatible && role + ? { + scopes: ['single'], + slotLabel: slotDisplayLabel(roofNode, role), + nodeNoun: 'roof', + } + : null, apply: compatible && role ? () => { @@ -1046,77 +964,9 @@ export const SelectionManager = () => { } } - if (node.type === 'stair' || node.type === 'stair-segment') { - const stairNode = - node.type === 'stair' - ? node - : node.parentId - ? useScene.getState().nodes[node.parentId as AnyNodeId] - : null - if (!stairNode || stairNode.type !== 'stair') return null - - const role = resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent) - const compatible = role !== null && paintEnabled - return { - key: `stair:${stairNode.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`, - hoveredId: stairNode.id as AnyNodeId, - hoverMode: compatible ? 'paint-ready' : 'paint-disabled', - apply: - compatible && role - ? () => { - useScene - .getState() - .updateNode( - stairNode.id as AnyNodeId, - buildStairSurfaceMaterialPatch( - stairNode as StairNode, - role, - paintSpec.material, - paintSpec.materialPreset, - ), - ) - } - : null, - preview: - compatible && role - ? () => applyStairPaintPreview(stairNode as StairNode, role, paintSpec) - : () => previewCursor('not-allowed'), - } - } - - // Registry-driven paint dispatch handled at the top of this - // function — kinds declaring `capabilities.paint` return there - // before any of the legacy roof / stair / single-surface arms - // below run. - - if (node.type === 'fence' || node.type === 'column' || node.type === 'shelf') { - const compatible = paintEnabled - - return { - key: `${node.type}:${node.id}:surface:${eraser ? 'erase' : 'paint'}`, - hoveredId: node.id as AnyNodeId, - hoverMode: compatible ? 'paint-ready' : 'paint-disabled', - apply: compatible - ? () => { - useScene - .getState() - .updateNode( - node.id as AnyNodeId, - buildSingleSurfaceMaterialPatch< - FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode - >(paintSpec.material, paintSpec.materialPreset), - ) - } - : null, - preview: compatible - ? () => - applySingleSurfacePaintPreview( - node as FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode, - paintSpec, - ) - : () => previewCursor('not-allowed'), - } - } + // Only `roof` / `roof-segment` reach a legacy paint arm (above) — every + // other paintable kind declares `capabilities.paint` and returns from the + // registry-driven dispatch at the top of this function. const disabledNodeTypes = ['zone'] if (disabledNodeTypes.includes(node.type)) { @@ -1124,6 +974,7 @@ export const SelectionManager = () => { key: `${node.type}:${node.id}:unsupported`, hoveredId: node.id as AnyNodeId, hoverMode: 'paint-disabled', + paintHover: null, apply: null, preview: () => previewCursor('not-allowed'), } @@ -1143,6 +994,12 @@ export const SelectionManager = () => { if (!interaction) return event.stopPropagation() + lastEnterEvent = event + + // Drive the paint HUD off this hover: the interaction carries the scopes + + // labels for the painted surface (`null` when it isn't paintable — no + // slots, etc. — which makes the HUD show the "hover a surface" hint). + useEditor.getState().setPaintHover(interaction.paintHover) if (activePreview?.key === interaction.key) { return @@ -1162,6 +1019,10 @@ export const SelectionManager = () => { const interaction = getPaintInteraction(event) if (!interaction) return + // Leaving any surface → the HUD shows the "hover a surface" hint again. + lastEnterEvent = null + useEditor.getState().setPaintHover(null) + if (activePreview?.key !== interaction.key) { return } @@ -1229,7 +1090,16 @@ export const SelectionManager = () => { emitter.on(`${type}:click` as any, onClick as any) } + // Cycling the application scope (Shift) fires no pointer event, so replay + // the last hover to re-resolve the spread and re-apply the preview at once. + const unsubscribePaintScope = useEditor.subscribe((state, prev) => { + if (state.paintScope === prev.paintScope || !lastEnterEvent) return + clearActivePreview() + onEnter(lastEnterEvent) + }) + return () => { + unsubscribePaintScope() for (const type of subscribedKinds) { emitter.off(`${type}:enter` as any, onEnter as any) emitter.off(`${type}:move` as any, onEnter as any) @@ -1239,6 +1109,7 @@ export const SelectionManager = () => { clearActivePreview() useViewer.setState({ hoveredId: null }) setHoverHighlightMode('default') + useEditor.getState().setPaintHover(null) } }, [isCurveReshape, mode, movingNode, setHoverHighlightMode]) diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index 1d9dbf0c..c949f94c 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -1,16 +1,26 @@ 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' +import useEditor, { getActiveSnappingMode } from '../../../store/use-editor' -// Sentinel returned when the active snapping mode disables grid snapping. +// Sentinel returned when the active context's snapping mode disables grid snap. // 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. +// raw value through. For items the default mode is now `lines` (grid off), so +// item placement/move is free + line-snap unless the user opts into `grid`. function getGridSnapStep(): number { - const state = useEditor.getState() - return resolveSnapFlags(state.snappingMode).grid ? state.gridSnapStep : 0 + return resolveSnapFlags(getActiveSnappingMode()).grid ? useEditor.getState().gridSnapStep : 0 +} + +const ROTATION_QUANTUM = Math.PI / 4 + +/** + * R/T rotation: round the current angle to the nearest 45° then step ONE + * increment in `direction` (+1 / -1), so the node always lands on a clean 45° + * multiple regardless of its starting angle (12° → 45°, 40° → 90°) rather than a + * blind ±45° from an arbitrary angle. + */ +export function steppedRotation(current: number, direction: 1 | -1): number { + return (Math.round(current / ROTATION_QUANTUM) + direction) * ROTATION_QUANTUM } function positiveModulo(value: number, divisor: number): number { diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 9bb2a2d1..2a751883 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -115,14 +115,13 @@ 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?.altKey === true - const [x, z] = bypassSnap - ? [event.localPosition[0], event.localPosition[2]] - : snapWorldXZForActiveBuilding( - snapToGrid(event.position[0], swapDims ? dimZ : dimX), - snapToGrid(event.position[2], swapDims ? dimX : dimZ), - 0, - ).local + // Snapping is governed by the active mode (snapToGrid returns raw in Off / + // non-grid modes); Alt is force-place only and never bypasses snapping here. + const [x, z] = snapWorldXZForActiveBuilding( + snapToGrid(event.position[0], swapDims ? dimZ : dimX), + snapToGrid(event.position[2], swapDims ? dimX : dimZ), + 0, + ).local const y = ctx.gridPosition.y return { @@ -204,10 +203,9 @@ export const wallStrategy = { const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - 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]) + const x = snapToHalf(event.localPosition[0]) + const y = snapToHalf(event.localPosition[1]) + const z = snapToHalf(event.localPosition[2]) // Get auto-adjusted Y position from validator const rawDims = ctx.draftItem @@ -239,13 +237,11 @@ export const wallStrategy = { }, cursorRotationY: cursorRotation, gridPosition: [x, adjustedY, z], - cursorPosition: bypassSnap - ? [event.position[0], event.position[1], event.position[2]] - : [ - snapToHalf(event.position[0]), - snapToHalf(event.position[1]), - snapToHalf(event.position[2]), - ], + cursorPosition: [ + snapToHalf(event.position[0]), + snapToHalf(event.position[1]), + snapToHalf(event.position[2]), + ], stopPropagation: true, } }, @@ -268,10 +264,9 @@ export const wallStrategy = { const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - 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]) + const snappedX = snapToHalf(event.localPosition[0]) + const snappedY = snapToHalf(event.localPosition[1]) + const snappedZ = snapToHalf(event.localPosition[2]) // Get auto-adjusted Y position from validator const validation = validators.canPlaceOnWall( @@ -289,13 +284,11 @@ export const wallStrategy = { return { gridPosition: [snappedX, adjustedY, snappedZ], - cursorPosition: bypassSnap - ? [event.position[0], event.position[1], event.position[2]] - : [ - snapToHalf(event.position[0]), - snapToHalf(event.position[1]), - snapToHalf(event.position[2]), - ], + cursorPosition: [ + snapToHalf(event.position[0]), + snapToHalf(event.position[1]), + snapToHalf(event.position[2]), + ], cursorRotationY: cursorRotation, nodeUpdate: { position: [snappedX, adjustedY, snappedZ], @@ -416,8 +409,10 @@ function resolveRoofWallTarget( const dims = getGridAlignedDimensions(rawDims, attachTo) const [width, height] = dims - const u = freePlace ? hit.u : snapToHalf(hit.u) - const centerV = (freePlace ? hit.v : snapToHalf(hit.v)) + height / 2 + // Snap follows the active mode (snapToHalf returns raw in Off/non-grid); + // `freePlace` (Alt) is force-place — it only skips the face-fit validity gate. + const u = snapToHalf(hit.u) + const centerV = 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 @@ -617,13 +612,8 @@ 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?.altKey === true - const x = bypassSnap - ? event.localPosition[0] - : snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) - const z = bypassSnap - ? event.localPosition[2] - : snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) + const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) + const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) // Recessed fixtures seat flush with the ceiling plane (body rising into the // void above); everything else hangs its full height below the ceiling. const seatY = ctx.asset.recessed ? 0 : -itemHeight @@ -656,13 +646,8 @@ export const ceilingStrategy = { const rotY = ctx.draftItem.rotation?.[1] ?? 0 const swapDims = Math.abs(Math.sin(rotY)) > 0.9 - const bypassSnap = event.nativeEvent?.altKey === true - const x = bypassSnap - ? event.localPosition[0] - : snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) - const z = bypassSnap - ? event.localPosition[2] - : snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) + const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) + const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) // Recessed fixtures seat flush with the ceiling plane (body rising into the // void above); everything else hangs its full height below the ceiling. const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight @@ -773,9 +758,8 @@ export const itemSurfaceStrategy = { const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) if (surfaceHeight === null) return null - 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 x = snapToGrid(localPos.x, ourDims[0]) + const z = snapToGrid(localPos.z, ourDims[2]) const y = surfaceHeight const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) @@ -825,9 +809,8 @@ export const itemSurfaceStrategy = { const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) if (surfaceHeight === null) return null - 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 x = snapToGrid(localPos.x, ourDims[0]) + const z = snapToGrid(localPos.z, ourDims[2]) const y = surfaceHeight const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) @@ -926,9 +909,8 @@ export const shelfSurfaceStrategy = { const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) if (rowY === null) return null - 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 x = snapToGrid(localPos.x, ourDims[0]) + const z = snapToGrid(localPos.z, ourDims[2]) const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z)) @@ -971,9 +953,8 @@ export const shelfSurfaceStrategy = { const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) if (rowY === null) return null - 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 x = snapToGrid(localPos.x, ourDims[0]) + const z = snapToGrid(localPos.z, ourDims[2]) const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z)) return { diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 9cf2ac8f..a2bba0b8 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -56,7 +56,9 @@ import { getDetachedAttachmentPreviewLift, getGridAlignedDimensions, snapToGrid, + snapToHalf, snapUpToGridStep, + steppedRotation, } from './placement-math' import { ceilingStrategy, @@ -779,8 +781,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const draft = draftNode.current let alignX = 0 let alignZ = 0 - const freePlace = floorEvent.nativeEvent?.altKey === true - const bypassAlign = freePlace || !isMagneticSnapActive() + // Alignment ("lines") follows the snapping mode only — Alt is force-place, + // it does NOT bypass snapping (Off mode is the no-snap bypass). + const bypassAlign = !isMagneticSnapActive() if (!bypassAlign && draft) { alignmentCandidates ??= collectAlignmentAnchors( useScene.getState().nodes, @@ -814,7 +817,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // Play snap sound when grid position changes if ( - !freePlace && previousGridPos && (gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2]) ) { @@ -999,7 +1001,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.z !== result.gridPosition[2] // Play snap sound when grid position changes - if (event.nativeEvent?.altKey !== true && posChanged) { + if (posChanged) { sfxEmitter.emit('sfx:grid-snap') } @@ -1169,7 +1171,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.y !== result.gridPosition[1] || gridPosition.current.z !== result.gridPosition[2] - if (!altFreeRef.current && posChanged) { + if (posChanged) { sfxEmitter.emit('sfx:grid-snap') } @@ -1263,9 +1265,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea event.position[1], event.position[2], ) - 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 + // Mode-aware snap (raw in Off / non-grid); Alt is force-place, not bypass. + const wx = snapToHalf(buildingLocalPoint.x) + const wz = snapToHalf(buildingLocalPoint.z) const floorPos: [number, number, number] = [wx, 0, wz] Object.assign(placementState.current, { @@ -1600,7 +1602,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.y !== result.gridPosition[1] || gridPosition.current.z !== result.gridPosition[2] - if (event.nativeEvent?.altKey !== true && posChanged) { + if (posChanged) { sfxEmitter.emit('sfx:grid-snap') } @@ -1791,9 +1793,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Keyboard rotation ---- - // 45° increments — matches the R-key rotation step for already-placed - // 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 === 'Alt') { altFreeRef.current = true @@ -1813,17 +1812,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // manual rotation would skew them off the wall plane. if (placementState.current.surface === 'roof-wall') return - let rotationDelta = 0 + let rotationDir: 1 | -1 | 0 = 0 if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey) - rotationDelta = ROTATION_STEP + rotationDir = 1 else if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey) - rotationDelta = -ROTATION_STEP + rotationDir = -1 - if (rotationDelta !== 0) { + if (rotationDir !== 0) { event.preventDefault() sfxEmitter.emit('sfx:item-rotate') const currentRotation = draft.rotation - const newRotationY = (currentRotation[1] ?? 0) + rotationDelta + // Round to the nearest 45° then step, matching the placed-item R/T. + const newRotationY = steppedRotation(currentRotation[1] ?? 0, rotationDir) draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]] // Ref + cursor mesh + item mesh — no store update during drag diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index dc61471d..e4e0eeb6 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -31,7 +31,7 @@ import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata' import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement' import { sfxEmitter } from '../../../lib/sfx-bus' import { resolveSnapFlags } from '../../../lib/snapping-mode' -import useEditor, { isMagneticSnapActive } from '../../../store/use-editor' +import useEditor, { getActiveSnappingMode, 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' @@ -42,9 +42,8 @@ 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 state = useEditor.getState() - if (!resolveSnapFlags(state.snappingMode).grid) return value - const step = state.gridSnapStep + if (!resolveSnapFlags(getActiveSnappingMode()).grid) return value + const step = useEditor.getState().gridSnapStep return Math.round(value / step) * step } @@ -420,7 +419,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { original: [originalPosition[0], originalPosition[2]], anchor: dragAnchorRef.current, mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative', - snap: event.nativeEvent?.altKey === true ? (value) => value : snapToGridStep, + // Snap follows the mode (raw in Off via snapToGridStep); Alt = force only. + snap: snapToGridStep, }) dragAnchorRef.current = resolved.anchor let [x, z] = resolved.point @@ -429,10 +429,9 @@ 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 (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() + // on an actual point. Alignment ("lines") follows the snapping mode only — + // Alt is force-place (forces a valid drop), it does not bypass snapping. + const bypass = !isMagneticSnapActive() if (!bypass && alignmentCandidates.length > 0) { const result = resolveAlignment({ moving: movingFootprintAnchors(node, x, z, rotationRef.current), @@ -493,7 +492,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { previewConnectivity(position, rotationRef.current) const prev = previousSnapRef.current - if (!freePlace && (!prev || prev[0] !== x || prev[1] !== z)) { + if (!prev || prev[0] !== x || prev[1] !== z) { sfxEmitter.emit('sfx:grid-snap') previousSnapRef.current = [x, z] } diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index fcc1e7fb..6e9baf46 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -746,10 +746,9 @@ export const PolygonEditor: React.FC = ({ const onGridMove = (event: GridEvent) => { const point = levelNode ? event.localPosition : event.position const rawPoint: [number, number] = [point[0], point[2]] - const bypassSnap = event.nativeEvent.shiftKey === true - const gridPoint: [number, number] = bypassSnap - ? rawPoint - : [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])] + // Snapping follows the active mode (snapToHalf returns raw in Off / non-grid); + // no Shift bypass — Shift cycles the mode, Off is the bypass. + const gridPoint: [number, number] = [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])] const newPosition = dragState?.isDragging && resolvePlanPoint ? resolvePlanPoint({ @@ -766,7 +765,6 @@ export const PolygonEditor: React.FC = ({ // Play snap sound when cursor moves to a new grid cell during drag if ( - !bypassSnap && dragState?.isDragging && previousPositionRef.current && (newPosition[0] !== previousPositionRef.current[0] || diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index 09841935..52d6a2cd 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -14,7 +14,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { sfxEmitter } from '../../../lib/sfx-bus' import { resolveSnapFlags } from '../../../lib/snapping-mode' -import useEditor, { isMagneticSnapActive } from '../../../store/use-editor' +import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor' import { distanceSquared, findWallSnapTarget, @@ -52,12 +52,11 @@ type WallSplitIntersection = { } export function getSegmentGridStep(): number { - 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 + return resolveSnapFlags(getActiveSnappingMode()).grid ? useEditor.getState().gridSnapStep : 0 } export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number { diff --git a/packages/editor/src/components/ui/action-menu/index.tsx b/packages/editor/src/components/ui/action-menu/index.tsx index bf1ce152..d87bd746 100644 --- a/packages/editor/src/components/ui/action-menu/index.tsx +++ b/packages/editor/src/components/ui/action-menu/index.tsx @@ -9,7 +9,7 @@ import { cn } from './../../../lib/utils' import useEditor from './../../../store/use-editor' import { CameraActions } from './camera-actions' import { ControlModes } from './control-modes' -import { GridSnapControl, SecondaryToggles } from './view-toggles' +import { SecondaryToggles } from './view-toggles' // Mobile bottom offset matches the viewer's overlap behind the sheet's // rounded corners (SHEET_OVERLAP_PX in editor-layout-mobile) so the menu sits @@ -57,9 +57,8 @@ export function ActionMenu({ className }: { className?: string }) {
- {/* Row 2: grid snap + secondary toggles (orbit + top view hidden) */} + {/* Row 2: secondary toggles (orbit + top view hidden) */}
-
@@ -67,7 +66,6 @@ export function ActionMenu({ className }: { className?: string }) {
-
diff --git a/packages/editor/src/components/ui/action-menu/view-toggles.tsx b/packages/editor/src/components/ui/action-menu/view-toggles.tsx index 0d7b57dc..e3023323 100644 --- a/packages/editor/src/components/ui/action-menu/view-toggles.tsx +++ b/packages/editor/src/components/ui/action-menu/view-toggles.tsx @@ -1,6 +1,5 @@ 'use client' -import { Icon } from '@iconify/react' import { type AnyNodeId, type BuildingNode, @@ -16,23 +15,17 @@ import { useShallow } from 'zustand/react/shallow' import { getLevelDisplayName } from '@pascal-app/core' import { createLocalGuideImage } from '../../../lib/local-guide-image' import { cn } from '../../../lib/utils' -import useEditor, { type GridSnapStep } from '../../../store/use-editor' +import useEditor from '../../../store/use-editor' import { useUploadStore } from '../../../store/use-upload' import { SliderControl } from '../controls/slider-control' import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip' import { ActionButton } from './action-button' const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif' -const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05] const REFERENCES_EMPTY_TEXT = 'Upload GLB meshes as scan references or blueprint images as guide references.' -function formatGridSnapStep(step: GridSnapStep) { - return step.toFixed(2) -} - // ── Helper: get guide images for the current level ────────────────────────── function useLevelGuides(): GuideNode[] { @@ -353,70 +346,6 @@ function GuidesControl() { ) } -// ── Grid snap toggle ──────────────────────────────────────────────────────── - -function GridSnapControl() { - const [isOpen, setIsOpen] = useState(false) - const gridSnapStep = useEditor((state) => state.gridSnapStep) - const setGridSnapStep = useEditor((state) => state.setGridSnapStep) - - return ( - - - - - - - - Grid snap: {formatGridSnapStep(gridSnapStep)} - - - -
- {GRID_SNAP_STEPS.map((step) => { - const isActive = step === gridSnapStep - return ( - - ) - })} -
-
-
- ) -} - // ── Scans toggle + dropdown ───────────────────────────────────────────────── function ScansControl() { @@ -1014,8 +943,6 @@ function RiserControl() { // ── Exports ───────────────────────────────────────────────────────────────── -export { GridSnapControl } - export function SecondaryToggles() { return (
@@ -1027,7 +954,6 @@ export function SecondaryToggles() { export function ViewToggles() { return (
- diff --git a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx index cc370cfe..ab93bd7e 100644 --- a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx +++ b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx @@ -1,6 +1,12 @@ import { Icon } from '@iconify/react' import type { ContextualShortcutHint } from '../../../lib/contextual-help' -import { resolveSnapFlags } from '../../../lib/snapping-mode' +import { hasActivePaintMaterial } from '../../../lib/material-paint' +import { paintScopeLabel, type PaintScope } from '../../../lib/paint-scope' +import { + cycleSnappingModeIn, + resolveSnapFlags, + type SnapContext, +} from '../../../lib/snapping-mode' import { cn } from '../../../lib/utils' import useEditor, { type GridSnapStep } from '../../../store/use-editor' import { ShortcutToken } from '../primitives/shortcut-token' @@ -9,12 +15,14 @@ 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' +// Multiple keys in a contextual hint are alternatives (e.g. Rotate R / T), not a +// chord — the HUD never shows key chords — so they read on one line split by "/". function ShortcutSequence({ keys }: { keys: string[] }) { return (
{keys.map((key, index) => (
- {index > 0 ? + : null} + {index > 0 ? / : null}
))} @@ -43,12 +51,13 @@ function nextGridSnapStep(step: GridSnapStep): GridSnapStep { 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) +// Interactive chip rows: the active interaction's own snapping controls, scoped +// to its context (wall / item / polygon) so each action shows only the modes +// that make sense for it. The surrounding stack is `pointer-events-none` (passive +// key hints), so these pills carve out `pointer-events-auto` to stay clickable. +function SnappingChips({ context }: { context: SnapContext }) { + const snappingMode = useEditor((s) => s.snappingModeByContext[context]) + const setSnappingMode = useEditor((s) => s.setSnappingMode) const gridSnapStep = useEditor((s) => s.gridSnapStep) const setGridSnapStep = useEditor((s) => s.setGridSnapStep) @@ -61,7 +70,7 @@ function SnappingChips() { + + Paint scope — click or press Shift to cycle + + ) +} + export function ContextualHelperPanel({ hints, - showSnapping = false, + snapContext = null, + showPaintScope = false, }: { hints: ContextualShortcutHint[] - showSnapping?: boolean + // The active snapping context drives the snapping chips (which mode set). Null + // → no snapping chips for this interaction. + snapContext?: SnapContext | null + showPaintScope?: boolean }) { - if (hints.length === 0 && !showSnapping) return null + if (hints.length === 0 && !snapContext && !showPaintScope) return null return (
- {showSnapping ? : null} + {snapContext ? : null} + {showPaintScope ? : null} {hints.map((hint) => (
s.mode) const tool = useEditor((s) => s.tool) + const scope = useInteractionScope((s) => s.scope) const movingNode = useMovingNode() const activeHandleDrag = useActiveHandleDrag() const selectedIds = useViewer((s) => s.selection.selectedIds) @@ -78,6 +100,18 @@ export function HelperManager() { .filter((node): node is AnyNode => node !== undefined), ), ) + // The snapping context for whatever's active (wall / item / polygon) — drives + // which snapping chips the HUD shows, derived once and shared by every branch. + const snapContext = useMemo( + () => + snapContextOf({ + scope, + mode, + tool, + profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile, + }), + [scope, mode, tool], + ) const selectModeHints = useMemo( () => resolveSelectModeHelpHints({ @@ -100,16 +134,36 @@ export function HelperManager() { return } + // Reshaping a node's geometry (endpoint / curve / polygon corner). Checked + // before the select branch so the idle "drag selected / add objects" hints + // never leak over an in-progress reshape — and it gets its own snapping chip. + if (scope.kind === 'reshaping') { + return + } + if (movingNode) { if (movingNode.type === 'building') return - return + // Force-place only makes sense for kinds that collision-validate their drop; + // structural kinds (wall/slab/…) never reject, so don't advertise Alt. + return ( + + ) } + // Paint mode advertises (and cycles, via Shift) the application scope — the + // only contextual control here. The chip hides itself for targets that only + // paint one surface, so this renders nothing until a scoped target is active. if (mode === 'material-paint') { - return null + return } - if (mode === 'select') { + // Idle select only — an active scope (handle-drag, box-select, …) must not show + // the idle selection hints. + if (mode === 'select' && scope.kind === 'idle') { return } @@ -119,13 +173,19 @@ export function HelperManager() { if (tool) { const def = nodeRegistry.get(tool) if (def?.toolHints && def.toolHints.length > 0) { - return + return ( + + ) } } // Legacy fallback — only `roof` remains because it hasn't migrated to // `def.tool` / `def.toolHints` yet (no Stage D port). When roof // migrates, this switch deletes outright. - if (tool === 'roof') return + if (tool === 'roof') return return null } diff --git a/packages/editor/src/components/ui/helpers/item-helper.tsx b/packages/editor/src/components/ui/helpers/item-helper.tsx index c1c75fae..9ebe284a 100644 --- a/packages/editor/src/components/ui/helpers/item-helper.tsx +++ b/packages/editor/src/components/ui/helpers/item-helper.tsx @@ -1,21 +1,26 @@ +import type { SnapContext } from '../../../lib/snapping-mode' import { ContextualHelperPanel } from './contextual-helper-panel' interface ItemHelperProps { showEsc?: boolean + snapContext?: SnapContext | null + // Whether to advertise Alt = force-place. Only meaningful for kinds that + // collision-validate their drop (structural kinds never reject, so it's hidden). + showForce?: boolean } -export function ItemHelper({ showEsc }: ItemHelperProps) { +// Snapping mode is the chip on the right (Shift cycles it), so it's not repeated +// as a key hint. Rotate is the two keys; Alt forces an invalid (red) drop. +export function ItemHelper({ showEsc, snapContext, showForce }: ItemHelperProps) { return ( ) } diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx index 8ed6d94f..1b9eb0c7 100644 --- a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx +++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx @@ -1,4 +1,6 @@ import type { ToolHint } from '@pascal-app/core' +import type { SnapContext } from '../../../lib/snapping-mode' +import useEditor from '../../../store/use-editor' import { ContextualHelperPanel } from './contextual-helper-panel' /** @@ -13,26 +15,37 @@ import { ContextualHelperPanel } from './contextual-helper-panel' export function RegisteredToolHelper({ hints, shiftPressed = false, + snapContext = null, }: { hints: ToolHint[] shiftPressed?: boolean + snapContext?: SnapContext | null }) { - if (hints.length === 0) return null + // Live vertex count of an in-progress polygon draft, so hints gated on a + // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible. + const draftVertexCount = useEditor((s) => s.draftVertexCount) + // The snapping chip (when a context is active) already shows Shift = cycle, so + // drop the redundant 'Cycle snapping mode' tool hint to avoid a double pill; + // also hide draft-gated hints until the draft is far enough along. + const visible = hints.filter( + (hint) => + !(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') && + (hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices), + ) + if (visible.length === 0 && !snapContext) return null return ( { - // 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' + hints={visible.map((hint) => { + // Shift is a per-kind bypass for opening / zone / duct placement ("Free + // place", "Free angle", …) — those flip to a bypassed state while held. + const isBypassHint = hint.key === 'Shift' return { keys: [hint.key], label: shiftPressed && isBypassHint ? 'Guided constraints bypassed' : hint.label, active: shiftPressed && isBypassHint, } })} + snapContext={snapContext} /> ) } diff --git a/packages/editor/src/components/ui/helpers/roof-helper.tsx b/packages/editor/src/components/ui/helpers/roof-helper.tsx index ad45108a..3056f5fe 100644 --- a/packages/editor/src/components/ui/helpers/roof-helper.tsx +++ b/packages/editor/src/components/ui/helpers/roof-helper.tsx @@ -1,18 +1,14 @@ +import type { SnapContext } from '../../../lib/snapping-mode' import { ContextualHelperPanel } from './contextual-helper-panel' -export function RoofHelper({ shiftPressed = false }: { shiftPressed?: boolean }) { +export function RoofHelper({ snapContext }: { snapContext?: SnapContext | null }) { return ( ) } diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 19489201..d10b3b64 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -1,6 +1,7 @@ import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' +import { steppedRotation } from '../components/tools/item/placement-math' import { toggleDoorOpenState } from '../lib/door-interaction' import { runRedo, runUndo } from '../lib/history' import { @@ -9,7 +10,7 @@ import { } from '../lib/scene-clipboard' import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus' import { toggleWindowOpenState } from '../lib/window-interaction' -import useEditor from '../store/use-editor' +import useEditor, { getActiveSnapContext } from '../store/use-editor' import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope' // Tools call this in their onCancel handler when they have an active mid-action to cancel, @@ -51,13 +52,16 @@ export const useKeyboard = ({ // 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. + // Shift cycles the snapping mode (and clean-tap Ctrl the grid step) whenever + // there's an active snapping context — i.e. exactly when the HUD shows a + // snapping chip. That single source covers wall/fence/item drafting, every + // node move (including wall-hosted items), and endpoint/polygon reshaping, + // so the keys never silently stop working. Door / window keep Shift = free + // place until the modifier model unifies them. const isSnappingCycleContext = () => { - const ed = useEditor.getState() const moving = getMovingNode() - if (moving != null) return moving.type !== 'door' && moving.type !== 'window' - return ( - ed.mode === 'build' && (ed.tool === 'wall' || ed.tool === 'fence' || ed.tool === 'item') - ) + if (moving?.type === 'door' || moving?.type === 'window') return false + return getActiveSnapContext() != null } // A "clean tap" of Ctrl/Meta (pressed and released with NO other key in @@ -83,6 +87,16 @@ export const useKeyboard = ({ return } + if (e.key === 'Shift' && !e.repeat && useEditor.getState().mode === 'material-paint') { + // In paint mode Shift cycles the application scope (this surface → + // whole item / all matching / room) — the paint-mode analogue of the + // snapping-mode cycle below. The scope chip mirrors this key. + e.preventDefault() + useEditor.getState().cyclePaintScope() + sfxEmitter.emit('sfx:grid-snap') + 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. @@ -283,14 +297,18 @@ export const useKeyboard = ({ sfxEmitter.emit('sfx:item-rotate') } else if (node && 'rotation' in node) { e.preventDefault() - const ROTATION_STEP = Math.PI / 4 - - // Handle different rotation types (number for roof, array for items/windows/doors) + // Round to the nearest 45° then step one increment (not a blind +45°). if (typeof node.rotation === 'number') { - useScene.getState().updateNode(node.id, { rotation: node.rotation + ROTATION_STEP }) + useScene + .getState() + .updateNode(node.id, { rotation: steppedRotation(node.rotation, 1) }) } else if (Array.isArray(node.rotation)) { useScene.getState().updateNode(node.id, { - rotation: [node.rotation[0], node.rotation[1] + ROTATION_STEP, node.rotation[2]], + rotation: [ + node.rotation[0], + steppedRotation(node.rotation[1], 1), + node.rotation[2], + ], }) } sfxEmitter.emit('sfx:item-rotate') @@ -316,13 +334,18 @@ export const useKeyboard = ({ sfxEmitter.emit('sfx:item-rotate') } else if (node && 'rotation' in node) { e.preventDefault() - const ROTATION_STEP = Math.PI / 4 - + // Round to the nearest 45° then step one increment back. if (typeof node.rotation === 'number') { - useScene.getState().updateNode(node.id, { rotation: node.rotation - ROTATION_STEP }) + useScene + .getState() + .updateNode(node.id, { rotation: steppedRotation(node.rotation, -1) }) } else if (Array.isArray(node.rotation)) { useScene.getState().updateNode(node.id, { - rotation: [node.rotation[0], node.rotation[1] - ROTATION_STEP, node.rotation[2]], + rotation: [ + node.rotation[0], + steppedRotation(node.rotation[1], -1), + node.rotation[2], + ], }) } sfxEmitter.emit('sfx:item-rotate') diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index f319265b..a837cd1b 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -246,6 +246,7 @@ export { } from './lib/floorplan' export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' export { + boundaryReshapeScope, curveReshapeScope, endpointReshapeScope, holeEditScope, @@ -331,7 +332,12 @@ export type { ViewMode, WorkspaceMode, } from './store/use-editor' -export { default as useEditor, isAngleSnapActive, isMagneticSnapActive } from './store/use-editor' +export { + default as useEditor, + isAngleSnapActive, + isGridSnapActive, + isMagneticSnapActive, +} from './store/use-editor' export { default as useInteractionScope, getEditingHole, diff --git a/packages/editor/src/lib/interaction/scope.ts b/packages/editor/src/lib/interaction/scope.ts index e84f5a71..d4b972fd 100644 --- a/packages/editor/src/lib/interaction/scope.ts +++ b/packages/editor/src/lib/interaction/scope.ts @@ -168,3 +168,9 @@ export function endpointReshapeScope( ): ActiveInteractionScope { return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint } } + +// Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping +// HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen. +export function boundaryReshapeScope(nodeId: string): ActiveInteractionScope { + return { kind: 'reshaping', nodeId, reshape: 'boundary' } +} diff --git a/packages/editor/src/lib/paint-scope.test.ts b/packages/editor/src/lib/paint-scope.test.ts new file mode 100644 index 00000000..7460f898 --- /dev/null +++ b/packages/editor/src/lib/paint-scope.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'bun:test' +import type { AnyNode, ItemNode, SlabNode, Space } from '@pascal-app/core' +import { + availablePaintScopes, + cyclePaintScope, + type PaintHoverInfo, + type PaintScope, + paintScopeLabel, + resolvePaintScopeTargets, +} from './paint-scope' + +describe('availablePaintScopes', () => { + it('every node offers single', () => { + expect(availablePaintScopes({ node: roof(), slotRoles: ['top'] })).toEqual(['single']) + }) + it('more than one slot adds whole-object', () => { + expect(availablePaintScopes({ node: roof(), slotRoles: ['top', 'edge'] })).toEqual([ + 'single', + 'object', + ]) + }) + it('a single slot does not add whole-object', () => { + expect(availablePaintScopes({ node: roof(), slotRoles: ['top'] })).not.toContain('object') + }) + it('an asset adds all-matching (items)', () => { + expect(availablePaintScopes({ node: item('a', 'sofa'), slotRoles: ['seat'] })).toContain( + 'matching', + ) + }) + // `room` derives from the kind's registry `capabilities.paint.roomScope`, which + // isn't wired in this unit context; its resolver behaviour is covered below. +}) + +describe('cyclePaintScope', () => { + it('wraps within the given set', () => { + const set: PaintScope[] = ['single', 'object', 'matching'] + expect(cyclePaintScope('single', set)).toBe('object') + expect(cyclePaintScope('object', set)).toBe('matching') + expect(cyclePaintScope('matching', set)).toBe('single') + }) + it('a scope foreign to the set restarts at the first entry', () => { + expect(cyclePaintScope('matching', ['single', 'room'])).toBe('single') + }) + it('an empty set stays single', () => { + expect(cyclePaintScope('single', [])).toBe('single') + }) +}) + +describe('paintScopeLabel', () => { + const info = (over: Partial): PaintHoverInfo => ({ + scopes: ['single'], + slotLabel: 'Seat cushion', + nodeNoun: 'item', + ...over, + }) + it('single shows the hovered slot label', () => { + expect(paintScopeLabel('single', info({ slotLabel: 'Seat cushion' }))).toBe('Seat cushion') + }) + it('single falls back when there is no slot label', () => { + expect(paintScopeLabel('single', info({ slotLabel: '' }))).toBe('This surface') + }) + it('object reads "Whole "', () => { + expect(paintScopeLabel('object', info({ nodeNoun: 'shelf' }))).toBe('Whole shelf') + }) + it('matching / room are kind-agnostic', () => { + expect(paintScopeLabel('matching', info({}))).toBe('All matching') + expect(paintScopeLabel('room', info({}))).toBe('Room') + }) +}) + +// ── resolvePaintScopeTargets ──────────────────────────────────────────────── + +function item(id: string, assetId: string): ItemNode { + return { id, type: 'item', asset: { id: assetId } } as unknown as ItemNode +} +function slab(id: string, polygon: Array<[number, number]>): SlabNode { + return { id, type: 'slab', polygon } as unknown as SlabNode +} +function wall(id: string, start: [number, number], end: [number, number]): AnyNode { + return { id, type: 'wall', start, end } as unknown as AnyNode +} +function roof(): AnyNode { + return { id: 'r', type: 'roof' } as unknown as AnyNode +} +function asMap(nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((node) => [node.id, node])) +} +const noSlotRoles = () => [] as string[] + +// `nodeId` is a branded id type; compare by plain `id:role` strings. +function keys(targets: Array<{ nodeId: string; role: string }>): string[] { + return targets.map((target) => `${target.nodeId}:${target.role}`) +} + +function resolve(args: { + node: AnyNode + role?: string + scope: PaintScope + nodes: AnyNode[] + spaces?: Space[] + slotRolesOf?: (node: AnyNode) => string[] +}) { + return resolvePaintScopeTargets({ + node: args.node, + role: args.role ?? 'surface', + scope: args.scope, + nodes: asMap(args.nodes), + spaces: Object.fromEntries((args.spaces ?? []).map((s) => [s.id, s])), + slotRolesOf: args.slotRolesOf ?? noSlotRoles, + }) +} + +describe('resolvePaintScopeTargets', () => { + it('single always returns just the clicked surface', () => { + const a = item('a', 'sofa') + expect( + keys(resolve({ node: a, role: 'seat', scope: 'single', nodes: [a, item('b', 'sofa')] })), + ).toEqual(['a:seat']) + }) + + it('item matching fans the same slot across same-asset items only', () => { + const a = item('a', 'sofa') + const b = item('b', 'sofa') + const c = item('c', 'lamp') + const result = resolve({ node: a, role: 'seat', scope: 'matching', nodes: [a, b, c] }) + expect(keys(result).sort()).toEqual(['a:seat', 'b:seat']) + }) + + it('item whole-item fans every enumerated slot of the clicked item', () => { + const a = item('a', 'sofa') + const result = resolve({ + node: a, + role: 'seat', + scope: 'object', + nodes: [a], + slotRolesOf: () => ['seat', 'legs', 'cushion'], + }) + expect(keys(result)).toEqual(['a:seat', 'a:legs', 'a:cushion']) + }) + + it('item whole-item falls back to the single slot when the subtree is unmounted', () => { + const a = item('a', 'sofa') + expect(keys(resolve({ node: a, role: 'seat', scope: 'object', nodes: [a] }))).toEqual([ + 'a:seat', + ]) + }) + + it('wall room fans the same side across the walls bounding the room polygon', () => { + // A 4×4 room: each wall's endpoints are exact polygon vertices. + const w1 = wall('w1', [0, 0], [4, 0]) + const w2 = wall('w2', [4, 0], [4, 4]) + const w3 = wall('w3', [4, 4], [0, 4]) + const w4 = wall('w4', [0, 4], [0, 0]) + const wOut = wall('wOut', [10, 10], [14, 10]) // not on the room boundary + const space: Space = { + id: 's1', + levelId: 'l1', + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + wallIds: [], // always empty in practice — membership is geometric + isExterior: false, + } + const result = resolve({ + node: w1, + role: 'interior', + scope: 'room', + nodes: [w1, w2, w3, w4, wOut], + spaces: [space], + }) + expect(keys(result).sort()).toEqual([ + 'w1:interior', + 'w2:interior', + 'w3:interior', + 'w4:interior', + ]) + }) + + it('wall room with no enclosing space falls back to single', () => { + const w1 = wall('w1', [0, 0], [4, 0]) + expect( + keys(resolve({ node: w1, role: 'interior', scope: 'room', nodes: [w1], spaces: [] })), + ).toEqual(['w1:interior']) + }) + + it('slab room fans across slabs whose centroid sits in the same space', () => { + const inside = slab('inA', [ + [1, 1], + [3, 1], + [3, 3], + [1, 3], + ]) + const alsoInside = slab('inB', [ + [2, 2], + [2.5, 2], + [2.5, 2.5], + [2, 2.5], + ]) + const outside = slab('out', [ + [20, 20], + [21, 20], + [21, 21], + [20, 21], + ]) + const space: Space = { + id: 's1', + levelId: 'l1', + polygon: [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + ], + wallIds: [], + isExterior: false, + } + const result = resolve({ + node: inside, + role: 'surface', + scope: 'room', + nodes: [inside, alsoInside, outside], + spaces: [space], + }) + expect(keys(result).sort()).toEqual(['inA:surface', 'inB:surface']) + }) +}) diff --git a/packages/editor/src/lib/paint-scope.ts b/packages/editor/src/lib/paint-scope.ts new file mode 100644 index 00000000..7a42f0fc --- /dev/null +++ b/packages/editor/src/lib/paint-scope.ts @@ -0,0 +1,318 @@ +import { + type AnyNode, + type AnyNodeId, + generateSceneMaterialId, + type ItemNode, + type MaterialSchema, + nodeRegistry, + pointInPolygon2D, + pointOnSegment, + type SceneMaterial, + type SceneMaterialId, + type SlabNode, + type Space, + slotLabelFromId, + toSceneMaterialRef, + useScene, + type WallNode, +} from '@pascal-app/core' + +/** + * Painter application scope — how far one paint click spreads. The scope set is + * DERIVED from the hovered node, not a per-kind table: any slot-model node with + * more than one slot offers `object` (whole node); a node with an `asset` offers + * `matching` (every instance of that asset); a kind that declares + * `capabilities.paint.roomScope` offers `room`. One global mode (not per-tool), + * defaulting to the narrowest `'single'`; the active interaction's HUD shows + + * cycles it within the hovered node's set. + */ +export type PaintScope = 'single' | 'object' | 'matching' | 'room' + +/** What the paint HUD needs to render + cycle the scope chip for a hover. */ +export type PaintHoverInfo = { + /** The scopes available for the hovered node, in cycle order (always ≥ 1). */ + scopes: PaintScope[] + /** Display name of the hovered slot — the label for the `'single'` scope. */ + slotLabel: string + /** Kind noun for the `'object'` label (e.g. "Whole shelf"). */ + nodeNoun: string +} + +function nodeHasAsset(node: AnyNode): boolean { + return Boolean((node as { asset?: { id?: string } }).asset?.id) +} + +function nodeOffersRoomScope(node: AnyNode): boolean { + return nodeRegistry.get(node.type)?.capabilities?.paint?.roomScope === true +} + +/** + * The scopes a hovered node offers, derived from the node itself: every node + * paints `single`; > 1 slot adds `object`; an `asset` adds `matching`; a + * `roomScope`-declaring kind adds `room`. `slotRoles` is the node's full slot set + * (declared or mesh-derived), passed in by the caller. + */ +export function availablePaintScopes(args: { node: AnyNode; slotRoles: string[] }): PaintScope[] { + const scopes: PaintScope[] = ['single'] + if (args.slotRoles.length > 1) scopes.push('object') + if (nodeHasAsset(args.node)) scopes.push('matching') + if (nodeOffersRoomScope(args.node)) scopes.push('room') + return scopes +} + +export function cyclePaintScope(scope: PaintScope, scopes: PaintScope[]): PaintScope { + const list = scopes.length > 0 ? scopes : (['single'] as PaintScope[]) + const index = list.indexOf(scope) + return list[(index + 1) % list.length] ?? 'single' +} + +export function paintScopeLabel(scope: PaintScope, info: PaintHoverInfo): string { + switch (scope) { + case 'object': + return `Whole ${info.nodeNoun}` + case 'matching': + return 'All matching' + case 'room': + return 'Room' + default: + return info.slotLabel || 'This surface' + } +} + +/** + * All paintable slot roles of a node. Prefers the kind's declared + * `capabilities.slots` (node-authored, stable); falls back to the runtime mesh + * tags via the injected `meshSlotRoles` for kinds whose slots come from a GLB + * (items) rather than a declaration. + */ +export function nodeSlotRoles(node: AnyNode, meshSlotRoles: (node: AnyNode) => string[]): string[] { + const declared = nodeRegistry.get(node.type)?.capabilities?.slots?.(node) + if (declared && declared.length > 0) return declared.map((slot) => slot.slotId) + return meshSlotRoles(node) +} + +/** Display label for the hovered slot — declared label wins, else derived from the id. */ +export function slotDisplayLabel(node: AnyNode, role: string): string { + const declared = nodeRegistry + .get(node.type) + ?.capabilities?.slots?.(node) + ?.find((slot) => slot.slotId === role) + return declared?.label ?? slotLabelFromId(role) +} + +// ── Fan-out resolution ────────────────────────────────────────────────────── + +type SlotsNode = AnyNode & { slots?: Record } + +// Room polygons are built from wall *centerline* endpoints (see +// `extractRoomPolygons`), so a wall's `start`/`end` are exact polygon vertices — +// a small tolerance only absorbs float round-trips. `Space.wallIds` is always +// empty, so room membership is resolved geometrically here instead. +const WALL_ON_BOUNDARY_TOLERANCE = 0.05 + +function pointOnPolygonBoundary( + point: readonly [number, number], + polygon: ReadonlyArray, + tolerance: number, +): boolean { + for (let i = 0; i < polygon.length; i += 1) { + const a = polygon[i] + const b = polygon[(i + 1) % polygon.length] + if ( + a && + b && + pointOnSegment( + point as [number, number], + a as [number, number], + b as [number, number], + tolerance, + ) + ) { + return true + } + } + return false +} + +// A wall bounds a room when both its endpoints lie on the room polygon's +// boundary (a shared wall lies on two rooms' boundaries; a wall radiating out of +// a corner has only one endpoint on it and is correctly excluded). +function wallBoundsRoom( + wall: WallNode, + polygon: ReadonlyArray, +): boolean { + return ( + pointOnPolygonBoundary(wall.start, polygon, WALL_ON_BOUNDARY_TOLERANCE) && + pointOnPolygonBoundary(wall.end, polygon, WALL_ON_BOUNDARY_TOLERANCE) + ) +} + +function polygonCentroid( + points: ReadonlyArray, +): [number, number] | null { + if (points.length === 0) return null + let x = 0 + let z = 0 + for (const point of points) { + x += point[0] + z += point[1] + } + return [x / points.length, z / points.length] +} + +/** + * Expand one paint hit (`node` + resolved `role`) into the full list of + * (node, role) targets the current `scope` should paint. Returns just the + * clicked surface for `'single'`, for any target whose scope set doesn't + * include the current scope, and whenever the spread resolves to a single + * element — so callers can keep the kind-specific single-node commit for that + * case and only batch when there's genuinely more than one target. + * + * `slotRolesOf` enumerates the node's full slot set (declared or mesh-derived, + * injected by the caller) for the whole-object scope. + */ +export function resolvePaintScopeTargets(args: { + node: AnyNode + role: string + scope: PaintScope + nodes: Record + spaces: Record + slotRolesOf: (node: AnyNode) => string[] +}): Array<{ nodeId: AnyNodeId; role: string }> { + const { node, role, scope, nodes, spaces, slotRolesOf } = args + const single = [{ nodeId: node.id as AnyNodeId, role }] + if (scope === 'single') return single + + // Whole object: paint every slot of the clicked node. Generic across any + // slot-model kind (item, shelf, door, …) — not item-specific. + if (scope === 'object') { + const roles = slotRolesOf(node) + const set = roles.length > 0 ? roles : [role] + return set.map((slotRole) => ({ nodeId: node.id as AnyNodeId, role: slotRole })) + } + + // All matching: same slot across every instance of the node's asset (items). + if (scope === 'matching') { + const assetId = (node as ItemNode).asset?.id + if (!assetId) return single + return Object.values(nodes) + .filter((other) => other.type === 'item' && (other as ItemNode).asset?.id === assetId) + .map((other) => ({ nodeId: other.id as AnyNodeId, role })) + } + + if (node.type === 'wall' && scope === 'room') { + const wall = node as WallNode + const space = Object.values(spaces).find((candidate) => wallBoundsRoom(wall, candidate.polygon)) + if (!space) return single + return Object.values(nodes) + .filter((other) => other.type === 'wall' && wallBoundsRoom(other as WallNode, space.polygon)) + .map((other) => ({ nodeId: other.id as AnyNodeId, role })) + } + + if (node.type === 'slab' && scope === 'room') { + const centroid = polygonCentroid((node as SlabNode).polygon) + if (!centroid) return single + const space = Object.values(spaces).find((candidate) => + pointInPolygon2D(centroid, candidate.polygon), + ) + if (!space) return single + return Object.values(nodes) + .filter((other) => { + if (other.type !== 'slab') return false + const otherCentroid = polygonCentroid((other as SlabNode).polygon) + return otherCentroid != null && pointInPolygon2D(otherCentroid, space.polygon) + }) + .map((other) => ({ nodeId: other.id as AnyNodeId, role })) + } + + return single +} + +// ── Batched commit ────────────────────────────────────────────────────────── + +// Structural equality for the one-off-colour dedup below. The slot model is +// uniform across item / wall / slab (`node.slots[role] = ref`), so the same +// matcher the per-kind commits use applies to the whole fan-out. +function materialsEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true + if (typeof a !== typeof b || a === null || b === null) return false + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false + return a.every((value, index) => materialsEqual(value, b[index])) + } + if (typeof a === 'object') { + const aRecord = a as Record + const bRecord = b as Record + const aKeys = Object.keys(aRecord) + if (aKeys.length !== Object.keys(bRecord).length) return false + return aKeys.every( + (key) => Object.hasOwn(bRecord, key) && materialsEqual(aRecord[key], bRecord[key]), + ) + } + return false +} + +/** + * Apply one paint to many slot-model targets in a single undo step. Resolves + * the slot ref ONCE — a one-off colour creates a single shared scene material + * for the whole fan-out, not one per node — then writes every `node.slots[role]` + * (or deletes it, for the eraser) in one `useScene.setState`. Only ever called + * for item / wall / slab fan-outs, all of which use the unified slot model. + */ +export function commitPaintScopeFanout( + targets: ReadonlyArray<{ nodeId: AnyNodeId; role: string }>, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): void { + if (targets.length === 0) return + const state = useScene.getState() + + let ref: string | undefined + let newSceneMaterial: SceneMaterial | null = null + if (material === undefined && materialPreset === undefined) { + ref = undefined // eraser → clear the slot back to its default + } else if (materialPreset) { + ref = materialPreset + } else if (material) { + const existing = Object.values(state.materials).find((scene) => + materialsEqual(scene.material, material), + ) + if (existing) { + ref = toSceneMaterialRef(existing.id) + } else { + const id = generateSceneMaterialId() + newSceneMaterial = { + id, + name: `Material ${Object.keys(state.materials).length + 1}`, + material, + } + ref = toSceneMaterialRef(id) + } + } else { + return + } + + useScene.setState((current) => { + if (current.readOnly) return current + const nextNodes = { ...current.nodes } + let changed = false + for (const { nodeId, role } of targets) { + const node = nextNodes[nodeId] as SlotsNode | undefined + if (!node) continue + const nextSlots = { ...(node.slots ?? {}) } + if (ref) nextSlots[role] = ref + else delete nextSlots[role] + nextNodes[nodeId] = { ...node, slots: nextSlots } as AnyNode + changed = true + } + if (!changed) return current + return { + nodes: nextNodes, + materials: newSceneMaterial + ? { ...current.materials, [newSceneMaterial.id as SceneMaterialId]: newSceneMaterial } + : current.materials, + } + }) + + for (const { nodeId } of targets) state.markDirty(nodeId) +} diff --git a/packages/editor/src/lib/snapping-mode.test.ts b/packages/editor/src/lib/snapping-mode.test.ts index 3c9499bc..779b1d6d 100644 --- a/packages/editor/src/lib/snapping-mode.test.ts +++ b/packages/editor/src/lib/snapping-mode.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from 'bun:test' import { + cycleSnappingModeIn, DEFAULT_SNAPPING_MODE, + defaultSnappingModeFor, nextSnappingMode, resolveSnapFlags, SNAPPING_MODES, + snapContextOf, + snappingModesFor, } from './snapping-mode' describe('resolveSnapFlags', () => { @@ -11,8 +15,8 @@ describe('resolveSnapFlags', () => { 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("modes are exclusive: 'grid' snaps to the lattice only", () => { + expect(resolveSnapFlags('grid')).toEqual({ grid: true, magnetic: false, angles: false }) }) it("'off' disables grid, magnetic, and angles", () => { @@ -42,3 +46,72 @@ describe('resolveSnapFlags', () => { expect(nextSnappingMode(mode)).toBe(DEFAULT_SNAPPING_MODE) }) }) + +describe('per-context snapping', () => { + it('items default to free (lines) with no angle lock', () => { + expect(defaultSnappingModeFor('item')).toBe('lines') + expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off']) + expect(snappingModesFor('item')).not.toContain('angles') + }) + + it('walls default to grid and expose the angle lock; polygons do NOT', () => { + expect(defaultSnappingModeFor('wall')).toBe('grid') + expect(defaultSnappingModeFor('polygon')).toBe('grid') + expect(snappingModesFor('wall')).toContain('angles') + // Angle lock is wall/fence-only — slabs, curves and translates never get it. + expect(snappingModesFor('polygon')).not.toContain('angles') + expect(snappingModesFor('polygon')).toEqual(['grid', 'lines', 'off']) + }) + + it('cycles within the context set and clamps a foreign value', () => { + expect(cycleSnappingModeIn('item', 'lines')).toBe('grid') + expect(cycleSnappingModeIn('item', 'off')).toBe('lines') + // 'angles' isn't an item mode → restart at the first entry + expect(cycleSnappingModeIn('item', 'angles')).toBe('lines') + }) +}) + +describe('snapContextOf (profile-driven, node-declared)', () => { + // Stands in for the registry's declared `def.snapProfile` (the only per-kind + // data) — the resolver itself has no kind switch. + const declared: Record = { + wall: 'structural', + fence: 'structural', + item: 'item', + slab: 'structural', + ceiling: 'structural', + roof: 'structural', + zone: 'structural', + } + const profileOf = (t: string) => declared[t] + const ctx = ( + scope: { kind: string; nodeType?: string; reshape?: string; tool?: string }, + mode = 'select', + tool: string | null = null, + ) => snapContextOf({ scope, mode, tool, profileOf }) + + it('translating a whole structural node has no angle (polygon, not wall)', () => { + expect(ctx({ kind: 'moving', nodeType: 'wall' })).toBe('polygon') + expect(ctx({ kind: 'moving', nodeType: 'slab' })).toBe('polygon') + expect(ctx({ kind: 'placing', nodeType: 'item' }, 'build', 'item')).toBe('item') + }) + + it('endpoint reshape is angle-bearing (wall); curve + polygon vertex edits are not', () => { + expect(ctx({ kind: 'reshaping', reshape: 'endpoint' })).toBe('wall') + expect(ctx({ kind: 'reshaping', reshape: 'curve' })).toBe('polygon') + expect(ctx({ kind: 'reshaping', reshape: 'boundary' })).toBe('polygon') + expect(ctx({ kind: 'reshaping', reshape: 'hole' })).toBe('polygon') + }) + + it('drafting a structural kind (wall OR slab) is angle-bearing (wall)', () => { + expect(ctx({ kind: 'idle' }, 'build', 'wall')).toBe('wall') + expect(ctx({ kind: 'idle' }, 'build', 'slab')).toBe('wall') + expect(ctx({ kind: 'idle' }, 'build', 'item')).toBe('item') + expect(ctx({ kind: 'idle' }, 'select', null)).toBeNull() + }) + + it('an undeclared kind (no snapProfile) gets no snap context', () => { + expect(ctx({ kind: 'moving', nodeType: 'door' })).toBeNull() + expect(ctx({ kind: 'idle' }, 'build', 'shelf')).toBeNull() + }) +}) diff --git a/packages/editor/src/lib/snapping-mode.ts b/packages/editor/src/lib/snapping-mode.ts index 257c5867..2815fd6c 100644 --- a/packages/editor/src/lib/snapping-mode.ts +++ b/packages/editor/src/lib/snapping-mode.ts @@ -1,3 +1,5 @@ +import type { SnapProfile } from '@pascal-app/core' + /** * Snapping mode is a single global, user-cyclable control that maps onto the * two pre-existing snap knobs (`gridSnapStep` grid snap + `magneticSnap`). @@ -19,19 +21,20 @@ export type SnapFlags = { } /** - * Pure mapping from the curated mode enum onto the individual snap knobs. + * Pure mapping from the mode enum onto the individual snap knobs. Modes are + * EXCLUSIVE — each does exactly what its chip label says, one guide at a time, + * so the HUD is honest: * - * - `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. + * - `grid` → grid lattice only. + * - `lines` → magnetic only: alignment axes + wall corner-join (connectivity + * is part of the "lines" magnetic snap, not a separate always-on behaviour). + * - `angles` → angle lock only (15°/45° rays). + * - `off` → nothing snaps (raw cursor). */ export function resolveSnapFlags(mode: SnappingMode): SnapFlags { switch (mode) { case 'grid': - return { grid: true, magnetic: true, angles: true } + return { grid: true, magnetic: false, angles: false } case 'lines': return { grid: false, magnetic: true, angles: false } case 'angles': @@ -56,3 +59,99 @@ export function nextSnappingMode(mode: SnappingMode): SnappingMode { const index = SNAPPING_MODES.indexOf(mode) return SNAPPING_MODES[(index + 1) % SNAPPING_MODES.length] ?? DEFAULT_SNAPPING_MODE } + +// ── Per-context snapping ────────────────────────────────────────────────────── +// +// Snapping is no longer one global value: each *activity* has its own mode set +// and default, because they want different behaviour (drawing a wall wants a +// grid + angle lock; nudging an item wants free movement that only catches on +// alignment lines). The mode is remembered per context and shown live, so it's +// never a silent surprise — it just matches what you're doing. + +export type SnapContext = 'wall' | 'item' | 'polygon' + +// The cyclable mode-set for a context (distinct from the node's `SnapProfile`). +type SnapModeSet = { modes: SnappingMode[]; default: SnappingMode } + +// `modes[0]` is the cycle's first entry; `default` is what a context starts at. +// The 'wall' set is the ONLY one with an angle lock — it applies solely when +// you're setting a segment's DIRECTION (wall/fence drafting + endpoint drag). +// Translating a whole wall, curving it, or drawing/moving a slab can't change an +// angle, so those use the no-angle 'polygon' set. +const SNAP_PROFILES: Record = { + // Wall / fence drafting + endpoint reshape: direction matters → angle lock. + wall: { modes: ['grid', 'lines', 'angles', 'off'], default: 'grid' }, + // Item placement / move: free by default (lines = magnetic alignment only, no + // grid lattice), grid opt-in, no angle lock (meaningless for a footprint). + item: { modes: ['lines', 'grid', 'off'], default: 'lines' }, + // Structural / surface, no direction to set: slab / ceiling / roof draft+move, + // whole wall/fence translate, curve reshape, polygon boundary edit. Grid by + // default, NO angle lock. + polygon: { modes: ['grid', 'lines', 'off'], default: 'grid' }, +} + +export const SNAP_CONTEXTS: SnapContext[] = ['wall', 'item', 'polygon'] + +export function snappingModesFor(context: SnapContext): SnappingMode[] { + return SNAP_PROFILES[context].modes +} + +export function defaultSnappingModeFor(context: SnapContext): SnappingMode { + return SNAP_PROFILES[context].default +} + +// Cycle within the context's own set (clamps a foreign value to the first entry). +export function cycleSnappingModeIn(context: SnapContext, mode: SnappingMode): SnappingMode { + const modes = SNAP_PROFILES[context].modes + const index = modes.indexOf(mode) + return modes[(index + 1) % modes.length] ?? modes[0] ?? DEFAULT_SNAPPING_MODE +} + +// The kind's declared `snapProfile` (from the registry) → the active mode-set +// context. The only behaviour difference is the angle lock, which a `structural` +// kind gets while SETTING DIRECTION (drafting a run/polygon, dragging an endpoint +// or a polygon vertex) — never while translating or curving. A node with no +// declared profile has no snapping UI (chip) yet — returns null. +function contextForProfile( + profile: SnapProfile | undefined, + directionSetting: boolean, +): SnapContext | null { + if (profile === 'item') return 'item' + if (profile === 'structural') return directionSetting ? 'wall' : 'polygon' + return null +} + +/** + * The active snapping context, derived from what the user is doing — fully + * node-declared: the kind's `snapProfile` (looked up via the injected + * `profileOf`) supplies the data, and this maps (profile × action) to the + * mode-set. No per-kind switch lives here. `profileOf` is injected so this stays + * pure + testable and `snapping-mode` need not import the registry. + * + * Prefers the authoritative interaction scope; falls back to the build tool + * because the `drafting` scope isn't wired yet (wall/slab draw runs idle). + * Returns null when nothing snappable is active → no chip, safe-default snap. + */ +export function snapContextOf(args: { + scope: { kind: string; nodeType?: string; reshape?: string; nodeId?: string; tool?: string } + mode: string + tool: string | null + profileOf: (typeOrTool: string) => SnapProfile | undefined +}): SnapContext | null { + const { scope, mode, tool, profileOf } = args + switch (scope.kind) { + case 'placing': + case 'moving': + // A whole-node translate never sets direction → no angle. + return scope.nodeType ? contextForProfile(profileOf(scope.nodeType), false) : null + case 'reshaping': + // Dragging a wall ENDPOINT sets the segment's direction → angle-bearing + // 'wall'. Curving, and polygon vertex/edge edits (boundary / hole), don't + // — they use the no-angle 'polygon' set (grid / lines / off). + return scope.reshape === 'endpoint' ? 'wall' : 'polygon' + case 'drafting': + return scope.tool ? contextForProfile(profileOf(scope.tool), true) : null + default: + return mode === 'build' && tool ? contextForProfile(profileOf(tool), true) : null + } +} diff --git a/packages/editor/src/lib/surface-plan-snap.ts b/packages/editor/src/lib/surface-plan-snap.ts index 67d93011..0851bca7 100644 --- a/packages/editor/src/lib/surface-plan-snap.ts +++ b/packages/editor/src/lib/surface-plan-snap.ts @@ -209,8 +209,11 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac useWallSnapIndicator.getState().clear() + // Alignment is the magnetic ("lines") guide. Modes are exclusive, so it runs + // only when magnetic snap is on — `grid`/`angles`/`off` keep the grid/raw + // `fallbackPoint` instead of being pulled onto an alignment axis. const basePoint = fallbackPoint ?? wallSnap.point - if (input.align === false || input.altKey) { + if (input.align === false || input.altKey || !magnetic) { useAlignmentGuides.getState().clear() return { point: basePoint, wallSnap: null, guides: [], wallIds: [] } } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 2ba22d43..e0e34cb9 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -16,6 +16,7 @@ import { type FenceNode, type ItemNode, type LevelNode, + nodeRegistry, type RoofNode, type RoofSegmentNode, type RoofSurfaceMaterialRole, @@ -41,11 +42,18 @@ import { type SingleSurfaceMaterialRole, } from '../lib/material-paint' import { - DEFAULT_SNAPPING_MODE, - nextSnappingMode, + cyclePaintScope as cyclePaintScopeValue, + type PaintHoverInfo, + type PaintScope, +} from '../lib/paint-scope' +import { + cycleSnappingModeIn, + defaultSnappingModeFor, resolveSnapFlags, - SNAPPING_MODES, + type SnapContext, type SnappingMode, + snapContextOf, + snappingModesFor, } from '../lib/snapping-mode' import useInteractionScope from './use-interaction-scope' @@ -278,13 +286,30 @@ type EditorState = { setActivePaintMaterial: (material: ActivePaintMaterial | null) => void activePaintTarget: PaintableMaterialTarget setActivePaintTarget: (target: PaintableMaterialTarget) => void + // Live vertex count of an in-progress polygon draft (slab / ceiling), so the + // contextual HUD can gate hints on it (e.g. "Finish" only once ≥ 3 points). + // 0 when not drafting. Not persisted. + draftVertexCount: number + setDraftVertexCount: (count: number) => void + // Painter application scope — how far one paint click spreads (this surface / + // whole item / all matching / room). One global mode, target-aware in the HUD + // (see `lib/paint-scope.ts`), defaulting to the narrowest `'single'`. Not + // persisted: a "paint everything" scope should reset each session. + paintScope: PaintScope + setPaintScope: (scope: PaintScope) => void + // Cycle the scope within the hovered node's available set and return the new + // value. Bound to Shift while in paint mode. + cyclePaintScope: () => PaintScope // When true, clicking a surface in paint mode clears it back to its // default material instead of applying `activePaintMaterial`. paintEraser: boolean setPaintEraser: (eraser: boolean) => void primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot - hoveredPaintTarget: PaintableMaterialTarget | null - setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void + // What the cursor is over in paint mode: the scopes it offers + labels for the + // HUD chip. `null` when not over a paintable surface (drives the "hover a + // surface" hint). Set by the selection-manager paint hover; not persisted. + paintHover: PaintHoverInfo | null + setPaintHover: (info: PaintHoverInfo | null) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void guideUi: Record @@ -338,11 +363,15 @@ type EditorState = { // 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 + // Per-context, user-cyclable snapping mode (see `lib/snapping-mode.ts`). Each + // activity (wall / item / polygon) keeps its own mode + default, because they + // want different snapping — drawing a wall wants grid + angle, nudging an item + // wants free movement that only catches alignment lines. Resolved to the live + // context via `getActiveSnappingMode()`; maps onto `gridSnapStep`/`magneticSnap` + // via `resolveSnapFlags`. Persisted per context. + snappingModeByContext: Record + setSnappingMode: (context: SnapContext, mode: SnappingMode) => void + // Cycle the *active* context's mode within its own set; returns the new value. cycleSnappingMode: () => SnappingMode showReferenceFloor: boolean toggleReferenceFloor: () => void @@ -392,7 +421,7 @@ type PersistedEditorLayoutState = Pick< | 'floorplanSelectionTool' | 'gridSnapStep' | 'magneticSnap' - | 'snappingMode' + | 'snappingModeByContext' | 'showReferenceFloor' | 'referenceFloorOffset' | 'referenceFloorOpacity' @@ -416,7 +445,11 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = floorplanSelectionTool: 'click', gridSnapStep: 0.5, magneticSnap: true, - snappingMode: DEFAULT_SNAPPING_MODE, + snappingModeByContext: { + wall: defaultSnappingModeFor('wall'), + item: defaultSnappingModeFor('item'), + polygon: defaultSnappingModeFor('polygon'), + }, showReferenceFloor: false, referenceFloorOffset: 1, referenceFloorOpacity: 0.35, @@ -519,6 +552,14 @@ export function normalizePersistedEditorUiState( } } +// Validate a persisted per-context mode against that context's allowed set +// (so e.g. a stale `angles` for items resets), falling back to its default. +function migrateSnappingMode(value: unknown, context: SnapContext): SnappingMode { + return snappingModesFor(context).includes(value as SnappingMode) + ? (value as SnappingMode) + : defaultSnappingModeFor(context) +} + function normalizePersistedEditorLayoutState( state: Partial | null | undefined, ): PersistedEditorLayoutState { @@ -535,9 +576,11 @@ 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, + snappingModeByContext: { + wall: migrateSnappingMode(state?.snappingModeByContext?.wall, 'wall'), + item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'), + polygon: migrateSnappingMode(state?.snappingModeByContext?.polygon, 'polygon'), + }, showReferenceFloor: state?.showReferenceFloor === true, referenceFloorOffset: typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1 @@ -823,6 +866,19 @@ const useEditor = create()( set((state) => state.activePaintTarget === target ? state : { activePaintTarget: target }, ), + draftVertexCount: 0, + setDraftVertexCount: (count) => + set((state) => (state.draftVertexCount === count ? state : { draftVertexCount: count })), + paintScope: 'single', + setPaintScope: (scope) => set({ paintScope: scope }), + cyclePaintScope: () => { + // Cycle within the hovered node's available scopes (what the click will + // actually hit). With nothing paintable hovered there's only `single`. + const scopes = get().paintHover?.scopes ?? (['single'] as PaintScope[]) + const next = cyclePaintScopeValue(get().paintScope, scopes) + set({ paintScope: next }) + return next + }, paintEraser: false, setPaintEraser: (eraser) => set({ paintEraser: eraser }), primeMaterialPaintFromSelection: () => { @@ -852,11 +908,8 @@ const useEditor = create()( activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial, } }, - hoveredPaintTarget: null, - setHoveredPaintTarget: (target) => - set((state) => - state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target }, - ), + paintHover: null, + setPaintHover: (info) => set({ paintHover: info }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), guideUi: {}, @@ -981,11 +1034,18 @@ const useEditor = create()( }, magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap, setMagneticSnap: (enabled) => set({ magneticSnap: enabled }), - snappingMode: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.snappingMode, - setSnappingMode: (mode) => set({ snappingMode: mode }), + snappingModeByContext: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.snappingModeByContext, + setSnappingMode: (context, mode) => + set((state) => ({ + snappingModeByContext: { ...state.snappingModeByContext, [context]: mode }, + })), cycleSnappingMode: () => { - const next = nextSnappingMode(get().snappingMode) - set({ snappingMode: next }) + const context = getActiveSnapContext() ?? 'item' + const current = get().snappingModeByContext[context] + const next = cycleSnappingModeIn(context, current) + set((state) => ({ + snappingModeByContext: { ...state.snappingModeByContext, [context]: next }, + })) return next }, showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor, @@ -1080,7 +1140,7 @@ const useEditor = create()( floorplanSelectionTool: state.floorplanSelectionTool, gridSnapStep: state.gridSnapStep, magneticSnap: state.magneticSnap, - snappingMode: state.snappingMode, + snappingModeByContext: state.snappingModeByContext, showReferenceFloor: state.showReferenceFloor, referenceFloorOffset: state.referenceFloorOffset, referenceFloorOpacity: state.referenceFloorOpacity, @@ -1090,27 +1150,59 @@ const useEditor = create()( ) /** - * 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. + * Effective magnetic-snap state: the legacy `magneticSnap` flag AND the active + * context's snapping mode. With exclusive modes, magnetic (alignment axes + wall + * corner-join) is on only in `'lines'`. 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 + return state.magneticSnap && resolveSnapFlags(getActiveSnappingMode()).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. + * Effective angle-lock state: the active context's snapping mode. With exclusive + * modes the 15°/45° lock is on only in `'angles'`. Read from the smallest + * angle-lock choke points (wall / fence draft call sites). */ export function isAngleSnapActive(): boolean { - return resolveSnapFlags(useEditor.getState().snappingMode).angles + return resolveSnapFlags(getActiveSnappingMode()).angles +} + +/** + * Effective grid-lattice state: the active context's snapping mode. With + * exclusive modes the grid quantize is on only in `'grid'`. + */ +export function isGridSnapActive(): boolean { + return resolveSnapFlags(getActiveSnappingMode()).grid +} + +/** + * The snapping context for what the user is currently doing (wall / item / + * polygon), or null when nothing snappable is active. Derived from the + * authoritative interaction scope, falling back to the armed build tool (the + * `drafting` scope isn't wired). The single source every snap reader + the HUD + * resolve their mode through. + */ +export function getActiveSnapContext(): SnapContext | null { + const editor = useEditor.getState() + return snapContextOf({ + scope: useInteractionScope.getState().scope, + mode: editor.mode, + tool: editor.tool, + profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile, + }) +} + +/** + * The effective snapping mode for the active context. Falls back to `item`'s + * default (free) when no snappable context is active, so a stray reader never + * grid-quantizes outside an interaction. + */ +export function getActiveSnappingMode(): SnappingMode { + const context = getActiveSnapContext() + if (!context) return defaultSnappingModeFor('item') + return useEditor.getState().snappingModeByContext[context] } export default useEditor diff --git a/packages/nodes/src/ceiling/boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx index 63f27ce0..1db4bde6 100644 --- a/packages/nodes/src/ceiling/boundary-editor.tsx +++ b/packages/nodes/src/ceiling/boundary-editor.tsx @@ -2,11 +2,13 @@ import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' import { + boundaryReshapeScope, clearCeilingSnapFeedback, PolygonEditor, type PolygonEditorPlanPointSnapContext, resolveCeilingPlanPointSnap, triggerSFX, + useInteractionScope, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -95,13 +97,19 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = const handleDragStateChange = useCallback( (isDragging: boolean) => { - if (!isDragging) { + // A vertex/edge drag is a `boundary` reshape — drive the snapping HUD + // (no-angle 'polygon' set) and keep the idle select hints off-screen. + const scope = useInteractionScope.getState() + if (isDragging) { + scope.begin(boundaryReshapeScope(ceilingId)) + } else { + scope.endIf((s) => s.kind === 'reshaping' && s.reshape === 'boundary') ownsPolygonPreviewRef.current = false clearCeilingSnapFeedback() } setCeilingHandleHover(isDragging) }, - [setCeilingHandleHover], + [ceilingId, setCeilingHandleHover], ) const handlePolygonEditorDragCommit = useCallback(() => { @@ -126,7 +134,6 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = levelId: ceilingLevelId, excludeId: ceilingId, altKey: context.nativeEvent?.altKey === true, - shiftKey: context.nativeEvent?.shiftKey === true, }).point, [ceilingId, ceilingLevelId], ) @@ -136,6 +143,9 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = clearCeilingSnapFeedback() useLiveNodeOverrides.getState().clear(ceilingId) useScene.getState().markDirty(ceilingId) + useInteractionScope + .getState() + .endIf((s) => s.kind === 'reshaping' && s.reshape === 'boundary') ownsPolygonPreviewRef.current = false if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) { useViewer.getState().setHoveredId(null) diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index 8183f36a..6504f515 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -79,6 +79,7 @@ function ceilingHandles(_node: CeilingNodeType): HandleDescriptor = { kind: 'ceiling', + snapProfile: 'structural', schemaVersion: 1, schema: CeilingNode, category: 'structure', @@ -155,8 +156,7 @@ export const ceilingDefinition: NodeDefinition = { toolHints: [ { key: 'Left click', label: 'Trace ceiling outline' }, - { key: 'Enter', label: 'Finish ceiling' }, - { key: 'Shift', label: 'Free outline' }, + { key: 'Enter', label: 'Finish ceiling', minDraftVertices: 3 }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index d6aaab1d..bf2e7d5a 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -16,6 +16,7 @@ import { import { CursorSphere, consumePlacementDragRelease, + isMagneticSnapActive, markToolCancelConsumed, triggerSFX, useAlignmentGuides, @@ -37,7 +38,7 @@ import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from * mesh's X/Z position on rebuild (`mesh.position.x = 0`, * `mesh.position.z = 0`) so the visual transitions smoothly. * - * Snaps to the editor's configured grid step (Shift bypasses). + * Snaps to the editor's configured grid step. */ function snap(value: number) { return snapScalar(value, useEditor.getState().gridSnapStep) @@ -149,12 +150,10 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { const onGridMove = (event: GridEvent) => { if (isFloorplanSourcedEvent(event)) return - const bypassSnap = event.nativeEvent?.shiftKey === true - const localX = bypassSnap ? event.localPosition[0] : snap(event.localPosition[0]) - const localZ = bypassSnap ? event.localPosition[2] : snap(event.localPosition[2]) + const localX = snap(event.localPosition[0]) + const localZ = snap(event.localPosition[2]) if ( - !bypassSnap && previousGridPosRef.current && (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) ) { @@ -170,8 +169,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { // Figma-style alignment snap: align the ceiling's translated polygon // vertices to other objects' anchors; fold the snap into the delta and - // publish a guide. Alt bypasses alignment; Shift bypasses all snap. - const bypass = event.nativeEvent?.altKey === true || bypassSnap + // publish a guide. Alignment follows the global magnetic snap mode. + const bypass = !isMagneticSnapActive() if (!bypass && alignmentCandidates.length > 0) { const result = resolveAlignment({ moving: polygonAnchors(ceilingId, translatePolygon(originalPolygon, deltaX, deltaZ)), diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx index b2369b75..5d07a4a6 100644 --- a/packages/nodes/src/ceiling/tool.tsx +++ b/packages/nodes/src/ceiling/tool.tsx @@ -13,6 +13,9 @@ import { CursorSphere, clearCeilingSnapFeedback, EDITOR_LAYER, + isAngleSnapActive, + isGridSnapActive, + isMagneticSnapActive, markToolCancelConsumed, resolveCeilingPlanPointSnap, triggerSFX, @@ -30,7 +33,6 @@ import { CeilingNode } from './schema' * Multi-click polygon drawing at the ceiling height (2.52m default) * with a vertical TSL-gradient connector + ground-shadow lines so the * draft is visible against both the ceiling plane and the floor. - * Shift defeats the 15° angle snap during drag. */ const CEILING_HEIGHT = 2.52 @@ -65,7 +67,6 @@ export const CeilingTool: React.FC = () => { const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0]) const [levelY, setLevelY] = useState(0) const previousSnappedPointRef = useRef<[number, number] | null>(null) - const shiftPressed = useRef(false) // Clear preset-seeded defaults on deactivation so a later manual ceiling // draw isn't built with a stale preset's parameters. Unmount-only. @@ -73,6 +74,12 @@ export const CeilingTool: React.FC = () => { useEffect(() => () => clearCeilingSnapFeedback(), []) + // Publish the live vertex count so the HUD shows "Finish" only at ≥ 3 points. + useEffect(() => { + useEditor.getState().setDraftVertexCount(points.length) + }, [points.length]) + useEffect(() => () => useEditor.getState().setDraftVertexCount(0), []) + const verticalGeo = useMemo( () => new BufferGeometry().setFromPoints([ @@ -93,38 +100,27 @@ export const CeilingTool: React.FC = () => { const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && gridCursorRef.current)) return const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] - const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true - const gridPosition: [number, number] = bypassSnap - ? rawPoint - : [...snapPointToGrid(rawPoint, useEditor.getState().gridSnapStep)] + // Honour the active snapping mode: grid lattice + 15° angle lock are each + // gated on the mode (off / lines → free), like the slab tool. + const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + const gridPosition: [number, number] = [...snapPointToGrid(rawPoint, gridStep)] setCursorPosition(gridPosition) setLevelY(event.localPosition[1]) const ceilingY = event.localPosition[1] + CEILING_HEIGHT const gridY = event.localPosition[1] + GRID_OFFSET const lastPoint = points[points.length - 1] - // 15° angle snap from the raw cursor (matching the 2D floorplan - // pipeline) with the distance snapped along the ray to the grid step. const orthoPoint: [number, number] = - bypassSnap || !lastPoint - ? gridPosition - : [ - ...snapPointAlongAngleRay( - lastPoint, - rawPoint, - DEFAULT_ANGLE_STEP, - useEditor.getState().gridSnapStep, - ), - ] + isAngleSnapActive() && lastPoint + ? [...snapPointAlongAngleRay(lastPoint, rawPoint, DEFAULT_ANGLE_STEP, gridStep)] + : gridPosition const displayPoint = resolveCeilingPlanPointSnap({ rawPoint, fallbackPoint: orthoPoint, levelId: currentLevelId, - altKey: event.nativeEvent?.altKey === true, - shiftKey: bypassSnap, + altKey: !isMagneticSnapActive(), }).point setSnappedCursorPosition(displayPoint) if ( - !bypassSnap && points.length > 0 && previousSnappedPointRef.current && (displayPoint[0] !== previousSnappedPointRef.current[0] || @@ -178,28 +174,12 @@ export const CeilingTool: React.FC = () => { clearCeilingSnapFeedback() } - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = true - } - const onKeyUp = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = false - } - const onWindowBlur = () => { - shiftPressed.current = false - } - document.addEventListener('keydown', onKeyDown) - document.addEventListener('keyup', onKeyUp) - window.addEventListener('blur', onWindowBlur) - emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('grid:double-click', onGridDoubleClick) emitter.on('tool:cancel', onCancel) return () => { - document.removeEventListener('keydown', onKeyDown) - document.removeEventListener('keyup', onKeyUp) - window.removeEventListener('blur', onWindowBlur) emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('grid:double-click', onGridDoubleClick) diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index f4f901c9..b2ba870a 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -363,7 +363,6 @@ export const columnDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ { key: 'Left click', label: 'Place column' }, - { key: 'Shift', label: 'Free place' }, { key: 'Esc', label: 'Cancel' }, ], floorplan: buildColumnFloorplan, diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index f13516ab..d531c02e 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -251,7 +251,6 @@ export const doorDefinition: NodeDefinition = { toolHints: [ { key: 'Left click', label: 'Place door on wall' }, - { key: 'Shift', label: 'Free place' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/duct-segment/definition.ts b/packages/nodes/src/duct-segment/definition.ts index 9c5e74af..8d2389a8 100644 --- a/packages/nodes/src/duct-segment/definition.ts +++ b/packages/nodes/src/duct-segment/definition.ts @@ -165,7 +165,6 @@ export const ductSegmentDefinition: NodeDefinition = { toolHints: [ { key: 'Click', label: 'Start segment' }, { key: 'Click again', label: 'Place it (locked to 45°)' }, - { key: 'Shift', label: 'Free angle' }, { key: 'Alt + drag', label: 'Go vertical ↕, click to place' }, { key: '[ / ]', label: 'Duct diameter down / up' }, { key: 'Q', label: 'Round / rect trunk' }, diff --git a/packages/nodes/src/duct-terminal/definition.ts b/packages/nodes/src/duct-terminal/definition.ts index 4a960268..3939091f 100644 --- a/packages/nodes/src/duct-terminal/definition.ts +++ b/packages/nodes/src/duct-terminal/definition.ts @@ -81,7 +81,6 @@ export const ductTerminalDefinition: NodeDefinition = { { key: 'Click', label: 'Place register' }, { key: 'M', label: 'Mount: floor / ceiling / wall' }, { key: 'R / T', label: 'Rotate ±45° (floor / ceiling)' }, - { key: 'Shift', label: 'Smooth (no grid snap)' }, { key: 'Esc', label: 'Exit' }, ], diff --git a/packages/nodes/src/fence/actions/move-endpoint.ts b/packages/nodes/src/fence/actions/move-endpoint.ts index 2525309b..1290efb1 100644 --- a/packages/nodes/src/fence/actions/move-endpoint.ts +++ b/packages/nodes/src/fence/actions/move-endpoint.ts @@ -11,6 +11,7 @@ import { } from '@pascal-app/core' import { type FencePlanPoint, + isAngleSnapActive, isMagneticSnapActive, isSegmentLongEnough, snapFenceDraftPoint, @@ -164,15 +165,18 @@ export const moveFenceEndpointDragAction: DragAction { const planPoint: FencePlanPoint = [point[0], point[1]] - // Endpoint move = grid snap only; the 45°-from-start angle snap - // is draft-only. Shift is a hard snap bypass. + // Endpoint move honours the active snapping mode (HUD chip): grid → lattice; + // lines → magnetic corner/alignment; angles → lock to 15° rays from the + // fixed corner; off → raw. No Shift bypass — Shift cycles the mode; Off is + // the bypass. const snapped = snapFenceDraftPoint({ point: planPoint, walls: ctx.levelWalls, fences: ctx.levelFences, ignoreFenceIds: [ctx.fenceId as string], - bypassSnap: modifiers.shift, - magnetic: !modifiers.shift && isMagneticSnapActive(), + start: ctx.fixedPoint, + angleSnap: isAngleSnapActive(), + magnetic: isMagneticSnapActive(), }) // Figma-style alignment: nudge the dragged endpoint onto another wall / @@ -180,7 +184,7 @@ export const moveFenceEndpointDragAction: DragAction 0) { + if (isMagneticSnapActive() && ctx.alignCandidates.length > 0) { const ar = resolveAlignment({ moving: [{ nodeId: ctx.fenceId as string, kind: 'corner', x: snapped[0], z: snapped[1] }], candidates: ctx.alignCandidates, diff --git a/packages/nodes/src/fence/curve-tool.tsx b/packages/nodes/src/fence/curve-tool.tsx index 2b4dfee5..d4a310cf 100644 --- a/packages/nodes/src/fence/curve-tool.tsx +++ b/packages/nodes/src/fence/curve-tool.tsx @@ -29,8 +29,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' * Phase 5 Stage D — fence curve tool (kind-owned). * * 1:1 port of the legacy `CurveFenceTool` (editor/components/tools/ - * fence/curve-fence-tool.tsx). Same snap pipeline, same Shift override, - * same history dance, same activation grace. Imports adjusted to the + * fence/curve-fence-tool.tsx). Same snap pipeline, same history dance, + * same activation grace. Imports adjusted to the * `@pascal-app/editor` public surface (triggerSFX, markToolCancelConsumed, * getSegmentGridStep, snapScalarToGrid). Mounted via * `def.affordanceTools.curve` — ToolManager picks it up at runtime, @@ -40,7 +40,6 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { const activatedAtRef = useRef(Date.now()) const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node)) const previousCurveOffsetRef = useRef(null) - const shiftPressedRef = useRef(false) const previewOffsetRef = useRef(originalCurveOffsetRef.current) const initialHandle = getWallMidpointHandlePoint(node) @@ -91,29 +90,21 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { } const onGridMove = (event: GridEvent) => { - const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true const snapStep = getSegmentGridStep() - const localX = bypassSnap - ? event.localPosition[0] - : snapScalarToGrid(event.localPosition[0], snapStep) - const localZ = bypassSnap - ? event.localPosition[2] - : snapScalarToGrid(event.localPosition[2], snapStep) + const localX = snapScalarToGrid(event.localPosition[0], snapStep) + const localZ = snapScalarToGrid(event.localPosition[2], snapStep) const offsetFromMidpoint = -( (localX - chord.midpoint.x) * chord.normal.x + (localZ - chord.midpoint.y) * chord.normal.y ) - const snappedOffset = bypassSnap - ? offsetFromMidpoint - : snapScalarToGrid(offsetFromMidpoint, snapStep) + const snappedOffset = snapScalarToGrid(offsetFromMidpoint, snapStep) const nextCurveOffset = normalizeWallCurveOffset( node, Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)), ) if ( - !bypassSnap && previousCurveOffsetRef.current !== null && nextCurveOffset !== previousCurveOffsetRef.current ) { @@ -159,23 +150,9 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { exitCurveMode() } - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - shiftPressedRef.current = true - } - } - - const onKeyUp = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - shiftPressedRef.current = false - } - } - emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) - window.addEventListener('keydown', onKeyDown) - window.addEventListener('keyup', onKeyUp) return () => { if (!wasCommitted) { @@ -185,8 +162,6 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) - window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keyup', onKeyUp) } }, [exitCurveMode, node]) diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index c9bcf999..23242038 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -134,6 +134,7 @@ const fenceHandles: HandleDescriptor[] = [ */ export const fenceDefinition: NodeDefinition = { kind: 'fence', + snapProfile: 'structural', schemaVersion: 1, schema: FenceNode, category: 'structure', diff --git a/packages/nodes/src/hvac-equipment/definition.ts b/packages/nodes/src/hvac-equipment/definition.ts index 47921bd9..7ea715fe 100644 --- a/packages/nodes/src/hvac-equipment/definition.ts +++ b/packages/nodes/src/hvac-equipment/definition.ts @@ -86,7 +86,6 @@ export const hvacEquipmentDefinition: NodeDefinition = toolHints: [ { key: 'Click', label: 'Place unit' }, { key: 'R / T', label: 'Rotate ±45°' }, - { key: 'Shift', label: 'Smooth (no grid snap)' }, { key: 'Esc', label: 'Exit' }, ], diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index bd9164a2..0f6f41eb 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -166,6 +166,7 @@ function itemWallMoveHandle(): HandleDescriptor { */ export const itemDefinition: NodeDefinition = { kind: 'item', + snapProfile: 'item', schemaVersion: 1, schema: ItemNode, category: 'furnish', @@ -316,7 +317,7 @@ export const itemDefinition: NodeDefinition = { { key: 'R', label: 'Rotate counterclockwise' }, { key: 'T', label: 'Rotate clockwise' }, { key: 'Shift', label: 'Cycle snapping mode' }, - { key: 'Alt', label: 'Free place (no snap)' }, + { key: 'Alt', label: 'Force place' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/lineset/definition.ts b/packages/nodes/src/lineset/definition.ts index 03e40495..0f222599 100644 --- a/packages/nodes/src/lineset/definition.ts +++ b/packages/nodes/src/lineset/definition.ts @@ -111,7 +111,6 @@ export const linesetDefinition: NodeDefinition = { toolHints: [ { key: 'Click', label: 'Start lineset' }, { key: 'Click again', label: 'Place it (locked to 45°)' }, - { key: 'Shift', label: 'Free angle' }, { key: 'Alt + drag', label: 'Go vertical ↕, click to place' }, { key: 'Esc', label: 'Cancel start point' }, ], diff --git a/packages/nodes/src/liquid-line/definition.ts b/packages/nodes/src/liquid-line/definition.ts index 87a0cc69..9eca06f4 100644 --- a/packages/nodes/src/liquid-line/definition.ts +++ b/packages/nodes/src/liquid-line/definition.ts @@ -102,7 +102,6 @@ export const liquidLineDefinition: NodeDefinition = { toolHints: [ { key: 'Click', label: 'Start liquid line' }, { key: 'Click again', label: 'Place it (locked to 45°)' }, - { key: 'Shift', label: 'Free angle' }, { key: 'Alt + drag', label: 'Go vertical ↕, click to place' }, { key: 'F', label: 'Follow: trace a lineset' }, { key: 'Esc', label: 'Cancel' }, diff --git a/packages/nodes/src/pipe-segment/definition.ts b/packages/nodes/src/pipe-segment/definition.ts index ce280055..c34801f8 100644 --- a/packages/nodes/src/pipe-segment/definition.ts +++ b/packages/nodes/src/pipe-segment/definition.ts @@ -110,7 +110,6 @@ export const pipeSegmentDefinition: NodeDefinition = { { key: 'Q', label: 'Waste / vent' }, { key: '[ / ]', label: 'Pipe size down / up' }, { key: 'Alt + drag', label: 'Vertical stack ↕, click to place' }, - { key: 'Shift', label: 'Free angle' }, { key: 'Esc', label: 'Cancel start point' }, ], diff --git a/packages/nodes/src/pipe-trap/definition.ts b/packages/nodes/src/pipe-trap/definition.ts index 541afb06..77279a36 100644 --- a/packages/nodes/src/pipe-trap/definition.ts +++ b/packages/nodes/src/pipe-trap/definition.ts @@ -52,7 +52,6 @@ export const pipeTrapDefinition: NodeDefinition = { toolHints: [ { key: 'Click', label: 'Place trap' }, { key: 'R / T', label: 'Rotate ±45°' }, - { key: 'Shift', label: 'Smooth (no grid snap)' }, { key: 'Esc', label: 'Exit' }, ], diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts index 8860e79f..a90fd5e3 100644 --- a/packages/nodes/src/roof/definition.ts +++ b/packages/nodes/src/roof/definition.ts @@ -93,6 +93,7 @@ const roofHandles: HandleDescriptor[] = [roofMoveHandle()] */ export const roofDefinition: NodeDefinition = { kind: 'roof', + snapProfile: 'structural', schemaVersion: 1, schema: RoofNode, category: 'structure', diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts index 3e46173a..f26f742c 100644 --- a/packages/nodes/src/shared/slot-paint.ts +++ b/packages/nodes/src/shared/slot-paint.ts @@ -243,10 +243,13 @@ export type SlotPaintConfig = { node: AnyNode, role: string, ) => { material: MaterialSchema | undefined; materialPreset: string | undefined } | null + /** Opt into the painter's `room` application scope (walls, slabs). */ + roomScope?: boolean } export function createSlotPaintCapability(config: SlotPaintConfig): PaintCapability { return { + roomScope: config.roomScope, resolveRole: config.resolveRole, buildPatch: ({ node, role, materialPreset }) => { const slots = { ...((node as SlotsNode).slots ?? {}) } diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index 8819f0f9..6513ee8e 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -263,7 +263,6 @@ export const shelfDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ { key: 'Left click', label: 'Place shelf' }, - { key: 'Shift', label: 'Free place' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/slab/boundary-editor.tsx b/packages/nodes/src/slab/boundary-editor.tsx index 43726dd3..47a5071b 100644 --- a/packages/nodes/src/slab/boundary-editor.tsx +++ b/packages/nodes/src/slab/boundary-editor.tsx @@ -2,10 +2,12 @@ import { resolveLevelId, type SlabNode, useLiveNodeOverrides, useScene } from '@pascal-app/core' import { + boundaryReshapeScope, clearSlabSnapFeedback, PolygonEditor, type PolygonEditorPlanPointSnapContext, resolveSlabPlanPointSnap, + useInteractionScope, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect } from 'react' @@ -65,6 +67,17 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI clearSlabSnapFeedback() }, []) + // A vertex/edge drag is a `boundary` reshape — drive the snapping HUD (the + // no-angle 'polygon' set) and keep the idle select hints off-screen. + const handleDragStateChange = useCallback( + (isDragging: boolean) => { + const scope = useInteractionScope.getState() + if (isDragging) scope.begin(boundaryReshapeScope(slabId)) + else scope.endIf((s) => s.kind === 'reshaping' && s.reshape === 'boundary') + }, + [slabId], + ) + const resolvePolygonEditorPlanPoint = useCallback( (context: PolygonEditorPlanPointSnapContext) => resolveSlabPlanPointSnap({ @@ -73,7 +86,6 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI levelId: slabLevelId, excludeId: slabId, altKey: context.nativeEvent?.altKey === true, - shiftKey: context.nativeEvent?.shiftKey === true, }).point, [slabId, slabLevelId], ) @@ -86,6 +98,9 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI clearSlabSnapFeedback() useLiveNodeOverrides.getState().clear(slabId) useScene.getState().markDirty(slabId) + useInteractionScope + .getState() + .endIf((s) => s.kind === 'reshaping' && s.reshape === 'boundary') } }, [slabId]) @@ -98,6 +113,7 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI levelId={slabLevelId ?? undefined} minVertices={3} onDragCommit={handleDragCommit} + onDragStateChange={handleDragStateChange} onPolygonChange={handlePolygonChange} onPolygonPreview={handlePolygonPreview} polygon={slab.polygon} diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index d8f698bc..181d918e 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -133,6 +133,7 @@ function slabHandles(_node: SlabNodeType): HandleDescriptor[] { */ export const slabDefinition: NodeDefinition = { kind: 'slab', + snapProfile: 'structural', schemaVersion: 1, schema: SlabNode, category: 'structure', @@ -206,8 +207,7 @@ export const slabDefinition: NodeDefinition = { toolHints: [ { key: 'Left click', label: 'Trace slab outline' }, - { key: 'Enter', label: 'Finish slab' }, - { key: 'Shift', label: 'Free outline' }, + { key: 'Enter', label: 'Finish slab', minDraftVertices: 3 }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx index 344ffaa8..db9594e6 100644 --- a/packages/nodes/src/slab/move-tool.tsx +++ b/packages/nodes/src/slab/move-tool.tsx @@ -169,18 +169,15 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { const onGridMove = (event: GridEvent) => { if (isFloorplanSourcedEvent(event)) return const gridStep = getSegmentGridStep() - const bypassSnap = event.nativeEvent?.shiftKey === true const [localX, localZ] = snapFenceDraftPoint({ point: [event.localPosition[0], event.localPosition[2]], walls: levelWalls, fences: levelFences, - bypassSnap, - magnetic: !bypassSnap && isMagneticSnapActive(), + magnetic: isMagneticSnapActive(), gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep), }) if ( - !bypassSnap && previousGridPosRef.current && (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) ) { @@ -196,8 +193,8 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { // Figma-style alignment snap: align the slab's translated polygon // vertices to other objects' anchors; fold the snap into the delta and - // publish a guide. Alt bypasses alignment; Shift bypasses all snap. - const bypass = event.nativeEvent?.altKey === true || bypassSnap + // publish a guide. Alignment follows the global magnetic snap mode. + const bypass = !isMagneticSnapActive() if (!bypass && alignmentCandidates.length > 0) { const result = resolveAlignmentForActiveBuilding({ moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)), diff --git a/packages/nodes/src/slab/paint.ts b/packages/nodes/src/slab/paint.ts index 628c75cc..2186178b 100644 --- a/packages/nodes/src/slab/paint.ts +++ b/packages/nodes/src/slab/paint.ts @@ -8,6 +8,7 @@ import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-p * `node.slots[slotId]` (a shared scene-material or `library:` ref) like the shelf. */ export const slabPaint = createSlotPaintCapability({ + roomScope: true, resolveRole: ({ hitObject }) => { const slotId = (hitObject?.userData as { slotId?: string } | undefined)?.slotId return slotId === 'side' ? 'side' : 'surface' diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx index bc4252bc..12f10a55 100644 --- a/packages/nodes/src/slab/tool.tsx +++ b/packages/nodes/src/slab/tool.tsx @@ -13,6 +13,8 @@ import { CursorSphere, clearSlabSnapFeedback, EDITOR_LAYER, + isAngleSnapActive, + isGridSnapActive, markToolCancelConsumed, resolveSlabPlanPointSnap, triggerSFX, @@ -62,7 +64,6 @@ export const SlabTool: React.FC = () => { const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0]) const [levelY, setLevelY] = useState(0) const previousSnappedPointRef = useRef<[number, number] | null>(null) - const shiftPressed = useRef(false) // Clear preset-seeded defaults on deactivation so a later manual slab draw // isn't built with a stale preset's parameters. Unmount-only. @@ -70,42 +71,39 @@ export const SlabTool: React.FC = () => { useEffect(() => () => clearSlabSnapFeedback(), []) + // Publish the live vertex count so the HUD shows "Finish" only at ≥ 3 points. + useEffect(() => { + useEditor.getState().setDraftVertexCount(points.length) + }, [points.length]) + useEffect(() => () => useEditor.getState().setDraftVertexCount(0), []) + useEffect(() => { if (!currentLevelId) return const onGridMove = (event: GridEvent) => { if (!cursorRef.current) return const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] - const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true - const gridPosition: [number, number] = bypassSnap - ? rawPoint - : [...snapPointToGrid(rawPoint, useEditor.getState().gridSnapStep)] + // Slab drafting is the 'polygon' snap context (grid / lines / off — no + // angle, no Shift bypass; Shift cycles the mode, Off is the bypass). + const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + const gridPosition: [number, number] = [...snapPointToGrid(rawPoint, gridStep)] setCursorPosition(gridPosition) setLevelY(event.localPosition[1]) const lastPoint = points[points.length - 1] - // 15° angle snap from the raw cursor (matching the 2D floorplan - // pipeline) with the distance snapped along the ray to the grid step. + // Angle lock only when the mode asks for it (polygon never does today, but + // honour the flag so the behaviour follows the HUD). const orthoPoint: [number, number] = - bypassSnap || !lastPoint - ? gridPosition - : [ - ...snapPointAlongAngleRay( - lastPoint, - rawPoint, - DEFAULT_ANGLE_STEP, - useEditor.getState().gridSnapStep, - ), - ] + isAngleSnapActive() && lastPoint + ? [...snapPointAlongAngleRay(lastPoint, rawPoint, DEFAULT_ANGLE_STEP, gridStep)] + : gridPosition const displayPoint = resolveSlabPlanPointSnap({ rawPoint, fallbackPoint: orthoPoint, levelId: currentLevelId, altKey: event.nativeEvent?.altKey === true, - shiftKey: bypassSnap, }).point setSnappedCursorPosition(displayPoint) if ( - !bypassSnap && points.length > 0 && previousSnappedPointRef.current && (displayPoint[0] !== previousSnappedPointRef.current[0] || @@ -139,14 +137,18 @@ export const SlabTool: React.FC = () => { } } + // Finish the polygon (Enter or double-click): commit once there are enough + // vertices. Closing near the first vertex (in onGridClick) is the third way. + const finishDrawing = () => { + if (points.length < 3) return + const slabId = commitSlabDrawing(currentLevelId, points) + setSelection({ selectedIds: [slabId] }) + setPoints([]) + clearSlabSnapFeedback() + } + const onGridDoubleClick = (_event: GridEvent) => { - if (!currentLevelId) return - if (points.length >= 3) { - const slabId = commitSlabDrawing(currentLevelId, points) - setSelection({ selectedIds: [slabId] }) - setPoints([]) - clearSlabSnapFeedback() - } + finishDrawing() } const onCancel = () => { @@ -156,17 +158,12 @@ export const SlabTool: React.FC = () => { } const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = true - } - const onKeyUp = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = false - } - const onWindowBlur = () => { - shiftPressed.current = false + if (e.key === 'Enter') { + e.preventDefault() + finishDrawing() + } } document.addEventListener('keydown', onKeyDown) - document.addEventListener('keyup', onKeyUp) - window.addEventListener('blur', onWindowBlur) emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) @@ -175,8 +172,6 @@ export const SlabTool: React.FC = () => { return () => { document.removeEventListener('keydown', onKeyDown) - document.removeEventListener('keyup', onKeyUp) - window.removeEventListener('blur', onWindowBlur) emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('grid:double-click', onGridDoubleClick) diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts index 5233ccc4..482d32eb 100644 --- a/packages/nodes/src/spawn/definition.ts +++ b/packages/nodes/src/spawn/definition.ts @@ -100,7 +100,6 @@ export const spawnDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ { key: 'Left click', label: 'Place spawn point' }, - { key: 'Shift', label: 'Free place' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/wall/curve-tool.tsx b/packages/nodes/src/wall/curve-tool.tsx index 4490f075..abb59e99 100644 --- a/packages/nodes/src/wall/curve-tool.tsx +++ b/packages/nodes/src/wall/curve-tool.tsx @@ -27,8 +27,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' /** * Phase 5 Stage D — wall curve tool (kind-owned). * - * 1:1 port of the legacy `CurveWallTool`. Same snap pipeline, Shift - * override, history dance, activation grace. The wall variant uses + * 1:1 port of the legacy `CurveWallTool`. Same snap pipeline, + * history dance, activation grace. The wall variant uses * `useScene.temporal.getState().pause()` / `.resume()` directly rather * than the depth-counted `pauseSceneHistory` helpers — matches legacy. */ @@ -36,7 +36,6 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const activatedAtRef = useRef(Date.now()) const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node)) const previousCurveOffsetRef = useRef(null) - const shiftPressedRef = useRef(false) const previewOffsetRef = useRef(originalCurveOffsetRef.current) const initialHandle = getWallMidpointHandlePoint(node) @@ -87,14 +86,14 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } const onGridMove = (event: GridEvent) => { - const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true const snapStep = getSegmentGridStep() // Snap the cursor on the WORLD XZ grid (still in building-local // coords for the rest of the math) so a rotated building doesn't // pull the curve handle off the visible grid lines. - const [snappedLocalX, snappedLocalZ] = bypassSnap - ? [event.localPosition[0], event.localPosition[2]] - : snapBuildingLocalToWorldGrid([event.localPosition[0], event.localPosition[2]], snapStep) + const [snappedLocalX, snappedLocalZ] = snapBuildingLocalToWorldGrid( + [event.localPosition[0], event.localPosition[2]], + snapStep, + ) const localX = snappedLocalX const localZ = snappedLocalZ @@ -102,16 +101,13 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { (localX - chord.midpoint.x) * chord.normal.x + (localZ - chord.midpoint.y) * chord.normal.y ) - const snappedOffset = bypassSnap - ? offsetFromMidpoint - : snapScalarToGrid(offsetFromMidpoint, snapStep) + const snappedOffset = snapScalarToGrid(offsetFromMidpoint, snapStep) const nextCurveOffset = normalizeWallCurveOffset( node, Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)), ) if ( - !bypassSnap && previousCurveOffsetRef.current !== null && nextCurveOffset !== previousCurveOffsetRef.current ) { @@ -157,23 +153,9 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { exitCurveMode() } - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - shiftPressedRef.current = true - } - } - - const onKeyUp = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - shiftPressedRef.current = false - } - } - emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) - window.addEventListener('keydown', onKeyDown) - window.addEventListener('keyup', onKeyUp) return () => { if (!wasCommitted) { @@ -183,8 +165,6 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) - window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keyup', onKeyUp) } }, [exitCurveMode, node]) diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index f7138351..f6b1ceb8 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -25,6 +25,7 @@ import { wallSlots } from './slots' */ export const wallDefinition: NodeDefinition = { kind: 'wall', + snapProfile: 'structural', schemaVersion: 1, schema: WallNode, category: 'structure', diff --git a/packages/nodes/src/wall/floorplan-affordances.ts b/packages/nodes/src/wall/floorplan-affordances.ts index e408dee1..2b924f85 100644 --- a/packages/nodes/src/wall/floorplan-affordances.ts +++ b/packages/nodes/src/wall/floorplan-affordances.ts @@ -43,8 +43,8 @@ import { * the final state to scene in one tracked update and clears the * overrides. `canCommit` still guards against collapsed walls. * - * Alt-detach (drop linked walls) and SHIFT-free-place (skip angle snap) - * are wired via the standard modifier flags on the session. + * Alt-detach (drop linked walls) is wired via the standard modifier + * flags on the session. */ type WallEndpointPayload = { wallId: AnyNodeId; endpoint: 'start' | 'end' } @@ -95,7 +95,7 @@ function collectLinkedWalls( * Wall curve sagitta drag — 1:1 port of the legacy * `handleWallCurvePointerDown` + commit flow. Drag projects the pointer * onto the chord normal to compute a `curveOffset`, snapped to the - * grid step (Shift bypasses snap), clamped to `getMaxWallCurveOffset`, + * grid step, clamped to `getMaxWallCurveOffset`, * normalized via `normalizeWallCurveOffset`. Same single-undo dance as * the move-endpoint affordance — the dispatcher handles snapshot / * pause / resume around `apply`. @@ -111,13 +111,11 @@ export const wallCurveAffordance: FloorplanAffordance = { return { affectedIds: [node.id], - apply({ planPoint, modifiers }) { + apply({ planPoint }) { const snapStep = getSegmentGridStep() // World-grid snap so a rotated building doesn't drag the curve // handle off the visible grid. - const [x, y] = modifiers.shiftKey - ? [planPoint[0], planPoint[1]] - : snapBuildingLocalToWorldGrid([planPoint[0], planPoint[1]], snapStep) + const [x, y] = snapBuildingLocalToWorldGrid([planPoint[0], planPoint[1]], snapStep) // Signed projection of (snappedPoint - chord midpoint) onto the // chord normal. Legacy negates because the SVG y-axis flips @@ -129,9 +127,7 @@ export const wallCurveAffordance: FloorplanAffordance = { (x - chord.midpoint.x) * chord.normal.x + (y - chord.midpoint.y) * chord.normal.y ) - const snappedOffset = modifiers.shiftKey - ? offsetFromMidpoint - : snapScalarToGrid(offsetFromMidpoint, snapStep) + const snappedOffset = snapScalarToGrid(offsetFromMidpoint, snapStep) const nextCurveOffset = normalizeWallCurveOffset( node, Math.max(-maxOffset, Math.min(maxOffset, snappedOffset)), @@ -188,13 +184,11 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { const sceneNodes = useScene.getState().nodes const walls = collectLevelWalls(sceneNodes, node.id) // Endpoint move = grid snap, never 45° from the fixed corner. - // Shift bypasses grid, magnetic, and alignment snap. const snapped = snapWallDraftPoint({ point: planPoint as WallPlanPoint, walls, ignoreWallIds: [node.id], - bypassSnap: modifiers.shiftKey, - magnetic: !modifiers.shiftKey && isMagneticSnapActive(), + magnetic: isMagneticSnapActive(), gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP), }) // Figma-style alignment on the dragged corner — snaps it onto another @@ -202,7 +196,6 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { // and its linked siblings (which cascade with the corner) are excluded // from the candidate pool. Alt is reserved for detach, NOT bypass. const aligned = alignFloorplanDraftPoint(snapped, { - bypass: modifiers.shiftKey, excludeIds: [node.id, ...linkedWalls.map((w) => w.id)], }) as WallPlanPoint diff --git a/packages/nodes/src/wall/floorplan-move.ts b/packages/nodes/src/wall/floorplan-move.ts index c891eec4..a4f9bdd8 100644 --- a/packages/nodes/src/wall/floorplan-move.ts +++ b/packages/nodes/src/wall/floorplan-move.ts @@ -105,7 +105,7 @@ export const wallFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) const session: FloorplanMoveTargetSession = { affectedIds: [wallId, ...linkedOriginals.map((w) => w.id as AnyNodeId)], - apply({ planPoint, modifiers }) { + apply({ planPoint }) { if (!rawAnchor) { rawAnchor = [planPoint[0], planPoint[1]] return @@ -119,19 +119,19 @@ export const wallFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) // the original centre + raw cursor delta onto the axis, snap the // absolute projection to a grid multiple, then translate the wall // by `axis * perpDelta`. Matches `MoveWallTool` so 2D and 3D drag - // produce identical wall topology. Shift bypasses snap. + // produce identical wall topology. let dx: number let dz: number if (moveAxis) { const originalProj = originalCenter[0] * moveAxis[0] + originalCenter[1] * moveAxis[1] const rawProj = originalProj + rawDx * moveAxis[0] + rawDz * moveAxis[1] - const snappedProj = modifiers.shiftKey ? rawProj : snapScalarToGrid(rawProj, step) + const snappedProj = snapScalarToGrid(rawProj, step) const perpDelta = snappedProj - originalProj dx = moveAxis[0] * perpDelta dz = moveAxis[1] * perpDelta } else { - dx = modifiers.shiftKey ? rawDx : snapScalarToGrid(rawDx, step) - dz = modifiers.shiftKey ? rawDz : snapScalarToGrid(rawDz, step) + dx = snapScalarToGrid(rawDx, step) + dz = snapScalarToGrid(rawDz, step) } if (dx === lastDelta[0] && dz === lastDelta[1]) return diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index 0c85e2a4..01ee2a04 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -19,6 +19,7 @@ import { formatAngleRadians, getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, + isAngleSnapActive, isMagneticSnapActive, isSegmentLongEnough, MeasurementPill, @@ -177,7 +178,6 @@ function getLinkedWallUpdates( export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ target }) => { const hasDraggedRef = useRef(false) const previousGridPosRef = useRef(null) - const shiftPressedRef = useRef(false) const altPressedRef = useRef(false) const nodeIdRef = useRef(target.wall.id) const originalStartRef = useRef([...target.wall.start] as WallPlanPoint) @@ -288,21 +288,17 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ const onGridMove = (event: GridEvent) => { const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] - // Endpoint *move* snaps to the grid (and to other wall corners) — - // 45° angle snap is for the initial draft, where it gives clean - // orthogonal corners; here it would fight every perpendicular - // drag by warping the endpoint onto the nearest 45° line from - // the fixed corner. - // - // Shift is a hard snap bypass: raw endpoint position, no grid, - // no magnetic wall snap, and no alignment guide snap. - const bypassSnap = shiftPressedRef.current || event.nativeEvent.shiftKey + // Endpoint move honours the active snapping mode (the HUD chip): grid → + // lattice; lines → magnetic corner/alignment snap; angles → lock the + // segment to 15° rays from the FIXED corner; off → raw. No Shift bypass — + // Shift cycles the mode now, and Off is the bypass. const snapResult = snapWallDraftPointDetailed({ point: planPoint, walls: levelWalls, ignoreWallIds: [nodeId], - bypassSnap, - magnetic: !bypassSnap && isMagneticSnapActive(), + start: fixedPoint, + angleSnap: isAngleSnapActive(), + magnetic: isMagneticSnapActive(), }) const snappedPoint = snapResult.point @@ -312,8 +308,10 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ // candidate, so the dot always sits on an actual point (endpoint / // midpoint), never an empty-space bbox corner. Layered on top of the // grid + corner snap above; Alt is reserved for corner-detach here. + // Alignment axes are the "Lines" snap, so gate them on the magnetic flag — + // Off / Grid / Angles must not pull the endpoint onto other elements' lines. let alignedPoint = snappedPoint - if (!bypassSnap && wallAlignmentCandidates.length > 0) { + if (isMagneticSnapActive() && wallAlignmentCandidates.length > 0) { const ar = resolveAlignment({ moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }], candidates: wallAlignmentCandidates, @@ -328,7 +326,6 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ } if ( - !bypassSnap && previousGridPosRef.current && (alignedPoint[0] !== previousGridPosRef.current[0] || alignedPoint[1] !== previousGridPosRef.current[1]) @@ -414,9 +411,6 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { return } - if (event.key === 'Shift') { - shiftPressedRef.current = true - } if (event.key === 'Alt') { altPressedRef.current = true setAltPressed(true) @@ -424,9 +418,6 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ } const onKeyUp = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - shiftPressedRef.current = false - } if (event.key === 'Alt') { altPressedRef.current = false setAltPressed(false) @@ -434,7 +425,6 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ } const onWindowBlur = () => { - shiftPressedRef.current = false altPressedRef.current = false setAltPressed(false) } diff --git a/packages/nodes/src/wall/move-tool.tsx b/packages/nodes/src/wall/move-tool.tsx index 7ba487cf..0a3b71ec 100644 --- a/packages/nodes/src/wall/move-tool.tsx +++ b/packages/nodes/src/wall/move-tool.tsx @@ -66,7 +66,7 @@ import { * operation. * - **`isNew` metadata strip** — first commit after a fresh wall * placement clears the placement marker. - * - **Activation grace** (150ms) + Shift to bypass grid snap. + * - **Activation grace** (150ms). * * Mounted via `def.affordanceTools.move` from `wall/definition.ts`. */ @@ -190,7 +190,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const nodeIdRef = useRef(node.id) const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) const pendingRotationRef = useRef(0) - const shiftPressedRef = useRef(false) const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { const centerX = (node.start[0] + node.end[0]) / 2 @@ -462,7 +461,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } const onGridMove = (event: GridEvent) => { - const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true const rawX = event.localPosition[0] const rawZ = event.localPosition[2] const snapStep = getSegmentGridStep() @@ -493,13 +491,10 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { if (axis) { const originalProj = originalCenter[0] * axis[0] + originalCenter[1] * axis[1] const rawProj = originalProj + rawDeltaX * axis[0] + rawDeltaZ * axis[1] - const snappedProj = bypassSnap ? rawProj : snapScalarToGrid(rawProj, snapStep) + const snappedProj = snapScalarToGrid(rawProj, snapStep) const perpDelta = snappedProj - originalProj deltaX = axis[0] * perpDelta deltaZ = axis[1] * perpDelta - } else if (bypassSnap) { - deltaX = rawDeltaX - deltaZ = rawDeltaZ } else { // Snap the resulting wall center to the WORLD XZ grid (projected // back into building-local), then express the result as a delta @@ -517,7 +512,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ] if ( - !bypassSnap && previousGridPosRef.current && (constrainedGridPos[0] !== previousGridPosRef.current[0] || constrainedGridPos[1] !== previousGridPosRef.current[1]) @@ -633,11 +627,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { return } - if (event.key === 'Shift') { - shiftPressedRef.current = true - return - } - const ROTATION_STEP = Math.PI / 4 let rotationDelta = 0 if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP @@ -661,12 +650,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { applyPreview(nextWall.start, nextWall.end) } - const onKeyUp = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - shiftPressedRef.current = false - } - } - const onCancel = () => { shouldRestoreOnCleanup = false restoreOriginal() @@ -683,7 +666,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPointerUp) window.addEventListener('keydown', onKeyDown) - window.addEventListener('keyup', onKeyUp) return () => { if (shouldRestoreOnCleanup) { @@ -698,13 +680,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { restoreOriginal() } } - shiftPressedRef.current = false resumeSceneHistory(useScene) emitter.off('grid:move', onGridMove) emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keyup', onKeyUp) } }, [exitMoveMode, isNew, node.metadata, node.parentId]) diff --git a/packages/nodes/src/wall/paint.ts b/packages/nodes/src/wall/paint.ts index d4f5cfa4..45eaefe1 100644 --- a/packages/nodes/src/wall/paint.ts +++ b/packages/nodes/src/wall/paint.ts @@ -104,6 +104,7 @@ function applyWallPreview(args: PaintPreviewArgs): (() => void) | null { * picker still shows the current value on a pre-migration scene. */ export const wallPaint: PaintCapability = createSlotPaintCapability({ + roomScope: true, resolveRole: ({ node, materialIndex, normal, localPosition }) => resolveWallRole({ node: node as WallNode, materialIndex, normal, localPosition }), applyPreview: applyWallPreview, diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index ec86c478..7b3b00bf 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -554,7 +554,8 @@ export const WallTool: React.FC = () => { // angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass. // Alt still bypasses Figma-style alignment guides independently. const angleLocked = buildingState.current === 1 && isAngleSnapActive() - const bypassAlign = event.nativeEvent?.altKey === true + // Alignment guides follow the snapping mode (lines = magnetic on), not Alt. + const bypassAlign = !isMagneticSnapActive() const snapResult = snapWallDraftPointDetailed({ point: localPoint, walls, @@ -634,7 +635,8 @@ export const WallTool: React.FC = () => { const walls = getCurrentLevelWalls() const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] - const bypassAlign = event.nativeEvent?.altKey === true + // Alignment guides follow the snapping mode (lines = magnetic on), not Alt. + const bypassAlign = !isMagneticSnapActive() if (buildingState.current === 0) { const snappedStart = alignPoint( diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index ee5538ac..01616452 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -229,7 +229,6 @@ export const windowDefinition: NodeDefinition = { toolHints: [ { key: 'Left click', label: 'Place window on wall' }, - { key: 'Shift', label: 'Free place' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/zone/definition.ts b/packages/nodes/src/zone/definition.ts index 7aeb8ec2..345731e2 100644 --- a/packages/nodes/src/zone/definition.ts +++ b/packages/nodes/src/zone/definition.ts @@ -17,6 +17,7 @@ import { ZoneNode } from './schema' */ export const zoneDefinition: NodeDefinition = { kind: 'zone', + snapProfile: 'structural', schemaVersion: 1, schema: ZoneNode, category: 'site',