Snap roof segment resize handles to grid

This commit is contained in:
Aymeric Rabot
2026-06-07 00:44:58 -04:00
parent fe0a1056f2
commit 21b79c0d62
6 changed files with 21 additions and 3 deletions
+2
View File
@@ -130,6 +130,8 @@ export type LinearResizeHandle<N> = {
overrideTarget?: (node: N, sceneApi: SceneApi) => AnyNodeId | undefined overrideTarget?: (node: N, sceneApi: SceneApi) => AnyNodeId | undefined
min?: number | ((node: N, sceneApi: SceneApi) => number) min?: number | ((node: N, sceneApi: SceneApi) => number)
max?: 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<N> placement: HandlePlacement<N>
/** /**
* Dimension this handle steers (e.g. `'height'`). When set, the editor * Dimension this handle steers (e.g. `'height'`). When set, the editor
+2
View File
@@ -541,6 +541,8 @@ export type FloorplanAffordance<N> = {
nodes: Record<AnyNodeId, AnyNode> nodes: Record<AnyNodeId, AnyNode>
/** Initial pointer position in plan coordinates. */ /** Initial pointer position in plan coordinates. */
initialPlanPoint: FloorplanAffordancePoint initialPlanPoint: FloorplanAffordancePoint
/** Active editor grid step in meters. */
gridSnapStep: number
}): FloorplanAffordanceSession }): FloorplanAffordanceSession
} }
@@ -471,6 +471,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
payload, payload,
nodes: sceneNodes, nodes: sceneNodes,
initialPlanPoint, initialPlanPoint,
gridSnapStep: useEditor.getState().gridSnapStep,
}) })
const snapshots: NodeSnapshot[] = [] const snapshots: NodeSnapshot[] = []
@@ -12,6 +12,7 @@ import {
nodeRegistry, nodeRegistry,
type RadialResizeHandle, type RadialResizeHandle,
sceneRegistry, sceneRegistry,
snapScalar,
type TapActionHandle, type TapActionHandle,
type TranslateHandle, type TranslateHandle,
useLiveNodeOverrides, useLiveNodeOverrides,
@@ -581,6 +582,10 @@ function LinearArrow({
descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z
const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi) const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
const maxBound = resolveBound(descriptor.max, Number.POSITIVE_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 = const factor =
descriptor.kind === 'radial-resize' descriptor.kind === 'radial-resize'
? 1 ? 1
@@ -615,7 +620,10 @@ function LinearArrow({
? intersectionLocal.y ? intersectionLocal.y
: intersectionLocal.z : intersectionLocal.z
const delta = currentPointer - initialPointer 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<AnyNode> return descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
}, },
} }
@@ -56,6 +56,7 @@ function roofSegmentWidthHandle(side: 'left' | 'right'): HandleDescriptor<RoofSe
// 'max' = +X edge anchored (left arrow grows the -X edge outward). // 'max' = +X edge anchored (left arrow grows the -X edge outward).
anchor: side === 'right' ? 'min' : 'max', anchor: side === 'right' ? 'min' : 'max',
min: MIN_ROOF_DIM, min: MIN_ROOF_DIM,
gridSnap: true,
currentValue: (n) => n.width, currentValue: (n) => n.width,
apply: (initial, newWidth) => { apply: (initial, newWidth) => {
const rotY = initial.rotation ?? 0 const rotY = initial.rotation ?? 0
@@ -100,6 +101,7 @@ function roofSegmentDepthHandle(side: 'front' | 'back'): HandleDescriptor<RoofSe
axis: 'z', axis: 'z',
anchor: side === 'front' ? 'min' : 'max', anchor: side === 'front' ? 'min' : 'max',
min: MIN_ROOF_DIM, min: MIN_ROOF_DIM,
gridSnap: true,
currentValue: (n) => n.depth, currentValue: (n) => n.depth,
apply: (initial, newDepth) => { apply: (initial, newDepth) => {
// Recenter so the anchored Z edge stays at the same world point. // Recenter so the anchored Z edge stays at the same world point.
@@ -4,6 +4,7 @@ import {
type FloorplanMoveTarget, type FloorplanMoveTarget,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
snapScalar,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -56,7 +57,7 @@ function resolveSegmentFrame(
* the math survives any parent-roof rotation. * the math survives any parent-roof rotation.
*/ */
export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> = { export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> = {
start({ node, payload, nodes, initialPlanPoint }) { start({ node, payload, nodes, initialPlanPoint, gridSnapStep }) {
const { axis, side } = payload as RoofSegmentResizePayload const { axis, side } = payload as RoofSegmentResizePayload
const segmentId = node.id as AnyNodeId const segmentId = node.id as AnyNodeId
const initialValue = axis === 'x' ? node.width : node.depth const initialValue = axis === 'x' ? node.width : node.depth
@@ -79,7 +80,9 @@ export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> =
apply({ planPoint }) { apply({ planPoint }) {
const currentLocal = projectLocalAxis(planPoint[0], planPoint[1]) const currentLocal = projectLocalAxis(planPoint[0], planPoint[1])
const delta = (currentLocal - initialLocal) * side 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 lastValue = newValue
useScene useScene
.getState() .getState()