feat(editor): node-declared per-context snapping + contextual HUD + painter scope

Generify the snapping/modifier HUD off the FSM scope and node declarations
instead of wall-creation-shaped, leaking pills.

- Per-context snapping (`snappingModeByContext`, persisted): wall / item /
  polygon mode-sets with exclusive modes (grid | lines | angles | off), each
  doing exactly what its chip says. Context is node-declared via the new
  `NodeDefinition.snapProfile` ('item' | 'structural'); the resolver maps
  (profile × action) → context with no per-kind switch.
- Scope-driven HUD: helper-manager reads the interaction scope; reshaping
  (endpoint/curve/boundary) and item move get their own chip, no select-hint
  leak. Rotate R/T rounds to 45°; Alt = force-place only (hidden for
  structural kinds); Shift = cycle everywhere.
- Slab/ceiling drafting: Shift=cycle, mode-aware grid/angle, Enter finishes
  (minDraftVertices); polygon boundary vertex/edge drag begins a reshaping
  scope. Fix grid/angle being ignored on boundary edit + slab creation:
  make resolveSurfacePlanPointSnap exclusive (alignment gated on magnetic) so
  grid/angles keep the snapped fallback instead of the raw cursor.
- Painter application scope: node-derived (single/object/matching/room) from
  the hovered node, cyclable via Shift, single-source HUD chip.
- Remove the redundant GridSnapControl from view-toggles (grid step lives in
  the contextual HUD now).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-24 14:23:15 -04:00
