diff --git a/README.md b/README.md index bf8e917..9affb8f 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,12 @@ async function loadFile(file) { - **Linetype rendering** — DASHED, HIDDEN, CENTER, PHANTOM, DOT, DASHDOT with LTSCALE support - **Hatch patterns** — 25 built-in AutoCAD patterns with multi-boundary clipping - **Vector text** — crisp at any zoom; Liberation Sans/Serif fonts; bold, italic, underline, MTEXT formatting +- **Picking & associations** — bbox-based raycast, hover/click events, semantic links derived from DXF (LEADER↔TEXT, INSERT+ATTRIB, MLEADER, DIMENSION) +- **Search APIs** — `findEntitiesByText` / `findEntitiesByLayer` / `findEntitiesByType`, paired with `viewer.zoomToEntity` / `zoomToLayer` for find-and-focus UX - **Dark theme** — instant switching -- **Layer panel** — toggle visibility with color indicators +- **Layer panel** — toggle visibility with color indicators; optional `localStorage` persistence per file +- **Keyboard navigation** — arrow keys pan, `+`/`-` zoom, `0` reset +- **Accessibility** — ARIA roles/labels on toolbar, layer panel, status/error overlays; respects `prefers-reduced-motion` - **Overlay positioning** — 6-cell grid system for positioning UI overlays (toolbar, coordinates, layers, etc.) - **Customizable UI** — 6 named slots (`#toolbar`, `#toolbar-extra`, `#loading`, `#error`, `#empty-state`, `#overlay`) with scoped data - **Error display** — parse/render/fetch errors shown in the viewer with retry support diff --git a/demo/components/StatsSection.vue b/demo/components/StatsSection.vue index 47c2cc2..c0db6f8 100644 --- a/demo/components/StatsSection.vue +++ b/demo/components/StatsSection.vue @@ -22,7 +22,7 @@ interface Stat { const stats: Stat[] = [ { value: 21, label: "entity types", sub: "LINE · ARC · SPLINE · HATCH · INSERT · MTEXT · …" }, - { value: 923, label: "tests", sub: "100% green CI on every push" }, + { value: 945, label: "tests", sub: "100% green CI on every push" }, { value: 25, label: "hatch patterns", sub: "ANSI31 · ANSI32 · ANSI33 · GRASS · NET · …" }, { value: 7, label: "dimension types", sub: "linear · aligned · radial · diametric · angular · ordinate · 3-pt" }, { value: 6, label: "AA modes", sub: "MSAA · SMAA · FXAA · TAA · SSAA · none" }, diff --git a/demo/components/WhatsNewSection.vue b/demo/components/WhatsNewSection.vue index e5c969a..9e75540 100644 --- a/demo/components/WhatsNewSection.vue +++ b/demo/components/WhatsNewSection.vue @@ -23,6 +23,26 @@ interface WhatsNewItem { } const whatsNew: WhatsNewItem[] = [ + { + pkg: "dxf-vuer", + version: "2.6.0", + text: "Keyboard navigation — arrow keys pan, +/- zoom, 0 resets the view; canvas is now focusable and respects form fields", + }, + { + pkg: "dxf-vuer", + version: "2.6.0", + text: "persistLayersKey prop — remember which layers were hidden across reloads via localStorage, scoped per file", + }, + { + pkg: "dxf-vuer", + version: "2.6.0", + text: "viewer.zoomToLayer(layerName) — fit the camera to a single layer; ARIA roles/labels added to toolbar, layer panel, and status overlays", + }, + { + pkg: "dxf-render", + version: "1.5.0", + text: "getZoomBoxForLayer / findEntitiesByLayer / findEntitiesByType — three pure utilities for layer- and type-based zoom and search", + }, { pkg: "dxf-vuer", version: "2.5.0", diff --git a/packages/dxf-render/CHANGELOG.md b/packages/dxf-render/CHANGELOG.md index 5e67d21..84df6e5 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 44 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); +} diff --git a/packages/dxf-vuer/CHANGELOG.md b/packages/dxf-vuer/CHANGELOG.md index 326be49..a5eabe2 100644 --- a/packages/dxf-vuer/CHANGELOG.md +++ b/packages/dxf-vuer/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 2.6.0 + +### Features + +- **`persistLayersKey` prop** — opt-in persistence of layer visibility via `localStorage`. When set, hidden layer names are stored under `${persistLayersKey}:${fileName || 'default'}` and restored on the next load of the same file. Stored names that no longer exist in the current DXF are silently ignored. Disabled by default. +- **Keyboard navigation** — arrow keys pan (5% of viewport per press), `+`/`=` and `-`/`_` zoom in/out (×1.2 per press), `0` resets the view. Active when the canvas is focused; new prop `keyboardNavigation` (default `true`) toggles the listener. Canvas is now focusable (`tabindex="0"`). +- **`viewer.zoomToLayer(layerName)`** — imperative method on the viewer ref that fits the camera to all entities of a given layer. Requires `pickingEnabled`. +- **Public composable `useKeyboardNavigation`** — exported for advanced integrations that bypass ``. +- **ARIA improvements** — `role="region"` + `aria-label` on the viewer container, `role="toolbar"` + per-button `aria-label`/`aria-pressed` on the toolbar, `role="region"` + `aria-expanded` on the layer panel header, per-layer toggles get `role="button"` + `aria-pressed`/`aria-disabled` and Enter/Space keyboard activation, loading overlay is `role="status" aria-live="polite"`, error overlay is `role="alert" aria-live="assertive"`, viewer container reflects `aria-busy` during loads. + +### Dependencies + +- Requires `dxf-render` ≥ 1.5.0 (new `getZoomBoxForLayer`, `findEntitiesByLayer`, `findEntitiesByType`). + ## 2.5.0 ### Features diff --git a/packages/dxf-vuer/README.md b/packages/dxf-vuer/README.md index bd21a91..6d2945d 100644 --- a/packages/dxf-vuer/README.md +++ b/packages/dxf-vuer/README.md @@ -83,6 +83,8 @@ async function loadFile(file) { | `highlightOnHover` | `boolean` | `true` | Draw a built-in highlight overlay on the hovered entity. Turn off if you render selection from your own UI | | `highlightAssociated` | `boolean` | `true` | When the hovered entity participates in an association (MLEADER / LEADER+TEXT / INSERT+ATTRIB / DIMENSION), highlight all its members instead of just the entity itself | | `highlightColor` | `string` | `"#ffaa00"` | Color used by the built-in hover highlight | +| `keyboardNavigation` | `boolean` | `true` | Enable keyboard pan/zoom (arrow keys, `+`/`-`, `0`). Listener fires only when the canvas is focused | +| `persistLayersKey` | `string` | `""` | When set, layer visibility is persisted to `localStorage` under `${persistLayersKey}:${fileName \|\| "default"}`. Empty string disables persistence | `OverlayPosition` = `"top-left"` | `"top-center"` | `"top-right"` | `"bottom-left"` | `"bottom-center"` | `"bottom-right"` @@ -162,6 +164,7 @@ async function loadFile(file) { | `getAssociations()` | Return all `EntityAssociation[]` derived from the loaded DXF | | `findAssociationsByHandle(handle: string)` | Return all associations a given handle participates in | | `zoomToEntity(handles: string[])` | Fit the camera to the union of the entities' bboxes, with 20% padding. Requires `pickingEnabled` | +| `zoomToLayer(layerName: string)` | Fit the camera to all entities on the given layer. Requires `pickingEnabled`. Layer names are case-sensitive (DXF spec) | | `getPickingIndex()` | Returns the underlying `PickingIndex \| null`. Useful for filtering external search results (e.g. from `findEntitiesByText`) to entities that are actually rendered in the scene | ```vue @@ -336,6 +339,18 @@ function search(query: string) { `findEntitiesByText` accepts `{ caseSensitive: true }` or `{ regex: true }`. +For layer-based or type-based focus, the matching helpers are `findEntitiesByLayer` and `findEntitiesByType` (also re-exported from `dxf-render`); pair them with `zoomToLayer` / `highlight`: + +```ts +import { findEntitiesByType } from 'dxf-vuer' + +// Highlight every dimension on the drawing +viewer.value!.highlight(findEntitiesByType(dxf, 'DIMENSION')) + +// Or focus the camera on the WALLS layer (no need to gather handles yourself) +viewer.value!.zoomToLayer('WALLS') +``` + ### Example: list every association in the file Handy for sanity-checking a DXF or building a "Notes" panel: @@ -361,17 +376,45 @@ if (firstMleader) viewer.value!.highlight(firstMleader.members); ## Accessibility +- **Keyboard navigation** — when `keyboardNavigation` is on (default), the canvas becomes focusable (`tabindex="0"`) and responds to: + + | Keys | Action | + | ---------------- | ---------------------------- | + | `←` `↑` `→` `↓` | Pan by 5% of the viewport | + | `+` / `=` | Zoom in (×1.2) | + | `-` / `_` | Zoom out (÷1.2) | + | `0` | Reset to fit-to-view | + + Listener bails out when the focused element is an ``, `