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 { 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.
|
* 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,
|
* 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.
|
* 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 halfDim = dimension / 2
|
||||||
const needsOffset = Math.abs(((halfDim * 2) % 1) - 0.5) < 0.01
|
const offset = positiveModulo(halfDim, step)
|
||||||
const offset = needsOffset ? 0.25 : 0
|
return Math.round((position - offset) / step) * step + offset
|
||||||
return Math.round((position - offset) * 2) / 2 + offset
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snap a value to 0.5 increments (used for wall-local positions).
|
* Snap a value to 0.5 increments (used for wall-local positions).
|
||||||
*/
|
*/
|
||||||
export function snapToHalf(value: number): number {
|
export function snapToHalf(value: number, step = getGridSnapStep()): number {
|
||||||
return Math.round(value * 2) / 2
|
return Math.round(value / step) * step
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
|
|
||||||
export type WallPlanPoint = [number, number]
|
export type WallPlanPoint = [number, number]
|
||||||
|
|
||||||
@@ -29,6 +30,10 @@ function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
|||||||
return dx * dx + dz * dz
|
return dx * dx + dz * dz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWallGridStep(): number {
|
||||||
|
return useEditor.getState().gridSnapStep
|
||||||
|
}
|
||||||
|
|
||||||
function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
|
function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
|
||||||
return Math.round(value / step) * step
|
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)]
|
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 dx = cursor[0] - start[0]
|
||||||
const dz = cursor[1] - start[1]
|
const dz = cursor[1] - start[1]
|
||||||
const angle = Math.atan2(dz, dx)
|
const angle = Math.atan2(dz, dx)
|
||||||
@@ -47,7 +56,7 @@ export function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint
|
|||||||
return snapPointToGrid([
|
return snapPointToGrid([
|
||||||
start[0] + Math.cos(snappedAngle) * distance,
|
start[0] + Math.cos(snappedAngle) * distance,
|
||||||
start[1] + Math.sin(snappedAngle) * distance,
|
start[1] + Math.sin(snappedAngle) * distance,
|
||||||
])
|
], step)
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
|
function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
|
||||||
@@ -348,7 +357,9 @@ export function snapWallDraftPoint(args: {
|
|||||||
ignoreWallIds?: string[]
|
ignoreWallIds?: string[]
|
||||||
}): WallPlanPoint {
|
}): WallPlanPoint {
|
||||||
const { point, walls, start, angleSnap = false, ignoreWallIds } = args
|
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 (
|
return (
|
||||||
findWallSnapTarget(basePoint, walls, {
|
findWallSnapTarget(basePoint, walls, {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ChevronsLeft, ChevronsRight, Columns2, Eye, Footprints, Moon, Sun } fro
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
import useEditor from '../../store/use-editor'
|
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 { useSidebarStore } from './primitives/sidebar'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from './primitives/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from './primitives/tooltip'
|
||||||
|
|
||||||
@@ -174,6 +174,18 @@ const levelModeLabels: Record<string, string> = {
|
|||||||
solo: 'Solo',
|
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() {
|
function LevelModeToggle() {
|
||||||
const levelMode = useViewer((s) => s.levelMode)
|
const levelMode = useViewer((s) => s.levelMode)
|
||||||
const setLevelMode = useViewer((s) => s.setLevelMode)
|
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 ────────────────────────────────────────────────────────
|
// ── Wall mode toggle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const wallModeOrder = ['cutaway', 'up', 'down'] as const
|
const wallModeOrder = ['cutaway', 'up', 'down'] as const
|
||||||
@@ -329,6 +364,7 @@ export function ViewerToolbarRight() {
|
|||||||
return (
|
return (
|
||||||
<div className={TOOLBAR_CONTAINER}>
|
<div className={TOOLBAR_CONTAINER}>
|
||||||
<LevelModeToggle />
|
<LevelModeToggle />
|
||||||
|
<GridSnapToggle />
|
||||||
<WallModeToggle />
|
<WallModeToggle />
|
||||||
<div className="my-1.5 w-px bg-border/50" />
|
<div className="my-1.5 w-px bg-border/50" />
|
||||||
<UnitToggle />
|
<UnitToggle />
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export type CatalogCategory =
|
|||||||
export type StructureLayer = 'zones' | 'elements'
|
export type StructureLayer = 'zones' | 'elements'
|
||||||
|
|
||||||
export type FloorplanSelectionTool = 'click' | 'marquee'
|
export type FloorplanSelectionTool = 'click' | 'marquee'
|
||||||
|
export type GridSnapStep = 0.5 | 0.25 | 0.1 | 0.05
|
||||||
|
|
||||||
// Combined tool type
|
// Combined tool type
|
||||||
export type Tool = SiteTool | StructureTool | FurnishTool
|
export type Tool = SiteTool | StructureTool | FurnishTool
|
||||||
@@ -150,6 +151,8 @@ type EditorState = {
|
|||||||
setFloorplanHovered: (hovered: boolean) => void
|
setFloorplanHovered: (hovered: boolean) => void
|
||||||
floorplanSelectionTool: FloorplanSelectionTool
|
floorplanSelectionTool: FloorplanSelectionTool
|
||||||
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
|
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
|
||||||
|
gridSnapStep: GridSnapStep
|
||||||
|
setGridSnapStep: (step: GridSnapStep) => void
|
||||||
// First-person walkthrough mode (street view)
|
// First-person walkthrough mode (street view)
|
||||||
isFirstPersonMode: boolean
|
isFirstPersonMode: boolean
|
||||||
_viewModeBeforeFirstPerson: ViewMode | null
|
_viewModeBeforeFirstPerson: ViewMode | null
|
||||||
@@ -170,7 +173,11 @@ export type PersistedEditorUiState = Pick<
|
|||||||
|
|
||||||
type PersistedEditorLayoutState = Pick<
|
type PersistedEditorLayoutState = Pick<
|
||||||
EditorState,
|
EditorState,
|
||||||
'activeSidebarPanel' | 'floorplanPaneRatio' | 'splitOrientation' | 'floorplanSelectionTool'
|
| 'activeSidebarPanel'
|
||||||
|
| 'floorplanPaneRatio'
|
||||||
|
| 'splitOrientation'
|
||||||
|
| 'floorplanSelectionTool'
|
||||||
|
| 'gridSnapStep'
|
||||||
>
|
>
|
||||||
type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState
|
type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState
|
||||||
|
|
||||||
@@ -189,8 +196,11 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
|
|||||||
floorplanPaneRatio: DEFAULT_FLOORPLAN_PANE_RATIO,
|
floorplanPaneRatio: DEFAULT_FLOORPLAN_PANE_RATIO,
|
||||||
splitOrientation: 'horizontal',
|
splitOrientation: 'horizontal',
|
||||||
floorplanSelectionTool: 'click',
|
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 {
|
function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
|
||||||
if (phase === 'site') {
|
if (phase === 'site') {
|
||||||
return 'select'
|
return 'select'
|
||||||
@@ -293,6 +303,9 @@ function normalizePersistedEditorLayoutState(
|
|||||||
floorplanPaneRatio: normalizeFloorplanPaneRatio(state?.floorplanPaneRatio),
|
floorplanPaneRatio: normalizeFloorplanPaneRatio(state?.floorplanPaneRatio),
|
||||||
splitOrientation: state?.splitOrientation === 'vertical' ? 'vertical' : 'horizontal',
|
splitOrientation: state?.splitOrientation === 'vertical' ? 'vertical' : 'horizontal',
|
||||||
floorplanSelectionTool: state?.floorplanSelectionTool === 'marquee' ? 'marquee' : 'click',
|
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 }),
|
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
|
||||||
floorplanSelectionTool: 'click' as FloorplanSelectionTool,
|
floorplanSelectionTool: 'click' as FloorplanSelectionTool,
|
||||||
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
|
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
|
||||||
|
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
||||||
|
setGridSnapStep: (step) => set({ gridSnapStep: step }),
|
||||||
allowUndergroundCamera: false,
|
allowUndergroundCamera: false,
|
||||||
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
|
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
|
||||||
isFirstPersonMode: false,
|
isFirstPersonMode: false,
|
||||||
@@ -585,6 +600,7 @@ const useEditor = create<EditorState>()(
|
|||||||
floorplanPaneRatio: state.floorplanPaneRatio,
|
floorplanPaneRatio: state.floorplanPaneRatio,
|
||||||
splitOrientation: state.splitOrientation,
|
splitOrientation: state.splitOrientation,
|
||||||
floorplanSelectionTool: state.floorplanSelectionTool,
|
floorplanSelectionTool: state.floorplanSelectionTool,
|
||||||
|
gridSnapStep: state.gridSnapStep,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user