diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index 2895de32..3f6295fa 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -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 } /** diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index 91ba5180..ab017197 100755 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -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, { diff --git a/packages/editor/src/components/ui/viewer-toolbar.tsx b/packages/editor/src/components/ui/viewer-toolbar.tsx index bf4c460c..b81a5c71 100644 --- a/packages/editor/src/components/ui/viewer-toolbar.tsx +++ b/packages/editor/src/components/ui/viewer-toolbar.tsx @@ -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 = { solo: 'Solo', } +const gridSnapOrder: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05] +const gridSnapLabels: Record = { + 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 ( + + + + + Grid snap: {formatGridSnapStep(gridSnapStep)} + + ) +} + // ── Wall mode toggle ──────────────────────────────────────────────────────── const wallModeOrder = ['cutaway', 'up', 'down'] as const @@ -329,6 +364,7 @@ export function ViewerToolbarRight() { return (
+
diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index c648451e..6fa9bb4b 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -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()( 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()( floorplanPaneRatio: state.floorplanPaneRatio, splitOrientation: state.splitOrientation, floorplanSelectionTool: state.floorplanSelectionTool, + gridSnapStep: state.gridSnapStep, }), }, ),