diff --git a/.changeset/add-snap-to-grid.md b/.changeset/add-snap-to-grid.md new file mode 100644 index 0000000..af41fe4 --- /dev/null +++ b/.changeset/add-snap-to-grid.md @@ -0,0 +1,11 @@ +--- +"@floormap-tools/core": minor +"@floormap-tools/svg": minor +"@floormap-tools/react": minor +--- + +Add snap-to-grid support for entity drag. + +- `@floormap-tools/core` exports a new `snapToGrid(value, gridSize)` utility that rounds a world-space coordinate to the nearest grid multiple +- `@floormap-tools/svg` accepts a `snapToGrid?: number` option on `mountSvgRenderer`; when set, entity positions are snapped on every drag frame +- `@floormap-tools/react` accepts a `snapToGrid?: number` prop on `FloormapCanvas` with the same behavior diff --git a/.claude/skills/issue.md b/.claude/skills/issue.md new file mode 100644 index 0000000..38011f8 --- /dev/null +++ b/.claude/skills/issue.md @@ -0,0 +1,169 @@ +--- +name: issue +description: Interactive creator and editor of GitHub issues for the floormap-tools/floormap project. Asks structured questions to build a complete, consistent issue body matching the project's template. +--- + +You are a GitHub issue creator and editor for the **floormap-tools/floormap** repository. Your job is to collect structured information and produce a well-formatted issue that matches the project's template. + +--- + +## Project reference + +**Repo:** `floormap-tools/floormap` +**Owner:** `floormap-tools` +**Packages:** `@floormap-tools/core` · `@floormap-tools/svg` · `@floormap-tools/react` · cross-cutting +**Milestones:** +| # | Title | Scope | +|---|-------|-------| +| 1 | Functional editor | snap, resize, rubber band | +| 2 | Undo/Redo & Persistence | undo/redo, serialization, event sourcing | +| 3 | Extend & Optimize | UI components, incremental renderer, batch ops, rotation | +| 4 | Ecosystem | v0.1.0 release, canvas adapter, vue adapter | + +**Issue types:** Feature · Bug · Chore +**Priority:** High · Medium · Low +**Size:** XS (<30 min) · S (<2 h) · M (~4 h) · L (1–2 days) · XL (>2 days) +**Labels:** `enhancement` (features) · `bug` · `documentation` · `chore` + +--- + +## How to use this skill + +The user will invoke `/issue` with optional args: +- `/issue` or `/issue create` → create a new issue +- `/issue edit ` → edit an existing issue (fetch it first, then update) + +--- + +## Step-by-step flow + +### 1 — Determine mode + +- If args contain a number → **edit mode**: call `mcp__github__get_issue` to fetch the current issue before asking questions. Pre-fill answers from existing data. +- Otherwise → **create mode**: start fresh. + +### 2 — Ask questions (use `AskUserQuestion` tool) + +Group questions into at most two rounds. **Round 1** — core identity: + +``` +Questions to ask in round 1: +1. "What is the issue title?" (header: "Title", no options — open text, so use Other) + Options: pick a sensible default from context if available, otherwise list none (user will type) +2. "What type of issue is this?" (header: "Type") + Options: Feature | Bug | Chore +3. "Which package does this affect?" (header: "Package") + Options: @floormap-tools/core | @floormap-tools/svg | @floormap-tools/react | cross-cutting +4. "Which milestone does this belong to?" (header: "Milestone") + Options: M1 — Functional editor | M2 — Undo/Redo & Persistence | M3 — Extend & Optimize | M4 — Ecosystem | None +``` + +**Round 2** — sizing and description: + +``` +Questions to ask in round 2: +1. "What is the priority?" (header: "Priority") + Options: High | Medium | Low +2. "What is the estimated size?" (header: "Size") + Options: XS (<30 min) | S (<2 h) | M (~4 h) | L (1–2 days) | XL (>2 days) +3. "Briefly describe what this issue is about and what needs to change." + (header: "Context", let user type freely via Other) +``` + +If in **edit mode**, only ask about fields the user seems to want to change. Use judgment based on any hint in the user's request. + +### 3 — Generate the issue body + +Use the answers to generate a complete body in this template: + +```markdown +## Context + +[1–2 paragraphs: what this is, why it matters, which use case it serves. For bugs, include reproduction steps.] + +--- + +## What changes + +[For Features/Chores: describe the implementation approach, affected module, proposed API if any. Include code snippets when relevant.] + +[For Bugs: describe root cause, expected vs actual behavior.] + +--- + +## Affected files + +| File | Change | +|------|--------| +| `path/to/file.ts` | Brief description of the change | + +--- + +## Invariants to preserve + +- [Key constraint or behavior that must not regress] +- [...] + +--- + +## Implementation checklist + +- [ ] [Concrete task] +- [ ] [...] +- [ ] Run `pnpm typecheck && pnpm test && pnpm lint` +``` + +**Rules for body generation:** +- For **Bugs**: replace "What changes" with "Root cause" and "Reproduction steps". Remove "Invariants" if not applicable. +- For **Chores**: simplify — Context + What changes + Checklist is usually enough. +- Use only facts derived from the answers + project context. Do not invent implementation details. +- All text in English. + +### 4 — Create or update the issue + +**Create:** +``` +mcp__github__create_issue( + owner: "floormap-tools", + repo: "floormap", + title: , + body: <generated body>, + labels: [<label matching type>], + milestone: <milestone number if chosen, else omit> +) +``` + +**Edit:** +``` +mcp__github__update_issue( + owner: "floormap-tools", + repo: "floormap", + issue_number: <number>, + title: <updated title if changed>, + body: <new body> +) +``` + +### 5 — Post-creation note + +After creating or updating the issue, always output this reminder: + +``` +Issue created/updated: <url> + +Project board fields to set manually in GitHub Projects: + • Priority → <answer> + • Size → <answer> + • Package → <answer> + +These fields can't be set via the API — open the issue in the project board to apply them. +``` + +--- + +## Absolute rules + +- Never skip the question rounds — always ask before writing the body. +- Never invent code or file paths not derivable from the project context. +- Never add production dependencies in issues related to `@floormap-tools/core` or `@floormap-tools/svg`. +- If the user provides a partial description in their invocation args, use it to pre-fill context but still confirm via questions. diff --git a/packages/adapters/react/src/FloormapCanvas.tsx b/packages/adapters/react/src/FloormapCanvas.tsx index bb9186b..0e39bdb 100644 --- a/packages/adapters/react/src/FloormapCanvas.tsx +++ b/packages/adapters/react/src/FloormapCanvas.tsx @@ -37,6 +37,8 @@ export interface FloormapCanvasProps { wheelZoomFactor?: number; pinchZoomFactor?: number; + snapToGrid?: number; + // SVG element className?: string; style?: CSSProperties; @@ -57,6 +59,7 @@ export const FloormapCanvas = memo(function FloormapCanvas({ clickThresholdPx = 3, wheelZoomFactor = 0.0015, pinchZoomFactor = 0.005, + snapToGrid, className, style, }: FloormapCanvasProps) { @@ -86,6 +89,7 @@ export const FloormapCanvas = memo(function FloormapCanvas({ clickThresholdPx, modifierSelect, pinchZoomFactor, + snapToGrid, onClickEntity: (id) => onClickEntityRef.current?.(id), }); }, [ @@ -99,6 +103,7 @@ export const FloormapCanvas = memo(function FloormapCanvas({ clickThresholdPx, modifierSelect, pinchZoomFactor, + snapToGrid, ]); // Keep viewport.screenSize in sync with the SVG element dimensions diff --git a/packages/adapters/react/src/internal/attachHandlers.ts b/packages/adapters/react/src/internal/attachHandlers.ts index fc0398b..dae3e21 100644 --- a/packages/adapters/react/src/internal/attachHandlers.ts +++ b/packages/adapters/react/src/internal/attachHandlers.ts @@ -1,3 +1,4 @@ +import { snapToGrid } from '@floormap-tools/core'; import type { FloormapCore, EntityId, Bounds } from '@floormap-tools/core'; export type AttachHandlersOptions = { @@ -11,6 +12,7 @@ export type AttachHandlersOptions = { onClickEntity?: (id: EntityId) => void; modifierSelect: boolean; pinchZoomFactor: number; + snapToGrid?: number; }; export function attachHandlers( @@ -123,12 +125,14 @@ export function attachHandlers( }; const patches = []; for (const [id, snap] of state.entitySnapshots) { + const rawX = snap.x + worldDelta.x; + const rawY = snap.y + worldDelta.y; patches.push({ id, patch: { bounds: { - x: snap.x + worldDelta.x, - y: snap.y + worldDelta.y, + x: options.snapToGrid ? snapToGrid(rawX, options.snapToGrid) : rawX, + y: options.snapToGrid ? snapToGrid(rawY, options.snapToGrid) : rawY, width: snap.width, height: snap.height, }, diff --git a/packages/core/src/__tests__/utils.test.ts b/packages/core/src/__tests__/utils.test.ts index e08b3e2..a632fa2 100644 --- a/packages/core/src/__tests__/utils.test.ts +++ b/packages/core/src/__tests__/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { clamp, containsPointAABB, expandBounds, unionBounds } from '../utils'; +import { clamp, containsPointAABB, expandBounds, unionBounds, snapToGrid } from '../utils'; import { Bounds, Vec2 } from '../types'; describe('clamp', () => { @@ -91,4 +91,31 @@ describe('unionBounds', () => { const result = unionBounds(a, b); expect(result).toEqual({ x: -10, y: -10, width: 25, height: 25 }); }); +}); + +describe('snapToGrid', () => { + it('snaps value to nearest grid point below', () => { + expect(snapToGrid(42, 40)).toBe(40); + expect(snapToGrid(127, 40)).toBe(120); + }); + + it('snaps value to nearest grid point above', () => { + expect(snapToGrid(61, 40)).toBe(80); + expect(snapToGrid(140, 40)).toBe(160); + }); + + it('value already on grid returns unchanged', () => { + expect(snapToGrid(80, 40)).toBe(80); + expect(snapToGrid(0, 40)).toBe(0); + }); + + it('works with negative values', () => { + expect(snapToGrid(-21, 40)).toBe(-40); + expect(snapToGrid(-85, 40)).toBe(-80); + }); + + it('works with non-round grid sizes', () => { + expect(snapToGrid(37, 25)).toBe(25); + expect(snapToGrid(38, 25)).toBe(50); + }); }); \ No newline at end of file diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 8959546..abca52d 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -24,4 +24,7 @@ export const unionBounds = (a: Bounds, b: Bounds): Bounds => { width: Math.max(a.x + a.width, b.x + b.width) - Math.min(a.x, b.x), height: Math.max(a.y + a.height, b.y + b.height) - Math.min(a.y, b.y) } -}; \ No newline at end of file +}; + +export const snapToGrid = (value: number, gridSize: number): number => + Math.round(value / gridSize) * gridSize; \ No newline at end of file diff --git a/packages/examples/js-vanilla/index.html b/packages/examples/js-vanilla/index.html index 60406ba..7b4e757 100644 --- a/packages/examples/js-vanilla/index.html +++ b/packages/examples/js-vanilla/index.html @@ -15,6 +15,12 @@ <button id="btn-fit" title="Fit all entities into view">Fit to scene</button> <button id="btn-add-desk" title="Add a new desk to the open space">Add desk</button> <button id="btn-delete" title="Delete selected entities (Del)">Delete selected</button> + <label style="display:flex;align-items:center;gap:4px;font-size:13px;cursor:pointer"> + <input type="checkbox" id="snap-toggle" title="Snap entities to grid when dragging" /> + Snap to grid + </label> + <input type="number" id="snap-size" value="40" min="5" max="200" step="5" + title="Grid size in world units" style="width:52px;font-size:13px;display:none" /> </div> <div id="zoom-display">100%</div> </header> diff --git a/packages/examples/js-vanilla/src/main.ts b/packages/examples/js-vanilla/src/main.ts index 0b35383..43bf097 100644 --- a/packages/examples/js-vanilla/src/main.ts +++ b/packages/examples/js-vanilla/src/main.ts @@ -79,66 +79,77 @@ for (let row = 0; row < DESK_GRID.rows; row++) { const NS = 'http://www.w3.org/2000/svg'; -mountSvgRenderer(core, { - mount: svgEl, - grid: { size: 40, stroke: '#f1f5f9', strokeWidth: 1 }, - selectionOverlay: false, +import type { Entity } from '@floormap-tools/core'; + +function drawEntity(entity: Entity, { g, selected }: { g: SVGGElement; selected: boolean }) { + const data = entity.data as EntityData; + const { x, y, width, height } = entity.bounds; + + if (data.kind === 'room') { + const rect = document.createElementNS(NS, 'rect'); + rect.setAttribute('x', String(x)); + rect.setAttribute('y', String(y)); + rect.setAttribute('width', String(width)); + rect.setAttribute('height', String(height)); + rect.setAttribute('fill', selected ? '#bfdbfe' : data.fill); + rect.setAttribute('stroke', '#cbd5e1'); + rect.setAttribute('stroke-width', '1.5'); + rect.setAttribute('rx', '4'); + g.appendChild(rect); + + const text = document.createElementNS(NS, 'text'); + text.setAttribute('x', String(x + width / 2)); + text.setAttribute('y', String(y + height / 2)); + text.setAttribute('text-anchor', 'middle'); + text.setAttribute('dominant-baseline', 'middle'); + text.setAttribute('font-size', '12'); + text.setAttribute('font-family', 'system-ui, sans-serif'); + text.setAttribute('fill', '#475569'); + text.setAttribute('pointer-events', 'none'); + text.textContent = data.name; + g.appendChild(text); + } - drawEntity(entity, { g, selected }) { - const data = entity.data as EntityData; - const { x, y, width, height } = entity.bounds; + if (data.kind === 'desk') { + const rect = document.createElementNS(NS, 'rect'); + rect.setAttribute('x', String(x)); + rect.setAttribute('y', String(y)); + rect.setAttribute('width', String(width)); + rect.setAttribute('height', String(height)); + rect.setAttribute('fill', selected ? '#bfdbfe' : '#e2e8f0'); + rect.setAttribute('stroke', '#94a3b8'); + rect.setAttribute('stroke-width', '1'); + rect.setAttribute('rx', '3'); + g.appendChild(rect); + + const text = document.createElementNS(NS, 'text'); + text.setAttribute('x', String(x + width / 2)); + text.setAttribute('y', String(y + height / 2)); + text.setAttribute('text-anchor', 'middle'); + text.setAttribute('dominant-baseline', 'middle'); + text.setAttribute('font-size', '10'); + text.setAttribute('font-family', 'system-ui, sans-serif'); + text.setAttribute('fill', '#64748b'); + text.setAttribute('pointer-events', 'none'); + text.textContent = data.label; + g.appendChild(text); + } +} - if (data.kind === 'room') { - const rect = document.createElementNS(NS, 'rect'); - rect.setAttribute('x', String(x)); - rect.setAttribute('y', String(y)); - rect.setAttribute('width', String(width)); - rect.setAttribute('height', String(height)); - rect.setAttribute('fill', selected ? '#bfdbfe' : data.fill); - rect.setAttribute('stroke', '#cbd5e1'); - rect.setAttribute('stroke-width', '1.5'); - rect.setAttribute('rx', '4'); - g.appendChild(rect); - - const text = document.createElementNS(NS, 'text'); - text.setAttribute('x', String(x + width / 2)); - text.setAttribute('y', String(y + height / 2)); - text.setAttribute('text-anchor', 'middle'); - text.setAttribute('dominant-baseline', 'middle'); - text.setAttribute('font-size', '12'); - text.setAttribute('font-family', 'system-ui, sans-serif'); - text.setAttribute('fill', '#475569'); - text.setAttribute('pointer-events', 'none'); - text.textContent = data.name; - g.appendChild(text); - } - - if (data.kind === 'desk') { - const rect = document.createElementNS(NS, 'rect'); - rect.setAttribute('x', String(x)); - rect.setAttribute('y', String(y)); - rect.setAttribute('width', String(width)); - rect.setAttribute('height', String(height)); - rect.setAttribute('fill', selected ? '#bfdbfe' : '#e2e8f0'); - rect.setAttribute('stroke', '#94a3b8'); - rect.setAttribute('stroke-width', '1'); - rect.setAttribute('rx', '3'); - g.appendChild(rect); - - const text = document.createElementNS(NS, 'text'); - text.setAttribute('x', String(x + width / 2)); - text.setAttribute('y', String(y + height / 2)); - text.setAttribute('text-anchor', 'middle'); - text.setAttribute('dominant-baseline', 'middle'); - text.setAttribute('font-size', '10'); - text.setAttribute('font-family', 'system-ui, sans-serif'); - text.setAttribute('fill', '#64748b'); - text.setAttribute('pointer-events', 'none'); - text.textContent = data.label; - g.appendChild(text); - } - }, -}); +let snapEnabled = false; +let snapGridSize = 40; + +function mountRenderer() { + return mountSvgRenderer(core, { + mount: svgEl, + grid: { size: snapEnabled ? snapGridSize : 40, stroke: '#f1f5f9', strokeWidth: 1 }, + selectionOverlay: false, + snapToGrid: snapEnabled ? snapGridSize : undefined, + drawEntity, + }); +} + +let renderer = mountRenderer(); // ── Fit on load ─────────────────────────────────────────────────────────────── @@ -150,6 +161,24 @@ document.getElementById('btn-fit')!.addEventListener('click', () => core.fitToSc document.getElementById('btn-delete')!.addEventListener('click', deleteSelected); +const snapToggleEl = document.getElementById('snap-toggle') as HTMLInputElement; +const snapSizeEl = document.getElementById('snap-size') as HTMLInputElement; + +snapToggleEl.addEventListener('change', () => { + snapEnabled = snapToggleEl.checked; + snapSizeEl.style.display = snapEnabled ? '' : 'none'; + renderer.destroy(); + renderer = mountRenderer(); +}); + +snapSizeEl.addEventListener('change', () => { + snapGridSize = Math.max(5, Number(snapSizeEl.value)); + if (snapEnabled) { + renderer.destroy(); + renderer = mountRenderer(); + } +}); + let deskCounter = DESK_GRID.cols * DESK_GRID.rows + 1; document.getElementById('btn-add-desk')!.addEventListener('click', () => { // Place new desk near the open space, wrapping columns diff --git a/packages/examples/react/src/App.tsx b/packages/examples/react/src/App.tsx index b63f8af..1f5ec84 100644 --- a/packages/examples/react/src/App.tsx +++ b/packages/examples/react/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { FloormapCanvas, useFloormapCore, useSelection, useViewport } from '@floormap-tools/react'; import { addEntity, createEmptyScene } from '@floormap-tools/core'; import type { Entity, EntityId, LayerId } from '@floormap-tools/core'; @@ -118,6 +118,9 @@ export function App() { const viewport = useViewport(core); const deskCounterRef = useRef(DESK_GRID.cols * DESK_GRID.rows + 1); + const [snapEnabled, setSnapEnabled] = useState(false); + const [snapSize, setSnapSize] = useState(40); + useEffect(() => { core.fitToScene(48); }, [core]); @@ -174,6 +177,26 @@ export function App() { <button onClick={() => { for (const id of [...selection]) core.remove(id); }}> Delete selected </button> + <label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 13, cursor: 'pointer' }}> + <input + type="checkbox" + checked={snapEnabled} + onChange={(e) => setSnapEnabled(e.target.checked)} + /> + Snap to grid + </label> + {snapEnabled && ( + <input + type="number" + value={snapSize} + min={5} + max={200} + step={5} + title="Grid size in world units" + onChange={(e) => setSnapSize(Math.max(5, Number(e.target.value)))} + style={{ width: 52, fontSize: 13 }} + /> + )} </div> <div id="zoom-display">{Math.round(viewport.zoom * 100)}%</div> </header> @@ -182,8 +205,9 @@ export function App() { <FloormapCanvas core={core} drawEntity={drawEntity} - grid={{ size: 40, stroke: '#f1f5f9', strokeWidth: 1 }} + grid={{ size: snapEnabled ? snapSize : 40, stroke: '#f1f5f9', strokeWidth: 1 }} selectionOverlay={false} + snapToGrid={snapEnabled ? snapSize : undefined} style={{ flex: 1 }} /> diff --git a/packages/svg/src/__tests__/handlers.test.ts b/packages/svg/src/__tests__/handlers.test.ts index 751f086..09ddff3 100644 --- a/packages/svg/src/__tests__/handlers.test.ts +++ b/packages/svg/src/__tests__/handlers.test.ts @@ -42,14 +42,24 @@ function makeCore() { } function click(svg: SVGSVGElement, x: number, y: number, opts: PointerEventInit = {}) { - svg.dispatchEvent(new PointerEvent('pointerdown', { button: 0, clientX: x, clientY: y, bubbles: true, ...opts })); - svg.dispatchEvent(new PointerEvent('pointerup', { button: 0, clientX: x, clientY: y, bubbles: true, ...opts })); + svg.dispatchEvent( + new PointerEvent('pointerdown', { button: 0, clientX: x, clientY: y, bubbles: true, ...opts }), + ); + svg.dispatchEvent( + new PointerEvent('pointerup', { button: 0, clientX: x, clientY: y, bubbles: true, ...opts }), + ); } function drag(svg: SVGSVGElement, fromX: number, fromY: number, toX: number, toY: number) { - svg.dispatchEvent(new PointerEvent('pointerdown', { button: 0, clientX: fromX, clientY: fromY, bubbles: true })); - svg.dispatchEvent(new PointerEvent('pointermove', { button: 0, clientX: toX, clientY: toY, bubbles: true })); - svg.dispatchEvent(new PointerEvent('pointerup', { button: 0, clientX: toX, clientY: toY, bubbles: true })); + svg.dispatchEvent( + new PointerEvent('pointerdown', { button: 0, clientX: fromX, clientY: fromY, bubbles: true }), + ); + svg.dispatchEvent( + new PointerEvent('pointermove', { button: 0, clientX: toX, clientY: toY, bubbles: true }), + ); + svg.dispatchEvent( + new PointerEvent('pointerup', { button: 0, clientX: toX, clientY: toY, bubbles: true }), + ); } describe('Svg renderer - multi-select modifier keys', () => { @@ -212,4 +222,39 @@ describe('Svg renderer - entity drag', () => { const e1 = core.scene.entities.get('e1' as any)!; expect(e1.bounds.x).toBe(100); // entity unmoved }); + + it('snapToGrid snaps entity position to nearest grid point', () => { + const core = makeCore(); + core.setSelection(['e1' as any], 'replace'); + mountSvgRenderer(core, { mount: svg, drawEntity: () => {}, snapToGrid: 40 }); + + drag(svg, 150, 150, 177, 150); + + const e1 = core.scene.entities.get('e1' as any)!; + + expect(e1.bounds.x).toBe(120); + expect(e1.bounds.y).toBe(120); + }); + + it('snapToGrid rounds up when past the midpoint', () => { + const core = makeCore(); + core.setSelection(['e1' as any], 'replace'); + mountSvgRenderer(core, { mount: svg, drawEntity: () => {}, snapToGrid: 40 }); + + drag(svg, 150, 150, 190, 150); + + const e1 = core.scene.entities.get('e1' as any)!; + expect(e1.bounds.x).toBe(160); + }); + + it('without snapToGrid entity position is not snapped', () => { + const core = makeCore(); + core.setSelection(['e1' as any], 'replace'); + mountSvgRenderer(core, { mount: svg, drawEntity: () => {} }); + + drag(svg, 150, 150, 177, 150); + + const e1 = core.scene.entities.get('e1' as any)!; + expect(e1.bounds.x).toBeCloseTo(127); + }); }); diff --git a/packages/svg/src/handlers.ts b/packages/svg/src/handlers.ts index 18913dd..a18b91a 100644 --- a/packages/svg/src/handlers.ts +++ b/packages/svg/src/handlers.ts @@ -1,4 +1,4 @@ -import { FloormapCore, EntityId } from '@floormap-tools/core'; +import { FloormapCore, EntityId, snapToGrid } from '@floormap-tools/core'; import type { Bounds } from '@floormap-tools/core'; type AttachHandlersOptions = { @@ -12,6 +12,7 @@ type AttachHandlersOptions = { onClickEntity?: (id: EntityId) => void; modifierSelect: boolean; pinchZoomFactor: number; + snapToGrid?: number; }; export function attachHandlers( @@ -134,12 +135,14 @@ export function attachHandlers( }; const patches = []; for (const [id, snap] of state.entitySnapshots) { + const rawX = snap.x + worldDelta.x; + const rawY = snap.y + worldDelta.y; patches.push({ id, patch: { bounds: { - x: snap.x + worldDelta.x, - y: snap.y + worldDelta.y, + x: options.snapToGrid ? snapToGrid(rawX, options.snapToGrid) : rawX, + y: options.snapToGrid ? snapToGrid(rawY, options.snapToGrid) : rawY, width: snap.width, height: snap.height, }, diff --git a/packages/svg/src/index.ts b/packages/svg/src/index.ts index bc11888..ae90c5e 100644 --- a/packages/svg/src/index.ts +++ b/packages/svg/src/index.ts @@ -25,6 +25,8 @@ export interface SvgRendererOptions { grid?: GridOptions | false; /** Remove SVG groups from DOM on destroy(). Default: true */ clearOnDestroy?: boolean; + /** Grid size in world units for snap-to-grid when dragging entities. Disabled when absent. */ + snapToGrid?: number; } export interface SvgRenderer { diff --git a/packages/svg/src/renderer.ts b/packages/svg/src/renderer.ts index 784b196..5868a14 100644 --- a/packages/svg/src/renderer.ts +++ b/packages/svg/src/renderer.ts @@ -23,6 +23,7 @@ export function mountSvgRenderer(core: FloormapCore, opts: SvgRendererOptions): selectionOverlay = {}, grid = false, clearOnDestroy = true, + snapToGrid, } = opts; setupSvgRoot(mount, defs); @@ -63,6 +64,7 @@ export function mountSvgRenderer(core: FloormapCore, opts: SvgRendererOptions): clickThresholdPx, modifierSelect, pinchZoomFactor, + snapToGrid, }); return {