Add cycling grid snap control to the viewer toolbar

This commit is contained in:
sudhir
2026-04-16 13:34:42 +05:30
parent b1d8180f62
commit 162c5806a7
4 changed files with 82 additions and 11 deletions
@@ -1,22 +1,30 @@
import { isObject } from '@pascal-app/core'
import useEditor from '../../../store/use-editor'
function getGridSnapStep(): number {
return useEditor.getState().gridSnapStep
}
function positiveModulo(value: number, divisor: number): number {
return ((value % divisor) + divisor) % divisor
}
/**
* Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.
* For items with dimensions like 2.5, the center would be at 1.25 from the edge,
* which doesn't align with 0.5 grid. This adds an offset so edges align instead.
*/
export function snapToGrid(position: number, dimension: number): number {
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
const halfDim = dimension / 2
const needsOffset = Math.abs(((halfDim * 2) % 1) - 0.5) < 0.01
const offset = needsOffset ? 0.25 : 0
return Math.round((position - offset) * 2) / 2 + offset
const offset = positiveModulo(halfDim, step)
return Math.round((position - offset) / step) * step + offset
}
/**
* Snap a value to 0.5 increments (used for wall-local positions).
*/
export function snapToHalf(value: number): number {
return Math.round(value * 2) / 2
export function snapToHalf(value: number, step = getGridSnapStep()): number {
return Math.round(value / step) * step
}
/**
@@ -11,6 +11,7 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
export type WallPlanPoint = [number, number]
@@ -29,6 +30,10 @@ function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
return dx * dx + dz * dz
}
function getWallGridStep(): number {
return useEditor.getState().gridSnapStep
}
function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
return Math.round(value / step) * step
}
@@ -37,7 +42,11 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa
return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
}
export function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint): WallPlanPoint {
export function snapPointTo45Degrees(
start: WallPlanPoint,
cursor: WallPlanPoint,
step = WALL_GRID_STEP,
): WallPlanPoint {
const dx = cursor[0] - start[0]
const dz = cursor[1] - start[1]
const angle = Math.atan2(dz, dx)
@@ -47,7 +56,7 @@ export function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint
return snapPointToGrid([
start[0] + Math.cos(snappedAngle) * distance,
start[1] + Math.sin(snappedAngle) * distance,
])
], step)
}
function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
@@ -348,7 +357,9 @@ export function snapWallDraftPoint(args: {
ignoreWallIds?: string[]
}): WallPlanPoint {
const { point, walls, start, angleSnap = false, ignoreWallIds } = args
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
const step = getWallGridStep()
const basePoint =
start && angleSnap ? snapPointTo45Degrees(start, point, step) : snapPointToGrid(point, step)
return (
findWallSnapTarget(basePoint, walls, {
@@ -6,7 +6,7 @@ import { ChevronsLeft, ChevronsRight, Columns2, Eye, Footprints, Moon, Sun } fro
import { useCallback } from 'react'
import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor'
import type { ViewMode } from '../../store/use-editor'
import type { GridSnapStep, ViewMode } from '../../store/use-editor'
import { useSidebarStore } from './primitives/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from './primitives/tooltip'
@@ -174,6 +174,18 @@ const levelModeLabels: Record<string, string> = {
solo: 'Solo',
}
const gridSnapOrder: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
const gridSnapLabels: Record<GridSnapStep, string> = {
0.5: '0.5',
0.25: '0.25',
0.1: '0.10',
0.05: '0.05',
}
function formatGridSnapStep(step: GridSnapStep): string {
return gridSnapLabels[step]
}
function LevelModeToggle() {
const levelMode = useViewer((s) => s.levelMode)
const setLevelMode = useViewer((s) => s.setLevelMode)
@@ -219,6 +231,29 @@ function LevelModeToggle() {
)
}
function GridSnapToggle() {
const gridSnapStep = useEditor((s) => s.gridSnapStep)
const setGridSnapStep = useEditor((s) => s.setGridSnapStep)
const cycle = () => {
const idx = gridSnapOrder.indexOf(gridSnapStep)
const next = gridSnapOrder[(idx + 1) % gridSnapOrder.length]
if (next) setGridSnapStep(next)
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button className={cn(TOOLBAR_BTN, 'w-auto gap-1.5 px-2.5')} onClick={cycle} type="button">
<IconifyIcon height={14} icon="lucide:grid-2x2" width={14} />
<span className="font-medium text-xs">{formatGridSnapStep(gridSnapStep)}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Grid snap: {formatGridSnapStep(gridSnapStep)}</TooltipContent>
</Tooltip>
)
}
// ── Wall mode toggle ────────────────────────────────────────────────────────
const wallModeOrder = ['cutaway', 'up', 'down'] as const
@@ -329,6 +364,7 @@ export function ViewerToolbarRight() {
return (
<div className={TOOLBAR_CONTAINER}>
<LevelModeToggle />
<GridSnapToggle />
<WallModeToggle />
<div className="my-1.5 w-px bg-border/50" />
<UnitToggle />
+17 -1
View File
@@ -70,6 +70,7 @@ export type CatalogCategory =
export type StructureLayer = 'zones' | 'elements'
export type FloorplanSelectionTool = 'click' | 'marquee'
export type GridSnapStep = 0.5 | 0.25 | 0.1 | 0.05
// Combined tool type
export type Tool = SiteTool | StructureTool | FurnishTool
@@ -150,6 +151,8 @@ type EditorState = {
setFloorplanHovered: (hovered: boolean) => void
floorplanSelectionTool: FloorplanSelectionTool
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
gridSnapStep: GridSnapStep
setGridSnapStep: (step: GridSnapStep) => void
// First-person walkthrough mode (street view)
isFirstPersonMode: boolean
_viewModeBeforeFirstPerson: ViewMode | null
@@ -170,7 +173,11 @@ export type PersistedEditorUiState = Pick<
type PersistedEditorLayoutState = Pick<
EditorState,
'activeSidebarPanel' | 'floorplanPaneRatio' | 'splitOrientation' | 'floorplanSelectionTool'
| 'activeSidebarPanel'
| 'floorplanPaneRatio'
| 'splitOrientation'
| 'floorplanSelectionTool'
| 'gridSnapStep'
>
type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState
@@ -189,8 +196,11 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
floorplanPaneRatio: DEFAULT_FLOORPLAN_PANE_RATIO,
splitOrientation: 'horizontal',
floorplanSelectionTool: 'click',
gridSnapStep: 0.5,
}
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
if (phase === 'site') {
return 'select'
@@ -293,6 +303,9 @@ function normalizePersistedEditorLayoutState(
floorplanPaneRatio: normalizeFloorplanPaneRatio(state?.floorplanPaneRatio),
splitOrientation: state?.splitOrientation === 'vertical' ? 'vertical' : 'horizontal',
floorplanSelectionTool: state?.floorplanSelectionTool === 'marquee' ? 'marquee' : 'click',
gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep)
? (state?.gridSnapStep as GridSnapStep)
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
}
}
@@ -520,6 +533,8 @@ const useEditor = create<EditorState>()(
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
floorplanSelectionTool: 'click' as FloorplanSelectionTool,
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
setGridSnapStep: (step) => set({ gridSnapStep: step }),
allowUndergroundCamera: false,
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
isFirstPersonMode: false,
@@ -585,6 +600,7 @@ const useEditor = create<EditorState>()(
floorplanPaneRatio: state.floorplanPaneRatio,
splitOrientation: state.splitOrientation,
floorplanSelectionTool: state.floorplanSelectionTool,
gridSnapStep: state.gridSnapStep,
}),
},
),