From 3c8544f16d3822aeb94ac45e43afd3c34f383276 Mon Sep 17 00:00:00 2001 From: Tim Arbaev Date: Fri, 1 May 2026 17:39:21 +0300 Subject: [PATCH 1/5] feat: zoomForLayer & findEntitiesBy --- packages/dxf-render/CHANGELOG.md | 12 +++ packages/dxf-render/README.md | 32 ++++++- packages/dxf-render/src/index.ts | 9 ++ .../__tests__/findEntitiesByLayer.test.ts | 68 ++++++++++++++ .../__tests__/findEntitiesByType.test.ts | 74 +++++++++++++++ .../__tests__/getZoomBoxForLayer.test.ts | 91 +++++++++++++++++++ .../src/utils/findEntitiesByLayer.ts | 36 ++++++++ .../src/utils/findEntitiesByType.ts | 34 +++++++ .../src/utils/getZoomBoxForLayer.ts | 34 +++++++ 9 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 packages/dxf-render/src/utils/__tests__/findEntitiesByLayer.test.ts create mode 100644 packages/dxf-render/src/utils/__tests__/findEntitiesByType.test.ts create mode 100644 packages/dxf-render/src/utils/__tests__/getZoomBoxForLayer.test.ts create mode 100644 packages/dxf-render/src/utils/findEntitiesByLayer.ts create mode 100644 packages/dxf-render/src/utils/findEntitiesByType.ts create mode 100644 packages/dxf-render/src/utils/getZoomBoxForLayer.ts diff --git a/packages/dxf-render/CHANGELOG.md b/packages/dxf-render/CHANGELOG.md index 5e67d21..3dfa906 100644 --- a/packages/dxf-render/CHANGELOG.md +++ b/packages/dxf-render/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.5.0 + +### Features + +- **`getZoomBoxForLayer(pickingIndex, layerName, options?)`** → `THREE.Box3 | null` — pure helper that unions bboxes of all picking entries on a given layer. Same options as `getZoomBox` plus `caseSensitive` (default `true`, since DXF layer names are case-sensitive). Feed the result into `fitCameraToBox()` to implement "zoom to layer" in any framework. +- **`findEntitiesByLayer(dxf, layerName, options?)`** → `string[]` — find handles of all entities belonging to a given layer. Walks top-level entities, INSERT ATTRIBs, and entities inside blocks (same coverage as `findEntitiesByText`). Case-sensitive by default; pass `{ caseSensitive: false }` to relax. +- **`findEntitiesByType(dxf, type | type[])`** → `string[]` — find handles by DXF entity type. Accepts a single type or an array; input case is normalized (DXF types are uppercase per spec). + +### Stats + +- 945 tests across 47 files (was 923 across 41). + ## 1.4.0 ### Features diff --git a/packages/dxf-render/README.md b/packages/dxf-render/README.md index db96db9..dfe9b98 100644 --- a/packages/dxf-render/README.md +++ b/packages/dxf-render/README.md @@ -20,7 +20,7 @@ For Vue 3 components, see the [dxf-vuer](https://www.npmjs.com/package/dxf-vuer) - **Accurate rendering** — linetype patterns, OCS transforms, hatch patterns, proper color resolution - **Picking & associations** — bbox-based raycast index plus DXF-driven entity links (LEADER↔TEXT, INSERT+ATTRIB, MLEADER, DIMENSION) - **Two entry points** — full renderer or parser-only (zero deps, works in Node.js) -- **Battle-tested** — 923 tests covering parser, renderer, and utilities +- **Battle-tested** — 945 tests covering parser, renderer, and utilities - **Modern stack** — TypeScript native, ES modules, tree-shakeable, Vite-built - **Framework-agnostic** — works with React, Svelte, Angular, vanilla JS, or any framework @@ -345,6 +345,18 @@ function zoomTo(handles: string[]) { } ``` +For "zoom to layer", use `getZoomBoxForLayer()` — same semantics, but unions every entry on the named layer. Layer names are case-sensitive by default: + +```ts +import { getZoomBoxForLayer } from "dxf-render"; + +const box = getZoomBoxForLayer(pickingIndex, "WALLS", { originOffset }); +if (box) fitCameraToBox(box, camera); + +// Forgiving lookup +getZoomBoxForLayer(pickingIndex, "walls", { originOffset, caseSensitive: false }); +``` + To raycast, temporarily flip the group's `visible` flag (it's `false` by default so it doesn't show up in normal rendering): ```ts @@ -430,6 +442,22 @@ const box = getZoomBox(pickingIndex, found, { originOffset }); if (box) fitCameraToBox(box, camera); ``` +`findEntitiesByLayer(dxf, layerName, options?)` and `findEntitiesByType(dxf, type | type[])` cover the two other common queries — same coverage (top-level entities, INSERT ATTRIBs, entities inside blocks), no picking index needed: + +```ts +import { findEntitiesByLayer, findEntitiesByType } from "dxf-render"; + +// All entities on the WALLS layer (case-sensitive by default — DXF spec) +findEntitiesByLayer(dxf, "WALLS"); +findEntitiesByLayer(dxf, "walls", { caseSensitive: false }); + +// All TEXT + MTEXT handles +findEntitiesByType(dxf, ["TEXT", "MTEXT"]); + +// Single type +findEntitiesByType(dxf, "DIMENSION"); +``` + ### Fonts - `loadDefaultFont(): Promise` — load embedded Liberation Sans Regular @@ -470,7 +498,7 @@ POLYLINE/LWPOLYLINE support includes per-vertex variable width (tapering), const | Geometry merging | ✅ | ✅ | — | ❌ | | Dark theme | ✅ instant switch | bg only | — | ❌ | | TypeScript | ✅ native | .d.ts | ✅ | ❌ | -| Tests | 923 tests | 0 | ✅ | 0 | +| Tests | 945 tests | 0 | ✅ | 0 | | Web Worker parsing | ✅ | ✅ | ❌ | ❌ | | Parser-only entry | ✅ zero deps | ❌ | ✅ | ❌ | | Framework | agnostic | agnostic | — | agnostic | diff --git a/packages/dxf-render/src/index.ts b/packages/dxf-render/src/index.ts index a00a8b4..3c0fd4a 100644 --- a/packages/dxf-render/src/index.ts +++ b/packages/dxf-render/src/index.ts @@ -18,10 +18,19 @@ export { } from "./render/createPickingGroup"; export { buildEntityIndex, extractEntityText } from "./utils/entityIndex"; export { getZoomBox, type GetZoomBoxOptions } from "./utils/getZoomBox"; +export { + getZoomBoxForLayer, + type GetZoomBoxForLayerOptions, +} from "./utils/getZoomBoxForLayer"; export { findEntitiesByText, type FindEntitiesByTextOptions, } from "./utils/findEntitiesByText"; +export { + findEntitiesByLayer, + type FindEntitiesByLayerOptions, +} from "./utils/findEntitiesByLayer"; +export { findEntitiesByType } from "./utils/findEntitiesByType"; // Associations export { buildAssociations } from "./utils/buildAssociations"; diff --git a/packages/dxf-render/src/utils/__tests__/findEntitiesByLayer.test.ts b/packages/dxf-render/src/utils/__tests__/findEntitiesByLayer.test.ts new file mode 100644 index 0000000..6704d6a --- /dev/null +++ b/packages/dxf-render/src/utils/__tests__/findEntitiesByLayer.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { findEntitiesByLayer } from "../findEntitiesByLayer"; +import type { + DxfData, + DxfTextEntity, + DxfLineEntity, + DxfInsertEntity, + DxfAttribEntity, + DxfBlock, +} from "@/types/dxf"; + +describe("findEntitiesByLayer", () => { + it("returns empty array for empty layer name", () => { + const line: DxfLineEntity = { type: "LINE", handle: "A", layer: "WALLS", + vertices: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }; + expect(findEntitiesByLayer({ entities: [line] }, "")).toEqual([]); + expect(findEntitiesByLayer({ entities: [line] }, " ")).toEqual([]); + }); + + it("returns handles of entities on the given layer", () => { + const a: DxfLineEntity = { type: "LINE", handle: "A", layer: "WALLS", + vertices: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }; + const b: DxfTextEntity = { type: "TEXT", handle: "B", layer: "WALLS", text: "x" }; + const c: DxfLineEntity = { type: "LINE", handle: "C", layer: "DOORS", + vertices: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }; + const dxf: DxfData = { entities: [a, b, c] }; + expect(findEntitiesByLayer(dxf, "WALLS").sort()).toEqual(["A", "B"]); + expect(findEntitiesByLayer(dxf, "DOORS")).toEqual(["C"]); + }); + + it("is case-sensitive by default", () => { + const a: DxfLineEntity = { type: "LINE", handle: "A", layer: "Walls", + vertices: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }; + expect(findEntitiesByLayer({ entities: [a] }, "WALLS")).toEqual([]); + expect(findEntitiesByLayer({ entities: [a] }, "Walls")).toEqual(["A"]); + }); + + it("respects caseSensitive: false option", () => { + const a: DxfLineEntity = { type: "LINE", handle: "A", layer: "Walls", + vertices: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }; + expect(findEntitiesByLayer({ entities: [a] }, "WALLS", { caseSensitive: false })).toEqual(["A"]); + }); + + it("ignores entities without a layer field", () => { + const a: DxfLineEntity = { type: "LINE", handle: "A", + vertices: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }; + expect(findEntitiesByLayer({ entities: [a] }, "0")).toEqual([]); + }); + + it("matches ATTRIBs attached to INSERTs", () => { + const att: DxfAttribEntity = { type: "ATTRIB", handle: "AT", layer: "TITLES", + tag: "PARTNO", text: "X" }; + const insert: DxfInsertEntity = { + type: "INSERT", handle: "I", name: "B", layer: "BLOCKS", + position: { x: 0, y: 0 }, attribs: [att], + }; + const dxf: DxfData = { entities: [insert] }; + expect(findEntitiesByLayer(dxf, "TITLES")).toEqual(["AT"]); + expect(findEntitiesByLayer(dxf, "BLOCKS")).toEqual(["I"]); + }); + + it("matches entities inside blocks", () => { + const blockText: DxfTextEntity = { type: "TEXT", handle: "BT", layer: "BLAYER", text: "x" }; + const block: DxfBlock = { entities: [blockText] }; + const dxf: DxfData = { entities: [], blocks: { BLOCK1: block } }; + expect(findEntitiesByLayer(dxf, "BLAYER")).toEqual(["BT"]); + }); +}); diff --git a/packages/dxf-render/src/utils/__tests__/findEntitiesByType.test.ts b/packages/dxf-render/src/utils/__tests__/findEntitiesByType.test.ts new file mode 100644 index 0000000..1e9ecd6 --- /dev/null +++ b/packages/dxf-render/src/utils/__tests__/findEntitiesByType.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { findEntitiesByType } from "../findEntitiesByType"; +import type { + DxfData, + DxfTextEntity, + DxfLineEntity, + DxfCircleEntity, + DxfInsertEntity, + DxfAttribEntity, + DxfBlock, +} from "@/types/dxf"; + +describe("findEntitiesByType", () => { + it("returns empty array for empty type", () => { + const t: DxfTextEntity = { type: "TEXT", handle: "A", text: "x" }; + expect(findEntitiesByType({ entities: [t] }, "")).toEqual([]); + expect(findEntitiesByType({ entities: [t] }, [])).toEqual([]); + expect(findEntitiesByType({ entities: [t] }, ["", " "])).toEqual([]); + }); + + it("matches a single type", () => { + const t: DxfTextEntity = { type: "TEXT", handle: "T1", text: "x" }; + const l: DxfLineEntity = { type: "LINE", handle: "L1", + vertices: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }; + const dxf: DxfData = { entities: [t, l] }; + expect(findEntitiesByType(dxf, "TEXT")).toEqual(["T1"]); + expect(findEntitiesByType(dxf, "LINE")).toEqual(["L1"]); + }); + + it("matches an array of types", () => { + const t: DxfTextEntity = { type: "TEXT", handle: "T1", text: "x" }; + const m: DxfTextEntity = { type: "MTEXT", handle: "M1", text: "y" }; + const l: DxfLineEntity = { type: "LINE", handle: "L1", + vertices: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }; + const dxf: DxfData = { entities: [t, m, l] }; + expect(findEntitiesByType(dxf, ["TEXT", "MTEXT"]).sort()).toEqual(["M1", "T1"]); + }); + + it("normalizes input case to uppercase", () => { + const c: DxfCircleEntity = { type: "CIRCLE", handle: "C1", + center: { x: 0, y: 0 }, radius: 1 }; + expect(findEntitiesByType({ entities: [c] }, "circle")).toEqual(["C1"]); + expect(findEntitiesByType({ entities: [c] }, ["Circle"])).toEqual(["C1"]); + }); + + it("returns empty array when no entity matches", () => { + const t: DxfTextEntity = { type: "TEXT", handle: "T1", text: "x" }; + expect(findEntitiesByType({ entities: [t] }, "LINE")).toEqual([]); + }); + + it("matches ATTRIBs attached to INSERTs", () => { + const att: DxfAttribEntity = { type: "ATTRIB", handle: "AT", tag: "T", text: "v" }; + const insert: DxfInsertEntity = { + type: "INSERT", handle: "I", name: "B", + position: { x: 0, y: 0 }, attribs: [att], + }; + const dxf: DxfData = { entities: [insert] }; + expect(findEntitiesByType(dxf, "ATTRIB")).toEqual(["AT"]); + expect(findEntitiesByType(dxf, "INSERT")).toEqual(["I"]); + }); + + it("matches entities inside blocks", () => { + const bl: DxfLineEntity = { type: "LINE", handle: "BL", + vertices: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }; + const block: DxfBlock = { entities: [bl] }; + const dxf: DxfData = { entities: [], blocks: { B1: block } }; + expect(findEntitiesByType(dxf, "LINE")).toEqual(["BL"]); + }); + + it("deduplicates types in input", () => { + const t: DxfTextEntity = { type: "TEXT", handle: "T1", text: "x" }; + expect(findEntitiesByType({ entities: [t] }, ["TEXT", "TEXT", "text"])).toEqual(["T1"]); + }); +}); diff --git a/packages/dxf-render/src/utils/__tests__/getZoomBoxForLayer.test.ts b/packages/dxf-render/src/utils/__tests__/getZoomBoxForLayer.test.ts new file mode 100644 index 0000000..03d3b3d --- /dev/null +++ b/packages/dxf-render/src/utils/__tests__/getZoomBoxForLayer.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import * as THREE from "three"; +import { getZoomBoxForLayer } from "../getZoomBoxForLayer"; +import type { PickingIndex, PickingEntry } from "@/render/pickingIndex"; + +function makeEntry( + handle: string, + layer: string, + min: [number, number, number], + max: [number, number, number], +): PickingEntry { + return { + id: handle, + handle, + type: "LINE", + layer, + bbox: new THREE.Box3(new THREE.Vector3(...min), new THREE.Vector3(...max)), + }; +} + +function makeIndex(entries: PickingEntry[]): PickingIndex { + const byHandle = new Map(); + const byId = new Map(); + for (const e of entries) { + const list = byHandle.get(e.handle); + if (list) list.push(e); + else byHandle.set(e.handle, [e]); + byId.set(e.id, e); + } + return { entries, byHandle, byId }; +} + +describe("getZoomBoxForLayer", () => { + it("returns null when layer has no entries", () => { + const idx = makeIndex([makeEntry("A", "WALLS", [0, 0, 0], [10, 10, 0])]); + expect(getZoomBoxForLayer(idx, "DOORS")).toBeNull(); + }); + + it("returns null for empty layer name", () => { + const idx = makeIndex([makeEntry("A", "WALLS", [0, 0, 0], [10, 10, 0])]); + expect(getZoomBoxForLayer(idx, "")).toBeNull(); + }); + + it("unions all entries on the layer", () => { + const idx = makeIndex([ + makeEntry("A", "WALLS", [0, 0, 0], [10, 10, 0]), + makeEntry("B", "WALLS", [20, -5, 0], [30, 5, 0]), + makeEntry("C", "DOORS", [100, 100, 0], [110, 110, 0]), + ]); + const box = getZoomBoxForLayer(idx, "WALLS", { paddingRatio: 0 })!; + expect(box.min.x).toBeCloseTo(0); + expect(box.min.y).toBeCloseTo(-5); + expect(box.max.x).toBeCloseTo(30); + expect(box.max.y).toBeCloseTo(10); + }); + + it("is case-sensitive by default", () => { + const idx = makeIndex([makeEntry("A", "Walls", [0, 0, 0], [10, 10, 0])]); + expect(getZoomBoxForLayer(idx, "WALLS")).toBeNull(); + expect(getZoomBoxForLayer(idx, "Walls")).not.toBeNull(); + }); + + it("supports case-insensitive matching via option", () => { + const idx = makeIndex([makeEntry("A", "Walls", [0, 0, 0], [10, 10, 0])]); + const box = getZoomBoxForLayer(idx, "WALLS", { caseSensitive: false, paddingRatio: 0 }); + expect(box).not.toBeNull(); + expect(box!.max.x).toBeCloseTo(10); + }); + + it("forwards padding and originOffset options", () => { + const idx = makeIndex([makeEntry("A", "L", [1000, 2000, 0], [1010, 2010, 0])]); + const box = getZoomBoxForLayer(idx, "L", { + originOffset: { x: 1000, y: 2000 }, + paddingRatio: 0, + })!; + expect(box.min.x).toBeCloseTo(0); + expect(box.max.x).toBeCloseTo(10); + }); + + it("includes all instances when same handle appears multiple times (INSERT array)", () => { + const idx = makeIndex([ + { id: "X:0:0", handle: "X", type: "INSERT", layer: "BL", + bbox: new THREE.Box3(new THREE.Vector3(0, 0, 0), new THREE.Vector3(5, 5, 0)) }, + { id: "X:0:1", handle: "X", type: "INSERT", layer: "BL", + bbox: new THREE.Box3(new THREE.Vector3(10, 0, 0), new THREE.Vector3(15, 5, 0)) }, + ]); + const box = getZoomBoxForLayer(idx, "BL", { paddingRatio: 0 })!; + expect(box.min.x).toBeCloseTo(0); + expect(box.max.x).toBeCloseTo(15); + }); +}); diff --git a/packages/dxf-render/src/utils/findEntitiesByLayer.ts b/packages/dxf-render/src/utils/findEntitiesByLayer.ts new file mode 100644 index 0000000..977d7ec --- /dev/null +++ b/packages/dxf-render/src/utils/findEntitiesByLayer.ts @@ -0,0 +1,36 @@ +import type { DxfData } from "@/types/dxf"; +import { buildEntityIndex } from "./entityIndex"; + +export interface FindEntitiesByLayerOptions { + /** Match exact case. Default: true (DXF layer names are case-sensitive). */ + caseSensitive?: boolean; +} + +/** + * Find handles of all entities that belong to a given layer. + * Walks the same flat index as `findEntitiesByText` — top-level entities, + * ATTRIBs attached to INSERTs, and entities inside blocks. + * + * Returns an empty array for empty/whitespace `layerName`. + */ +export function findEntitiesByLayer( + dxf: DxfData, + layerName: string, + options?: FindEntitiesByLayerOptions, +): string[] { + const trimmed = layerName?.trim(); + if (!trimmed) return []; + + const caseSensitive = options?.caseSensitive ?? true; + const target = caseSensitive ? trimmed : trimmed.toLowerCase(); + + const index = buildEntityIndex(dxf); + const out: string[] = []; + for (const [handle, entity] of index) { + const layer = entity.layer; + if (!layer) continue; + const cmp = caseSensitive ? layer : layer.toLowerCase(); + if (cmp === target) out.push(handle); + } + return out; +} diff --git a/packages/dxf-render/src/utils/findEntitiesByType.ts b/packages/dxf-render/src/utils/findEntitiesByType.ts new file mode 100644 index 0000000..5535c6c --- /dev/null +++ b/packages/dxf-render/src/utils/findEntitiesByType.ts @@ -0,0 +1,34 @@ +import type { DxfData } from "@/types/dxf"; +import { buildEntityIndex } from "./entityIndex"; + +/** + * Find handles of all entities matching the given DXF type (or any of the given types). + * Walks top-level entities, ATTRIBs attached to INSERTs, and entities inside blocks + * (same coverage as `findEntitiesByText` / `findEntitiesByLayer`). + * + * Type matching is uppercase (DXF entity types are always uppercase per spec, but + * inputs are normalized to be forgiving). + * + * Returns an empty array for empty type input. + */ +export function findEntitiesByType( + dxf: DxfData, + type: string | readonly string[], +): string[] { + const types = Array.isArray(type) ? type : [type as string]; + const wanted = new Set(); + for (const t of types) { + if (t && typeof t === "string") { + const trimmed = t.trim(); + if (trimmed) wanted.add(trimmed.toUpperCase()); + } + } + if (wanted.size === 0) return []; + + const index = buildEntityIndex(dxf); + const out: string[] = []; + for (const [handle, entity] of index) { + if (wanted.has(entity.type)) out.push(handle); + } + return out; +} diff --git a/packages/dxf-render/src/utils/getZoomBoxForLayer.ts b/packages/dxf-render/src/utils/getZoomBoxForLayer.ts new file mode 100644 index 0000000..4b3306c --- /dev/null +++ b/packages/dxf-render/src/utils/getZoomBoxForLayer.ts @@ -0,0 +1,34 @@ +import type * as THREE from "three"; +import type { PickingIndex } from "@/render/pickingIndex"; +import { getZoomBox, type GetZoomBoxOptions } from "./getZoomBox"; + +export interface GetZoomBoxForLayerOptions extends GetZoomBoxOptions { + /** Match exact case. Default: true (DXF layer names are case-sensitive). */ + caseSensitive?: boolean; +} + +/** + * Pure helper: build the bounding box that fits all picking entries on a given layer. + * Returns `null` when no entries match. + * + * Layer matching is case-sensitive by default (DXF layer names are case-sensitive + * per spec). Pass `{ caseSensitive: false }` for forgiving lookups. + */ +export function getZoomBoxForLayer( + pickingIndex: PickingIndex, + layerName: string, + options?: GetZoomBoxForLayerOptions, +): THREE.Box3 | null { + if (!layerName) return null; + const caseSensitive = options?.caseSensitive ?? true; + const target = caseSensitive ? layerName : layerName.toLowerCase(); + + const handles: string[] = []; + for (const entry of pickingIndex.entries) { + const layer = caseSensitive ? entry.layer : entry.layer.toLowerCase(); + if (layer === target) handles.push(entry.handle); + } + + if (handles.length === 0) return null; + return getZoomBox(pickingIndex, handles, options); +} From 0ae8f9ba9165f0e59745b46041cdad93cb39533e Mon Sep 17 00:00:00 2001 From: Tim Arbaev Date: Fri, 1 May 2026 17:50:29 +0300 Subject: [PATCH 2/5] feat: persistLayersKey prop --- .../composables/__tests__/useLayers.test.ts | 167 +++++++++++++++++- .../dxf-vuer/src/composables/useLayers.ts | 56 +++++- 2 files changed, 221 insertions(+), 2 deletions(-) diff --git a/packages/dxf-vuer/src/composables/__tests__/useLayers.test.ts b/packages/dxf-vuer/src/composables/__tests__/useLayers.test.ts index 3d1dc4d..9ff0a5d 100644 --- a/packages/dxf-vuer/src/composables/__tests__/useLayers.test.ts +++ b/packages/dxf-vuer/src/composables/__tests__/useLayers.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { useLayers } from "../useLayers"; import type { DxfLayer } from "dxf-render"; @@ -381,6 +381,171 @@ describe("layerList", () => { }); }); +// ── persistence (getStorageKey) ───────────────────────────────────────── + +// Minimal in-memory localStorage stub for the Node environment (vitest default). +function installLocalStorageStub() { + const store = new Map(); + const stub = { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => { store.set(k, String(v)); }, + removeItem: (k: string) => { store.delete(k); }, + clear: () => { store.clear(); }, + key: (i: number) => Array.from(store.keys())[i] ?? null, + get length() { return store.size; }, + }; + if (typeof globalThis.window === "undefined") { + (globalThis as unknown as { window: { localStorage: typeof stub } }).window = { localStorage: stub }; + } else { + (globalThis.window as unknown as { localStorage: typeof stub }).localStorage = stub; + } +} + +describe("persistence via getStorageKey", () => { + beforeEach(() => { + installLocalStorageStub(); + }); + + it("does not write to storage when getStorageKey returns null", () => { + const { initLayers, toggleLayerVisibility } = useLayers({ + getStorageKey: () => null, + }); + + initLayers({ A: makeLayer({ name: "A", visible: true, frozen: false }) }, {}); + toggleLayerVisibility("A"); + + expect(window.localStorage.length).toBe(0); + }); + + it("persists hidden layer names on toggle", () => { + const { initLayers, toggleLayerVisibility } = useLayers({ + getStorageKey: () => "test-key", + }); + + initLayers( + { + A: makeLayer({ name: "A", visible: true, frozen: false }), + B: makeLayer({ name: "B", visible: true, frozen: false }), + }, + {}, + ); + + toggleLayerVisibility("A"); + + expect(JSON.parse(window.localStorage.getItem("test-key")!)).toEqual(["A"]); + + toggleLayerVisibility("B"); + + expect(JSON.parse(window.localStorage.getItem("test-key")!).sort()).toEqual(["A", "B"]); + + toggleLayerVisibility("A"); + + expect(JSON.parse(window.localStorage.getItem("test-key")!)).toEqual(["B"]); + }); + + it("restores hidden state from storage on initLayers", () => { + window.localStorage.setItem("test-key", JSON.stringify(["B"])); + + const { initLayers, layers } = useLayers({ + getStorageKey: () => "test-key", + }); + + initLayers( + { + A: makeLayer({ name: "A", visible: true, frozen: false }), + B: makeLayer({ name: "B", visible: true, frozen: false }), + }, + {}, + ); + + expect(layers.value.get("A")!.visible).toBe(true); + expect(layers.value.get("B")!.visible).toBe(false); + }); + + it("ignores stored layer names that do not exist in the current DXF", () => { + window.localStorage.setItem("test-key", JSON.stringify(["GHOST", "A"])); + + const { initLayers, layers } = useLayers({ + getStorageKey: () => "test-key", + }); + + initLayers({ A: makeLayer({ name: "A", visible: true, frozen: false }) }, {}); + + expect(layers.value.size).toBe(1); + expect(layers.value.get("A")!.visible).toBe(false); + }); + + it("does not override frozen layers on restore", () => { + window.localStorage.setItem("test-key", JSON.stringify(["F"])); + + const { initLayers, layers } = useLayers({ + getStorageKey: () => "test-key", + }); + + initLayers( + { F: makeLayer({ name: "F", visible: true, frozen: true }) }, + {}, + ); + + // Frozen → visible already false; the persisted entry is harmless, + // but our toggle/persist logic intentionally excludes frozen names. + expect(layers.value.get("F")!.visible).toBe(false); + }); + + it("persists on showAllLayers and hideAllLayers", () => { + const { initLayers, showAllLayers, hideAllLayers } = useLayers({ + getStorageKey: () => "test-key", + }); + + initLayers( + { + A: makeLayer({ name: "A", visible: true, frozen: false }), + B: makeLayer({ name: "B", visible: true, frozen: false }), + }, + {}, + ); + + hideAllLayers(); + expect(JSON.parse(window.localStorage.getItem("test-key")!).sort()).toEqual(["A", "B"]); + + showAllLayers(); + expect(JSON.parse(window.localStorage.getItem("test-key")!)).toEqual([]); + }); + + it("survives malformed storage data without throwing", () => { + window.localStorage.setItem("test-key", "{not json"); + + const { initLayers, layers } = useLayers({ + getStorageKey: () => "test-key", + }); + + expect(() => + initLayers({ A: makeLayer({ name: "A", visible: true, frozen: false }) }, {}), + ).not.toThrow(); + + expect(layers.value.get("A")!.visible).toBe(true); + }); + + it("supports a dynamic key (different fileName → different storage slot)", () => { + let currentFile = "file1"; + const factory = () => + useLayers({ getStorageKey: () => `prefix:${currentFile}` }); + + const a = factory(); + a.initLayers({ X: makeLayer({ name: "X", visible: true, frozen: false }) }, {}); + a.toggleLayerVisibility("X"); + + expect(JSON.parse(window.localStorage.getItem("prefix:file1")!)).toEqual(["X"]); + + currentFile = "file2"; + const b = factory(); + b.initLayers({ X: makeLayer({ name: "X", visible: true, frozen: false }) }, {}); + + // file2 has no stored state — X must be visible + expect(b.layers.value.get("X")!.visible).toBe(true); + }); +}); + // ── clearLayers ───────────────────────────────────────────────────────── describe("clearLayers", () => { diff --git a/packages/dxf-vuer/src/composables/useLayers.ts b/packages/dxf-vuer/src/composables/useLayers.ts index 5bb5802..46ce815 100644 --- a/packages/dxf-vuer/src/composables/useLayers.ts +++ b/packages/dxf-vuer/src/composables/useLayers.ts @@ -15,9 +15,50 @@ export interface LayerState { themeSentinel?: string; } -export function useLayers() { +export interface UseLayersOptions { + /** + * Returns the localStorage key under which hidden layer names are persisted, + * or null/undefined to disable persistence. Called fresh on every read/write, + * so the consumer can derive the key from reactive state (e.g. file name). + */ + getStorageKey?: () => string | null | undefined; +} + +const isStorageAvailable = (): boolean => + typeof window !== "undefined" && typeof window.localStorage !== "undefined"; + +export function useLayers(options?: UseLayersOptions) { const layers = ref>(new Map()); + const loadHiddenFromStorage = (): Set => { + if (!isStorageAvailable()) return new Set(); + const key = options?.getStorageKey?.(); + if (!key) return new Set(); + try { + const raw = window.localStorage.getItem(key); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? new Set(parsed.filter((n) => typeof n === "string")) : new Set(); + } catch { + return new Set(); + } + }; + + const persistHiddenToStorage = (): void => { + if (!isStorageAvailable()) return; + const key = options?.getStorageKey?.(); + if (!key) return; + const hidden: string[] = []; + layers.value.forEach((l) => { + if (!l.visible && !l.frozen) hidden.push(l.name); + }); + try { + window.localStorage.setItem(key, JSON.stringify(hidden)); + } catch { + // Quota exceeded or storage disabled — silently skip + } + }; + const initLayers = ( dxfLayers: Record, entityLayerCounts: Record, @@ -64,6 +105,16 @@ export function useLayers() { } } + // Apply persisted visibility (silently ignores names that no longer exist) + const hidden = loadHiddenFromStorage(); + if (hidden.size > 0) { + newLayers.forEach((layer) => { + if (!layer.frozen && hidden.has(layer.name)) { + layer.visible = false; + } + }); + } + layers.value = newLayers; }; @@ -71,6 +122,7 @@ export function useLayers() { const layer = layers.value.get(layerName); if (layer && !layer.frozen) { layer.visible = !layer.visible; + persistHiddenToStorage(); } }; @@ -78,12 +130,14 @@ export function useLayers() { layers.value.forEach((layer) => { if (!layer.frozen) layer.visible = true; }); + persistHiddenToStorage(); }; const hideAllLayers = () => { layers.value.forEach((layer) => { layer.visible = false; }); + persistHiddenToStorage(); }; const visibleLayerNames = computed(() => { From 9a9d6b66130935d767bc1e825c30237a02958079 Mon Sep 17 00:00:00 2001 From: Tim Arbaev Date: Fri, 1 May 2026 17:51:39 +0300 Subject: [PATCH 3/5] feat: keyboard navigation --- .../src/composables/useDXFRenderer.ts | 1 + .../src/composables/useKeyboardNavigation.ts | 125 ++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 packages/dxf-vuer/src/composables/useKeyboardNavigation.ts diff --git a/packages/dxf-vuer/src/composables/useDXFRenderer.ts b/packages/dxf-vuer/src/composables/useDXFRenderer.ts index b4cbbce..47a2eb6 100644 --- a/packages/dxf-vuer/src/composables/useDXFRenderer.ts +++ b/packages/dxf-vuer/src/composables/useDXFRenderer.ts @@ -317,6 +317,7 @@ export function useDXFRenderer() { getCamera, getRenderer, getScene, + getControls, getOriginOffset, render, }; diff --git a/packages/dxf-vuer/src/composables/useKeyboardNavigation.ts b/packages/dxf-vuer/src/composables/useKeyboardNavigation.ts new file mode 100644 index 0000000..04b179a --- /dev/null +++ b/packages/dxf-vuer/src/composables/useKeyboardNavigation.ts @@ -0,0 +1,125 @@ +import * as THREE from "three"; +import type { MapControls } from "three/addons/controls/MapControls.js"; + +export interface KeyboardNavigationHandlers { + /** Returns the orthographic camera, or null if scene not initialised. */ + getCamera: () => THREE.OrthographicCamera | null; + /** Returns MapControls so we can update target + emit change events. */ + getControls: () => MapControls | null; + /** Reset camera to its saved fit-to-view state. */ + resetView: () => void; + /** Trigger a render after we mutate the camera. */ + render: () => void; +} + +/** + * Step (in viewport-fraction) for arrow-key pan. 5% of the visible width/height + * per keypress feels close to a slow trackpad swipe — small enough to be precise, + * large enough to be noticeable on hold-repeat. + */ +const PAN_STEP_RATIO = 0.05; + +/** Multiplier per `+` / `-` press. Mirrors a single mouse-wheel notch. */ +const ZOOM_STEP = 1.2; + +/** + * Wire arrow-keys / +/- / 0 to pan, zoom and reset on a focused canvas. + * + * The canvas must be focusable (tabindex >= 0) for keydown events to reach it; + * the caller is responsible for setting that on the element it passes to `attach`. + */ +export function useKeyboardNavigation(handlers: KeyboardNavigationHandlers) { + let target: HTMLElement | null = null; + let enabled = true; + + const onKeyDown = (e: KeyboardEvent) => { + if (!enabled) return; + // Don't steal keystrokes from form inputs nested over the canvas + const t = e.target as HTMLElement | null; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; + + const camera = handlers.getCamera(); + const controls = handlers.getControls(); + if (!camera || !controls) return; + + switch (e.key) { + case "ArrowLeft": + pan(camera, controls, -PAN_STEP_RATIO, 0); + break; + case "ArrowRight": + pan(camera, controls, PAN_STEP_RATIO, 0); + break; + case "ArrowUp": + pan(camera, controls, 0, PAN_STEP_RATIO); + break; + case "ArrowDown": + pan(camera, controls, 0, -PAN_STEP_RATIO); + break; + case "+": + case "=": + zoomBy(camera, controls, ZOOM_STEP); + break; + case "-": + case "_": + zoomBy(camera, controls, 1 / ZOOM_STEP); + break; + case "0": + handlers.resetView(); + return; + default: + return; + } + e.preventDefault(); + handlers.render(); + }; + + const pan = ( + camera: THREE.OrthographicCamera, + controls: MapControls, + fx: number, + fy: number, + ) => { + const width = (camera.right - camera.left) / camera.zoom; + const height = (camera.top - camera.bottom) / camera.zoom; + const dx = width * fx; + const dy = height * fy; + camera.position.x += dx; + camera.position.y += dy; + controls.target.x += dx; + controls.target.y += dy; + controls.update(); + }; + + const zoomBy = ( + camera: THREE.OrthographicCamera, + controls: MapControls, + factor: number, + ) => { + const next = camera.zoom * factor; + const min = controls.minZoom ?? 0.00001; + const max = controls.maxZoom ?? 1000; + camera.zoom = Math.min(max, Math.max(min, next)); + camera.updateProjectionMatrix(); + controls.update(); + }; + + const attach = (element: HTMLElement) => { + detach(); + target = element; + if (target.tabIndex < 0) target.tabIndex = 0; + target.addEventListener("keydown", onKeyDown); + }; + + const detach = () => { + if (target) { + target.removeEventListener("keydown", onKeyDown); + target = null; + } + }; + + const setEnabled = (v: boolean) => { + enabled = v; + }; + + return { attach, detach, setEnabled }; +} From 53065daa37106d9c1d299fe48a18b5eedb5bc147 Mon Sep 17 00:00:00 2001 From: Tim Arbaev Date: Fri, 1 May 2026 17:52:27 +0300 Subject: [PATCH 4/5] feat: ARIA improvements --- .../dxf-vuer/src/components/DXFViewer.vue | 63 +++++++++++++++++-- .../dxf-vuer/src/components/FileUploader.vue | 1 + .../dxf-vuer/src/components/LayerPanel.vue | 36 +++++++++-- .../dxf-vuer/src/components/ViewerToolbar.vue | 6 +- 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/packages/dxf-vuer/src/components/DXFViewer.vue b/packages/dxf-vuer/src/components/DXFViewer.vue index d94a9db..749ebb4 100644 --- a/packages/dxf-vuer/src/components/DXFViewer.vue +++ b/packages/dxf-vuer/src/components/DXFViewer.vue @@ -3,6 +3,9 @@ ref="dxfContainer" class="dxf-viewer" :class="{ 'dark-theme': darkTheme }" + role="region" + aria-label="DXF drawing viewer" + :aria-busy="isLoading" @mousemove="handleMouseMove" @mouseleave="handleMouseLeave" @dragover.prevent="handleDragOver" @@ -104,7 +107,7 @@ -
+
@@ -127,7 +130,7 @@
-
+