Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion demo/components/StatsSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
20 changes: 20 additions & 0 deletions demo/components/WhatsNewSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/dxf-render/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
32 changes: 30 additions & 2 deletions packages/dxf-render/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Font>` — load embedded Liberation Sans Regular
Expand Down Expand Up @@ -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 |
Expand Down
9 changes: 9 additions & 0 deletions packages/dxf-render/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
74 changes: 74 additions & 0 deletions packages/dxf-render/src/utils/__tests__/findEntitiesByType.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
91 changes: 91 additions & 0 deletions packages/dxf-render/src/utils/__tests__/getZoomBoxForLayer.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, PickingEntry[]>();
const byId = new Map<string, PickingEntry>();
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);
});
});
Loading
Loading