co-authored by Claude Opus 4.8
parent b8b3d35f26
commit 04f1b0d59e
61 changed files with 1543 additions and 785 deletions
+1
View File
@@ -108,6 +108,7 @@ export type {
SelectableConfig, SelectableConfig,
SlotDeclaration, SlotDeclaration,
SnapPointKind, SnapPointKind,
SnapProfile,
SnappableConfig, SnappableConfig,
SnapServicesLike, SnapServicesLike,
SurfacePoint, SurfacePoint,
+35
View File
@@ -221,6 +221,13 @@ export type ToolHint = {
key: string key: string
/** Description of what the input does. Sentence case. */ /** Description of what the input does. Sentence case. */
label: string 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 = export type FloorplanGeometry =
@@ -710,6 +717,15 @@ export type SurfaceRole =
/** Role a kind plays in a duct / pipe / lineset distribution system. */ /** Role a kind plays in a duct / pipe / lineset distribution system. */
export type DistributionRole = 'run' | 'fitting' | 'terminal' | 'equipment' 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<S extends ZodObject<any>> = { export type NodeDefinition<S extends ZodObject<any>> = {
kind: string kind: string
schemaVersion: number schemaVersion: number
@@ -958,6 +974,18 @@ export type NodeDefinition<S extends ZodObject<any>> = {
*/ */
toolHints?: ToolHint[] 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 * 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. * show where the node will land, and by the placement tool's cursor.
@@ -1267,6 +1295,13 @@ export type SlotDeclaration = {
} }
export type PaintCapability = { 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` * Resolve which logical surface the user clicked. Returns `null`
* when the face shouldn't be painted (e.g. interior slot exposed * when the face shouldn't be painted (e.g. interior slot exposed
@@ -2,15 +2,11 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type BuildingNode, type BuildingNode,
type CeilingNode,
type ColumnNode,
createSceneApi, createSceneApi,
emitter, emitter,
type FenceNode,
type GridEvent, type GridEvent,
getEffectiveRoofSurfaceMaterial, getEffectiveRoofSurfaceMaterial,
getEffectiveSegmentSurfaceMaterial, getEffectiveSegmentSurfaceMaterial,
getMaterialPresetByRef,
getRoofSegmentSurfaceY, getRoofSegmentSurfaceY,
getSelectableKinds, getSelectableKinds,
type ItemNode, type ItemNode,
@@ -22,11 +18,7 @@ import {
type RoofSegmentEvent, type RoofSegmentEvent,
type RoofSegmentNode, type RoofSegmentNode,
resolveLevelId, resolveLevelId,
resolveMaterial,
type ShelfNode,
type SlabNode,
type StairEvent, type StairEvent,
type StairNode,
type StairSegmentEvent, type StairSegmentEvent,
type StairSurfaceMaterialRole, type StairSurfaceMaterialRole,
sceneRegistry, sceneRegistry,
@@ -35,12 +27,9 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
applyMaterialPresetToMaterials,
createMaterial, createMaterial,
createMaterialFromPresetRef, createMaterialFromPresetRef,
getRoofMaterialArray, getRoofMaterialArray,
getStairBodyMaterials,
getStairRailingMaterial,
useViewer, useViewer,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
@@ -56,11 +45,17 @@ import {
type ActivePaintMaterial, type ActivePaintMaterial,
buildRoofSegmentSurfaceMaterialPatch, buildRoofSegmentSurfaceMaterialPatch,
buildRoofSurfaceMaterialPatch, buildRoofSurfaceMaterialPatch,
buildSingleSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch,
hasActivePaintMaterial, hasActivePaintMaterial,
resolveActivePaintMaterialFromSelection, resolveActivePaintMaterialFromSelection,
} from '../../lib/material-paint' } from '../../lib/material-paint'
import {
availablePaintScopes,
commitPaintScopeFanout,
nodeSlotRoles,
type PaintHoverInfo,
resolvePaintScopeTargets,
slotDisplayLabel,
} from '../../lib/paint-scope'
import { import {
resolveNodeSelectionTarget, resolveNodeSelectionTarget,
resolveSelectedIdsForNodeClick, resolveSelectedIdsForNodeClick,
@@ -114,6 +109,9 @@ type PaintInteraction = {
hoverMode: HoverHighlightMode hoverMode: HoverHighlightMode
hoveredId: AnyNodeId hoveredId: AnyNodeId
preview: (() => PaintPreviewCleanup | null) | null 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 { interface SelectionStrategy {
@@ -240,6 +238,28 @@ function getRegisteredMesh(nodeId: string): Mesh | null {
return object && (object as Mesh).isMesh ? (object as 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<string>()
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() const roofSelectionWorldPoint = new Vector3()
function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null { 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( function applyRoofPaintPreview(
node: RoofNode, node: RoofNode,
role: 'top' | 'edge' | 'wall', role: 'top' | 'edge' | 'wall',
@@ -388,164 +394,6 @@ function applyRoofSegmentPaintPreview(
return previewMeshMaterial(mesh, arr) 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 `<group>` (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 // Chimney + dormer paint dispatch lives on their NodeDefinition's
// `capabilities.paint` (see packages/nodes/src/{chimney,dormer}/ // `capabilities.paint` (see packages/nodes/src/{chimney,dormer}/
// paint.ts). The generic registry-driven arm in this file consults // paint.ts). The generic registry-driven arm in this file consults
@@ -878,6 +726,9 @@ export const SelectionManager = () => {
if (movingNode || isCurveReshape) return if (movingNode || isCurveReshape) return
let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null 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 = () => { const clearActivePreview = () => {
activePreview?.restore() activePreview?.restore()
@@ -939,13 +790,52 @@ export const SelectionManager = () => {
ray: event.nativeEvent.ray, ray: event.nativeEvent.ray,
}) })
const compatible = role !== null && paintEnabled 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 { 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, hoveredId: node.id as AnyNodeId,
hoverMode: compatible ? 'paint-ready' : 'paint-disabled', hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
paintHover:
compatible && role
? {
scopes: availablePaintScopes({ node, slotRoles }),
slotLabel: slotDisplayLabel(node, role),
nodeNoun: node.type,
}
: null,
apply: apply:
compatible && role 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 = { const args = {
node, node,
role, role,
@@ -967,15 +857,33 @@ export const SelectionManager = () => {
preview: preview:
compatible && role compatible && role
? () => { ? () => {
const root = getRegisteredNodeObject(node.id) // Preview every surface the click would paint, so room /
if (!root) return null // whole-item / all-matching show the full spread, not just the
return paintCap.applyPreview({ // hovered surface. Each target is the same kind, so its own
node, // paint capability builds the preview; restores combine.
role, 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, material: paintSpec.material,
materialPreset: paintSpec.materialPreset, materialPreset: paintSpec.materialPreset,
root, 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'), : () => previewCursor('not-allowed'),
} }
@@ -1004,6 +912,16 @@ export const SelectionManager = () => {
}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`, }:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId, hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId,
hoverMode: compatible ? 'paint-ready' : 'paint-disabled', 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: apply:
compatible && role compatible && role
? () => { ? () => {
@@ -1046,77 +964,9 @@ export const SelectionManager = () => {
} }
} }
if (node.type === 'stair' || node.type === 'stair-segment') { // Only `roof` / `roof-segment` reach a legacy paint arm (above) — every
const stairNode = // other paintable kind declares `capabilities.paint` and returns from the
node.type === 'stair' // registry-driven dispatch at the top of this function.
? 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'),
}
}
const disabledNodeTypes = ['zone'] const disabledNodeTypes = ['zone']
if (disabledNodeTypes.includes(node.type)) { if (disabledNodeTypes.includes(node.type)) {
@@ -1124,6 +974,7 @@ export const SelectionManager = () => {
key: `${node.type}:${node.id}:unsupported`, key: `${node.type}:${node.id}:unsupported`,
hoveredId: node.id as AnyNodeId, hoveredId: node.id as AnyNodeId,
hoverMode: 'paint-disabled', hoverMode: 'paint-disabled',
paintHover: null,
apply: null, apply: null,
preview: () => previewCursor('not-allowed'), preview: () => previewCursor('not-allowed'),
} }
@@ -1143,6 +994,12 @@ export const SelectionManager = () => {
if (!interaction) return if (!interaction) return
event.stopPropagation() 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) { if (activePreview?.key === interaction.key) {
return return
@@ -1162,6 +1019,10 @@ export const SelectionManager = () => {
const interaction = getPaintInteraction(event) const interaction = getPaintInteraction(event)
if (!interaction) return 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) { if (activePreview?.key !== interaction.key) {
return return
} }
@@ -1229,7 +1090,16 @@ export const SelectionManager = () => {
emitter.on(`${type}:click` as any, onClick as any) 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 () => { return () => {
unsubscribePaintScope()
for (const type of subscribedKinds) { for (const type of subscribedKinds) {
emitter.off(`${type}:enter` as any, onEnter as any) emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:move` as any, onEnter as any) emitter.off(`${type}:move` as any, onEnter as any)
@@ -1239,6 +1109,7 @@ export const SelectionManager = () => {
clearActivePreview() clearActivePreview()
useViewer.setState({ hoveredId: null }) useViewer.setState({ hoveredId: null })
setHoverHighlightMode('default') setHoverHighlightMode('default')
useEditor.getState().setPaintHover(null)
} }
}, [isCurveReshape, mode, movingNode, setHoverHighlightMode]) }, [isCurveReshape, mode, movingNode, setHoverHighlightMode])
@@ -1,16 +1,26 @@
import { type AssetInput, isObject } from '@pascal-app/core' import { type AssetInput, isObject } from '@pascal-app/core'
import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three' import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three'
import { resolveSnapFlags } from '../../../lib/snapping-mode' 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 // 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) // raw value through. For items the default mode is now `lines` (grid off), so
// this returns the user's `gridSnapStep` exactly as before — so the default // item placement/move is free + line-snap unless the user opts into `grid`.
// path is byte-identical to the pre-mode behaviour.
function getGridSnapStep(): number { function getGridSnapStep(): number {
const state = useEditor.getState() return resolveSnapFlags(getActiveSnappingMode()).grid ? useEditor.getState().gridSnapStep : 0
return resolveSnapFlags(state.snappingMode).grid ? state.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 { function positiveModulo(value: number, divisor: number): number {
@@ -115,10 +115,9 @@ export const floorStrategy = {
// is rotated; then project the world point back into building-local // is rotated; then project the world point back into building-local
// for storage. Without this, a rotated building drags placement off // for storage. Without this, a rotated building drags placement off
// the world grid. // the world grid.
const bypassSnap = event.nativeEvent?.altKey === true // Snapping is governed by the active mode (snapToGrid returns raw in Off /
const [x, z] = bypassSnap // non-grid modes); Alt is force-place only and never bypasses snapping here.
? [event.localPosition[0], event.localPosition[2]] const [x, z] = snapWorldXZForActiveBuilding(
: snapWorldXZForActiveBuilding(
snapToGrid(event.position[0], swapDims ? dimZ : dimX), snapToGrid(event.position[0], swapDims ? dimZ : dimX),
snapToGrid(event.position[2], swapDims ? dimX : dimZ), snapToGrid(event.position[2], swapDims ? dimX : dimZ),
0, 0,
@@ -204,10 +203,9 @@ export const wallStrategy = {
const itemRotation = calculateItemRotation(event.normal) const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const bypassSnap = event.nativeEvent?.altKey === true const x = snapToHalf(event.localPosition[0])
const x = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0]) const y = snapToHalf(event.localPosition[1])
const y = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1]) const z = snapToHalf(event.localPosition[2])
const z = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
// Get auto-adjusted Y position from validator // Get auto-adjusted Y position from validator
const rawDims = ctx.draftItem const rawDims = ctx.draftItem
@@ -239,9 +237,7 @@ export const wallStrategy = {
}, },
cursorRotationY: cursorRotation, cursorRotationY: cursorRotation,
gridPosition: [x, adjustedY, z], gridPosition: [x, adjustedY, z],
cursorPosition: bypassSnap cursorPosition: [
? [event.position[0], event.position[1], event.position[2]]
: [
snapToHalf(event.position[0]), snapToHalf(event.position[0]),
snapToHalf(event.position[1]), snapToHalf(event.position[1]),
snapToHalf(event.position[2]), snapToHalf(event.position[2]),
@@ -268,10 +264,9 @@ export const wallStrategy = {
const itemRotation = calculateItemRotation(event.normal) const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const bypassSnap = event.nativeEvent?.altKey === true const snappedX = snapToHalf(event.localPosition[0])
const snappedX = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0]) const snappedY = snapToHalf(event.localPosition[1])
const snappedY = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1]) const snappedZ = snapToHalf(event.localPosition[2])
const snappedZ = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
// Get auto-adjusted Y position from validator // Get auto-adjusted Y position from validator
const validation = validators.canPlaceOnWall( const validation = validators.canPlaceOnWall(
@@ -289,9 +284,7 @@ export const wallStrategy = {
return { return {
gridPosition: [snappedX, adjustedY, snappedZ], gridPosition: [snappedX, adjustedY, snappedZ],
cursorPosition: bypassSnap cursorPosition: [
? [event.position[0], event.position[1], event.position[2]]
: [
snapToHalf(event.position[0]), snapToHalf(event.position[0]),
snapToHalf(event.position[1]), snapToHalf(event.position[1]),
snapToHalf(event.position[2]), snapToHalf(event.position[2]),
@@ -416,8 +409,10 @@ function resolveRoofWallTarget(
const dims = getGridAlignedDimensions(rawDims, attachTo) const dims = getGridAlignedDimensions(rawDims, attachTo)
const [width, height] = dims const [width, height] = dims
const u = freePlace ? hit.u : snapToHalf(hit.u) // Snap follows the active mode (snapToHalf returns raw in Off/non-grid);
const centerV = (freePlace ? hit.v : snapToHalf(hit.v)) + height / 2 // `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) const fitted = freePlace ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
if (!fitted && !freePlace) return null if (!fitted && !freePlace) return null
const finalU = fitted?.u ?? u const finalU = fitted?.u ?? u
@@ -617,13 +612,8 @@ export const ceilingStrategy = {
// Ceiling items are stored in ceiling-local coordinates, so snapping must // Ceiling items are stored in ceiling-local coordinates, so snapping must
// use the ceiling hit's local position rather than world position. // use the ceiling hit's local position rather than world position.
const bypassSnap = event.nativeEvent?.altKey === true const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const x = bypassSnap const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
? event.localPosition[0]
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = bypassSnap
? event.localPosition[2]
: snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
// Recessed fixtures seat flush with the ceiling plane (body rising into the // Recessed fixtures seat flush with the ceiling plane (body rising into the
// void above); everything else hangs its full height below the ceiling. // void above); everything else hangs its full height below the ceiling.
const seatY = ctx.asset.recessed ? 0 : -itemHeight const seatY = ctx.asset.recessed ? 0 : -itemHeight
@@ -656,13 +646,8 @@ export const ceilingStrategy = {
const rotY = ctx.draftItem.rotation?.[1] ?? 0 const rotY = ctx.draftItem.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9 const swapDims = Math.abs(Math.sin(rotY)) > 0.9
const bypassSnap = event.nativeEvent?.altKey === true const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const x = bypassSnap const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
? event.localPosition[0]
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = bypassSnap
? event.localPosition[2]
: snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
// Recessed fixtures seat flush with the ceiling plane (body rising into the // Recessed fixtures seat flush with the ceiling plane (body rising into the
// void above); everything else hangs its full height below the ceiling. // void above); everything else hangs its full height below the ceiling.
const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight
@@ -773,9 +758,8 @@ export const itemSurfaceStrategy = {
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
if (surfaceHeight === null) return null if (surfaceHeight === null) return null
const bypassSnap = event.nativeEvent?.altKey === true const x = snapToGrid(localPos.x, ourDims[0])
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const z = snapToGrid(localPos.z, ourDims[2])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const y = surfaceHeight const y = surfaceHeight
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
@@ -825,9 +809,8 @@ export const itemSurfaceStrategy = {
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
if (surfaceHeight === null) return null if (surfaceHeight === null) return null
const bypassSnap = event.nativeEvent?.altKey === true const x = snapToGrid(localPos.x, ourDims[0])
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const z = snapToGrid(localPos.z, ourDims[2])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const y = surfaceHeight const y = surfaceHeight
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
@@ -926,9 +909,8 @@ export const shelfSurfaceStrategy = {
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null if (rowY === null) return null
const bypassSnap = event.nativeEvent?.altKey === true const x = snapToGrid(localPos.x, ourDims[0])
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const z = snapToGrid(localPos.z, ourDims[2])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z)) const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
@@ -971,9 +953,8 @@ export const shelfSurfaceStrategy = {
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null if (rowY === null) return null
const bypassSnap = event.nativeEvent?.altKey === true const x = snapToGrid(localPos.x, ourDims[0])
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const z = snapToGrid(localPos.z, ourDims[2])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z)) const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
return { return {
@@ -56,7 +56,9 @@ import {
getDetachedAttachmentPreviewLift, getDetachedAttachmentPreviewLift,
getGridAlignedDimensions, getGridAlignedDimensions,
snapToGrid, snapToGrid,
snapToHalf,
snapUpToGridStep, snapUpToGridStep,
steppedRotation,
} from './placement-math' } from './placement-math'
import { import {
ceilingStrategy, ceilingStrategy,
@@ -779,8 +781,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const draft = draftNode.current const draft = draftNode.current
let alignX = 0 let alignX = 0
let alignZ = 0 let alignZ = 0
const freePlace = floorEvent.nativeEvent?.altKey === true // Alignment ("lines") follows the snapping mode only — Alt is force-place,
const bypassAlign = freePlace || !isMagneticSnapActive() // it does NOT bypass snapping (Off mode is the no-snap bypass).
const bypassAlign = !isMagneticSnapActive()
if (!bypassAlign && draft) { if (!bypassAlign && draft) {
alignmentCandidates ??= collectAlignmentAnchors( alignmentCandidates ??= collectAlignmentAnchors(
useScene.getState().nodes, useScene.getState().nodes,
@@ -814,7 +817,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Play snap sound when grid position changes // Play snap sound when grid position changes
if ( if (
!freePlace &&
previousGridPos && previousGridPos &&
(gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2]) (gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
) { ) {
@@ -999,7 +1001,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.z !== result.gridPosition[2] gridPosition.current.z !== result.gridPosition[2]
// Play snap sound when grid position changes // Play snap sound when grid position changes
if (event.nativeEvent?.altKey !== true && posChanged) { if (posChanged) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -1169,7 +1171,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.y !== result.gridPosition[1] || gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2] gridPosition.current.z !== result.gridPosition[2]
if (!altFreeRef.current && posChanged) { if (posChanged) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -1263,9 +1265,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
event.position[1], event.position[1],
event.position[2], event.position[2],
) )
const bypassSnap = event.nativeEvent?.altKey === true // Mode-aware snap (raw in Off / non-grid); Alt is force-place, not bypass.
const wx = bypassSnap ? buildingLocalPoint.x : Math.round(buildingLocalPoint.x * 2) / 2 const wx = snapToHalf(buildingLocalPoint.x)
const wz = bypassSnap ? buildingLocalPoint.z : Math.round(buildingLocalPoint.z * 2) / 2 const wz = snapToHalf(buildingLocalPoint.z)
const floorPos: [number, number, number] = [wx, 0, wz] const floorPos: [number, number, number] = [wx, 0, wz]
Object.assign(placementState.current, { Object.assign(placementState.current, {
@@ -1600,7 +1602,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.y !== result.gridPosition[1] || gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2] gridPosition.current.z !== result.gridPosition[2]
if (event.nativeEvent?.altKey !== true && posChanged) { if (posChanged) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -1791,9 +1793,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// ---- Keyboard rotation ---- // ---- 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) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Alt') { if (event.key === 'Alt') {
altFreeRef.current = true altFreeRef.current = true
@@ -1813,17 +1812,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// manual rotation would skew them off the wall plane. // manual rotation would skew them off the wall plane.
if (placementState.current.surface === 'roof-wall') return 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) 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) 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() event.preventDefault()
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
const currentRotation = draft.rotation 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]] draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
// Ref + cursor mesh + item mesh — no store update during drag // Ref + cursor mesh + item mesh — no store update during drag
@@ -31,7 +31,7 @@ import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement' import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { resolveSnapFlags } from '../../../lib/snapping-mode' 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 { swallowNextClick } from '../../editor/node-arrow-handles'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { DragBoundingBox } from '../shared/drag-bounding-box' 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 /** 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. */ * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
const snapToGridStep = (value: number) => { const snapToGridStep = (value: number) => {
const state = useEditor.getState() if (!resolveSnapFlags(getActiveSnappingMode()).grid) return value
if (!resolveSnapFlags(state.snappingMode).grid) return value const step = useEditor.getState().gridSnapStep
const step = state.gridSnapStep
return Math.round(value / step) * step return Math.round(value / step) * step
} }
@@ -420,7 +419,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
original: [originalPosition[0], originalPosition[2]], original: [originalPosition[0], originalPosition[2]],
anchor: dragAnchorRef.current, anchor: dragAnchorRef.current,
mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative', 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 dragAnchorRef.current = resolved.anchor
let [x, z] = resolved.point 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, // 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 // snap and publish a guide. The guide connects to the nearest real
// corner of the candidate (resolver tie-break), so the dot always sits // corner of the candidate (resolver tie-break), so the dot always sits
// on an actual point. Alt (free place) bypasses all snap; the active // on an actual point. Alignment ("lines") follows the snapping mode only —
// snapping mode governs whether magnetic alignment runs at all. // Alt is force-place (forces a valid drop), it does not bypass snapping.
const freePlace = event.nativeEvent?.altKey === true const bypass = !isMagneticSnapActive()
const bypass = freePlace || !isMagneticSnapActive()
if (!bypass && alignmentCandidates.length > 0) { if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({ const result = resolveAlignment({
moving: movingFootprintAnchors(node, x, z, rotationRef.current), moving: movingFootprintAnchors(node, x, z, rotationRef.current),
@@ -493,7 +492,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
previewConnectivity(position, rotationRef.current) previewConnectivity(position, rotationRef.current)
const prev = previousSnapRef.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') sfxEmitter.emit('sfx:grid-snap')
previousSnapRef.current = [x, z] previousSnapRef.current = [x, z]
} }
@@ -746,10 +746,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const point = levelNode ? event.localPosition : event.position const point = levelNode ? event.localPosition : event.position
const rawPoint: [number, number] = [point[0], point[2]] const rawPoint: [number, number] = [point[0], point[2]]
const bypassSnap = event.nativeEvent.shiftKey === true // Snapping follows the active mode (snapToHalf returns raw in Off / non-grid);
const gridPoint: [number, number] = bypassSnap // no Shift bypass — Shift cycles the mode, Off is the bypass.
? rawPoint const gridPoint: [number, number] = [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])]
: [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])]
const newPosition = const newPosition =
dragState?.isDragging && resolvePlanPoint dragState?.isDragging && resolvePlanPoint
? resolvePlanPoint({ ? resolvePlanPoint({
@@ -766,7 +765,6 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
// Play snap sound when cursor moves to a new grid cell during drag // Play snap sound when cursor moves to a new grid cell during drag
if ( if (
!bypassSnap &&
dragState?.isDragging && dragState?.isDragging &&
previousPositionRef.current && previousPositionRef.current &&
(newPosition[0] !== previousPositionRef.current[0] || (newPosition[0] !== previousPositionRef.current[0] ||
@@ -14,7 +14,7 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { resolveSnapFlags } from '../../../lib/snapping-mode' import { resolveSnapFlags } from '../../../lib/snapping-mode'
import useEditor, { isMagneticSnapActive } from '../../../store/use-editor' import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor'
import { import {
distanceSquared, distanceSquared,
findWallSnapTarget, findWallSnapTarget,
@@ -52,12 +52,11 @@ type WallSplitIntersection = {
} }
export function getSegmentGridStep(): number { export function getSegmentGridStep(): number {
const state = useEditor.getState()
// A 0 step means "no grid lattice" — every grid-snap consumer guards on // 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 // `step <= 0` and returns the raw value, so disabling grid here suppresses
// the lattice for walls, fences, and every node move/affordance that reads // the lattice for walls, fences, and every node move/affordance that reads
// this choke point, without retuning their snap math. // 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 { export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
@@ -9,7 +9,7 @@ import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor' import useEditor from './../../../store/use-editor'
import { CameraActions } from './camera-actions' import { CameraActions } from './camera-actions'
import { ControlModes } from './control-modes' 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 // 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 // rounded corners (SHEET_OVERLAP_PX in editor-layout-mobile) so the menu sits
@@ -57,9 +57,8 @@ export function ActionMenu({ className }: { className?: string }) {
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
<ControlModes /> <ControlModes />
</div> </div>
{/* Row 2: grid snap + secondary toggles (orbit + top view hidden) */} {/* Row 2: secondary toggles (orbit + top view hidden) */}
<div className="flex items-center justify-center gap-1 border-border/50 border-t pt-1"> <div className="flex items-center justify-center gap-1 border-border/50 border-t pt-1">
<GridSnapControl />
<SecondaryToggles /> <SecondaryToggles />
</div> </div>
</div> </div>
@@ -67,7 +66,6 @@ export function ActionMenu({ className }: { className?: string }) {
<div className="flex items-center justify-center gap-1 px-2 py-1.5"> <div className="flex items-center justify-center gap-1 px-2 py-1.5">
<ControlModes /> <ControlModes />
<div className="mx-1 h-5 w-px bg-border" /> <div className="mx-1 h-5 w-px bg-border" />
<GridSnapControl />
<SecondaryToggles /> <SecondaryToggles />
<div className="mx-1 h-5 w-px bg-border" /> <div className="mx-1 h-5 w-px bg-border" />
<CameraActions /> <CameraActions />
@@ -1,6 +1,5 @@
'use client' 'use client'
import { Icon } from '@iconify/react'
import { import {
type AnyNodeId, type AnyNodeId,
type BuildingNode, type BuildingNode,
@@ -16,23 +15,17 @@ import { useShallow } from 'zustand/react/shallow'
import { getLevelDisplayName } from '@pascal-app/core' import { getLevelDisplayName } from '@pascal-app/core'
import { createLocalGuideImage } from '../../../lib/local-guide-image' import { createLocalGuideImage } from '../../../lib/local-guide-image'
import { cn } from '../../../lib/utils' 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 { useUploadStore } from '../../../store/use-upload'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover' import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
import { ActionButton } from './action-button' import { ActionButton } from './action-button'
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif' 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 = const REFERENCES_EMPTY_TEXT =
'Upload GLB meshes as scan references or blueprint images as guide references.' '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 ────────────────────────── // ── Helper: get guide images for the current level ──────────────────────────
function useLevelGuides(): GuideNode[] { 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 (
<Popover onOpenChange={setIsOpen} open={isOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
aria-expanded={isOpen}
aria-label={`Grid snap: ${formatGridSnapStep(gridSnapStep)}`}
className={cn(
'flex h-11 w-11 flex-col items-center justify-center rounded-lg text-muted-foreground transition-all hover:bg-white/5 hover:text-foreground',
isOpen && 'bg-white/10 text-foreground',
)}
type="button"
>
<Icon height={16} icon="lucide:grid-2x2" width={16} />
<span className="mt-1 font-medium text-[9px] leading-none">
{formatGridSnapStep(gridSnapStep)}
</span>
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top">Grid snap: {formatGridSnapStep(gridSnapStep)}</TooltipContent>
</Tooltip>
<PopoverContent
align="center"
className="w-36 rounded-xl border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
side="top"
sideOffset={14}
>
<div className="space-y-1">
{GRID_SNAP_STEPS.map((step) => {
const isActive = step === gridSnapStep
return (
<button
className={cn(
'flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-white/8',
isActive && 'bg-white/10 text-foreground',
)}
key={step}
onClick={() => {
setGridSnapStep(step)
setIsOpen(false)
}}
type="button"
>
<span>{formatGridSnapStep(step)}</span>
{isActive ? <Check className="h-3.5 w-3.5" /> : <span className="h-3.5 w-3.5" />}
</button>
)
})}
</div>
</PopoverContent>
</Popover>
)
}
// ── Scans toggle + dropdown ───────────────────────────────────────────────── // ── Scans toggle + dropdown ─────────────────────────────────────────────────
function ScansControl() { function ScansControl() {
@@ -1014,8 +943,6 @@ function RiserControl() {
// ── Exports ───────────────────────────────────────────────────────────────── // ── Exports ─────────────────────────────────────────────────────────────────
export { GridSnapControl }
export function SecondaryToggles() { export function SecondaryToggles() {
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -1027,7 +954,6 @@ export function SecondaryToggles() {
export function ViewToggles() { export function ViewToggles() {
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<GridSnapControl />
<ScansControl /> <ScansControl />
<GuidesControl /> <GuidesControl />
<ReferenceFloorControl /> <ReferenceFloorControl />
@@ -1,6 +1,12 @@
import { Icon } from '@iconify/react' import { Icon } from '@iconify/react'
import type { ContextualShortcutHint } from '../../../lib/contextual-help' 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 { cn } from '../../../lib/utils'
import useEditor, { type GridSnapStep } from '../../../store/use-editor' import useEditor, { type GridSnapStep } from '../../../store/use-editor'
import { ShortcutToken } from '../primitives/shortcut-token' import { ShortcutToken } from '../primitives/shortcut-token'
@@ -9,12 +15,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
const PILL_CLASS = 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' '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[] }) { function ShortcutSequence({ keys }: { keys: string[] }) {
return ( return (
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{keys.map((key, index) => ( {keys.map((key, index) => (
<div className="flex items-center gap-1" key={`${key}-${index}`}> <div className="flex items-center gap-1" key={`${key}-${index}`}>
{index > 0 ? <span className="text-[9px] text-muted-foreground/70">+</span> : null} {index > 0 ? <span className="text-[9px] text-muted-foreground/70">/</span> : null}
<ShortcutToken className="h-6 px-1.5 text-[10px]" value={key} /> <ShortcutToken className="h-6 px-1.5 text-[10px]" value={key} />
</div> </div>
))} ))}
@@ -43,12 +51,13 @@ function nextGridSnapStep(step: GridSnapStep): GridSnapStep {
return GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]! 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 // Interactive chip rows: the active interaction's own snapping controls, scoped
// surrounding stack is `pointer-events-none` (passive key hints), so these // to its context (wall / item / polygon) so each action shows only the modes
// pills carve out `pointer-events-auto` to stay clickable. // that make sense for it. The surrounding stack is `pointer-events-none` (passive
function SnappingChips() { // key hints), so these pills carve out `pointer-events-auto` to stay clickable.
const snappingMode = useEditor((s) => s.snappingMode) function SnappingChips({ context }: { context: SnapContext }) {
const cycleSnappingMode = useEditor((s) => s.cycleSnappingMode) const snappingMode = useEditor((s) => s.snappingModeByContext[context])
const setSnappingMode = useEditor((s) => s.setSnappingMode)
const gridSnapStep = useEditor((s) => s.gridSnapStep) const gridSnapStep = useEditor((s) => s.gridSnapStep)
const setGridSnapStep = useEditor((s) => s.setGridSnapStep) const setGridSnapStep = useEditor((s) => s.setGridSnapStep)
@@ -61,7 +70,7 @@ function SnappingChips() {
<button <button
aria-label={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`} aria-label={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
className={`${PILL_CLASS} pointer-events-auto cursor-pointer transition-colors hover:bg-accent`} className={`${PILL_CLASS} pointer-events-auto cursor-pointer transition-colors hover:bg-accent`}
onClick={() => cycleSnappingMode()} onClick={() => setSnappingMode(context, cycleSnappingModeIn(context, snappingMode))}
type="button" type="button"
> >
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium"> <span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium">
@@ -101,18 +110,107 @@ function SnappingChips() {
) )
} }
const PAINT_SCOPE_ICONS: Record<PaintScope, string> = {
single: 'lucide:square',
object: 'lucide:box',
matching: 'lucide:copy',
room: 'lucide:scan',
}
// The painter's application-scope chip. Driven entirely by the hovered node's
// derived `paintHover` (scopes + labels), so it works for any kind without a
// per-target table. Carves out `pointer-events-auto` like the snapping chips.
function PaintScopeChip() {
// What the cursor is over (that's what the next click paints). `null` when not
// over a paintable surface — including an item with no slots.
const paintHover = useEditor((s) => s.paintHover)
const paintScope = useEditor((s) => s.paintScope)
const cyclePaintScope = useEditor((s) => s.cyclePaintScope)
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
const paintEraser = useEditor((s) => s.paintEraser)
// Nothing to paint with yet (no material picked, not erasing) → the first step
// is choosing a material, so say that before anything about scope or hovering.
if (!(paintEraser || hasActivePaintMaterial(activePaintMaterial))) {
return (
<div className={PILL_CLASS}>
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium text-muted-foreground">
<Icon className="shrink-0" height={13} icon="lucide:palette" width={13} />
<span className="truncate">Select a material to paint</span>
</span>
</div>
)
}
// Not over anything paintable → guide the user to hover, still teaching Shift.
if (!paintHover) {
return (
<div className={PILL_CLASS}>
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium text-muted-foreground">
<Icon className="shrink-0" height={13} icon="lucide:mouse-pointer-click" width={13} />
<span className="truncate">Hover a surface to paint</span>
</span>
<ShortcutToken className="h-6 px-1.5 text-[10px]" value="Shift" />
</div>
)
}
const { scopes } = paintHover
// A scope carried over from another node (the mode is global) falls back to
// the narrowest for both display and — via the apply-time resolver — behaviour.
const effective: PaintScope = scopes.includes(paintScope) ? paintScope : 'single'
// Paintable but with no scope choice (roof, a one-slot node, …) → a passive
// pill that still names the surface, so the user always sees what they'll paint.
if (scopes.length <= 1) {
return (
<div className={PILL_CLASS}>
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium">
<Icon className="shrink-0" height={13} icon={PAINT_SCOPE_ICONS[effective]} width={13} />
<span className="truncate">Paint: {paintScopeLabel(effective, paintHover)}</span>
</span>
</div>
)
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={`Paint scope: ${paintScopeLabel(effective, paintHover)}`}
className={`${PILL_CLASS} pointer-events-auto cursor-pointer transition-colors hover:bg-accent`}
onClick={() => cyclePaintScope()}
type="button"
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium">
<Icon className="shrink-0" height={13} icon={PAINT_SCOPE_ICONS[effective]} width={13} />
<span className="truncate">Paint: {paintScopeLabel(effective, paintHover)}</span>
</span>
<ShortcutToken className="h-6 px-1.5 text-[10px]" value="Shift" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Paint scope click or press Shift to cycle</TooltipContent>
</Tooltip>
)
}
export function ContextualHelperPanel({ export function ContextualHelperPanel({
hints, hints,
showSnapping = false, snapContext = null,
showPaintScope = false,
}: { }: {
hints: ContextualShortcutHint[] 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 ( return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex max-w-[260px] -translate-y-1/2 flex-col items-end gap-2"> <div className="pointer-events-none fixed top-1/2 right-4 z-40 flex max-w-[260px] -translate-y-1/2 flex-col items-end gap-2">
{showSnapping ? <SnappingChips /> : null} {snapContext ? <SnappingChips context={snapContext} /> : null}
{showPaintScope ? <PaintScopeChip /> : null}
{hints.map((hint) => ( {hints.map((hint) => (
<div <div
className={cn( className={cn(
@@ -11,19 +11,40 @@ import { useEffect, useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useIsMobile } from '../../../hooks/use-mobile' import { useIsMobile } from '../../../hooks/use-mobile'
import { import {
type ContextualShortcutHint,
ROTATE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL,
resolveRotateHandleHelpHints, resolveRotateHandleHelpHints,
resolveSelectModeHelpHints, resolveSelectModeHelpHints,
} from '../../../lib/contextual-help' } from '../../../lib/contextual-help'
import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation' import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation'
import type { ReshapeKind } from '../../../lib/interaction/scope'
import { snapContextOf } from '../../../lib/snapping-mode'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { useActiveHandleDrag, useMovingNode } from '../../../store/use-interaction-scope' import useInteractionScope, {
useActiveHandleDrag,
useMovingNode,
} from '../../../store/use-interaction-scope'
import { BuildingHelper } from './building-helper' import { BuildingHelper } from './building-helper'
import { ContextualHelperPanel } from './contextual-helper-panel' import { ContextualHelperPanel } from './contextual-helper-panel'
import { ItemHelper } from './item-helper' import { ItemHelper } from './item-helper'
import { RegisteredToolHelper } from './registered-tool-helper' import { RegisteredToolHelper } from './registered-tool-helper'
import { RoofHelper } from './roof-helper' import { RoofHelper } from './roof-helper'
// Reshaping a selected node's geometry (endpoint / curve / polygon corner). The
// snapping chip is the main control; these just name the gesture + Esc.
function reshapingHints(reshape: ReshapeKind): ContextualShortcutHint[] {
const action =
reshape === 'curve'
? 'Curve'
: reshape === 'endpoint'
? 'Move endpoint'
: 'Move corner'
return [
{ keys: ['Drag'], label: action },
{ keys: ['Esc'], label: 'Cancel' },
]
}
type ActiveModifierKeys = { type ActiveModifierKeys = {
command: boolean command: boolean
shift: boolean shift: boolean
@@ -66,6 +87,7 @@ function useActiveModifierKeys(): ActiveModifierKeys {
export function HelperManager() { export function HelperManager() {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const tool = useEditor((s) => s.tool) const tool = useEditor((s) => s.tool)
const scope = useInteractionScope((s) => s.scope)
const movingNode = useMovingNode() const movingNode = useMovingNode()
const activeHandleDrag = useActiveHandleDrag() const activeHandleDrag = useActiveHandleDrag()
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -78,6 +100,18 @@ export function HelperManager() {
.filter((node): node is AnyNode => node !== undefined), .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( const selectModeHints = useMemo(
() => () =>
resolveSelectModeHelpHints({ resolveSelectModeHelpHints({
@@ -100,16 +134,36 @@ export function HelperManager() {
return <ContextualHelperPanel hints={resolveRotateHandleHelpHints(modifiers.shift)} /> return <ContextualHelperPanel hints={resolveRotateHandleHelpHints(modifiers.shift)} />
} }
// 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 <ContextualHelperPanel hints={reshapingHints(scope.reshape)} snapContext={snapContext} />
}
if (movingNode) { if (movingNode) {
if (movingNode.type === 'building') return <BuildingHelper showRotate /> if (movingNode.type === 'building') return <BuildingHelper showRotate />
return <ItemHelper showEsc /> // Force-place only makes sense for kinds that collision-validate their drop;
// structural kinds (wall/slab/…) never reject, so don't advertise Alt.
return (
<ItemHelper
showEsc
showForce={nodeRegistry.get(movingNode.type)?.snapProfile !== 'structural'}
snapContext={snapContext}
/>
)
} }
// 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') { if (mode === 'material-paint') {
return null return <ContextualHelperPanel hints={[]} showPaintScope />
} }
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 <ContextualHelperPanel hints={selectModeHints} /> return <ContextualHelperPanel hints={selectModeHints} />
} }
@@ -119,13 +173,19 @@ export function HelperManager() {
if (tool) { if (tool) {
const def = nodeRegistry.get(tool) const def = nodeRegistry.get(tool)
if (def?.toolHints && def.toolHints.length > 0) { if (def?.toolHints && def.toolHints.length > 0) {
return <RegisteredToolHelper hints={def.toolHints} shiftPressed={modifiers.shift} /> return (
<RegisteredToolHelper
hints={def.toolHints}
shiftPressed={modifiers.shift}
snapContext={snapContext}
/>
)
} }
} }
// Legacy fallback — only `roof` remains because it hasn't migrated to // Legacy fallback — only `roof` remains because it hasn't migrated to
// `def.tool` / `def.toolHints` yet (no Stage D port). When roof // `def.tool` / `def.toolHints` yet (no Stage D port). When roof
// migrates, this switch deletes outright. // migrates, this switch deletes outright.
if (tool === 'roof') return <RoofHelper shiftPressed={modifiers.shift} /> if (tool === 'roof') return <RoofHelper snapContext={snapContext} />
return null return null
} }
@@ -1,21 +1,26 @@
import type { SnapContext } from '../../../lib/snapping-mode'
import { ContextualHelperPanel } from './contextual-helper-panel' import { ContextualHelperPanel } from './contextual-helper-panel'
interface ItemHelperProps { interface ItemHelperProps {
showEsc?: boolean 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 ( return (
<ContextualHelperPanel <ContextualHelperPanel
showSnapping
hints={[ hints={[
{ keys: ['Left click'], label: 'Place item' }, { keys: ['Left click'], label: 'Place' },
{ keys: ['R'], label: 'Rotate counterclockwise' }, { keys: ['R', 'T'], label: 'Rotate' },
{ keys: ['T'], label: 'Rotate clockwise' }, ...(showForce ? [{ keys: ['Alt'], label: 'Force place' }] : []),
{ keys: ['Shift'], label: 'Cycle snapping mode' },
{ keys: ['Alt'], label: 'Free place (no snap)' },
{ keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' }, { keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' },
]} ]}
snapContext={snapContext}
/> />
) )
} }
@@ -1,4 +1,6 @@
import type { ToolHint } from '@pascal-app/core' 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' import { ContextualHelperPanel } from './contextual-helper-panel'
/** /**
@@ -13,26 +15,37 @@ import { ContextualHelperPanel } from './contextual-helper-panel'
export function RegisteredToolHelper({ export function RegisteredToolHelper({
hints, hints,
shiftPressed = false, shiftPressed = false,
snapContext = null,
}: { }: {
hints: ToolHint[] hints: ToolHint[]
shiftPressed?: boolean 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 ( return (
<ContextualHelperPanel <ContextualHelperPanel
showSnapping hints={visible.map((hint) => {
hints={hints.map((hint) => { // Shift is a per-kind bypass for opening / zone / duct placement ("Free
// Shift is a per-kind bypass for item / opening / zone / duct placement // place", "Free angle", …) — those flip to a bypassed state while held.
// ("Free place", "Free angle", …) — those hints flip to a bypassed const isBypassHint = hint.key === 'Shift'
// state while held. For wall / fence, Shift now cycles the snapping
// mode (no hold-to-bypass), so it must NOT show the bypass treatment.
const isBypassHint = hint.key === 'Shift' && hint.label !== 'Cycle snapping mode'
return { return {
keys: [hint.key], keys: [hint.key],
label: shiftPressed && isBypassHint ? 'Guided constraints bypassed' : hint.label, label: shiftPressed && isBypassHint ? 'Guided constraints bypassed' : hint.label,
active: shiftPressed && isBypassHint, active: shiftPressed && isBypassHint,
} }
})} })}
snapContext={snapContext}
/> />
) )
} }
@@ -1,18 +1,14 @@
import type { SnapContext } from '../../../lib/snapping-mode'
import { ContextualHelperPanel } from './contextual-helper-panel' import { ContextualHelperPanel } from './contextual-helper-panel'
export function RoofHelper({ shiftPressed = false }: { shiftPressed?: boolean }) { export function RoofHelper({ snapContext }: { snapContext?: SnapContext | null }) {
return ( return (
<ContextualHelperPanel <ContextualHelperPanel
showSnapping
hints={[ hints={[
{ keys: ['Left click'], label: 'Set corner' }, { keys: ['Left click'], label: 'Set corner' },
{
keys: ['Shift'],
label: shiftPressed ? 'Guided constraints bypassed' : 'Free corner',
active: shiftPressed,
},
{ keys: ['Esc'], label: 'Cancel' }, { keys: ['Esc'], label: 'Cancel' },
]} ]}
snapContext={snapContext}
/> />
) )
} }
+38 -15
View File
@@ -1,6 +1,7 @@
import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core' import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { steppedRotation } from '../components/tools/item/placement-math'
import { toggleDoorOpenState } from '../lib/door-interaction' import { toggleDoorOpenState } from '../lib/door-interaction'
import { runRedo, runUndo } from '../lib/history' import { runRedo, runUndo } from '../lib/history'
import { import {
@@ -9,7 +10,7 @@ import {
} from '../lib/scene-clipboard' } from '../lib/scene-clipboard'
import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus' import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus'
import { toggleWindowOpenState } from '../lib/window-interaction' 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' import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
// Tools call this in their onCancel handler when they have an active mid-action to cancel, // 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 // free-place bypass during opening / zone placement — so this predicate
// must NOT fire for those. Door / window moves still use Shift for free // must NOT fire for those. Door / window moves still use Shift for free
// place (out of this overhaul's scope), so they're excluded. // 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 isSnappingCycleContext = () => {
const ed = useEditor.getState()
const moving = getMovingNode() const moving = getMovingNode()
if (moving != null) return moving.type !== 'door' && moving.type !== 'window' if (moving?.type === 'door' || moving?.type === 'window') return false
return ( return getActiveSnapContext() != null
ed.mode === 'build' && (ed.tool === 'wall' || ed.tool === 'fence' || ed.tool === 'item')
)
} }
// A "clean tap" of Ctrl/Meta (pressed and released with NO other key in // A "clean tap" of Ctrl/Meta (pressed and released with NO other key in
@@ -83,6 +87,16 @@ export const useKeyboard = ({
return 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()) { if (e.key === 'Shift' && !e.repeat && isSnappingCycleContext()) {
// Cycle the global snapping mode (grid → lines → angles → off). // Cycle the global snapping mode (grid → lines → angles → off).
// `'off'` is the snap bypass now, so Shift no longer holds-to-bypass. // `'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') sfxEmitter.emit('sfx:item-rotate')
} else if (node && 'rotation' in node) { } else if (node && 'rotation' in node) {
e.preventDefault() e.preventDefault()
const ROTATION_STEP = Math.PI / 4 // Round to the nearest 45° then step one increment (not a blind +45°).
// Handle different rotation types (number for roof, array for items/windows/doors)
if (typeof node.rotation === 'number') { 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)) { } else if (Array.isArray(node.rotation)) {
useScene.getState().updateNode(node.id, { 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') sfxEmitter.emit('sfx:item-rotate')
@@ -316,13 +334,18 @@ export const useKeyboard = ({
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
} else if (node && 'rotation' in node) { } else if (node && 'rotation' in node) {
e.preventDefault() e.preventDefault()
const ROTATION_STEP = Math.PI / 4 // Round to the nearest 45° then step one increment back.
if (typeof node.rotation === 'number') { 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)) { } else if (Array.isArray(node.rotation)) {
useScene.getState().updateNode(node.id, { 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') sfxEmitter.emit('sfx:item-rotate')
+7 -1
View File
@@ -246,6 +246,7 @@ export {
} from './lib/floorplan' } from './lib/floorplan'
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
export { export {
boundaryReshapeScope,
curveReshapeScope, curveReshapeScope,
endpointReshapeScope, endpointReshapeScope,
holeEditScope, holeEditScope,
@@ -331,7 +332,12 @@ export type {
ViewMode, ViewMode,
WorkspaceMode, WorkspaceMode,
} from './store/use-editor' } 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 { export {
default as useInteractionScope, default as useInteractionScope,
getEditingHole, getEditingHole,
@@ -168,3 +168,9 @@ export function endpointReshapeScope(
): ActiveInteractionScope { ): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint } 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' }
}
+229
View File
@@ -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>): 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 <noun>"', () => {
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<string, AnyNode> {
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'])
})
})
+318
View File
@@ -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<string, string> }
// 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<readonly [number, number]>,
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<readonly [number, number]>,
): boolean {
return (
pointOnPolygonBoundary(wall.start, polygon, WALL_ON_BOUNDARY_TOLERANCE) &&
pointOnPolygonBoundary(wall.end, polygon, WALL_ON_BOUNDARY_TOLERANCE)
)
}
function polygonCentroid(
points: ReadonlyArray<readonly [number, number]>,
): [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<string, AnyNode>
spaces: Record<string, Space>
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<string, unknown>
const bRecord = b as Record<string, unknown>
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)
}
+75 -2
View File
@@ -1,9 +1,13 @@
import { describe, expect, it } from 'bun:test' import { describe, expect, it } from 'bun:test'
import { import {
cycleSnappingModeIn,
DEFAULT_SNAPPING_MODE, DEFAULT_SNAPPING_MODE,
defaultSnappingModeFor,
nextSnappingMode, nextSnappingMode,
resolveSnapFlags, resolveSnapFlags,
SNAPPING_MODES, SNAPPING_MODES,
snapContextOf,
snappingModesFor,
} from './snapping-mode' } from './snapping-mode'
describe('resolveSnapFlags', () => { describe('resolveSnapFlags', () => {
@@ -11,8 +15,8 @@ describe('resolveSnapFlags', () => {
expect(DEFAULT_SNAPPING_MODE).toBe('grid') expect(DEFAULT_SNAPPING_MODE).toBe('grid')
}) })
it("default 'grid' reproduces today's full snapping (grid + magnetic + angles on)", () => { it("modes are exclusive: 'grid' snaps to the lattice only", () => {
expect(resolveSnapFlags('grid')).toEqual({ grid: true, magnetic: true, angles: true }) expect(resolveSnapFlags('grid')).toEqual({ grid: true, magnetic: false, angles: false })
}) })
it("'off' disables grid, magnetic, and angles", () => { it("'off' disables grid, magnetic, and angles", () => {
@@ -42,3 +46,72 @@ describe('resolveSnapFlags', () => {
expect(nextSnappingMode(mode)).toBe(DEFAULT_SNAPPING_MODE) 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<string, 'item' | 'structural'> = {
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()
})
})
+107 -8
View File
@@ -1,3 +1,5 @@
import type { SnapProfile } from '@pascal-app/core'
/** /**
* Snapping mode is a single global, user-cyclable control that maps onto the * Snapping mode is a single global, user-cyclable control that maps onto the
* two pre-existing snap knobs (`gridSnapStep` grid snap + `magneticSnap`). * 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). * - `grid` → grid lattice only.
* - `lines` → magnetic only (alignment / wall beacons, no grid lattice, no * - `lines` → magnetic only: alignment axes + wall corner-join (connectivity
* angle lock). * is part of the "lines" magnetic snap, not a separate always-on behaviour).
* - `angles` → angle lock only (15° wall/line rays, no grid lattice, no * - `angles` → angle lock only (15°/45° rays).
* magnetic beacons). * - `off` → nothing snaps (raw cursor).
* - `off` → nothing snaps.
*/ */
export function resolveSnapFlags(mode: SnappingMode): SnapFlags { export function resolveSnapFlags(mode: SnappingMode): SnapFlags {
switch (mode) { switch (mode) {
case 'grid': case 'grid':
return { grid: true, magnetic: true, angles: true } return { grid: true, magnetic: false, angles: false }
case 'lines': case 'lines':
return { grid: false, magnetic: true, angles: false } return { grid: false, magnetic: true, angles: false }
case 'angles': case 'angles':
@@ -56,3 +59,99 @@ export function nextSnappingMode(mode: SnappingMode): SnappingMode {
const index = SNAPPING_MODES.indexOf(mode) const index = SNAPPING_MODES.indexOf(mode)
return SNAPPING_MODES[(index + 1) % SNAPPING_MODES.length] ?? DEFAULT_SNAPPING_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<SnapContext, SnapModeSet> = {
// 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
}
}
+4 -1
View File
@@ -209,8 +209,11 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
useWallSnapIndicator.getState().clear() 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 const basePoint = fallbackPoint ?? wallSnap.point
if (input.align === false || input.altKey) { if (input.align === false || input.altKey || !magnetic) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] } return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
} }
+130 -38
View File
@@ -16,6 +16,7 @@ import {
type FenceNode, type FenceNode,
type ItemNode, type ItemNode,
type LevelNode, type LevelNode,
nodeRegistry,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type RoofSurfaceMaterialRole, type RoofSurfaceMaterialRole,
@@ -41,11 +42,18 @@ import {
type SingleSurfaceMaterialRole, type SingleSurfaceMaterialRole,
} from '../lib/material-paint' } from '../lib/material-paint'
import { import {
DEFAULT_SNAPPING_MODE, cyclePaintScope as cyclePaintScopeValue,
nextSnappingMode, type PaintHoverInfo,
type PaintScope,
} from '../lib/paint-scope'
import {
cycleSnappingModeIn,
defaultSnappingModeFor,
resolveSnapFlags, resolveSnapFlags,
SNAPPING_MODES, type SnapContext,
type SnappingMode, type SnappingMode,
snapContextOf,
snappingModesFor,
} from '../lib/snapping-mode' } from '../lib/snapping-mode'
import useInteractionScope from './use-interaction-scope' import useInteractionScope from './use-interaction-scope'
@@ -278,13 +286,30 @@ type EditorState = {
setActivePaintMaterial: (material: ActivePaintMaterial | null) => void setActivePaintMaterial: (material: ActivePaintMaterial | null) => void
activePaintTarget: PaintableMaterialTarget activePaintTarget: PaintableMaterialTarget
setActivePaintTarget: (target: PaintableMaterialTarget) => void 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 // When true, clicking a surface in paint mode clears it back to its
// default material instead of applying `activePaintMaterial`. // default material instead of applying `activePaintMaterial`.
paintEraser: boolean paintEraser: boolean
setPaintEraser: (eraser: boolean) => void setPaintEraser: (eraser: boolean) => void
primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
hoveredPaintTarget: PaintableMaterialTarget | null // What the cursor is over in paint mode: the scopes it offers + labels for the
setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void // 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 selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void setSelectedReferenceId: (id: string | null) => void
guideUi: Record<string, GuideUiState> guideUi: Record<string, GuideUiState>
@@ -338,11 +363,15 @@ type EditorState = {
// snap. On by default; toggled from the Display menu. // snap. On by default; toggled from the Display menu.
magneticSnap: boolean magneticSnap: boolean
setMagneticSnap: (enabled: boolean) => void setMagneticSnap: (enabled: boolean) => void
// Global, user-cyclable snapping mode. Maps onto `gridSnapStep` (grid) and // Per-context, user-cyclable snapping mode (see `lib/snapping-mode.ts`). Each
// `magneticSnap` via `resolveSnapFlags`. Default `'grid'` reproduces the // activity (wall / item / polygon) keeps its own mode + default, because they
// historical behaviour (grid + magnetic on). // want different snapping — drawing a wall wants grid + angle, nudging an item
snappingMode: SnappingMode // wants free movement that only catches alignment lines. Resolved to the live
setSnappingMode: (mode: SnappingMode) => void // context via `getActiveSnappingMode()`; maps onto `gridSnapStep`/`magneticSnap`
// via `resolveSnapFlags`. Persisted per context.
snappingModeByContext: Record<SnapContext, SnappingMode>
setSnappingMode: (context: SnapContext, mode: SnappingMode) => void
// Cycle the *active* context's mode within its own set; returns the new value.
cycleSnappingMode: () => SnappingMode cycleSnappingMode: () => SnappingMode
showReferenceFloor: boolean showReferenceFloor: boolean
toggleReferenceFloor: () => void toggleReferenceFloor: () => void
@@ -392,7 +421,7 @@ type PersistedEditorLayoutState = Pick<
| 'floorplanSelectionTool' | 'floorplanSelectionTool'
| 'gridSnapStep' | 'gridSnapStep'
| 'magneticSnap' | 'magneticSnap'
| 'snappingMode' | 'snappingModeByContext'
| 'showReferenceFloor' | 'showReferenceFloor'
| 'referenceFloorOffset' | 'referenceFloorOffset'
| 'referenceFloorOpacity' | 'referenceFloorOpacity'
@@ -416,7 +445,11 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
floorplanSelectionTool: 'click', floorplanSelectionTool: 'click',
gridSnapStep: 0.5, gridSnapStep: 0.5,
magneticSnap: true, magneticSnap: true,
snappingMode: DEFAULT_SNAPPING_MODE, snappingModeByContext: {
wall: defaultSnappingModeFor('wall'),
item: defaultSnappingModeFor('item'),
polygon: defaultSnappingModeFor('polygon'),
},
showReferenceFloor: false, showReferenceFloor: false,
referenceFloorOffset: 1, referenceFloorOffset: 1,
referenceFloorOpacity: 0.35, 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( function normalizePersistedEditorLayoutState(
state: Partial<PersistedEditorLayoutState> | null | undefined, state: Partial<PersistedEditorLayoutState> | null | undefined,
): PersistedEditorLayoutState { ): PersistedEditorLayoutState {
@@ -535,9 +576,11 @@ function normalizePersistedEditorLayoutState(
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
// Default on: only an explicit persisted `false` disables it. // Default on: only an explicit persisted `false` disables it.
magneticSnap: state?.magneticSnap !== false, magneticSnap: state?.magneticSnap !== false,
snappingMode: SNAPPING_MODES.includes(state?.snappingMode as SnappingMode) snappingModeByContext: {
? (state?.snappingMode as SnappingMode) wall: migrateSnappingMode(state?.snappingModeByContext?.wall, 'wall'),
: DEFAULT_SNAPPING_MODE, item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'),
polygon: migrateSnappingMode(state?.snappingModeByContext?.polygon, 'polygon'),
},
showReferenceFloor: state?.showReferenceFloor === true, showReferenceFloor: state?.showReferenceFloor === true,
referenceFloorOffset: referenceFloorOffset:
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1 typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
@@ -823,6 +866,19 @@ const useEditor = create<EditorState>()(
set((state) => set((state) =>
state.activePaintTarget === target ? state : { activePaintTarget: target }, 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, paintEraser: false,
setPaintEraser: (eraser) => set({ paintEraser: eraser }), setPaintEraser: (eraser) => set({ paintEraser: eraser }),
primeMaterialPaintFromSelection: () => { primeMaterialPaintFromSelection: () => {
@@ -852,11 +908,8 @@ const useEditor = create<EditorState>()(
activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial, activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial,
} }
}, },
hoveredPaintTarget: null, paintHover: null,
setHoveredPaintTarget: (target) => setPaintHover: (info) => set({ paintHover: info }),
set((state) =>
state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target },
),
selectedReferenceId: null, selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
guideUi: {}, guideUi: {},
@@ -981,11 +1034,18 @@ const useEditor = create<EditorState>()(
}, },
magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap, magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap,
setMagneticSnap: (enabled) => set({ magneticSnap: enabled }), setMagneticSnap: (enabled) => set({ magneticSnap: enabled }),
snappingMode: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.snappingMode, snappingModeByContext: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.snappingModeByContext,
setSnappingMode: (mode) => set({ snappingMode: mode }), setSnappingMode: (context, mode) =>
set((state) => ({
snappingModeByContext: { ...state.snappingModeByContext, [context]: mode },
})),
cycleSnappingMode: () => { cycleSnappingMode: () => {
const next = nextSnappingMode(get().snappingMode) const context = getActiveSnapContext() ?? 'item'
set({ snappingMode: next }) const current = get().snappingModeByContext[context]
const next = cycleSnappingModeIn(context, current)
set((state) => ({
snappingModeByContext: { ...state.snappingModeByContext, [context]: next },
}))
return next return next
}, },
showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor, showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
@@ -1080,7 +1140,7 @@ const useEditor = create<EditorState>()(
floorplanSelectionTool: state.floorplanSelectionTool, floorplanSelectionTool: state.floorplanSelectionTool,
gridSnapStep: state.gridSnapStep, gridSnapStep: state.gridSnapStep,
magneticSnap: state.magneticSnap, magneticSnap: state.magneticSnap,
snappingMode: state.snappingMode, snappingModeByContext: state.snappingModeByContext,
showReferenceFloor: state.showReferenceFloor, showReferenceFloor: state.showReferenceFloor,
referenceFloorOffset: state.referenceFloorOffset, referenceFloorOffset: state.referenceFloorOffset,
referenceFloorOpacity: state.referenceFloorOpacity, referenceFloorOpacity: state.referenceFloorOpacity,
@@ -1090,27 +1150,59 @@ const useEditor = create<EditorState>()(
) )
/** /**
* Effective magnetic-snap state: the legacy `magneticSnap` flag AND the * Effective magnetic-snap state: the legacy `magneticSnap` flag AND the active
* snapping mode's magnetic component. Default mode `'grid'` resolves magnetic * context's snapping mode. With exclusive modes, magnetic (alignment axes + wall
* to `true`, so with the default-on `magneticSnap` this returns `true` exactly * corner-join) is on only in `'lines'`. Read from the smallest magnetic choke
* as before; only `'off'` (or an explicitly-disabled `magneticSnap`) turns it * points so the mode is honoured without retuning any snap math.
* off. Read from the smallest magnetic choke points so the mode is honoured
* without retuning any snap math.
*/ */
export function isMagneticSnapActive(): boolean { export function isMagneticSnapActive(): boolean {
const state = useEditor.getState() 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 * Effective angle-lock state: the active context's snapping mode. With exclusive
* `'grid'` resolves angles to `true`, so the 15° draft lock behaves exactly as * modes the 15°/45° lock is on only in `'angles'`. Read from the smallest
* before; `'lines'` and `'off'` suppress it. Read from the smallest angle-lock * angle-lock choke points (wall / fence draft call sites).
* choke points (wall / fence draft call sites) so the mode is honoured without
* retuning any snap math.
*/ */
export function isAngleSnapActive(): boolean { 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 export default useEditor
+13 -3
View File
@@ -2,11 +2,13 @@
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import { import {
boundaryReshapeScope,
clearCeilingSnapFeedback, clearCeilingSnapFeedback,
PolygonEditor, PolygonEditor,
type PolygonEditorPlanPointSnapContext, type PolygonEditorPlanPointSnapContext,
resolveCeilingPlanPointSnap, resolveCeilingPlanPointSnap,
triggerSFX, triggerSFX,
useInteractionScope,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -95,13 +97,19 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
const handleDragStateChange = useCallback( const handleDragStateChange = useCallback(
(isDragging: boolean) => { (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 ownsPolygonPreviewRef.current = false
clearCeilingSnapFeedback() clearCeilingSnapFeedback()
} }
setCeilingHandleHover(isDragging) setCeilingHandleHover(isDragging)
}, },
[setCeilingHandleHover], [ceilingId, setCeilingHandleHover],
) )
const handlePolygonEditorDragCommit = useCallback(() => { const handlePolygonEditorDragCommit = useCallback(() => {
@@ -126,7 +134,6 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
levelId: ceilingLevelId, levelId: ceilingLevelId,
excludeId: ceilingId, excludeId: ceilingId,
altKey: context.nativeEvent?.altKey === true, altKey: context.nativeEvent?.altKey === true,
shiftKey: context.nativeEvent?.shiftKey === true,
}).point, }).point,
[ceilingId, ceilingLevelId], [ceilingId, ceilingLevelId],
) )
@@ -136,6 +143,9 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
clearCeilingSnapFeedback() clearCeilingSnapFeedback()
useLiveNodeOverrides.getState().clear(ceilingId) useLiveNodeOverrides.getState().clear(ceilingId)
useScene.getState().markDirty(ceilingId) useScene.getState().markDirty(ceilingId)
useInteractionScope
.getState()
.endIf((s) => s.kind === 'reshaping' && s.reshape === 'boundary')
ownsPolygonPreviewRef.current = false ownsPolygonPreviewRef.current = false
if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) { if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) {
useViewer.getState().setHoveredId(null) useViewer.getState().setHoveredId(null)
+2 -2
View File
@@ -79,6 +79,7 @@ function ceilingHandles(_node: CeilingNodeType): HandleDescriptor<CeilingNodeTyp
*/ */
export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = { export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
kind: 'ceiling', kind: 'ceiling',
snapProfile: 'structural',
schemaVersion: 1, schemaVersion: 1,
schema: CeilingNode, schema: CeilingNode,
category: 'structure', category: 'structure',
@@ -155,8 +156,7 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Trace ceiling outline' }, { key: 'Left click', label: 'Trace ceiling outline' },
{ key: 'Enter', label: 'Finish ceiling' }, { key: 'Enter', label: 'Finish ceiling', minDraftVertices: 3 },
{ key: 'Shift', label: 'Free outline' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
+6 -7
View File
@@ -16,6 +16,7 @@ import {
import { import {
CursorSphere, CursorSphere,
consumePlacementDragRelease, consumePlacementDragRelease,
isMagneticSnapActive,
markToolCancelConsumed, markToolCancelConsumed,
triggerSFX, triggerSFX,
useAlignmentGuides, 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's X/Z position on rebuild (`mesh.position.x = 0`,
* `mesh.position.z = 0`) so the visual transitions smoothly. * `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) { function snap(value: number) {
return snapScalar(value, useEditor.getState().gridSnapStep) return snapScalar(value, useEditor.getState().gridSnapStep)
@@ -149,12 +150,10 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (isFloorplanSourcedEvent(event)) return if (isFloorplanSourcedEvent(event)) return
const bypassSnap = event.nativeEvent?.shiftKey === true const localX = snap(event.localPosition[0])
const localX = bypassSnap ? event.localPosition[0] : snap(event.localPosition[0]) const localZ = snap(event.localPosition[2])
const localZ = bypassSnap ? event.localPosition[2] : snap(event.localPosition[2])
if ( if (
!bypassSnap &&
previousGridPosRef.current && previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) (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 // Figma-style alignment snap: align the ceiling's translated polygon
// vertices to other objects' anchors; fold the snap into the delta and // vertices to other objects' anchors; fold the snap into the delta and
// publish a guide. Alt bypasses alignment; Shift bypasses all snap. // publish a guide. Alignment follows the global magnetic snap mode.
const bypass = event.nativeEvent?.altKey === true || bypassSnap const bypass = !isMagneticSnapActive()
if (!bypass && alignmentCandidates.length > 0) { if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({ const result = resolveAlignment({
moving: polygonAnchors(ceilingId, translatePolygon(originalPolygon, deltaX, deltaZ)), moving: polygonAnchors(ceilingId, translatePolygon(originalPolygon, deltaX, deltaZ)),
+17 -37
View File
@@ -13,6 +13,9 @@ import {
CursorSphere, CursorSphere,
clearCeilingSnapFeedback, clearCeilingSnapFeedback,
EDITOR_LAYER, EDITOR_LAYER,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed, markToolCancelConsumed,
resolveCeilingPlanPointSnap, resolveCeilingPlanPointSnap,
triggerSFX, triggerSFX,
@@ -30,7 +33,6 @@ import { CeilingNode } from './schema'
* Multi-click polygon drawing at the ceiling height (2.52m default) * Multi-click polygon drawing at the ceiling height (2.52m default)
* with a vertical TSL-gradient connector + ground-shadow lines so the * with a vertical TSL-gradient connector + ground-shadow lines so the
* draft is visible against both the ceiling plane and the floor. * draft is visible against both the ceiling plane and the floor.
* Shift defeats the 15° angle snap during drag.
*/ */
const CEILING_HEIGHT = 2.52 const CEILING_HEIGHT = 2.52
@@ -65,7 +67,6 @@ export const CeilingTool: React.FC = () => {
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0]) const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
const [levelY, setLevelY] = useState(0) const [levelY, setLevelY] = useState(0)
const previousSnappedPointRef = useRef<[number, number] | null>(null) const previousSnappedPointRef = useRef<[number, number] | null>(null)
const shiftPressed = useRef(false)
// Clear preset-seeded defaults on deactivation so a later manual ceiling // Clear preset-seeded defaults on deactivation so a later manual ceiling
// draw isn't built with a stale preset's parameters. Unmount-only. // draw isn't built with a stale preset's parameters. Unmount-only.
@@ -73,6 +74,12 @@ export const CeilingTool: React.FC = () => {
useEffect(() => () => clearCeilingSnapFeedback(), []) 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( const verticalGeo = useMemo(
() => () =>
new BufferGeometry().setFromPoints([ new BufferGeometry().setFromPoints([
@@ -93,38 +100,27 @@ export const CeilingTool: React.FC = () => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && gridCursorRef.current)) return if (!(cursorRef.current && gridCursorRef.current)) return
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true // Honour the active snapping mode: grid lattice + 15° angle lock are each
const gridPosition: [number, number] = bypassSnap // gated on the mode (off / lines → free), like the slab tool.
? rawPoint const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
: [...snapPointToGrid(rawPoint, useEditor.getState().gridSnapStep)] const gridPosition: [number, number] = [...snapPointToGrid(rawPoint, gridStep)]
setCursorPosition(gridPosition) setCursorPosition(gridPosition)
setLevelY(event.localPosition[1]) setLevelY(event.localPosition[1])
const ceilingY = event.localPosition[1] + CEILING_HEIGHT const ceilingY = event.localPosition[1] + CEILING_HEIGHT
const gridY = event.localPosition[1] + GRID_OFFSET const gridY = event.localPosition[1] + GRID_OFFSET
const lastPoint = points[points.length - 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.
const orthoPoint: [number, number] = const orthoPoint: [number, number] =
bypassSnap || !lastPoint isAngleSnapActive() && lastPoint
? gridPosition ? [...snapPointAlongAngleRay(lastPoint, rawPoint, DEFAULT_ANGLE_STEP, gridStep)]
: [ : gridPosition
...snapPointAlongAngleRay(
lastPoint,
rawPoint,
DEFAULT_ANGLE_STEP,
useEditor.getState().gridSnapStep,
),
]
const displayPoint = resolveCeilingPlanPointSnap({ const displayPoint = resolveCeilingPlanPointSnap({
rawPoint, rawPoint,
fallbackPoint: orthoPoint, fallbackPoint: orthoPoint,
levelId: currentLevelId, levelId: currentLevelId,
altKey: event.nativeEvent?.altKey === true, altKey: !isMagneticSnapActive(),
shiftKey: bypassSnap,
}).point }).point
setSnappedCursorPosition(displayPoint) setSnappedCursorPosition(displayPoint)
if ( if (
!bypassSnap &&
points.length > 0 && points.length > 0 &&
previousSnappedPointRef.current && previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] || (displayPoint[0] !== previousSnappedPointRef.current[0] ||
@@ -178,28 +174,12 @@ export const CeilingTool: React.FC = () => {
clearCeilingSnapFeedback() 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:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick) emitter.on('grid:double-click', onGridDoubleClick)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
return () => { return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick) emitter.off('grid:double-click', onGridDoubleClick)
-1
View File
@@ -363,7 +363,6 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
tool: () => import('./tool'), tool: () => import('./tool'),
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Place column' }, { key: 'Left click', label: 'Place column' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
floorplan: buildColumnFloorplan, floorplan: buildColumnFloorplan,
-1
View File
@@ -251,7 +251,6 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Place door on wall' }, { key: 'Left click', label: 'Place door on wall' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
@@ -165,7 +165,6 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
toolHints: [ toolHints: [
{ key: 'Click', label: 'Start segment' }, { key: 'Click', label: 'Start segment' },
{ key: 'Click again', label: 'Place it (locked to 45°)' }, { key: 'Click again', label: 'Place it (locked to 45°)' },
{ key: 'Shift', label: 'Free angle' },
{ key: 'Alt + drag', label: 'Go vertical ↕, click to place' }, { key: 'Alt + drag', label: 'Go vertical ↕, click to place' },
{ key: '[ / ]', label: 'Duct diameter down / up' }, { key: '[ / ]', label: 'Duct diameter down / up' },
{ key: 'Q', label: 'Round / rect trunk' }, { key: 'Q', label: 'Round / rect trunk' },
@@ -81,7 +81,6 @@ export const ductTerminalDefinition: NodeDefinition<typeof DuctTerminalNode> = {
{ key: 'Click', label: 'Place register' }, { key: 'Click', label: 'Place register' },
{ key: 'M', label: 'Mount: floor / ceiling / wall' }, { key: 'M', label: 'Mount: floor / ceiling / wall' },
{ key: 'R / T', label: 'Rotate ±45° (floor / ceiling)' }, { key: 'R / T', label: 'Rotate ±45° (floor / ceiling)' },
{ key: 'Shift', label: 'Smooth (no grid snap)' },
{ key: 'Esc', label: 'Exit' }, { key: 'Esc', label: 'Exit' },
], ],
@@ -11,6 +11,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
type FencePlanPoint, type FencePlanPoint,
isAngleSnapActive,
isMagneticSnapActive, isMagneticSnapActive,
isSegmentLongEnough, isSegmentLongEnough,
snapFenceDraftPoint, snapFenceDraftPoint,
@@ -164,15 +165,18 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
preview: (ctx, point, modifiers) => { preview: (ctx, point, modifiers) => {
const planPoint: FencePlanPoint = [point[0], point[1]] const planPoint: FencePlanPoint = [point[0], point[1]]
// Endpoint move = grid snap only; the 45°-from-start angle snap // Endpoint move honours the active snapping mode (HUD chip): grid → lattice;
// is draft-only. Shift is a hard snap bypass. // 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({ const snapped = snapFenceDraftPoint({
point: planPoint, point: planPoint,
walls: ctx.levelWalls, walls: ctx.levelWalls,
fences: ctx.levelFences, fences: ctx.levelFences,
ignoreFenceIds: [ctx.fenceId as string], ignoreFenceIds: [ctx.fenceId as string],
bypassSnap: modifiers.shift, start: ctx.fixedPoint,
magnetic: !modifiers.shift && isMagneticSnapActive(), angleSnap: isAngleSnapActive(),
magnetic: isMagneticSnapActive(),
}) })
// Figma-style alignment: nudge the dragged endpoint onto another wall / // Figma-style alignment: nudge the dragged endpoint onto another wall /
@@ -180,7 +184,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
// guide. The resolver connects to the NEAREST real anchor, so the dot // guide. The resolver connects to the NEAREST real anchor, so the dot
// always sits on an actual point. Alt is reserved for detach. // always sits on an actual point. Alt is reserved for detach.
let aligned = snapped let aligned = snapped
if (!modifiers.shift && ctx.alignCandidates.length > 0) { if (isMagneticSnapActive() && ctx.alignCandidates.length > 0) {
const ar = resolveAlignment({ const ar = resolveAlignment({
moving: [{ nodeId: ctx.fenceId as string, kind: 'corner', x: snapped[0], z: snapped[1] }], moving: [{ nodeId: ctx.fenceId as string, kind: 'corner', x: snapped[0], z: snapped[1] }],
candidates: ctx.alignCandidates, candidates: ctx.alignCandidates,
+5 -30
View File
@@ -29,8 +29,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
* Phase 5 Stage D — fence curve tool (kind-owned). * Phase 5 Stage D — fence curve tool (kind-owned).
* *
* 1:1 port of the legacy `CurveFenceTool` (editor/components/tools/ * 1:1 port of the legacy `CurveFenceTool` (editor/components/tools/
* fence/curve-fence-tool.tsx). Same snap pipeline, same Shift override, * fence/curve-fence-tool.tsx). Same snap pipeline, same history dance,
* same history dance, same activation grace. Imports adjusted to the * same activation grace. Imports adjusted to the
* `@pascal-app/editor` public surface (triggerSFX, markToolCancelConsumed, * `@pascal-app/editor` public surface (triggerSFX, markToolCancelConsumed,
* getSegmentGridStep, snapScalarToGrid). Mounted via * getSegmentGridStep, snapScalarToGrid). Mounted via
* `def.affordanceTools.curve` — ToolManager picks it up at runtime, * `def.affordanceTools.curve` — ToolManager picks it up at runtime,
@@ -40,7 +40,6 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now()) const activatedAtRef = useRef<number>(Date.now())
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node)) const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
const previousCurveOffsetRef = useRef<number | null>(null) const previousCurveOffsetRef = useRef<number | null>(null)
const shiftPressedRef = useRef(false)
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current) const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
const initialHandle = getWallMidpointHandlePoint(node) const initialHandle = getWallMidpointHandlePoint(node)
@@ -91,29 +90,21 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true
const snapStep = getSegmentGridStep() const snapStep = getSegmentGridStep()
const localX = bypassSnap const localX = snapScalarToGrid(event.localPosition[0], snapStep)
? event.localPosition[0] const localZ = snapScalarToGrid(event.localPosition[2], snapStep)
: snapScalarToGrid(event.localPosition[0], snapStep)
const localZ = bypassSnap
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint = -( const offsetFromMidpoint = -(
(localX - chord.midpoint.x) * chord.normal.x + (localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y (localZ - chord.midpoint.y) * chord.normal.y
) )
const snappedOffset = bypassSnap const snappedOffset = snapScalarToGrid(offsetFromMidpoint, snapStep)
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset( const nextCurveOffset = normalizeWallCurveOffset(
node, node,
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)), Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
) )
if ( if (
!bypassSnap &&
previousCurveOffsetRef.current !== null && previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current nextCurveOffset !== previousCurveOffsetRef.current
) { ) {
@@ -159,23 +150,9 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
exitCurveMode() 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:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => { return () => {
if (!wasCommitted) { if (!wasCommitted) {
@@ -185,8 +162,6 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
} }
}, [exitCurveMode, node]) }, [exitCurveMode, node])
+1
View File
@@ -134,6 +134,7 @@ const fenceHandles: HandleDescriptor<FenceNodeType>[] = [
*/ */
export const fenceDefinition: NodeDefinition<typeof FenceNode> = { export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
kind: 'fence', kind: 'fence',
snapProfile: 'structural',
schemaVersion: 1, schemaVersion: 1,
schema: FenceNode, schema: FenceNode,
category: 'structure', category: 'structure',
@@ -86,7 +86,6 @@ export const hvacEquipmentDefinition: NodeDefinition<typeof HvacEquipmentNode> =
toolHints: [ toolHints: [
{ key: 'Click', label: 'Place unit' }, { key: 'Click', label: 'Place unit' },
{ key: 'R / T', label: 'Rotate ±45°' }, { key: 'R / T', label: 'Rotate ±45°' },
{ key: 'Shift', label: 'Smooth (no grid snap)' },
{ key: 'Esc', label: 'Exit' }, { key: 'Esc', label: 'Exit' },
], ],
+2 -1
View File
@@ -166,6 +166,7 @@ function itemWallMoveHandle(): HandleDescriptor<ItemNodeType> {
*/ */
export const itemDefinition: NodeDefinition<typeof ItemNode> = { export const itemDefinition: NodeDefinition<typeof ItemNode> = {
kind: 'item', kind: 'item',
snapProfile: 'item',
schemaVersion: 1, schemaVersion: 1,
schema: ItemNode, schema: ItemNode,
category: 'furnish', category: 'furnish',
@@ -316,7 +317,7 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
{ key: 'R', label: 'Rotate counterclockwise' }, { key: 'R', label: 'Rotate counterclockwise' },
{ key: 'T', label: 'Rotate clockwise' }, { key: 'T', label: 'Rotate clockwise' },
{ key: 'Shift', label: 'Cycle snapping mode' }, { key: 'Shift', label: 'Cycle snapping mode' },
{ key: 'Alt', label: 'Free place (no snap)' }, { key: 'Alt', label: 'Force place' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
-1
View File
@@ -111,7 +111,6 @@ export const linesetDefinition: NodeDefinition<typeof LinesetNode> = {
toolHints: [ toolHints: [
{ key: 'Click', label: 'Start lineset' }, { key: 'Click', label: 'Start lineset' },
{ key: 'Click again', label: 'Place it (locked to 45°)' }, { key: 'Click again', label: 'Place it (locked to 45°)' },
{ key: 'Shift', label: 'Free angle' },
{ key: 'Alt + drag', label: 'Go vertical ↕, click to place' }, { key: 'Alt + drag', label: 'Go vertical ↕, click to place' },
{ key: 'Esc', label: 'Cancel start point' }, { key: 'Esc', label: 'Cancel start point' },
], ],
@@ -102,7 +102,6 @@ export const liquidLineDefinition: NodeDefinition<typeof LiquidLineNode> = {
toolHints: [ toolHints: [
{ key: 'Click', label: 'Start liquid line' }, { key: 'Click', label: 'Start liquid line' },
{ key: 'Click again', label: 'Place it (locked to 45°)' }, { key: 'Click again', label: 'Place it (locked to 45°)' },
{ key: 'Shift', label: 'Free angle' },
{ key: 'Alt + drag', label: 'Go vertical ↕, click to place' }, { key: 'Alt + drag', label: 'Go vertical ↕, click to place' },
{ key: 'F', label: 'Follow: trace a lineset' }, { key: 'F', label: 'Follow: trace a lineset' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
@@ -110,7 +110,6 @@ export const pipeSegmentDefinition: NodeDefinition<typeof PipeSegmentNode> = {
{ key: 'Q', label: 'Waste / vent' }, { key: 'Q', label: 'Waste / vent' },
{ key: '[ / ]', label: 'Pipe size down / up' }, { key: '[ / ]', label: 'Pipe size down / up' },
{ key: 'Alt + drag', label: 'Vertical stack ↕, click to place' }, { key: 'Alt + drag', label: 'Vertical stack ↕, click to place' },
{ key: 'Shift', label: 'Free angle' },
{ key: 'Esc', label: 'Cancel start point' }, { key: 'Esc', label: 'Cancel start point' },
], ],
@@ -52,7 +52,6 @@ export const pipeTrapDefinition: NodeDefinition<typeof PipeTrapNode> = {
toolHints: [ toolHints: [
{ key: 'Click', label: 'Place trap' }, { key: 'Click', label: 'Place trap' },
{ key: 'R / T', label: 'Rotate ±45°' }, { key: 'R / T', label: 'Rotate ±45°' },
{ key: 'Shift', label: 'Smooth (no grid snap)' },
{ key: 'Esc', label: 'Exit' }, { key: 'Esc', label: 'Exit' },
], ],
+1
View File
@@ -93,6 +93,7 @@ const roofHandles: HandleDescriptor<RoofNodeType>[] = [roofMoveHandle()]
*/ */
export const roofDefinition: NodeDefinition<typeof RoofNode> = { export const roofDefinition: NodeDefinition<typeof RoofNode> = {
kind: 'roof', kind: 'roof',
snapProfile: 'structural',
schemaVersion: 1, schemaVersion: 1,
schema: RoofNode, schema: RoofNode,
category: 'structure', category: 'structure',
+3
View File
@@ -243,10 +243,13 @@ export type SlotPaintConfig = {
node: AnyNode, node: AnyNode,
role: string, role: string,
) => { material: MaterialSchema | undefined; materialPreset: string | undefined } | null ) => { 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 { export function createSlotPaintCapability(config: SlotPaintConfig): PaintCapability {
return { return {
roomScope: config.roomScope,
resolveRole: config.resolveRole, resolveRole: config.resolveRole,
buildPatch: ({ node, role, materialPreset }) => { buildPatch: ({ node, role, materialPreset }) => {
const slots = { ...((node as SlotsNode).slots ?? {}) } const slots = { ...((node as SlotsNode).slots ?? {}) }
-1
View File
@@ -263,7 +263,6 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
tool: () => import('./tool'), tool: () => import('./tool'),
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Place shelf' }, { key: 'Left click', label: 'Place shelf' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
+17 -1
View File
@@ -2,10 +2,12 @@
import { resolveLevelId, type SlabNode, useLiveNodeOverrides, useScene } from '@pascal-app/core' import { resolveLevelId, type SlabNode, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import { import {
boundaryReshapeScope,
clearSlabSnapFeedback, clearSlabSnapFeedback,
PolygonEditor, PolygonEditor,
type PolygonEditorPlanPointSnapContext, type PolygonEditorPlanPointSnapContext,
resolveSlabPlanPointSnap, resolveSlabPlanPointSnap,
useInteractionScope,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
@@ -65,6 +67,17 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
clearSlabSnapFeedback() 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( const resolvePolygonEditorPlanPoint = useCallback(
(context: PolygonEditorPlanPointSnapContext) => (context: PolygonEditorPlanPointSnapContext) =>
resolveSlabPlanPointSnap({ resolveSlabPlanPointSnap({
@@ -73,7 +86,6 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
levelId: slabLevelId, levelId: slabLevelId,
excludeId: slabId, excludeId: slabId,
altKey: context.nativeEvent?.altKey === true, altKey: context.nativeEvent?.altKey === true,
shiftKey: context.nativeEvent?.shiftKey === true,
}).point, }).point,
[slabId, slabLevelId], [slabId, slabLevelId],
) )
@@ -86,6 +98,9 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
clearSlabSnapFeedback() clearSlabSnapFeedback()
useLiveNodeOverrides.getState().clear(slabId) useLiveNodeOverrides.getState().clear(slabId)
useScene.getState().markDirty(slabId) useScene.getState().markDirty(slabId)
useInteractionScope
.getState()
.endIf((s) => s.kind === 'reshaping' && s.reshape === 'boundary')
} }
}, [slabId]) }, [slabId])
@@ -98,6 +113,7 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
levelId={slabLevelId ?? undefined} levelId={slabLevelId ?? undefined}
minVertices={3} minVertices={3}
onDragCommit={handleDragCommit} onDragCommit={handleDragCommit}
onDragStateChange={handleDragStateChange}
onPolygonChange={handlePolygonChange} onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview} onPolygonPreview={handlePolygonPreview}
polygon={slab.polygon} polygon={slab.polygon}
+2 -2
View File
@@ -133,6 +133,7 @@ function slabHandles(_node: SlabNodeType): HandleDescriptor<SlabNodeType>[] {
*/ */
export const slabDefinition: NodeDefinition<typeof SlabNode> = { export const slabDefinition: NodeDefinition<typeof SlabNode> = {
kind: 'slab', kind: 'slab',
snapProfile: 'structural',
schemaVersion: 1, schemaVersion: 1,
schema: SlabNode, schema: SlabNode,
category: 'structure', category: 'structure',
@@ -206,8 +207,7 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Trace slab outline' }, { key: 'Left click', label: 'Trace slab outline' },
{ key: 'Enter', label: 'Finish slab' }, { key: 'Enter', label: 'Finish slab', minDraftVertices: 3 },
{ key: 'Shift', label: 'Free outline' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
+3 -6
View File
@@ -169,18 +169,15 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (isFloorplanSourcedEvent(event)) return if (isFloorplanSourcedEvent(event)) return
const gridStep = getSegmentGridStep() const gridStep = getSegmentGridStep()
const bypassSnap = event.nativeEvent?.shiftKey === true
const [localX, localZ] = snapFenceDraftPoint({ const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]], point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls, walls: levelWalls,
fences: levelFences, fences: levelFences,
bypassSnap, magnetic: isMagneticSnapActive(),
magnetic: !bypassSnap && isMagneticSnapActive(),
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep), gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep),
}) })
if ( if (
!bypassSnap &&
previousGridPosRef.current && previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) (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 // Figma-style alignment snap: align the slab's translated polygon
// vertices to other objects' anchors; fold the snap into the delta and // vertices to other objects' anchors; fold the snap into the delta and
// publish a guide. Alt bypasses alignment; Shift bypasses all snap. // publish a guide. Alignment follows the global magnetic snap mode.
const bypass = event.nativeEvent?.altKey === true || bypassSnap const bypass = !isMagneticSnapActive()
if (!bypass && alignmentCandidates.length > 0) { if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignmentForActiveBuilding({ const result = resolveAlignmentForActiveBuilding({
moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)), moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)),
+1
View File
@@ -8,6 +8,7 @@ import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-p
* `node.slots[slotId]` (a shared scene-material or `library:` ref) like the shelf. * `node.slots[slotId]` (a shared scene-material or `library:` ref) like the shelf.
*/ */
export const slabPaint = createSlotPaintCapability({ export const slabPaint = createSlotPaintCapability({
roomScope: true,
resolveRole: ({ hitObject }) => { resolveRole: ({ hitObject }) => {
const slotId = (hitObject?.userData as { slotId?: string } | undefined)?.slotId const slotId = (hitObject?.userData as { slotId?: string } | undefined)?.slotId
return slotId === 'side' ? 'side' : 'surface' return slotId === 'side' ? 'side' : 'surface'
+27 -32
View File
@@ -13,6 +13,8 @@ import {
CursorSphere, CursorSphere,
clearSlabSnapFeedback, clearSlabSnapFeedback,
EDITOR_LAYER, EDITOR_LAYER,
isAngleSnapActive,
isGridSnapActive,
markToolCancelConsumed, markToolCancelConsumed,
resolveSlabPlanPointSnap, resolveSlabPlanPointSnap,
triggerSFX, triggerSFX,
@@ -62,7 +64,6 @@ export const SlabTool: React.FC = () => {
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0]) const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
const [levelY, setLevelY] = useState(0) const [levelY, setLevelY] = useState(0)
const previousSnappedPointRef = useRef<[number, number] | null>(null) const previousSnappedPointRef = useRef<[number, number] | null>(null)
const shiftPressed = useRef(false)
// Clear preset-seeded defaults on deactivation so a later manual slab draw // Clear preset-seeded defaults on deactivation so a later manual slab draw
// isn't built with a stale preset's parameters. Unmount-only. // isn't built with a stale preset's parameters. Unmount-only.
@@ -70,42 +71,39 @@ export const SlabTool: React.FC = () => {
useEffect(() => () => clearSlabSnapFeedback(), []) 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(() => { useEffect(() => {
if (!currentLevelId) return if (!currentLevelId) return
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return if (!cursorRef.current) return
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true // Slab drafting is the 'polygon' snap context (grid / lines / off — no
const gridPosition: [number, number] = bypassSnap // angle, no Shift bypass; Shift cycles the mode, Off is the bypass).
? rawPoint const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
: [...snapPointToGrid(rawPoint, useEditor.getState().gridSnapStep)] const gridPosition: [number, number] = [...snapPointToGrid(rawPoint, gridStep)]
setCursorPosition(gridPosition) setCursorPosition(gridPosition)
setLevelY(event.localPosition[1]) setLevelY(event.localPosition[1])
const lastPoint = points[points.length - 1] const lastPoint = points[points.length - 1]
// 15° angle snap from the raw cursor (matching the 2D floorplan // Angle lock only when the mode asks for it (polygon never does today, but
// pipeline) with the distance snapped along the ray to the grid step. // honour the flag so the behaviour follows the HUD).
const orthoPoint: [number, number] = const orthoPoint: [number, number] =
bypassSnap || !lastPoint isAngleSnapActive() && lastPoint
? gridPosition ? [...snapPointAlongAngleRay(lastPoint, rawPoint, DEFAULT_ANGLE_STEP, gridStep)]
: [ : gridPosition
...snapPointAlongAngleRay(
lastPoint,
rawPoint,
DEFAULT_ANGLE_STEP,
useEditor.getState().gridSnapStep,
),
]
const displayPoint = resolveSlabPlanPointSnap({ const displayPoint = resolveSlabPlanPointSnap({
rawPoint, rawPoint,
fallbackPoint: orthoPoint, fallbackPoint: orthoPoint,
levelId: currentLevelId, levelId: currentLevelId,
altKey: event.nativeEvent?.altKey === true, altKey: event.nativeEvent?.altKey === true,
shiftKey: bypassSnap,
}).point }).point
setSnappedCursorPosition(displayPoint) setSnappedCursorPosition(displayPoint)
if ( if (
!bypassSnap &&
points.length > 0 && points.length > 0 &&
previousSnappedPointRef.current && previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] || (displayPoint[0] !== previousSnappedPointRef.current[0] ||
@@ -139,14 +137,18 @@ export const SlabTool: React.FC = () => {
} }
} }
const onGridDoubleClick = (_event: GridEvent) => { // Finish the polygon (Enter or double-click): commit once there are enough
if (!currentLevelId) return // vertices. Closing near the first vertex (in onGridClick) is the third way.
if (points.length >= 3) { const finishDrawing = () => {
if (points.length < 3) return
const slabId = commitSlabDrawing(currentLevelId, points) const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] }) setSelection({ selectedIds: [slabId] })
setPoints([]) setPoints([])
clearSlabSnapFeedback() clearSlabSnapFeedback()
} }
const onGridDoubleClick = (_event: GridEvent) => {
finishDrawing()
} }
const onCancel = () => { const onCancel = () => {
@@ -156,17 +158,12 @@ export const SlabTool: React.FC = () => {
} }
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = true if (e.key === 'Enter') {
e.preventDefault()
finishDrawing()
} }
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = false
}
const onWindowBlur = () => {
shiftPressed.current = false
} }
document.addEventListener('keydown', onKeyDown) document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onWindowBlur)
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
@@ -175,8 +172,6 @@ export const SlabTool: React.FC = () => {
return () => { return () => {
document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick) emitter.off('grid:double-click', onGridDoubleClick)
-1
View File
@@ -100,7 +100,6 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
tool: () => import('./tool'), tool: () => import('./tool'),
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Place spawn point' }, { key: 'Left click', label: 'Place spawn point' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
+7 -27
View File
@@ -27,8 +27,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
/** /**
* Phase 5 Stage D — wall curve tool (kind-owned). * Phase 5 Stage D — wall curve tool (kind-owned).
* *
* 1:1 port of the legacy `CurveWallTool`. Same snap pipeline, Shift * 1:1 port of the legacy `CurveWallTool`. Same snap pipeline,
* override, history dance, activation grace. The wall variant uses * history dance, activation grace. The wall variant uses
* `useScene.temporal.getState().pause()` / `.resume()` directly rather * `useScene.temporal.getState().pause()` / `.resume()` directly rather
* than the depth-counted `pauseSceneHistory` helpers — matches legacy. * than the depth-counted `pauseSceneHistory` helpers — matches legacy.
*/ */
@@ -36,7 +36,6 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now()) const activatedAtRef = useRef<number>(Date.now())
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node)) const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
const previousCurveOffsetRef = useRef<number | null>(null) const previousCurveOffsetRef = useRef<number | null>(null)
const shiftPressedRef = useRef(false)
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current) const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
const initialHandle = getWallMidpointHandlePoint(node) const initialHandle = getWallMidpointHandlePoint(node)
@@ -87,14 +86,14 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true
const snapStep = getSegmentGridStep() const snapStep = getSegmentGridStep()
// Snap the cursor on the WORLD XZ grid (still in building-local // 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 // coords for the rest of the math) so a rotated building doesn't
// pull the curve handle off the visible grid lines. // pull the curve handle off the visible grid lines.
const [snappedLocalX, snappedLocalZ] = bypassSnap const [snappedLocalX, snappedLocalZ] = snapBuildingLocalToWorldGrid(
? [event.localPosition[0], event.localPosition[2]] [event.localPosition[0], event.localPosition[2]],
: snapBuildingLocalToWorldGrid([event.localPosition[0], event.localPosition[2]], snapStep) snapStep,
)
const localX = snappedLocalX const localX = snappedLocalX
const localZ = snappedLocalZ const localZ = snappedLocalZ
@@ -102,16 +101,13 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
(localX - chord.midpoint.x) * chord.normal.x + (localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y (localZ - chord.midpoint.y) * chord.normal.y
) )
const snappedOffset = bypassSnap const snappedOffset = snapScalarToGrid(offsetFromMidpoint, snapStep)
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset( const nextCurveOffset = normalizeWallCurveOffset(
node, node,
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)), Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
) )
if ( if (
!bypassSnap &&
previousCurveOffsetRef.current !== null && previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current nextCurveOffset !== previousCurveOffsetRef.current
) { ) {
@@ -157,23 +153,9 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
exitCurveMode() 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:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => { return () => {
if (!wasCommitted) { if (!wasCommitted) {
@@ -183,8 +165,6 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
} }
}, [exitCurveMode, node]) }, [exitCurveMode, node])
+1
View File
@@ -25,6 +25,7 @@ import { wallSlots } from './slots'
*/ */
export const wallDefinition: NodeDefinition<typeof WallNode> = { export const wallDefinition: NodeDefinition<typeof WallNode> = {
kind: 'wall', kind: 'wall',
snapProfile: 'structural',
schemaVersion: 1, schemaVersion: 1,
schema: WallNode, schema: WallNode,
category: 'structure', category: 'structure',
@@ -43,8 +43,8 @@ import {
* the final state to scene in one tracked update and clears the * the final state to scene in one tracked update and clears the
* overrides. `canCommit` still guards against collapsed walls. * overrides. `canCommit` still guards against collapsed walls.
* *
* Alt-detach (drop linked walls) and SHIFT-free-place (skip angle snap) * Alt-detach (drop linked walls) is wired via the standard modifier
* are wired via the standard modifier flags on the session. * flags on the session.
*/ */
type WallEndpointPayload = { wallId: AnyNodeId; endpoint: 'start' | 'end' } type WallEndpointPayload = { wallId: AnyNodeId; endpoint: 'start' | 'end' }
@@ -95,7 +95,7 @@ function collectLinkedWalls(
* Wall curve sagitta drag — 1:1 port of the legacy * Wall curve sagitta drag — 1:1 port of the legacy
* `handleWallCurvePointerDown` + commit flow. Drag projects the pointer * `handleWallCurvePointerDown` + commit flow. Drag projects the pointer
* onto the chord normal to compute a `curveOffset`, snapped to the * 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 * normalized via `normalizeWallCurveOffset`. Same single-undo dance as
* the move-endpoint affordance — the dispatcher handles snapshot / * the move-endpoint affordance — the dispatcher handles snapshot /
* pause / resume around `apply`. * pause / resume around `apply`.
@@ -111,13 +111,11 @@ export const wallCurveAffordance: FloorplanAffordance<WallNode> = {
return { return {
affectedIds: [node.id], affectedIds: [node.id],
apply({ planPoint, modifiers }) { apply({ planPoint }) {
const snapStep = getSegmentGridStep() const snapStep = getSegmentGridStep()
// World-grid snap so a rotated building doesn't drag the curve // World-grid snap so a rotated building doesn't drag the curve
// handle off the visible grid. // handle off the visible grid.
const [x, y] = modifiers.shiftKey const [x, y] = snapBuildingLocalToWorldGrid([planPoint[0], planPoint[1]], snapStep)
? [planPoint[0], planPoint[1]]
: snapBuildingLocalToWorldGrid([planPoint[0], planPoint[1]], snapStep)
// Signed projection of (snappedPoint - chord midpoint) onto the // Signed projection of (snappedPoint - chord midpoint) onto the
// chord normal. Legacy negates because the SVG y-axis flips // chord normal. Legacy negates because the SVG y-axis flips
@@ -129,9 +127,7 @@ export const wallCurveAffordance: FloorplanAffordance<WallNode> = {
(x - chord.midpoint.x) * chord.normal.x + (x - chord.midpoint.x) * chord.normal.x +
(y - chord.midpoint.y) * chord.normal.y (y - chord.midpoint.y) * chord.normal.y
) )
const snappedOffset = modifiers.shiftKey const snappedOffset = snapScalarToGrid(offsetFromMidpoint, snapStep)
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset( const nextCurveOffset = normalizeWallCurveOffset(
node, node,
Math.max(-maxOffset, Math.min(maxOffset, snappedOffset)), Math.max(-maxOffset, Math.min(maxOffset, snappedOffset)),
@@ -188,13 +184,11 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
const sceneNodes = useScene.getState().nodes const sceneNodes = useScene.getState().nodes
const walls = collectLevelWalls(sceneNodes, node.id) const walls = collectLevelWalls(sceneNodes, node.id)
// Endpoint move = grid snap, never 45° from the fixed corner. // Endpoint move = grid snap, never 45° from the fixed corner.
// Shift bypasses grid, magnetic, and alignment snap.
const snapped = snapWallDraftPoint({ const snapped = snapWallDraftPoint({
point: planPoint as WallPlanPoint, point: planPoint as WallPlanPoint,
walls, walls,
ignoreWallIds: [node.id], ignoreWallIds: [node.id],
bypassSnap: modifiers.shiftKey, magnetic: isMagneticSnapActive(),
magnetic: !modifiers.shiftKey && isMagneticSnapActive(),
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP), gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP),
}) })
// Figma-style alignment on the dragged corner — snaps it onto another // Figma-style alignment on the dragged corner — snaps it onto another
@@ -202,7 +196,6 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
// and its linked siblings (which cascade with the corner) are excluded // and its linked siblings (which cascade with the corner) are excluded
// from the candidate pool. Alt is reserved for detach, NOT bypass. // from the candidate pool. Alt is reserved for detach, NOT bypass.
const aligned = alignFloorplanDraftPoint(snapped, { const aligned = alignFloorplanDraftPoint(snapped, {
bypass: modifiers.shiftKey,
excludeIds: [node.id, ...linkedWalls.map((w) => w.id)], excludeIds: [node.id, ...linkedWalls.map((w) => w.id)],
}) as WallPlanPoint }) as WallPlanPoint
+5 -5
View File
@@ -105,7 +105,7 @@ export const wallFloorplanMoveTarget: FloorplanMoveTarget<WallNode> = ({ node })
const session: FloorplanMoveTargetSession = { const session: FloorplanMoveTargetSession = {
affectedIds: [wallId, ...linkedOriginals.map((w) => w.id as AnyNodeId)], affectedIds: [wallId, ...linkedOriginals.map((w) => w.id as AnyNodeId)],
apply({ planPoint, modifiers }) { apply({ planPoint }) {
if (!rawAnchor) { if (!rawAnchor) {
rawAnchor = [planPoint[0], planPoint[1]] rawAnchor = [planPoint[0], planPoint[1]]
return return
@@ -119,19 +119,19 @@ export const wallFloorplanMoveTarget: FloorplanMoveTarget<WallNode> = ({ node })
// the original centre + raw cursor delta onto the axis, snap the // the original centre + raw cursor delta onto the axis, snap the
// absolute projection to a grid multiple, then translate the wall // absolute projection to a grid multiple, then translate the wall
// by `axis * perpDelta`. Matches `MoveWallTool` so 2D and 3D drag // 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 dx: number
let dz: number let dz: number
if (moveAxis) { if (moveAxis) {
const originalProj = originalCenter[0] * moveAxis[0] + originalCenter[1] * moveAxis[1] const originalProj = originalCenter[0] * moveAxis[0] + originalCenter[1] * moveAxis[1]
const rawProj = originalProj + rawDx * moveAxis[0] + rawDz * 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 const perpDelta = snappedProj - originalProj
dx = moveAxis[0] * perpDelta dx = moveAxis[0] * perpDelta
dz = moveAxis[1] * perpDelta dz = moveAxis[1] * perpDelta
} else { } else {
dx = modifiers.shiftKey ? rawDx : snapScalarToGrid(rawDx, step) dx = snapScalarToGrid(rawDx, step)
dz = modifiers.shiftKey ? rawDz : snapScalarToGrid(rawDz, step) dz = snapScalarToGrid(rawDz, step)
} }
if (dx === lastDelta[0] && dz === lastDelta[1]) return if (dx === lastDelta[0] && dz === lastDelta[1]) return
+11 -21
View File
@@ -19,6 +19,7 @@ import {
formatAngleRadians, formatAngleRadians,
getAngleToSegmentReference, getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint, getSegmentAngleReferenceAtPoint,
isAngleSnapActive,
isMagneticSnapActive, isMagneticSnapActive,
isSegmentLongEnough, isSegmentLongEnough,
MeasurementPill, MeasurementPill,
@@ -177,7 +178,6 @@ function getLinkedWallUpdates(
export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ target }) => { export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ target }) => {
const hasDraggedRef = useRef(false) const hasDraggedRef = useRef(false)
const previousGridPosRef = useRef<WallPlanPoint | null>(null) const previousGridPosRef = useRef<WallPlanPoint | null>(null)
const shiftPressedRef = useRef(false)
const altPressedRef = useRef(false) const altPressedRef = useRef(false)
const nodeIdRef = useRef(target.wall.id) const nodeIdRef = useRef(target.wall.id)
const originalStartRef = useRef<WallPlanPoint>([...target.wall.start] as WallPlanPoint) const originalStartRef = useRef<WallPlanPoint>([...target.wall.start] as WallPlanPoint)
@@ -288,21 +288,17 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
// Endpoint *move* snaps to the grid (and to other wall corners) — // Endpoint move honours the active snapping mode (the HUD chip): grid →
// 45° angle snap is for the initial draft, where it gives clean // lattice; lines → magnetic corner/alignment snap; angles → lock the
// orthogonal corners; here it would fight every perpendicular // segment to 15° rays from the FIXED corner; off → raw. No Shift bypass —
// drag by warping the endpoint onto the nearest 45° line from // Shift cycles the mode now, and Off is the bypass.
// 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
const snapResult = snapWallDraftPointDetailed({ const snapResult = snapWallDraftPointDetailed({
point: planPoint, point: planPoint,
walls: levelWalls, walls: levelWalls,
ignoreWallIds: [nodeId], ignoreWallIds: [nodeId],
bypassSnap, start: fixedPoint,
magnetic: !bypassSnap && isMagneticSnapActive(), angleSnap: isAngleSnapActive(),
magnetic: isMagneticSnapActive(),
}) })
const snappedPoint = snapResult.point 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 / // candidate, so the dot always sits on an actual point (endpoint /
// midpoint), never an empty-space bbox corner. Layered on top of the // midpoint), never an empty-space bbox corner. Layered on top of the
// grid + corner snap above; Alt is reserved for corner-detach here. // 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 let alignedPoint = snappedPoint
if (!bypassSnap && wallAlignmentCandidates.length > 0) { if (isMagneticSnapActive() && wallAlignmentCandidates.length > 0) {
const ar = resolveAlignment({ const ar = resolveAlignment({
moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }], moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }],
candidates: wallAlignmentCandidates, candidates: wallAlignmentCandidates,
@@ -328,7 +326,6 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
} }
if ( if (
!bypassSnap &&
previousGridPosRef.current && previousGridPosRef.current &&
(alignedPoint[0] !== previousGridPosRef.current[0] || (alignedPoint[0] !== previousGridPosRef.current[0] ||
alignedPoint[1] !== previousGridPosRef.current[1]) 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) { if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return return
} }
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
if (event.key === 'Alt') { if (event.key === 'Alt') {
altPressedRef.current = true altPressedRef.current = true
setAltPressed(true) setAltPressed(true)
@@ -424,9 +418,6 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
} }
const onKeyUp = (event: KeyboardEvent) => { const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
if (event.key === 'Alt') { if (event.key === 'Alt') {
altPressedRef.current = false altPressedRef.current = false
setAltPressed(false) setAltPressed(false)
@@ -434,7 +425,6 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
} }
const onWindowBlur = () => { const onWindowBlur = () => {
shiftPressedRef.current = false
altPressedRef.current = false altPressedRef.current = false
setAltPressed(false) setAltPressed(false)
} }
+2 -22
View File
@@ -66,7 +66,7 @@ import {
* operation. * operation.
* - **`isNew` metadata strip** — first commit after a fresh wall * - **`isNew` metadata strip** — first commit after a fresh wall
* placement clears the placement marker. * 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`. * 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 nodeIdRef = useRef(node.id)
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
const pendingRotationRef = useRef(0) const pendingRotationRef = useRef(0)
const shiftPressedRef = useRef(false)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const centerX = (node.start[0] + node.end[0]) / 2 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 onGridMove = (event: GridEvent) => {
const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true
const rawX = event.localPosition[0] const rawX = event.localPosition[0]
const rawZ = event.localPosition[2] const rawZ = event.localPosition[2]
const snapStep = getSegmentGridStep() const snapStep = getSegmentGridStep()
@@ -493,13 +491,10 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
if (axis) { if (axis) {
const originalProj = originalCenter[0] * axis[0] + originalCenter[1] * axis[1] const originalProj = originalCenter[0] * axis[0] + originalCenter[1] * axis[1]
const rawProj = originalProj + rawDeltaX * axis[0] + rawDeltaZ * 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 const perpDelta = snappedProj - originalProj
deltaX = axis[0] * perpDelta deltaX = axis[0] * perpDelta
deltaZ = axis[1] * perpDelta deltaZ = axis[1] * perpDelta
} else if (bypassSnap) {
deltaX = rawDeltaX
deltaZ = rawDeltaZ
} else { } else {
// Snap the resulting wall center to the WORLD XZ grid (projected // Snap the resulting wall center to the WORLD XZ grid (projected
// back into building-local), then express the result as a delta // 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] const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ]
if ( if (
!bypassSnap &&
previousGridPosRef.current && previousGridPosRef.current &&
(constrainedGridPos[0] !== previousGridPosRef.current[0] || (constrainedGridPos[0] !== previousGridPosRef.current[0] ||
constrainedGridPos[1] !== previousGridPosRef.current[1]) constrainedGridPos[1] !== previousGridPosRef.current[1])
@@ -633,11 +627,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
return return
} }
if (event.key === 'Shift') {
shiftPressedRef.current = true
return
}
const ROTATION_STEP = Math.PI / 4 const ROTATION_STEP = Math.PI / 4
let rotationDelta = 0 let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP 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) applyPreview(nextWall.start, nextWall.end)
} }
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
const onCancel = () => { const onCancel = () => {
shouldRestoreOnCleanup = false shouldRestoreOnCleanup = false
restoreOriginal() restoreOriginal()
@@ -683,7 +666,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPointerUp) window.addEventListener('pointerup', onPointerUp)
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => { return () => {
if (shouldRestoreOnCleanup) { if (shouldRestoreOnCleanup) {
@@ -698,13 +680,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
restoreOriginal() restoreOriginal()
} }
} }
shiftPressedRef.current = false
resumeSceneHistory(useScene) resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
} }
}, [exitMoveMode, isNew, node.metadata, node.parentId]) }, [exitMoveMode, isNew, node.metadata, node.parentId])
+1
View File
@@ -104,6 +104,7 @@ function applyWallPreview(args: PaintPreviewArgs): (() => void) | null {
* picker still shows the current value on a pre-migration scene. * picker still shows the current value on a pre-migration scene.
*/ */
export const wallPaint: PaintCapability = createSlotPaintCapability({ export const wallPaint: PaintCapability = createSlotPaintCapability({
roomScope: true,
resolveRole: ({ node, materialIndex, normal, localPosition }) => resolveRole: ({ node, materialIndex, normal, localPosition }) =>
resolveWallRole({ node: node as WallNode, materialIndex, normal, localPosition }), resolveWallRole({ node: node as WallNode, materialIndex, normal, localPosition }),
applyPreview: applyWallPreview, applyPreview: applyWallPreview,
+4 -2
View File
@@ -554,7 +554,8 @@ export const WallTool: React.FC = () => {
// angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass. // angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass.
// Alt still bypasses Figma-style alignment guides independently. // Alt still bypasses Figma-style alignment guides independently.
const angleLocked = buildingState.current === 1 && isAngleSnapActive() 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({ const snapResult = snapWallDraftPointDetailed({
point: localPoint, point: localPoint,
walls, walls,
@@ -634,7 +635,8 @@ export const WallTool: React.FC = () => {
const walls = getCurrentLevelWalls() const walls = getCurrentLevelWalls()
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] 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) { if (buildingState.current === 0) {
const snappedStart = alignPoint( const snappedStart = alignPoint(
-1
View File
@@ -229,7 +229,6 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Place window on wall' }, { key: 'Left click', label: 'Place window on wall' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
+1
View File
@@ -17,6 +17,7 @@ import { ZoneNode } from './schema'
*/ */
export const zoneDefinition: NodeDefinition<typeof ZoneNode> = { export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
kind: 'zone', kind: 'zone',
snapProfile: 'structural',
schemaVersion: 1, schemaVersion: 1,
schema: ZoneNode, schema: ZoneNode,
category: 'site', category: 'site',