diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 62ffc589..2d69058b 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -130,6 +130,8 @@ export type LinearResizeHandle = { overrideTarget?: (node: N, sceneApi: SceneApi) => AnyNodeId | undefined min?: number | ((node: N, sceneApi: SceneApi) => number) max?: number | ((node: N, sceneApi: SceneApi) => number) + /** Snap the resized scalar to the editor's active grid step before apply. */ + gridSnap?: boolean placement: HandlePlacement /** * Dimension this handle steers (e.g. `'height'`). When set, the editor diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index c1ce0856..6013f5fd 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -541,6 +541,8 @@ export type FloorplanAffordance = { nodes: Record /** Initial pointer position in plan coordinates. */ initialPlanPoint: FloorplanAffordancePoint + /** Active editor grid step in meters. */ + gridSnapStep: number }): FloorplanAffordanceSession } diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 716729ef..f27326f2 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -471,6 +471,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { payload, nodes: sceneNodes, initialPlanPoint, + gridSnapStep: useEditor.getState().gridSnapStep, }) const snapshots: NodeSnapshot[] = [] diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 49463f80..c8bef3f3 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -12,6 +12,7 @@ import { nodeRegistry, type RadialResizeHandle, sceneRegistry, + snapScalar, type TapActionHandle, type TranslateHandle, useLiveNodeOverrides, @@ -581,6 +582,10 @@ function LinearArrow({ descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi) const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi) + const gridSnapStep = + descriptor.kind === 'linear-resize' && descriptor.gridSnap + ? useEditor.getState().gridSnapStep + : null const factor = descriptor.kind === 'radial-resize' ? 1 @@ -615,7 +620,10 @@ function LinearArrow({ ? intersectionLocal.y : intersectionLocal.z const delta = currentPointer - initialPointer - const next = Math.min(maxBound, Math.max(minBound, initialValue + delta * factor)) + const rawNext = initialValue + delta * factor + const snappedNext = + gridSnapStep && gridSnapStep > 0 ? snapScalar(rawNext, gridSnapStep) : rawNext + const next = Math.min(maxBound, Math.max(minBound, snappedNext)) return descriptor.apply(initialNode as never, next, sceneApi) as Partial }, } diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index 976466bf..71d64914 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -56,6 +56,7 @@ function roofSegmentWidthHandle(side: 'left' | 'right'): HandleDescriptor n.width, apply: (initial, newWidth) => { const rotY = initial.rotation ?? 0 @@ -100,6 +101,7 @@ function roofSegmentDepthHandle(side: 'front' | 'back'): HandleDescriptor n.depth, apply: (initial, newDepth) => { // Recenter so the anchored Z edge stays at the same world point. diff --git a/packages/nodes/src/roof-segment/floorplan-affordances.ts b/packages/nodes/src/roof-segment/floorplan-affordances.ts index cbf7ab86..17e6b279 100644 --- a/packages/nodes/src/roof-segment/floorplan-affordances.ts +++ b/packages/nodes/src/roof-segment/floorplan-affordances.ts @@ -4,6 +4,7 @@ import { type FloorplanMoveTarget, type RoofNode, type RoofSegmentNode, + snapScalar, useScene, } from '@pascal-app/core' @@ -56,7 +57,7 @@ function resolveSegmentFrame( * the math survives any parent-roof rotation. */ export const roofSegmentResizeAffordance: FloorplanAffordance = { - start({ node, payload, nodes, initialPlanPoint }) { + start({ node, payload, nodes, initialPlanPoint, gridSnapStep }) { const { axis, side } = payload as RoofSegmentResizePayload const segmentId = node.id as AnyNodeId const initialValue = axis === 'x' ? node.width : node.depth @@ -79,7 +80,9 @@ export const roofSegmentResizeAffordance: FloorplanAffordance = apply({ planPoint }) { const currentLocal = projectLocalAxis(planPoint[0], planPoint[1]) const delta = (currentLocal - initialLocal) * side - const newValue = Math.max(MIN_ROOF_DIM, initialValue + 2 * delta) + const rawValue = initialValue + 2 * delta + const snappedValue = gridSnapStep > 0 ? snapScalar(rawValue, gridSnapStep) : rawValue + const newValue = Math.max(MIN_ROOF_DIM, snappedValue) lastValue = newValue useScene .getState()