Add cycling grid snap control to the viewer toolbar
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user